diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2024-01-24 12:21:20 +0100 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2024-01-29 09:33:49 +0100 |
| commit | 3078fd30c1d8c9f3f86d9ddcfeddea3c83718f5b (patch) | |
| tree | 9a89e653dfc4e62a9eba36ed9a84c453ed94b5c2 /gui/src | |
| parent | be5c4892cbc6242140ebefddc8445cc76e57899e (diff) | |
| download | mullvadvpn-3078fd30c1d8c9f3f86d9ddcfeddea3c83718f5b.tar.xz mullvadvpn-3078fd30c1d8c9f3f86d9ddcfeddea3c83718f5b.zip | |
Add API access methods view
Diffstat (limited to 'gui/src')
| -rw-r--r-- | gui/src/config.json | 1 | ||||
| -rw-r--r-- | gui/src/renderer/components/ApiAccessMethods.tsx | 316 | ||||
| -rw-r--r-- | gui/src/renderer/components/AppRouter.tsx | 2 | ||||
| -rw-r--r-- | gui/src/renderer/components/Settings.tsx | 20 | ||||
| -rw-r--r-- | gui/src/renderer/lib/api-access-methods.ts | 81 | ||||
| -rw-r--r-- | gui/src/renderer/lib/routes.ts | 1 | ||||
| -rw-r--r-- | gui/src/shared/localization-contexts.ts | 1 |
7 files changed, 422 insertions, 0 deletions
diff --git a/gui/src/config.json b/gui/src/config.json index 783be371bc..22be1956af 100644 --- a/gui/src/config.json +++ b/gui/src/config.json @@ -24,6 +24,7 @@ "blue10": "rgba(41, 77, 115, 0.1)", "blue20": "rgba(41, 77, 115, 0.2)", "blue40": "rgba(41, 77, 115, 0.4)", + "blue50": "rgba(41, 77, 115, 0.5)", "blue60": "rgba(41, 77, 115, 0.6)", "blue80": "rgba(41, 77, 115, 0.8)", "red95": "rgba(227, 64, 57, 0.95)", diff --git a/gui/src/renderer/components/ApiAccessMethods.tsx b/gui/src/renderer/components/ApiAccessMethods.tsx new file mode 100644 index 0000000000..86f9e20ade --- /dev/null +++ b/gui/src/renderer/components/ApiAccessMethods.tsx @@ -0,0 +1,316 @@ +import { useCallback, useMemo } from 'react'; +import { sprintf } from 'sprintf-js'; +import styled from 'styled-components'; + +import { colors } from '../../config.json'; +import { AccessMethodSetting } from '../../shared/daemon-rpc-types'; +import { messages } from '../../shared/gettext'; +import { useAppContext } from '../context'; +import { useApiAccessMethodTest } from '../lib/api-access-methods'; +import { useHistory } from '../lib/history'; +import { generateRoutePath } from '../lib/routeHelpers'; +import { RoutePath } from '../lib/routes'; +import { useBoolean } from '../lib/utilityHooks'; +import { useSelector } from '../redux/store'; +import * as Cell from './cell'; +import { + ContextMenu, + ContextMenuContainer, + ContextMenuItem, + ContextMenuTrigger, +} from './ContextMenu'; +import ImageView from './ImageView'; +import InfoButton from './InfoButton'; +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, SmallButtonColor, SmallButtonGroup } from './SmallButton'; + +const StyledContextMenuButton = styled(Cell.Icon)({ + marginRight: '8px', +}); + +const StyledTitleInfoButton = styled(InfoButton)({ + marginLeft: '12px', +}); + +const StyledMethodInfoButton = styled(InfoButton)({ + marginRight: '11px', +}); + +const StyledSpinner = styled(ImageView)({ + height: '10px', + width: '10px', + marginRight: '6px', +}); + +const StyledNameLabel = styled(Cell.Label)({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}); + +const StyledTestResultCircle = styled.div<{ $result: boolean }>((props) => ({ + width: '10px', + height: '10px', + borderRadius: '50%', + backgroundColor: props.$result ? colors.green : colors.red, + marginRight: '6px', +})); + +// This component is the topmost component in the API access methods view. +export default function ApiAccessMethods() { + const history = useHistory(); + const methods = useSelector((state) => state.settings.apiAccessMethods); + const currentMethod = useSelector((state) => state.settings.currentApiAccessMethod); + + return ( + <BackAction action={history.pop}> + <Layout> + <SettingsContainer> + <NavigationContainer> + <NavigationBar> + <NavigationItems> + <TitleBarItem> + { + // TRANSLATORS: Title label in navigation bar + messages.pgettext('navigation-bar', 'API access') + } + </TitleBarItem> + </NavigationItems> + </NavigationBar> + + <StyledNavigationScrollbars fillContainer> + <StyledContent> + <SettingsHeader> + <HeaderTitle> + {messages.pgettext('navigation-bar', 'API access')} + <StyledTitleInfoButton + message={[ + messages.pgettext( + 'api-access-methods-view', + 'The app needs to communicate with a Mullvad API server to log you in, fetch server lists, and other critical operations.', + ), + messages.pgettext( + 'api-access-methods-view', + 'On some networks, where various types of censorship are being used, the API servers might not be directly reachable.', + ), + messages.pgettext( + 'api-access-methods-view', + 'This feature allows you to circumvent that censorship by adding custom ways to access the API via proxies and similar methods.', + ), + ]} + /> + </HeaderTitle> + <HeaderSubTitle> + {messages.pgettext( + 'api-access-methods-view', + 'Manage and add custom methods to access the Mullvad API.', + )} + </HeaderSubTitle> + </SettingsHeader> + + <StyledSettingsContent> + <Cell.Group> + {methods.map((method) => ( + <ApiAccessMethod + key={method.id} + method={method} + inUse={method.id === currentMethod?.id} + /> + ))} + </Cell.Group> + + <SmallButtonGroup $noMarginTop> + <SmallButton>{messages.pgettext('api-access-methods-view', 'Add')}</SmallButton> + </SmallButtonGroup> + </StyledSettingsContent> + </StyledContent> + </StyledNavigationScrollbars> + </NavigationContainer> + </SettingsContainer> + </Layout> + </BackAction> + ); +} + +interface ApiAccessMethodProps { + method: AccessMethodSetting; + inUse: boolean; +} + +function ApiAccessMethod(props: ApiAccessMethodProps) { + const { + setApiAccessMethod: setApiAccessMethodImpl, + updateApiAccessMethod, + removeApiAccessMethod, + } = useAppContext(); + const history = useHistory(); + + const [testing, testResult, testApiAccessMethod] = useApiAccessMethodTest(); + + // State for delete confirmation dialog. + const [removeConfirmationVisible, showRemoveConfirmation, hideRemoveConfirmation] = useBoolean(); + const confirmRemove = useCallback(() => { + void removeApiAccessMethod(props.method.id); + hideRemoveConfirmation(); + }, [props.method.id]); + + // Toggle on/off on an access method. + const toggle = useCallback( + async (value: boolean) => { + const updatedMethod = cloneMethod(props.method); + updatedMethod.enabled = value; + await updateApiAccessMethod(updatedMethod); + }, + [props.method], + ); + + const setApiAccessMethod = useCallback(async () => { + const reachable = await testApiAccessMethod(props.method.id); + if (reachable) { + await setApiAccessMethodImpl(props.method.id); + } + }, [testApiAccessMethod, props.method.id]); + + const menuItems = useMemo<Array<ContextMenuItem>>( + () => [ + { + type: 'item' as const, + label: 'Use', + disabled: props.inUse, + onClick: setApiAccessMethod, + }, + { type: 'item' as const, label: 'Test', onClick: () => testApiAccessMethod(props.method.id) }, + // Edit and Delete shouldn't be available for direct and bridges. + ...(props.method.type === 'direct' || props.method.type === 'bridges' + ? [] + : [ + { type: 'separator' as const }, + { + type: 'item' as const, + label: 'Edit', + onClick: () => + history.push( + generateRoutePath(RoutePath.editApiAccessMethods, { id: props.method.id }), + ), + }, + { + type: 'item' as const, + label: 'Delete', + onClick: showRemoveConfirmation, + }, + ]), + ], + [props.method.id, props.inUse, setApiAccessMethod, testApiAccessMethod, history.push], + ); + + return ( + <Cell.Row> + <Cell.LabelContainer> + <StyledNameLabel>{props.method.name}</StyledNameLabel> + {testing && ( + <Cell.SubLabel> + <StyledSpinner source="icon-spinner" /> + {messages.pgettext('api-access-methods-view', 'Testing...')} + </Cell.SubLabel> + )} + {!testing && testResult !== undefined && ( + <Cell.SubLabel> + <StyledTestResultCircle $result={testResult} /> + {testResult + ? messages.pgettext('api-access-methods-view', 'API reachable') + : messages.pgettext('api-access-methods-view', 'API unreachable')} + </Cell.SubLabel> + )} + {!testing && testResult === undefined && props.inUse && ( + <Cell.SubLabel>{messages.pgettext('api-access-methods-view', 'In use')}</Cell.SubLabel> + )} + </Cell.LabelContainer> + {props.method.type === 'direct' && ( + <StyledMethodInfoButton + message={[ + messages.pgettext( + 'api-access-methods-view', + 'With the “Direct” method, the app communicates with a Mullvad API server directly without any intermediate proxies.', + ), + messages.pgettext( + 'api-access-methods-view', + 'This can be useful when you are not affected by censorship.', + ), + ]} + /> + )} + {props.method.type === 'bridges' && ( + <StyledMethodInfoButton + message={[ + messages.pgettext( + 'api-access-methods-view', + 'With the “Mullvad bridges” method, the app communicates with a Mullvad API server via a Mullvad bridge server. It does this by sending the traffic obfuscated by Shadowsocks.', + ), + messages.pgettext( + 'api-access-methods-view', + 'This can be useful if the API is censored but Mullvad’s bridge servers are not.', + ), + ]} + /> + )} + <ContextMenuContainer> + <ContextMenuTrigger> + <StyledContextMenuButton + source="icon-more" + tintColor={colors.white} + tintHoverColor={colors.white80} + /> + </ContextMenuTrigger> + <ContextMenu items={menuItems} align="right" /> + </ContextMenuContainer> + <Cell.Switch isOn={props.method.enabled} onChange={toggle} /> + + {/* Confirmation dialog for method removal */} + <ModalAlert + isOpen={removeConfirmationVisible} + type={ModalAlertType.warning} + gridButtons={[ + <SmallButton key="cancel" onClick={hideRemoveConfirmation}> + {messages.gettext('Cancel')} + </SmallButton>, + <SmallButton key="confirm" onClick={confirmRemove} color={SmallButtonColor.red}> + {messages.pgettext('in-app-notifications', 'Delete')} + </SmallButton>, + ]} + close={hideRemoveConfirmation} + title={sprintf(messages.pgettext('api-access-methods-view', 'Delete %(name)s?'), { + name: props.method.name, + })} + message={ + props.inUse + ? messages.pgettext( + 'api-access-methods-view', + 'The in use API access method will change.', + ) + : undefined + } + /> + </Cell.Row> + ); +} + +function cloneMethod<T extends AccessMethodSetting>(method: T): T { + const clonedMethod = { + ...method, + }; + + if ( + method.type === 'socks5-remote' && + clonedMethod.type === 'socks5-remote' && + method.authentication !== undefined + ) { + clonedMethod.authentication = { ...method.authentication }; + } + + return clonedMethod; +} diff --git a/gui/src/renderer/components/AppRouter.tsx b/gui/src/renderer/components/AppRouter.tsx index aa474e30d5..6d5edfb23e 100644 --- a/gui/src/renderer/components/AppRouter.tsx +++ b/gui/src/renderer/components/AppRouter.tsx @@ -7,6 +7,7 @@ import { useAppContext } from '../context'; import { ITransitionSpecification, transitions, useHistory } from '../lib/history'; import { RoutePath } from '../lib/routes'; import Account from './Account'; +import ApiAccessMethods from './ApiAccessMethods'; import Connect from './Connect'; import Debug from './Debug'; import { DeviceRevokedView } from './DeviceRevokedView'; @@ -80,6 +81,7 @@ export default function AppRouter() { <Route exact path={RoutePath.wireguardSettings} component={WireguardSettings} /> <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.support} component={Support} /> <Route exact path={RoutePath.problemReport} component={ProblemReport} /> <Route exact path={RoutePath.debug} component={Debug} /> diff --git a/gui/src/renderer/components/Settings.tsx b/gui/src/renderer/components/Settings.tsx index 779814d3f4..53dbb386ff 100644 --- a/gui/src/renderer/components/Settings.tsx +++ b/gui/src/renderer/components/Settings.tsx @@ -72,6 +72,10 @@ export default function Support() { )} <Cell.Group> + <ApiAccessMethodsButton /> + </Cell.Group> + + <Cell.Group> <SupportButton /> <AppVersionButton /> </Cell.Group> @@ -136,6 +140,22 @@ function SplitTunnelingButton() { ); } +function ApiAccessMethodsButton() { + const history = useHistory(); + const navigate = useCallback(() => history.push(RoutePath.apiAccessMethods), [history]); + + return ( + <Cell.CellNavigationButton onClick={navigate}> + <Cell.Label> + { + // TRANSLATORS: Navigation button to the 'API access methods' view + messages.pgettext('settings-view', 'API access methods') + } + </Cell.Label> + </Cell.CellNavigationButton> + ); +} + function AppVersionButton() { const appVersion = useSelector((state) => state.version.current); const consistentVersion = useSelector((state) => state.version.consistent); diff --git a/gui/src/renderer/lib/api-access-methods.ts b/gui/src/renderer/lib/api-access-methods.ts new file mode 100644 index 0000000000..fd78d1484a --- /dev/null +++ b/gui/src/renderer/lib/api-access-methods.ts @@ -0,0 +1,81 @@ +import { useCallback, useRef, useState } from 'react'; + +import { CustomProxy } from '../../shared/daemon-rpc-types'; +import { useScheduler } from '../../shared/scheduler'; +import { useAppContext } from '../context'; +import { useBoolean } from './utilityHooks'; + +export function useApiAccessMethodTest( + autoReset = true, + minDuration = 0, +): [ + boolean, + boolean | undefined, + (method: CustomProxy | string) => Promise<boolean | void>, + () => void, +] { + const { testApiAccessMethodById, testCustomApiAccessMethod } = useAppContext(); + const delayScheduler = useScheduler(); + + // Whether or not the method is currently being tested. + const [testing, setTesting, unsetTesting] = useBoolean(); + const [testResult, setTestResult] = useState<boolean>(); + // We keep the promise for the most recent test to compare it when we receive the results to know + // if it's canceled or not. + const lastTestPromise = useRef<Promise<boolean>>(); + + // A few seconds after the test has finished the result should not be displayed anymore. This + // scheduler is used to clear it. + const testResultResetScheduler = useScheduler(); + + const testApiAccessMethod = useCallback(async (method: CustomProxy | string) => { + testResultResetScheduler.cancel(); + setTestResult(undefined); + + setTesting(); + let reachable; + let testPromise; + + const submitTimestamp = Date.now(); + try { + testPromise = + typeof method === 'string' + ? testApiAccessMethodById(method) + : testCustomApiAccessMethod(method); + + lastTestPromise.current = testPromise; + reachable = await testPromise; + } catch { + reachable = false; + } + + // Make sure the loading text is displayed for at least `minDuration` milliseconds. + const submitDuration = Date.now() - submitTimestamp; + if (submitDuration < minDuration) { + await new Promise<void>((resolve) => + delayScheduler.schedule(resolve, minDuration - submitDuration), + ); + } + + if (testPromise !== lastTestPromise.current) { + return; + } + + setTestResult(reachable); + unsetTesting(); + + if (autoReset) { + testResultResetScheduler.schedule(() => setTestResult(undefined), 5000); + } + + return reachable; + }, []); + + const resetTestResult = useCallback(() => { + lastTestPromise.current = undefined; + unsetTesting(); + setTestResult(undefined); + }, []); + + return [testing, testResult, testApiAccessMethod, resetTestResult]; +} diff --git a/gui/src/renderer/lib/routes.ts b/gui/src/renderer/lib/routes.ts index df8193b6d8..86c0fb4b4f 100644 --- a/gui/src/renderer/lib/routes.ts +++ b/gui/src/renderer/lib/routes.ts @@ -17,6 +17,7 @@ export enum RoutePath { wireguardSettings = '/settings/advanced/wireguard', openVpnSettings = '/settings/advanced/openvpn', splitTunneling = '/settings/split-tunneling', + apiAccessMethods = '/settings/api-access-methods', support = '/settings/support', problemReport = '/settings/support/problem-report', debug = '/settings/debug', diff --git a/gui/src/shared/localization-contexts.ts b/gui/src/shared/localization-contexts.ts index d342a4210e..081d5a622f 100644 --- a/gui/src/shared/localization-contexts.ts +++ b/gui/src/shared/localization-contexts.ts @@ -30,6 +30,7 @@ export type LocalizationContexts = | 'openvpn-settings-nav' | 'split-tunneling-view' | 'split-tunneling-nav' + | 'api-access-methods-view' | 'support-view' | 'select-language-nav' | 'tray-icon-context-menu' |
