diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2024-04-08 07:57:05 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2024-04-11 17:21:23 +0200 |
| commit | 41a01db5963e0f499c0dc23419af8ed6bd8bf772 (patch) | |
| tree | 4824fd3a7818cdb9cbfb1241b29334ca0336ce7b /gui/src | |
| parent | 9e7b4bf6638c1b57d5291305a838367865ce8f76 (diff) | |
| download | mullvadvpn-41a01db5963e0f499c0dc23419af8ed6bd8bf772.tar.xz mullvadvpn-41a01db5963e0f499c0dc23419af8ed6bd8bf772.zip | |
Refactor custom proxy form out of Api access method component
Diffstat (limited to 'gui/src')
| -rw-r--r-- | gui/src/renderer/components/EditApiAccessMethod.tsx | 453 | ||||
| -rw-r--r-- | gui/src/renderer/components/ProxyForm.tsx | 532 |
2 files changed, 555 insertions, 430 deletions
diff --git a/gui/src/renderer/components/EditApiAccessMethod.tsx b/gui/src/renderer/components/EditApiAccessMethod.tsx index b5b3bd6d48..59a5f2ff37 100644 --- a/gui/src/renderer/components/EditApiAccessMethod.tsx +++ b/gui/src/renderer/components/EditApiAccessMethod.tsx @@ -1,38 +1,27 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useParams } from 'react-router'; import { sprintf } from 'sprintf-js'; import { - AccessMethod, - AccessMethodSetting, CustomProxy, + NamedCustomProxy, NewAccessMethodSetting, - RelayProtocol, - ShadowsocksAccessMethod, - Socks5LocalAccessMethod, - Socks5RemoteAccessMethod, } from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; import { useScheduler } from '../../shared/scheduler'; import { useAppContext } from '../context'; import { useApiAccessMethodTest } from '../lib/api-access-methods'; import { useHistory } from '../lib/history'; -import { IpAddress } from '../lib/ip'; import { useSelector } from '../redux/store'; -import * as Cell from './cell'; -import { SettingsForm, useSettingsFormSubmittable } from './cell/SettingsForm'; -import { SettingsGroup } from './cell/SettingsGroup'; -import { SettingsRadioGroup } from './cell/SettingsRadioGroup'; -import { SettingsRow } from './cell/SettingsRow'; -import { SettingsSelect, SettingsSelectItem } from './cell/SettingsSelect'; -import { SettingsNumberInput, SettingsTextInput } from './cell/SettingsTextInput'; +import { SettingsForm } from './cell/SettingsForm'; import { BackAction } from './KeyboardNavigation'; import { Layout, SettingsContainer } from './Layout'; import { ModalAlert, ModalAlertType } from './Modal'; import { NavigationBar, NavigationContainer, NavigationItems, TitleBarItem } from './NavigationBar'; +import { NamedProxyForm } from './ProxyForm'; import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader'; import { StyledContent, StyledNavigationScrollbars, StyledSettingsContent } from './SettingsStyles'; -import { SmallButton, SmallButtonGroup } from './SmallButton'; +import { SmallButton } from './SmallButton'; export function EditApiAccessMethod() { return ( @@ -45,7 +34,7 @@ export function EditApiAccessMethod() { function AccessMethodForm() { const history = useHistory(); const { addApiAccessMethod, updateApiAccessMethod } = useAppContext(); - const methods = useSelector((state) => state.settings.apiAccessMethods); + const methods = useSelector((state) => state.settings.apiAccessMethods.custom); const [testing, testResult, testApiAccessMethod, resetTestResult] = useApiAccessMethodTest( false, @@ -55,19 +44,9 @@ function AccessMethodForm() { // Use id in url to figure out which method is to be edited. undefined means this is a new method. const { id } = useParams<{ id: string | undefined }>(); - // Ugly way of iterating over all access methods, but it works. - const method = [methods.direct, methods.mullvadBridges, ...methods.custom].find( - (method) => method.id === id, - ); - - const updatedMethod = useRef<NewAccessMethodSetting | undefined>(method); - const updateMethod = useCallback( - (method: NewAccessMethodSetting) => (updatedMethod.current = method), - [], - ); + const method = methods.find((method) => method.id === id); - // Contains form submittability to know whether or not to enable the Add/Save button. - const formSubmittable = useSettingsFormSubmittable(); + const updatedMethod = useRef<NewAccessMethodSetting<CustomProxy> | undefined>(method); const save = useCallback(() => { if (updatedMethod.current !== undefined) { @@ -81,15 +60,20 @@ function AccessMethodForm() { } }, [updatedMethod.current, id]); - const onSave = useCallback(async () => { - if ( - updatedMethod.current !== undefined && - (await testApiAccessMethod(updatedMethod.current as CustomProxy)) - ) { - // Hide the save dialog after 1.5 seconds. - saveScheduler.schedule(save, 1500); - } - }, [updatedMethod, save, history.pop]); + const onSave = useCallback( + async (newMethod: NamedCustomProxy) => { + const enabled = id === undefined ? true : method?.enabled ?? true; + updatedMethod.current = { ...newMethod, enabled }; + if ( + updatedMethod.current !== undefined && + (await testApiAccessMethod(updatedMethod.current as CustomProxy)) + ) { + // Hide the save dialog after 1.5 seconds. + saveScheduler.schedule(save, 1500); + } + }, + [updatedMethod, save, history.pop], + ); const title = getTitle(id === undefined); const subtitle = getSubtitle(id === undefined); @@ -116,15 +100,8 @@ function AccessMethodForm() { {id !== undefined && method === undefined ? ( <span>Failed to open method</span> ) : ( - <AccessMethodFormImpl method={method} updateMethod={updateMethod} /> + <NamedProxyForm proxy={method} onSave={onSave} onCancel={history.pop} /> )} - - <SmallButtonGroup> - <SmallButton onClick={history.pop}>{messages.gettext('Cancel')}</SmallButton> - <SmallButton onClick={onSave} disabled={!formSubmittable}> - {id === undefined ? messages.gettext('Add') : messages.gettext('Save')} - </SmallButton> - </SmallButtonGroup> </StyledSettingsContent> <TestingDialog @@ -257,387 +234,3 @@ function getTestingDialogButtons(type: ModalAlertType, save: () => void, cancel: return [cancelButton]; } } - -interface EditApiAccessMethodImplProps { - method?: AccessMethodSetting; - updateMethod: (method: NewAccessMethodSetting) => void; -} - -function AccessMethodFormImpl(props: EditApiAccessMethodImplProps) { - // Available method types. - const types = useMemo<Array<SettingsSelectItem<AccessMethod['type']>>>( - () => [ - { value: 'shadowsocks', label: 'Shadowsocks' }, - { - value: 'socks5-remote', - label: messages.pgettext('api-access-methods-view', 'SOCKS5 remote'), - }, - { - value: 'socks5-local', - label: messages.pgettext('api-access-methods-view', 'SOCKS5 local'), - }, - ], - [], - ); - const [type, setType] = useState(props.method?.type ?? 'shadowsocks'); - - // State for the name input. - const name = useRef(props.method?.name ?? ''); - const method = useRef<AccessMethod | undefined>(props.method); - - // When the form makes up a valid method the parent is updated. - const onUpdate = useCallback(() => { - if (method.current !== undefined && name.current !== '') { - props.updateMethod({ ...method.current, name: name.current, enabled: true }); - } - }, []); - - const updateName = useCallback( - (value: string) => { - name.current = value; - onUpdate(); - }, - [onUpdate], - ); - - const updateMethod = useCallback( - (value: AccessMethod) => { - method.current = value; - onUpdate(); - }, - [onUpdate], - ); - - return ( - <> - <SettingsRow label={messages.gettext('Name')}> - <SettingsTextInput - defaultValue={name.current} - placeholder={messages.pgettext('api-access-methods-view', 'Enter name')} - onUpdate={updateName} - /> - </SettingsRow> - - <SettingsRow label={messages.gettext('Type')}> - <SettingsSelect defaultValue={type} onUpdate={setType} items={types} /> - </SettingsRow> - - {type === 'shadowsocks' && ( - <EditShadowsocks - onUpdate={updateMethod} - method={props.method?.type === 'shadowsocks' ? props.method : undefined} - /> - )} - {type === 'socks5-remote' && ( - <EditSocks5Remote - onUpdate={updateMethod} - method={props.method?.type === 'socks5-remote' ? props.method : undefined} - /> - )} - {type === 'socks5-local' && ( - <EditSocks5Local - onUpdate={updateMethod} - method={props.method?.type === 'socks5-local' ? props.method : undefined} - /> - )} - </> - ); -} - -interface EditMethodProps<T> { - method?: T; - onUpdate: (method: AccessMethod) => void; -} - -function EditShadowsocks(props: EditMethodProps<ShadowsocksAccessMethod>) { - const [ip, setIp] = useState(props.method?.ip ?? ''); - const [port, setPort] = useState(props.method?.port); - const [password, setPassword] = useState(props.method?.password ?? ''); - const [cipher, setCipher] = useState(props.method?.cipher); - - const ciphers = useMemo( - () => - [ - { value: 'aes-128-cfb', label: 'aes-128-cfb' }, - { value: 'aes-128-cfb1', label: 'aes-128-cfb1' }, - { value: 'aes-128-cfb8', label: 'aes-128-cfb8' }, - { value: 'aes-128-cfb128', label: 'aes-128-cfb128' }, - { value: 'aes-256-cfb', label: 'aes-256-cfb' }, - { value: 'aes-256-cfb1', label: 'aes-256-cfb1' }, - { value: 'aes-256-cfb8', label: 'aes-256-cfb8' }, - { value: 'aes-256-cfb128', label: 'aes-256-cfb128' }, - { value: 'rc4', label: 'rc4' }, - { value: 'rc4-md5', label: 'rc4-md5' }, - { value: 'chacha20', label: 'chacha20' }, - { value: 'salsa20', label: 'salsa20' }, - { value: 'chacha20-ietf', label: 'chacha20-ietf' }, - { value: 'aes-128-gcm', label: 'aes-128-gcm' }, - { value: 'aes-256-gcm', label: 'aes-256-gcm' }, - { value: 'chacha20-ietf-poly1305', label: 'chacha20-ietf-poly1305' }, - { value: 'xchacha20-ietf-poly1305', label: 'xchacha20-ietf-poly1305' }, - { value: 'aes-128-pmac-siv', label: 'aes-128-pmac-siv' }, - { value: 'aes-256-pmac-siv', label: 'aes-256-pmac-siv' }, - ].sort((a, b) => a.label.localeCompare(b.label)), - [], - ); - - // Report back to form component with the method values when all required values are set. - useEffect(() => { - if (ip !== '' && port !== undefined && cipher !== undefined) { - props.onUpdate({ - type: 'shadowsocks', - ip, - port, - password, - cipher, - }); - } - }, [ip, port, password, cipher]); - - return ( - <SettingsGroup title={messages.pgettext('api-access-methods-view', 'Server details')}> - <SettingsRow - label={messages.pgettext('api-access-methods-view', 'Server')} - errorMessage={messages.pgettext( - 'api-access-methods-view', - 'Please enter a valid IPv4 or IPv6 address.', - )}> - <SettingsTextInput - value={ip} - placeholder={messages.pgettext('api-access-methods-view', 'Enter IP')} - onUpdate={setIp} - validate={validateIp} - /> - </SettingsRow> - - <SettingsRow - label={messages.gettext('Port')} - errorMessage={messages.pgettext( - 'api-access-methods-view', - 'Please enter a valid remote server port.', - )}> - <SettingsNumberInput - value={port ?? ''} - placeholder={messages.pgettext('api-access-methods-view', 'Enter port')} - onUpdate={setPort} - validate={validatePort} - /> - </SettingsRow> - - <SettingsRow label={messages.gettext('Password')}> - <SettingsTextInput - value={password} - placeholder={messages.gettext('Optional')} - onUpdate={setPassword} - optionalInForm - /> - </SettingsRow> - - <SettingsRow label={messages.gettext('Cipher')}> - <SettingsSelect - data-testid="ciphers" - direction="up" - defaultValue={cipher} - onUpdate={setCipher} - items={ciphers} - /> - </SettingsRow> - </SettingsGroup> - ); -} - -function EditSocks5Remote(props: EditMethodProps<Socks5RemoteAccessMethod>) { - const [ip, setIp] = useState(props.method?.ip ?? ''); - const [port, setPort] = useState(props.method?.port); - const [authentication, setAuthentication] = useState(props.method?.authentication !== undefined); - const [username, setUsername] = useState(props.method?.authentication?.username ?? ''); - const [password, setPassword] = useState(props.method?.authentication?.password ?? ''); - - // Report back to form component with the method values when all required values are set. - useEffect(() => { - if ( - ip !== '' && - port !== undefined && - (!authentication || (username !== '' && password !== '')) - ) { - props.onUpdate({ - type: 'socks5-remote', - ip, - port, - authentication: authentication ? { username, password } : undefined, - }); - } - }, [ip, port, username, password]); - - return ( - <SettingsGroup title={messages.pgettext('api-access-methods-view', 'Remote Server')}> - <SettingsRow - label={messages.pgettext('api-access-methods-view', 'Server')} - errorMessage={messages.pgettext( - 'api-access-methods-view', - 'Please enter a valid IPv4 or IPv6 address.', - )}> - <SettingsTextInput - value={ip} - placeholder={messages.pgettext('api-access-methods-view', 'Enter IP')} - onUpdate={setIp} - validate={validateIp} - /> - </SettingsRow> - - <SettingsRow - label={messages.gettext('Port')} - errorMessage={messages.pgettext( - 'api-access-methods-view', - 'Please enter a valid remote server port.', - )}> - <SettingsNumberInput - value={port ?? ''} - placeholder={messages.pgettext('api-access-methods-view', 'Enter port')} - onUpdate={setPort} - validate={validatePort} - /> - </SettingsRow> - - <SettingsRow label={messages.pgettext('api-access-methods-view', 'Authentication')}> - <Cell.Switch isOn={authentication} onChange={setAuthentication} /> - </SettingsRow> - - {authentication && ( - <> - <SettingsRow label={messages.gettext('Username')}> - <SettingsTextInput - value={username} - placeholder={messages.gettext('Required')} - onUpdate={setUsername} - /> - </SettingsRow> - - <SettingsRow label={messages.gettext('Password')}> - <SettingsTextInput - value={password} - placeholder={messages.gettext('Required')} - onUpdate={setPassword} - /> - </SettingsRow> - </> - )} - </SettingsGroup> - ); -} - -function EditSocks5Local(props: EditMethodProps<Socks5LocalAccessMethod>) { - const [remoteIp, setRemoteIp] = useState(props.method?.remoteIp ?? ''); - const [remotePort, setRemotePort] = useState(props.method?.remotePort); - const [remoteTransportProtocol, setRemoteTransportProtocol] = useState<RelayProtocol>( - props.method?.remoteTransportProtocol ?? 'tcp', - ); - const [localPort, setLocalPort] = useState(props.method?.localPort); - - const remoteTransportProtocols = useMemo<Array<SettingsSelectItem<RelayProtocol>>>( - () => [ - { value: 'tcp', label: 'TCP' }, - { value: 'udp', label: 'UDP' }, - ], - [], - ); - - useEffect(() => { - if (remoteIp !== '' && remotePort !== undefined && localPort !== undefined) { - props.onUpdate({ - type: 'socks5-local', - remoteIp, - remotePort, - remoteTransportProtocol, - localPort, - }); - } - }, [remoteIp, remotePort, localPort, remoteTransportProtocol]); - - return ( - <> - <SettingsGroup - title={messages.pgettext('api-access-methods-view', 'Local SOCKS5 server')} - infoMessage={messages.pgettext( - 'api-access-methods-view', - 'The TCP port where your local SOCKS5 server is listening.', - )}> - <SettingsRow - label={messages.gettext('Port')} - errorMessage={messages.pgettext( - 'api-access-methods-view', - 'Please enter a valid localhost port.', - )}> - <SettingsNumberInput - value={localPort} - placeholder={messages.pgettext('api-access-methods-view', 'Enter port')} - onUpdate={setLocalPort} - validate={validatePort} - /> - </SettingsRow> - </SettingsGroup> - - <SettingsGroup - title={messages.pgettext('api-access-methods-view', 'Remote Server')} - infoMessage={[ - messages.pgettext( - 'api-access-methods-view', - 'The app needs the remote server details, where your local SOCKS5 server will forward your traffic.', - ), - messages.pgettext( - 'api-access-methods-view', - 'This is needed so our app can allow that traffic in the firewall.', - ), - ]}> - <SettingsRow - label={messages.pgettext('api-access-methods-view', 'Server')} - errorMessage={messages.pgettext( - 'api-access-methods-view', - 'Please enter a valid IPv4 or IPv6 address.', - )}> - <SettingsTextInput - value={remoteIp} - placeholder={messages.pgettext('api-access-methods-view', 'Enter IP')} - onUpdate={setRemoteIp} - validate={validateIp} - /> - </SettingsRow> - - <SettingsRow - label={messages.gettext('Port')} - errorMessage={messages.pgettext( - 'api-access-methods-view', - 'Please enter a valid remote server port.', - )}> - <SettingsNumberInput - value={remotePort ?? ''} - placeholder={messages.pgettext('api-access-methods-view', 'Enter port')} - onUpdate={setRemotePort} - validate={validatePort} - /> - </SettingsRow> - - <SettingsRow label={messages.pgettext('api-access-methods-view', 'Transport protocol')}> - <SettingsRadioGroup<'tcp' | 'udp'> - defaultValue={remoteTransportProtocol} - onUpdate={setRemoteTransportProtocol} - items={remoteTransportProtocols} - /> - </SettingsRow> - </SettingsGroup> - </> - ); -} - -function validateIp(ip: string): boolean { - try { - void IpAddress.fromString(ip); - return true; - } catch { - return false; - } -} - -function validatePort(port: number): boolean { - return port > 0 && port <= 65535; -} diff --git a/gui/src/renderer/components/ProxyForm.tsx b/gui/src/renderer/components/ProxyForm.tsx new file mode 100644 index 0000000000..5fbcf34d0f --- /dev/null +++ b/gui/src/renderer/components/ProxyForm.tsx @@ -0,0 +1,532 @@ +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import React from 'react'; + +import { + CustomProxy, + NamedCustomProxy, + RelayProtocol, + ShadowsocksCustomProxy, + Socks5LocalCustomProxy, + Socks5RemoteCustomProxy, +} from '../../shared/daemon-rpc-types'; +import { messages } from '../../shared/gettext'; +import { IpAddress } from '../lib/ip'; +import * as Cell from './cell'; +import { SettingsForm, useSettingsFormSubmittable } from './cell/SettingsForm'; +import { SettingsGroup } from './cell/SettingsGroup'; +import { SettingsRadioGroup } from './cell/SettingsRadioGroup'; +import { SettingsRow } from './cell/SettingsRow'; +import { SettingsSelect, SettingsSelectItem } from './cell/SettingsSelect'; +import { SettingsNumberInput, SettingsTextInput } from './cell/SettingsTextInput'; +import { + SmallButton, + SmallButtonColor, + SmallButtonGroup, + SmallButtonGroupStart, +} from './SmallButton'; + +interface ProxyFormContext { + proxy?: CustomProxy; + setProxy: (proxy: CustomProxy) => void; + onSave: () => void; + onCancel: () => void; + onDelete?: () => void; +} + +const proxyFormContext = React.createContext<ProxyFormContext>({ + get proxy(): CustomProxy { + throw new Error('Missing ProxyFromContext provider'); + }, + setProxy(): void { + throw new Error('Missing ProxyFromContext provider'); + }, + onSave(): void { + throw new Error('Missing ProxyFromContext provider'); + }, + onCancel(): void { + throw new Error('Missing ProxyFromContext provider'); + }, + onDelete(): void { + throw new Error('Missing ProxyFromContext provider'); + }, +}); + +interface ProxyFormContextProviderProps { + proxy?: CustomProxy; + onSave: (proxy: CustomProxy) => void; + onCancel: () => void; + onDelete?: () => void; +} + +function ProxyFormContextProvider(props: React.PropsWithChildren<ProxyFormContextProviderProps>) { + const [proxy, setProxy] = useState<CustomProxy | undefined>(props.proxy); + + const onSave = useCallback(() => { + if (proxy !== undefined) { + props.onSave(proxy); + } + }, [proxy, props.onSave]); + + const value = useMemo( + () => ({ proxy, setProxy, onSave, onCancel: props.onCancel, onDelete: props.onDelete }), + [proxy, onSave, props.onCancel, props.onDelete], + ); + + return <proxyFormContext.Provider value={value}>{props.children}</proxyFormContext.Provider>; +} + +export function ProxyForm(props: ProxyFormContextProviderProps) { + return ( + <ProxyFormContextProvider {...props}> + <SettingsForm> + <ProxyFormInner /> + <ProxyFormButtons /> + </SettingsForm> + </ProxyFormContextProvider> + ); +} + +interface NamedProxyFormContext { + name?: string; + setName: (name: string) => void; +} + +const namedProxyFormContext = React.createContext<NamedProxyFormContext>({ + get name(): string { + throw new Error('Missing NamedProxyFromContext provider'); + }, + setName(): void { + throw new Error('Missing NamedProxyFromContext provider'); + }, +}); + +interface NamedProxyFormContainerProps + extends Omit<ProxyFormContextProviderProps, 'proxy' | 'onSave'> { + proxy?: NamedCustomProxy; + onSave: (proxy: NamedCustomProxy) => void; +} + +export function NamedProxyForm(props: NamedProxyFormContainerProps) { + const { onSave, ...otherProps } = props; + + const [name, setName] = useState<string>(props.proxy?.name ?? ''); + + const save = useCallback( + (proxy: CustomProxy) => { + if (name !== '') { + onSave({ ...proxy, name }); + } + }, + [name, onSave], + ); + + const nameContextValue = useMemo(() => ({ name, setName }), [name]); + + return ( + <namedProxyFormContext.Provider value={nameContextValue}> + <ProxyFormContextProvider {...otherProps} onSave={save}> + <SettingsForm> + <ProxyFormNameField /> + <ProxyFormInner /> + <ProxyFormButtons /> + </SettingsForm> + </ProxyFormContextProvider> + </namedProxyFormContext.Provider> + ); +} + +function ProxyFormNameField() { + const { name, setName } = useContext(namedProxyFormContext); + + return ( + <SettingsRow label={messages.gettext('Name')}> + <SettingsTextInput + defaultValue={name} + placeholder={messages.pgettext('api-access-methods-view', 'Enter name')} + onUpdate={setName} + /> + </SettingsRow> + ); +} + +export function ProxyFormButtons() { + const { proxy, onSave, onCancel, onDelete } = useContext(proxyFormContext); + + // Contains form submittability to know whether or not to enable the Add/Save button. + const formSubmittable = useSettingsFormSubmittable(); + + return ( + <SmallButtonGroup> + {onDelete !== undefined && ( + <SmallButtonGroupStart> + <SmallButton color={SmallButtonColor.red} onClick={onDelete}> + {messages.gettext('Delete')} + </SmallButton> + </SmallButtonGroupStart> + )} + <SmallButton onClick={onCancel}>{messages.gettext('Cancel')}</SmallButton> + <SmallButton onClick={onSave} disabled={!formSubmittable}> + {proxy === undefined ? messages.gettext('Add') : messages.gettext('Save')} + </SmallButton> + </SmallButtonGroup> + ); +} + +function ProxyFormInner() { + const { proxy, setProxy } = useContext(proxyFormContext); + + // Available custom proxies + const types = useMemo<Array<SettingsSelectItem<CustomProxy['type']>>>( + () => [ + { value: 'shadowsocks', label: 'Shadowsocks' }, + { + value: 'socks5-remote', + label: messages.pgettext('api-access-methods-view', 'SOCKS5 remote'), + }, + { + value: 'socks5-local', + label: messages.pgettext('api-access-methods-view', 'SOCKS5 local'), + }, + ], + [], + ); + const [type, setType] = useState(proxy?.type ?? 'shadowsocks'); + const proxyRef = useRef<CustomProxy | undefined>(proxy); + + const updateProxy = useCallback( + (value: CustomProxy) => { + proxyRef.current = value; + + // When the form makes up a valid proxy the parent is updated. + if (proxyRef.current !== undefined) { + setProxy(proxyRef.current); + } + }, + [setProxy], + ); + + return ( + <> + <SettingsRow label={messages.gettext('Type')}> + <SettingsSelect defaultValue={type} onUpdate={setType} items={types} /> + </SettingsRow> + + {type === 'shadowsocks' && ( + <EditShadowsocks + onUpdate={updateProxy} + proxy={proxy?.type === 'shadowsocks' ? proxy : undefined} + /> + )} + {type === 'socks5-remote' && ( + <EditSocks5Remote + onUpdate={updateProxy} + proxy={proxy?.type === 'socks5-remote' ? proxy : undefined} + /> + )} + {type === 'socks5-local' && ( + <EditSocks5Local + onUpdate={updateProxy} + proxy={proxy?.type === 'socks5-local' ? proxy : undefined} + /> + )} + </> + ); +} + +interface EditProxyProps<T> { + proxy?: T; + onUpdate: (proxy: CustomProxy) => void; +} + +function EditShadowsocks(props: EditProxyProps<ShadowsocksCustomProxy>) { + const [ip, setIp] = useState(props.proxy?.ip ?? ''); + const [port, setPort] = useState(props.proxy?.port); + const [password, setPassword] = useState(props.proxy?.password ?? ''); + const [cipher, setCipher] = useState(props.proxy?.cipher); + + const ciphers = useMemo( + () => + [ + { value: 'aes-128-cfb', label: 'aes-128-cfb' }, + { value: 'aes-128-cfb1', label: 'aes-128-cfb1' }, + { value: 'aes-128-cfb8', label: 'aes-128-cfb8' }, + { value: 'aes-128-cfb128', label: 'aes-128-cfb128' }, + { value: 'aes-256-cfb', label: 'aes-256-cfb' }, + { value: 'aes-256-cfb1', label: 'aes-256-cfb1' }, + { value: 'aes-256-cfb8', label: 'aes-256-cfb8' }, + { value: 'aes-256-cfb128', label: 'aes-256-cfb128' }, + { value: 'rc4', label: 'rc4' }, + { value: 'rc4-md5', label: 'rc4-md5' }, + { value: 'chacha20', label: 'chacha20' }, + { value: 'salsa20', label: 'salsa20' }, + { value: 'chacha20-ietf', label: 'chacha20-ietf' }, + { value: 'aes-128-gcm', label: 'aes-128-gcm' }, + { value: 'aes-256-gcm', label: 'aes-256-gcm' }, + { value: 'chacha20-ietf-poly1305', label: 'chacha20-ietf-poly1305' }, + { value: 'xchacha20-ietf-poly1305', label: 'xchacha20-ietf-poly1305' }, + { value: 'aes-128-pmac-siv', label: 'aes-128-pmac-siv' }, + { value: 'aes-256-pmac-siv', label: 'aes-256-pmac-siv' }, + ].sort((a, b) => a.label.localeCompare(b.label)), + [], + ); + + // Report back to form component with the proxy values when all required values are set. + useEffect(() => { + if (ip !== '' && port !== undefined && cipher !== undefined) { + props.onUpdate({ + type: 'shadowsocks', + ip, + port, + password, + cipher, + }); + } + }, [ip, port, password, cipher]); + + return ( + <SettingsGroup title={messages.pgettext('api-access-methods-view', 'Server details')}> + <SettingsRow + label={messages.pgettext('api-access-methods-view', 'Server')} + errorMessage={messages.pgettext( + 'api-access-methods-view', + 'Please enter a valid IPv4 or IPv6 address.', + )}> + <SettingsTextInput + value={ip} + placeholder={messages.pgettext('api-access-methods-view', 'Enter IP')} + onUpdate={setIp} + validate={validateIp} + /> + </SettingsRow> + + <SettingsRow + label={messages.gettext('Port')} + errorMessage={messages.pgettext( + 'api-access-methods-view', + 'Please enter a valid remote server port.', + )}> + <SettingsNumberInput + value={port ?? ''} + placeholder={messages.pgettext('api-access-methods-view', 'Enter port')} + onUpdate={setPort} + validate={validatePort} + /> + </SettingsRow> + + <SettingsRow label={messages.gettext('Password')}> + <SettingsTextInput + value={password} + placeholder={messages.gettext('Optional')} + onUpdate={setPassword} + optionalInForm + /> + </SettingsRow> + + <SettingsRow label={messages.gettext('Cipher')}> + <SettingsSelect + data-testid="ciphers" + direction="up" + defaultValue={cipher} + onUpdate={setCipher} + items={ciphers} + /> + </SettingsRow> + </SettingsGroup> + ); +} + +function EditSocks5Remote(props: EditProxyProps<Socks5RemoteCustomProxy>) { + const [ip, setIp] = useState(props.proxy?.ip ?? ''); + const [port, setPort] = useState(props.proxy?.port); + const [authentication, setAuthentication] = useState(props.proxy?.authentication !== undefined); + const [username, setUsername] = useState(props.proxy?.authentication?.username ?? ''); + const [password, setPassword] = useState(props.proxy?.authentication?.password ?? ''); + + // Report back to form component with the proxy values when all required values are set. + useEffect(() => { + if ( + ip !== '' && + port !== undefined && + (!authentication || (username !== '' && password !== '')) + ) { + props.onUpdate({ + type: 'socks5-remote', + ip, + port, + authentication: authentication ? { username, password } : undefined, + }); + } + }, [ip, port, username, password]); + + return ( + <SettingsGroup title={messages.pgettext('api-access-methods-view', 'Remote Server')}> + <SettingsRow + label={messages.pgettext('api-access-methods-view', 'Server')} + errorMessage={messages.pgettext( + 'api-access-methods-view', + 'Please enter a valid IPv4 or IPv6 address.', + )}> + <SettingsTextInput + value={ip} + placeholder={messages.pgettext('api-access-methods-view', 'Enter IP')} + onUpdate={setIp} + validate={validateIp} + /> + </SettingsRow> + + <SettingsRow + label={messages.gettext('Port')} + errorMessage={messages.pgettext( + 'api-access-methods-view', + 'Please enter a valid remote server port.', + )}> + <SettingsNumberInput + value={port ?? ''} + placeholder={messages.pgettext('api-access-methods-view', 'Enter port')} + onUpdate={setPort} + validate={validatePort} + /> + </SettingsRow> + + <SettingsRow label={messages.pgettext('api-access-methods-view', 'Authentication')}> + <Cell.Switch isOn={authentication} onChange={setAuthentication} /> + </SettingsRow> + + {authentication && ( + <> + <SettingsRow label={messages.gettext('Username')}> + <SettingsTextInput + value={username} + placeholder={messages.gettext('Required')} + onUpdate={setUsername} + /> + </SettingsRow> + + <SettingsRow label={messages.gettext('Password')}> + <SettingsTextInput + value={password} + placeholder={messages.gettext('Required')} + onUpdate={setPassword} + /> + </SettingsRow> + </> + )} + </SettingsGroup> + ); +} + +function EditSocks5Local(props: EditProxyProps<Socks5LocalCustomProxy>) { + const [remoteIp, setRemoteIp] = useState(props.proxy?.remoteIp ?? ''); + const [remotePort, setRemotePort] = useState(props.proxy?.remotePort); + const [remoteTransportProtocol, setRemoteTransportProtocol] = useState<RelayProtocol>( + props.proxy?.remoteTransportProtocol ?? 'tcp', + ); + const [localPort, setLocalPort] = useState(props.proxy?.localPort); + + const remoteTransportProtocols = useMemo<Array<SettingsSelectItem<RelayProtocol>>>( + () => [ + { value: 'tcp', label: 'TCP' }, + { value: 'udp', label: 'UDP' }, + ], + [], + ); + + useEffect(() => { + if (remoteIp !== '' && remotePort !== undefined && localPort !== undefined) { + props.onUpdate({ + type: 'socks5-local', + remoteIp, + remotePort, + remoteTransportProtocol, + localPort, + }); + } + }, [remoteIp, remotePort, localPort, remoteTransportProtocol]); + + return ( + <> + <SettingsGroup + title={messages.pgettext('api-access-methods-view', 'Local SOCKS5 server')} + infoMessage={messages.pgettext( + 'api-access-methods-view', + 'The TCP port where your local SOCKS5 server is listening.', + )}> + <SettingsRow + label={messages.gettext('Port')} + errorMessage={messages.pgettext( + 'api-access-methods-view', + 'Please enter a valid localhost port.', + )}> + <SettingsNumberInput + value={localPort} + placeholder={messages.pgettext('api-access-methods-view', 'Enter port')} + onUpdate={setLocalPort} + validate={validatePort} + /> + </SettingsRow> + </SettingsGroup> + + <SettingsGroup + title={messages.pgettext('api-access-methods-view', 'Remote Server')} + infoMessage={[ + messages.pgettext( + 'api-access-methods-view', + 'The app needs the remote server details, where your local SOCKS5 server will forward your traffic.', + ), + messages.pgettext( + 'api-access-methods-view', + 'This is needed so our app can allow that traffic in the firewall.', + ), + ]}> + <SettingsRow + label={messages.pgettext('api-access-methods-view', 'Server')} + errorMessage={messages.pgettext( + 'api-access-methods-view', + 'Please enter a valid IPv4 or IPv6 address.', + )}> + <SettingsTextInput + value={remoteIp} + placeholder={messages.pgettext('api-access-methods-view', 'Enter IP')} + onUpdate={setRemoteIp} + validate={validateIp} + /> + </SettingsRow> + + <SettingsRow + label={messages.gettext('Port')} + errorMessage={messages.pgettext( + 'api-access-methods-view', + 'Please enter a valid remote server port.', + )}> + <SettingsNumberInput + value={remotePort ?? ''} + placeholder={messages.pgettext('api-access-methods-view', 'Enter port')} + onUpdate={setRemotePort} + validate={validatePort} + /> + </SettingsRow> + + <SettingsRow label={messages.pgettext('api-access-methods-view', 'Transport protocol')}> + <SettingsRadioGroup<'tcp' | 'udp'> + defaultValue={remoteTransportProtocol} + onUpdate={setRemoteTransportProtocol} + items={remoteTransportProtocols} + /> + </SettingsRow> + </SettingsGroup> + </> + ); +} + +function validateIp(ip: string): boolean { + try { + void IpAddress.fromString(ip); + return true; + } catch { + return false; + } +} + +function validatePort(port: number): boolean { + return port > 0 && port <= 65535; +} |
