summaryrefslogtreecommitdiffhomepage
path: root/gui/src
diff options
context:
space:
mode:
Diffstat (limited to 'gui/src')
-rw-r--r--gui/src/renderer/components/ApiAccessMethods.tsx14
-rw-r--r--gui/src/renderer/components/AppRouter.tsx2
-rw-r--r--gui/src/renderer/components/EditApiAccessMethod.tsx618
-rw-r--r--gui/src/renderer/lib/routes.ts1
4 files changed, 633 insertions, 2 deletions
diff --git a/gui/src/renderer/components/ApiAccessMethods.tsx b/gui/src/renderer/components/ApiAccessMethods.tsx
index 86f9e20ade..c8a6f98b19 100644
--- a/gui/src/renderer/components/ApiAccessMethods.tsx
+++ b/gui/src/renderer/components/ApiAccessMethods.tsx
@@ -67,6 +67,16 @@ export default function ApiAccessMethods() {
const methods = useSelector((state) => state.settings.apiAccessMethods);
const currentMethod = useSelector((state) => state.settings.currentApiAccessMethod);
+ const navigateToEdit = useCallback(
+ (id?: string) => {
+ const path = generateRoutePath(RoutePath.editApiAccessMethods, { id });
+ history.push(path);
+ },
+ [history],
+ );
+
+ const navigateToNew = useCallback(() => navigateToEdit(), [navigateToEdit]);
+
return (
<BackAction action={history.pop}>
<Layout>
@@ -125,7 +135,7 @@ export default function ApiAccessMethods() {
</Cell.Group>
<SmallButtonGroup $noMarginTop>
- <SmallButton>{messages.pgettext('api-access-methods-view', 'Add')}</SmallButton>
+ <SmallButton onClick={navigateToNew}>{messages.gettext('Add')}</SmallButton>
</SmallButtonGroup>
</StyledSettingsContent>
</StyledContent>
@@ -279,7 +289,7 @@ function ApiAccessMethod(props: ApiAccessMethodProps) {
{messages.gettext('Cancel')}
</SmallButton>,
<SmallButton key="confirm" onClick={confirmRemove} color={SmallButtonColor.red}>
- {messages.pgettext('in-app-notifications', 'Delete')}
+ {messages.gettext('Delete')}
</SmallButton>,
]}
close={hideRemoveConfirmation}
diff --git a/gui/src/renderer/components/AppRouter.tsx b/gui/src/renderer/components/AppRouter.tsx
index 6d5edfb23e..6c45083290 100644
--- a/gui/src/renderer/components/AppRouter.tsx
+++ b/gui/src/renderer/components/AppRouter.tsx
@@ -11,6 +11,7 @@ import ApiAccessMethods from './ApiAccessMethods';
import Connect from './Connect';
import Debug from './Debug';
import { DeviceRevokedView } from './DeviceRevokedView';
+import { EditApiAccessMethod } from './EditApiAccessMethod';
import {
SetupFinished,
TimeAdded,
@@ -82,6 +83,7 @@ export default function AppRouter() {
<Route exact path={RoutePath.openVpnSettings} component={OpenVpnSettings} />
<Route exact path={RoutePath.splitTunneling} component={SplitTunnelingSettings} />
<Route exact path={RoutePath.apiAccessMethods} component={ApiAccessMethods} />
+ <Route exact path={RoutePath.editApiAccessMethods} component={EditApiAccessMethod} />
<Route exact path={RoutePath.support} component={Support} />
<Route exact path={RoutePath.problemReport} component={ProblemReport} />
<Route exact path={RoutePath.debug} component={Debug} />
diff --git a/gui/src/renderer/components/EditApiAccessMethod.tsx b/gui/src/renderer/components/EditApiAccessMethod.tsx
new file mode 100644
index 0000000000..578c8fe31a
--- /dev/null
+++ b/gui/src/renderer/components/EditApiAccessMethod.tsx
@@ -0,0 +1,618 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useParams } from 'react-router';
+import { sprintf } from 'sprintf-js';
+
+import {
+ AccessMethod,
+ AccessMethodSetting,
+ CustomProxy,
+ 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 { BackAction } from './KeyboardNavigation';
+import { Layout, SettingsContainer } from './Layout';
+import { ModalAlert, ModalAlertType } from './Modal';
+import { NavigationBar, NavigationContainer, NavigationItems, TitleBarItem } from './NavigationBar';
+import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader';
+import { StyledContent, StyledNavigationScrollbars, StyledSettingsContent } from './SettingsStyles';
+import { SmallButton, SmallButtonGroup } from './SmallButton';
+
+export function EditApiAccessMethod() {
+ return (
+ <SettingsForm>
+ <AccessMethodForm></AccessMethodForm>
+ </SettingsForm>
+ );
+}
+
+function AccessMethodForm() {
+ const history = useHistory();
+ const { addApiAccessMethod, updateApiAccessMethod } = useAppContext();
+ const methods = useSelector((state) => state.settings.apiAccessMethods);
+
+ const [testing, testResult, testApiAccessMethod, resetTestResult] = useApiAccessMethodTest(
+ false,
+ 500,
+ );
+ const saveScheduler = useScheduler();
+
+ // 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 }>();
+ const method = methods.find((method) => method.id === id);
+
+ const updatedMethod = useRef<NewAccessMethodSetting | undefined>(method);
+ const updateMethod = useCallback(
+ (method: NewAccessMethodSetting) => (updatedMethod.current = method),
+ [],
+ );
+
+ // Contains form submittability to know whether or not to enable the Add/Save button.
+ const formSubmittable = useSettingsFormSubmittable();
+
+ const save = useCallback(() => {
+ if (updatedMethod.current !== undefined) {
+ resetTestResult();
+ if (id === undefined) {
+ void addApiAccessMethod(updatedMethod.current);
+ } else {
+ void updateApiAccessMethod({ ...updatedMethod.current, id });
+ }
+ history.pop();
+ }
+ }, [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 title = getTitle(id !== undefined);
+ const subtitle = getSubtitle(id !== undefined);
+
+ return (
+ <BackAction action={history.pop}>
+ <Layout>
+ <SettingsContainer>
+ <NavigationContainer>
+ <NavigationBar>
+ <NavigationItems>
+ <TitleBarItem>{title}</TitleBarItem>
+ </NavigationItems>
+ </NavigationBar>
+
+ <StyledNavigationScrollbars fillContainer>
+ <StyledContent>
+ <SettingsHeader>
+ <HeaderTitle>{title}</HeaderTitle>
+ <HeaderSubTitle>{subtitle}</HeaderSubTitle>
+ </SettingsHeader>
+
+ <StyledSettingsContent>
+ {id !== undefined && method === undefined ? (
+ <span>Failed to open method</span>
+ ) : (
+ <AccessMethodFormImpl method={method} updateMethod={updateMethod} />
+ )}
+
+ <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
+ name={updatedMethod.current?.name ?? ''}
+ newMethod={id === undefined}
+ testing={testing}
+ testResult={testResult}
+ cancel={resetTestResult}
+ save={save}
+ />
+ </StyledContent>
+ </StyledNavigationScrollbars>
+ </NavigationContainer>
+ </SettingsContainer>
+ </Layout>
+ </BackAction>
+ );
+}
+
+function getTitle(isNewMethod: boolean) {
+ return isNewMethod
+ ? messages.pgettext('api-access-methods-view', 'Add method')
+ : messages.pgettext('api-access-methods-view', 'Edit method');
+}
+
+function getSubtitle(isNewMethod: boolean) {
+ return isNewMethod
+ ? messages.pgettext('api-access-methods-view', 'Adding a new API access method also tests it.')
+ : messages.pgettext('api-access-methods-view', 'Editing an API access method also tests it.');
+}
+
+interface TestingDialogProps {
+ name: string;
+ newMethod: boolean;
+ testing: boolean;
+ testResult?: boolean;
+ cancel: () => void;
+ save: () => void;
+}
+
+function TestingDialog(props: TestingDialogProps) {
+ const type = props.testing
+ ? ModalAlertType.loading
+ : props.testResult
+ ? ModalAlertType.success
+ : ModalAlertType.failure;
+ const prevType = useRef<ModalAlertType>(type);
+
+ const isOpen = props.testing || props.testResult !== undefined;
+ const typeValue = isOpen ? type : prevType.current;
+
+ useEffect(() => {
+ if (isOpen) {
+ prevType.current = type;
+ }
+ }, [type]);
+
+ return (
+ <ModalAlert
+ isOpen={isOpen}
+ type={typeValue}
+ gridButtons={getTestingDialogButtons(typeValue, props.save, props.cancel)}
+ close={props.cancel}
+ title={getTestingDialogTitle(typeValue, props.newMethod)}
+ message={getTestingDialogSubTitle(typeValue, props.newMethod, props.name)}
+ />
+ );
+}
+
+function getTestingDialogTitle(type: ModalAlertType, newMethod: boolean) {
+ switch (type) {
+ case ModalAlertType.success:
+ return newMethod
+ ? messages.pgettext('api-access-methods-view', 'API reachable, adding method…')
+ : messages.pgettext('api-access-methods-view', 'API reachable, saving method…');
+ case ModalAlertType.failure:
+ return newMethod
+ ? messages.pgettext('api-access-methods-view', 'API unreachable, add anyway?')
+ : messages.pgettext('api-access-methods-view', 'API unreachable, save anyway?');
+ default:
+ case ModalAlertType.loading:
+ return messages.pgettext('api-access-methods-view', 'Testing method...');
+ }
+}
+
+function getTestingDialogSubTitle(type: ModalAlertType, newMethod: boolean, name: string) {
+ switch (type) {
+ case ModalAlertType.failure:
+ return newMethod
+ ? sprintf(
+ messages.pgettext(
+ 'api-access-methods-view',
+ 'The API could not be reached using the %(name)s method.',
+ ),
+ { name },
+ )
+ : messages.pgettext(
+ 'api-access-methods-view',
+ 'Clicking “Save” changes the in use method.',
+ );
+ default:
+ return undefined;
+ }
+}
+
+function getTestingDialogButtons(type: ModalAlertType, save: () => void, cancel: () => void) {
+ const saveButton = (
+ <SmallButton key="confirm" onClick={save}>
+ {messages.gettext('Save')}
+ </SmallButton>
+ );
+ const cancelButton = (
+ <SmallButton key="cancel" onClick={cancel}>
+ {messages.gettext('Cancel')}
+ </SmallButton>
+ );
+ const disabledCancelButton = (
+ <SmallButton key="cancel" onClick={cancel} disabled>
+ {messages.gettext('Cancel')}
+ </SmallButton>
+ );
+
+ switch (type) {
+ case ModalAlertType.success:
+ return [disabledCancelButton];
+ case ModalAlertType.failure:
+ return [cancelButton, saveButton];
+ case ModalAlertType.loading:
+ default:
+ 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 updateName = useCallback((value: string) => (name.current = value), []);
+
+ // When the form makes up a valid method the parent is updated.
+ const updateMethod = useCallback((value: AccessMethod) => {
+ if (name.current !== '') {
+ props.updateMethod({ ...value, name: name.current, enabled: true });
+ }
+ }, []);
+
+ 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 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/lib/routes.ts b/gui/src/renderer/lib/routes.ts
index 86c0fb4b4f..a7cf89d9ce 100644
--- a/gui/src/renderer/lib/routes.ts
+++ b/gui/src/renderer/lib/routes.ts
@@ -18,6 +18,7 @@ export enum RoutePath {
openVpnSettings = '/settings/advanced/openvpn',
splitTunneling = '/settings/split-tunneling',
apiAccessMethods = '/settings/api-access-methods',
+ editApiAccessMethods = '/settings/api-access-methods/edit/:id?',
support = '/settings/support',
problemReport = '/settings/support/problem-report',
debug = '/settings/debug',