diff --git a/components/TextInput.tsx b/components/TextInput.tsx index 254958daa9..a54421997e 100644 --- a/components/TextInput.tsx +++ b/components/TextInput.tsx @@ -19,6 +19,7 @@ interface TextInputProps { numberOfLines?: number; style?: ViewStyle; textInputStyle?: StyleProp; + textColor?: string; placeholderTextColor?: string; locked?: boolean; keyboardType?: KeyboardTypeOptions; @@ -49,6 +50,7 @@ const TextInput = React.forwardRef( numberOfLines, style, textInputStyle, + textColor, placeholderTextColor, locked, keyboardType, @@ -156,9 +158,11 @@ const TextInput = React.forwardRef( style={{ ...StyleSheet.flatten(textInputStyle), ...styles.input, - color: locked - ? themeColor('secondaryText') - : themeColor('text') + color: + textColor || + (locked + ? themeColor('secondaryText') + : themeColor('text')) }} placeholderTextColor={ placeholderTextColor || themeColor('secondaryText') diff --git a/locales/en.json b/locales/en.json index 1f19ed8b38..892979db13 100644 --- a/locales/en.json +++ b/locales/en.json @@ -128,8 +128,6 @@ "general.experimental": "Experimental", "general.defaultNodeNickname": "My Lightning Node", "general.active": "Active", - "general.pastedInvalidData": "The pasted text contains characters we can't use here. Please check and try again.", - "general.invalidHost": "Invalid host. Please enter a valid host, or host:port.", "restart.title": "Restart required", "restart.msg": "ZEUS has to be restarted before the new configuration is applied.", "restart.msg1": "Would you like to restart now?", diff --git a/utils/ValidationUtils.test.ts b/utils/ValidationUtils.test.ts new file mode 100644 index 0000000000..6b0ef3f3df --- /dev/null +++ b/utils/ValidationUtils.test.ts @@ -0,0 +1,197 @@ +import ValidationUtils from './ValidationUtils'; + +describe('isValidHostAndPort', () => { + it('accepts valid hostname without port', () => { + expect(ValidationUtils.isValidHostAndPort('example.com')).toBe(true); + }); + + it('accepts valid hostname with port', () => { + expect(ValidationUtils.isValidHostAndPort('example.com:8080')).toBe( + true + ); + }); + + it('accepts valid hostname with protocol', () => { + expect(ValidationUtils.isValidHostAndPort('https://example.com')).toBe( + true + ); + expect(ValidationUtils.isValidHostAndPort('http://example.com')).toBe( + true + ); + }); + + it('rejects invalid port numbers', () => { + expect(ValidationUtils.isValidHostAndPort('example.com:0')).toBe(false); + expect(ValidationUtils.isValidHostAndPort('example.com:65536')).toBe( + false + ); + }); + + it('rejects invalid hostnames', () => { + expect(ValidationUtils.isValidHostAndPort('example..com')).toBe(false); + expect(ValidationUtils.isValidHostAndPort('-example.com')).toBe(false); + }); +}); + +describe('isValidHttpsHostAndPort', () => { + it('accepts valid hostname without protocol', () => { + expect(ValidationUtils.isValidHttpsHostAndPort('example.com')).toBe( + true + ); + }); + + it('accepts valid hostname with https protocol', () => { + expect( + ValidationUtils.isValidHttpsHostAndPort('https://example.com') + ).toBe(true); + }); + + it('accepts valid hostname with port', () => { + expect( + ValidationUtils.isValidHttpsHostAndPort('example.com:8080') + ).toBe(true); + expect( + ValidationUtils.isValidHttpsHostAndPort('https://example.com:8080') + ).toBe(true); + }); + + it('rejects hostname with http protocol', () => { + expect( + ValidationUtils.isValidHttpsHostAndPort('http://example.com') + ).toBe(false); + expect( + ValidationUtils.isValidHttpsHostAndPort('http://example.com:8080') + ).toBe(false); + }); + + it('rejects invalid port numbers', () => { + expect(ValidationUtils.isValidHttpsHostAndPort('example.com:0')).toBe( + false + ); + expect( + ValidationUtils.isValidHttpsHostAndPort('example.com:65536') + ).toBe(false); + }); + + it('rejects invalid hostnames', () => { + expect(ValidationUtils.isValidHttpsHostAndPort('example..com')).toBe( + false + ); + expect(ValidationUtils.isValidHttpsHostAndPort('-example.com')).toBe( + false + ); + }); +}); + +describe('isValidHostname', () => { + it('accepts valid hostnames', () => { + expect(ValidationUtils.isValidHostname('example.com')).toBe(true); + expect(ValidationUtils.isValidHostname('sub.example.com')).toBe(true); + expect(ValidationUtils.isValidHostname('example-domain.com')).toBe( + true + ); + expect(ValidationUtils.isValidHostname('localhost')).toBe(true); + expect(ValidationUtils.isValidHostname('10.0.2.2')).toBe(true); + }); + + it('accepts hostnames with protocol', () => { + expect(ValidationUtils.isValidHostname('http://example.com')).toBe( + true + ); + expect(ValidationUtils.isValidHostname('https://example.com')).toBe( + true + ); + }); + + it('rejects invalid hostnames', () => { + expect(ValidationUtils.isValidHostname('example..com')).toBe(false); + expect(ValidationUtils.isValidHostname('.example.com')).toBe(false); + expect(ValidationUtils.isValidHostname('example.com:')).toBe(false); + }); +}); + +describe('isValidPort', () => { + it('accepts valid port numbers', () => { + expect(ValidationUtils.isValidPort('80')).toBe(true); + expect(ValidationUtils.isValidPort('8080')).toBe(true); + expect(ValidationUtils.isValidPort('65535')).toBe(true); + }); + + it('rejects invalid port numbers', () => { + expect(ValidationUtils.isValidPort('0')).toBe(false); + expect(ValidationUtils.isValidPort('65536')).toBe(false); + expect(ValidationUtils.isValidPort('-1')).toBe(false); + }); +}); + +describe('hasValidRuneChars', () => { + it('accepts valid rune characters', () => { + expect(ValidationUtils.hasValidRuneChars('abcDEF123-_=')).toBe(true); + expect(ValidationUtils.hasValidRuneChars('ABC123')).toBe(true); + }); + + it('rejects invalid rune characters', () => { + expect(ValidationUtils.hasValidRuneChars('abc!')).toBe(false); + expect(ValidationUtils.hasValidRuneChars('abc@')).toBe(false); + expect(ValidationUtils.hasValidRuneChars('abc space')).toBe(false); + }); +}); + +describe('hasValidMacaroonChars', () => { + it('accepts valid macaroon hex characters', () => { + expect(ValidationUtils.hasValidMacaroonChars('0123456789abcdef')).toBe( + true + ); + expect(ValidationUtils.hasValidMacaroonChars('ABCDEF')).toBe(true); + }); + + it('rejects invalid macaroon hex characters', () => { + expect(ValidationUtils.hasValidMacaroonChars('0123g')).toBe(false); + expect(ValidationUtils.hasValidMacaroonChars('abcd!')).toBe(false); + expect(ValidationUtils.hasValidMacaroonChars('0123 4567')).toBe(false); + }); +}); + +describe('hasValidPairingPhraseCharsAndWordcount', () => { + it('accepts valid 10-word pairing phrase', () => { + expect( + ValidationUtils.hasValidPairingPhraseCharsAndWordcount( + 'cherry truth mask employ box silver mass bunker fiscal vote' + ) + ).toBe(true); + }); + + it('accepts phrase with extra spaces and normalizes', () => { + expect( + ValidationUtils.hasValidPairingPhraseCharsAndWordcount( + ' cherry truth mask employ box silver mass bunker fiscal vote ' + ) + ).toBe(true); + }); + + it('rejects phrases with wrong word count', () => { + expect( + ValidationUtils.hasValidPairingPhraseCharsAndWordcount( + 'less than ten words' + ) + ).toBe(false); + expect( + ValidationUtils.hasValidPairingPhraseCharsAndWordcount( + 'more than ten words truth mask employ box silver mass bunker' + ) + ).toBe(false); + }); + + it('rejects phrases with invalid characters', () => { + expect( + ValidationUtils.hasValidPairingPhraseCharsAndWordcount( + 'cherry truth mask employ box silver mass bunker fiscal 123' + ) + ).toBe(false); + expect( + ValidationUtils.hasValidPairingPhraseCharsAndWordcount( + 'cherry truth mask employ box silver mass bunker fiscal @' + ) + ).toBe(false); + }); +}); diff --git a/utils/ValidationUtils.ts b/utils/ValidationUtils.ts new file mode 100644 index 0000000000..925251b5e5 --- /dev/null +++ b/utils/ValidationUtils.ts @@ -0,0 +1,68 @@ +const isValidHostAndPort = (input: string): boolean => { + const urlPattern = + /^(https?:\/\/)?(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])(:\d+)?$/; + const portPattern = /:\d+$/; + + if (!urlPattern.test(input)) return false; + + const portMatch = input.match(portPattern); + if (portMatch) { + const port = parseInt(portMatch[0].substring(1)); + if (port < 1 || port > 65535) return false; + } + + return true; +}; + +const isValidHttpsHostAndPort = (input: string): boolean => { + const urlPattern = + /^(https:\/\/)?(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])(:\d+)?$/; + const portPattern = /:\d+$/; + + if (!urlPattern.test(input)) return false; + + const portMatch = input.match(portPattern); + if (portMatch) { + const port = parseInt(portMatch[0].substring(1)); + if (port < 1 || port > 65535) return false; + } + + return true; +}; + +const isValidHostname = (hostname: string): boolean => { + const urlPattern = + /^(https?:\/\/)?(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/; + return urlPattern.test(hostname); +}; + +const isValidPort = (port: string): boolean => { + const portNum = parseInt(port); + return portNum >= 1 && portNum <= 65535; +}; + +const hasValidRuneChars = (rune: string): boolean => { + return /^[A-Za-z0-9\-_=]+$/.test(rune); +}; + +const hasValidMacaroonChars = (macaroon: string): boolean => { + return /^[0-9a-fA-F]+$/.test(macaroon); +}; + +const hasValidPairingPhraseCharsAndWordcount = (phrase: string): boolean => { + const normalizedPhrase = phrase.trim().replace(/\s+/g, ' '); + if (!/^[a-zA-Z\s]+$/.test(normalizedPhrase)) return false; + return normalizedPhrase.split(' ').length === 10; +}; + +const ValidationUtils = { + isValidHostAndPort, + isValidHttpsHostAndPort, + isValidHostname, + isValidPort, + hasValidRuneChars, + hasValidMacaroonChars, + hasValidPairingPhraseCharsAndWordcount +}; + +export default ValidationUtils; diff --git a/views/Settings/WalletConfiguration.tsx b/views/Settings/WalletConfiguration.tsx index ca3b561d14..cb327f23db 100644 --- a/views/Settings/WalletConfiguration.tsx +++ b/views/Settings/WalletConfiguration.tsx @@ -24,6 +24,7 @@ import ConnectionFormatUtils from '../../utils/ConnectionFormatUtils'; import { localeString } from '../../utils/LocaleUtils'; import BackendUtils from '../../utils/BackendUtils'; import { themeColor } from '../../utils/ThemeUtils'; +import ValidationUtils from '../../utils/ValidationUtils'; import Button from '../../components/Button'; import CollapsedQR from '../../components/CollapsedQR'; @@ -122,6 +123,14 @@ interface WalletConfigurationState { channelBackupsBase64: string; creatingWallet: boolean; errorCreatingWallet: boolean; + lndhubUrlError: boolean; + usernameError: boolean; + hostError: boolean; + runeError: boolean; + macaroonHexError: boolean; + portError: boolean; + customMailboxServerError: boolean; + pairingPhraseError: boolean; } const ScanBadge = ({ onPress }: { onPress: () => void }) => ( @@ -178,7 +187,15 @@ export default class WalletConfiguration extends React.Component< recoveryCipherSeed: '', channelBackupsBase64: '', creatingWallet: false, - errorCreatingWallet: false + errorCreatingWallet: false, + lndhubUrlError: false, + usernameError: false, + hostError: false, + runeError: false, + macaroonHexError: false, + portError: false, + customMailboxServerError: false, + pairingPhraseError: false }; scrollViewRef = React.createRef(); @@ -727,7 +744,15 @@ export default class WalletConfiguration extends React.Component< recoveryCipherSeed, channelBackupsBase64, creatingWallet, - errorCreatingWallet + errorCreatingWallet, + lndhubUrlError, + usernameError, + hostError, + runeError, + macaroonHexError, + portError, + customMailboxServerError, + pairingPhraseError } = this.state; const { loading, @@ -1152,18 +1177,16 @@ export default class WalletConfiguration extends React.Component< {!adminMacaroon && implementation === 'embedded-lnd' && ( - {!adminMacaroon && ( - { - this.setState({ - embeddedLndNetwork: value - }); - }} - values={EMBEDDED_NODE_NETWORK_KEYS} - /> - )} + { + this.setState({ + embeddedLndNetwork: value + }); + }} + values={EMBEDDED_NODE_NETWORK_KEYS} + /> {false && ( <> { - const validHostChars = - /^[a-zA-Z0-9-.:]+$/; + this.setState({ + lndhubUrlError: false + }); // Allow backspace/delete operations without validation if ( @@ -1368,7 +1397,7 @@ export default class WalletConfiguration extends React.Component< (lndhubUrl?.length || 0) + 1 ) { const cleanedText = text.replace( - /[^a-zA-Z0-9-.:]/g, + /[^a-zA-Z0-9-.:/]/g, '' ); this.setState({ @@ -1380,20 +1409,25 @@ export default class WalletConfiguration extends React.Component< // For pasted content const trimmedText = text.trim(); - if (!validHostChars.test(trimmedText)) { - this.props.ModalStore.toggleInfoModal( - localeString( - 'general.pastedInvalidData' - ) - ); - return; - } - this.setState({ lndhubUrl: trimmedText, + lndhubUrlError: + !ValidationUtils.isValidHostAndPort( + trimmedText + ), saved: false }); }} + onBlur={() => { + if (lndhubUrl) { + this.setState({ + lndhubUrlError: + !ValidationUtils.isValidHostAndPort( + lndhubUrl + ) + }); + } + }} locked={loading} /> @@ -1434,8 +1468,22 @@ export default class WalletConfiguration extends React.Component< + this.setState({ + usernameError: false + }) + } onChangeText={(text: string) => { + this.setState({ + usernameError: false + }); + // Allow backspace/delete operations without validation if ( text.length < @@ -1464,17 +1512,11 @@ export default class WalletConfiguration extends React.Component< // For pasted content const trimmedText = text.trim(); - if (/\s/.test(trimmedText)) { - this.props.ModalStore.toggleInfoModal( - localeString( - 'general.pastedInvalidData' - ) - ); - return; - } - this.setState({ username: trimmedText, + usernameError: /\s/.test( + trimmedText + ), saved: false }); }} @@ -1564,10 +1606,17 @@ export default class WalletConfiguration extends React.Component< { + this.setState({ hostError: false }); + // Allow backspace/delete operations without validation if (text.length < (host?.length || 0)) { this.setState({ @@ -1583,10 +1632,9 @@ export default class WalletConfiguration extends React.Component< (host?.length || 0) + 1 ) { const cleanedText = text.replace( - /[^a-zA-Z0-9-.]/g, + /[^a-zA-Z0-9-./:]/g, '' ); - this.setState({ host: cleanedText, saved: false @@ -1596,24 +1644,25 @@ export default class WalletConfiguration extends React.Component< // For pasted content const trimmedText = text.trim(); - if ( - !/^[a-zA-Z0-9-.]+$/.test( - trimmedText - ) - ) { - this.props.ModalStore.toggleInfoModal( - localeString( - 'general.pastedInvalidData' - ) - ); - return; - } - this.setState({ host: trimmedText, + hostError: + !ValidationUtils.isValidHostname( + trimmedText + ), saved: false }); }} + onBlur={() => { + if (host) { + this.setState({ + hostError: + !ValidationUtils.isValidHostname( + host + ) + }); + } + }} locked={loading} /> @@ -1632,10 +1681,19 @@ export default class WalletConfiguration extends React.Component< { + this.setState({ + runeError: false + }); + // Allow backspace/delete operations without validation if ( text.length < @@ -1667,21 +1725,12 @@ export default class WalletConfiguration extends React.Component< // For pasted content const trimmedText = text.trim(); - if ( - !/^[A-Za-z0-9\-_=]+$/.test( - trimmedText - ) - ) { - this.props.ModalStore.toggleInfoModal( - localeString( - 'general.pastedInvalidData' - ) - ); - return; - } - this.setState({ rune: trimmedText, + runeError: + !ValidationUtils.hasValidRuneChars( + trimmedText + ), saved: false }); }} @@ -1703,10 +1752,19 @@ export default class WalletConfiguration extends React.Component< { + this.setState({ + macaroonHexError: false + }); + // Allow backspace/delete operations without validation if ( text.length < @@ -1740,21 +1798,12 @@ export default class WalletConfiguration extends React.Component< // For pasted content const trimmedText = text.trim(); - if ( - !/^[0-9a-fA-F]+$/.test( - trimmedText - ) - ) { - this.props.ModalStore.toggleInfoModal( - localeString( - 'general.pastedInvalidData' - ) - ); - return; - } - this.setState({ macaroonHex: trimmedText, + macaroonHexError: + !ValidationUtils.hasValidMacaroonChars( + trimmedText + ), saved: false }); }} @@ -1775,13 +1824,60 @@ export default class WalletConfiguration extends React.Component< + onChangeText={(text: string) => { + this.setState({ portError: false }); + + // Allow backspace/delete operations without validation + if (text.length < (port?.length || 0)) { + this.setState({ + port: text, + saved: false + }); + return; + } + + // For single character additions + if ( + text.length === + (port?.length || 0) + 1 + ) { + const cleanedText = text + .replace(/[^0-9]/g, '') + .replace(/^0+/, ''); + this.setState({ + port: cleanedText, + saved: false + }); + return; + } + + // For pasted content + const trimmedText = text.trim(); this.setState({ - port: text.trim(), + port: trimmedText, + portError: + !ValidationUtils.isValidPort( + trimmedText + ), saved: false - }) - } + }); + }} + onBlur={() => { + if (port) { + this.setState({ + portError: + !ValidationUtils.isValidPort( + port + ) + }); + } + }} locked={loading} /> @@ -1807,10 +1903,20 @@ export default class WalletConfiguration extends React.Component< placeholder={ 'my-custom.lnc.server:443' } + textColor={ + customMailboxServerError + ? themeColor('error') + : themeColor('text') + } autoCorrect={false} autoCapitalize="none" value={customMailboxServer} onChangeText={(text: string) => { + this.setState({ + customMailboxServerError: + false + }); + // Allow backspace/delete operations without validation if ( text.length < @@ -1837,7 +1943,7 @@ export default class WalletConfiguration extends React.Component< const cleanedText = text.replace( - /[^a-zA-Z0-9-.:]/g, + /[^a-zA-Z0-9-./:]/g, '' ); this.setState({ @@ -1850,37 +1956,24 @@ export default class WalletConfiguration extends React.Component< // For pasted content const trimmedText = text.trim(); - if ( - !/^[a-zA-Z0-9-.:]+$/.test( - trimmedText - ) - ) { - this.props.ModalStore.toggleInfoModal( - localeString( - 'general.pastedInvalidData' - ) - ); - return; - } - this.setState({ customMailboxServer: trimmedText, + customMailboxServerError: + !ValidationUtils.isValidHttpsHostAndPort( + trimmedText + ), saved: false }); }} onBlur={() => { - if ( - customMailboxServer && - !/^[a-zA-Z0-9-.]+(:\d+)?$/.test( - customMailboxServer - ) - ) { - this.props.ModalStore.toggleInfoModal( - localeString( - 'general.invalidHost' - ) - ); + if (customMailboxServer) { + this.setState({ + customMailboxServerError: + !ValidationUtils.isValidHttpsHostAndPort( + customMailboxServer + ) + }); } }} locked={loading} @@ -1900,9 +1993,18 @@ export default class WalletConfiguration extends React.Component< placeholder={ 'cherry truth mask employ box silver mass bunker fiscal vote' } + textColor={ + pairingPhraseError + ? themeColor('error') + : themeColor('text') + } autoCapitalize="none" value={pairingPhrase} onChangeText={(text: string) => { + this.setState({ + pairingPhraseError: false + }); + // Allow backspace/delete operations without validation if ( text.length < @@ -1938,21 +2040,12 @@ export default class WalletConfiguration extends React.Component< const normalizedPhrase = text .trim() .replace(/\s+/g, ' '); - if ( - !/^[a-zA-Z\s]+$/.test( - normalizedPhrase - ) - ) { - this.props.ModalStore.toggleInfoModal( - localeString( - 'general.pastedInvalidData' - ) - ); - return; - } - this.setState({ pairingPhrase: normalizedPhrase, + pairingPhraseError: + !ValidationUtils.hasValidPairingPhraseCharsAndWordcount( + normalizedPhrase + ), saved: false }); }} @@ -1960,28 +2053,18 @@ export default class WalletConfiguration extends React.Component< const normalizedPhrase = pairingPhrase ?.trim() .replace(/\s+/g, ' '); - const wordCount = - normalizedPhrase?.split(' ') - .length || 0; - - if ( - normalizedPhrase && - wordCount !== 10 - ) { - this.props.ModalStore.toggleInfoModal( - localeString( - 'views.Settings.AddEditNode.wrongLncPairingPhraseLength' - ) - ); - } - this.setState({ pairingPhrase: normalizedPhrase, + pairingPhraseError: + !ValidationUtils.hasValidPairingPhraseCharsAndWordcount( + normalizedPhrase + ), saved: false }); }} locked={loading} /> + {!!localKey && ( <>