summaryrefslogtreecommitdiffhomepage
path: root/gui/src
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2024-01-24 12:21:20 +0100
committerOskar Nyberg <oskar@mullvad.net>2024-01-29 09:33:49 +0100
commit3078fd30c1d8c9f3f86d9ddcfeddea3c83718f5b (patch)
tree9a89e653dfc4e62a9eba36ed9a84c453ed94b5c2 /gui/src
parentbe5c4892cbc6242140ebefddc8445cc76e57899e (diff)
downloadmullvadvpn-3078fd30c1d8c9f3f86d9ddcfeddea3c83718f5b.tar.xz
mullvadvpn-3078fd30c1d8c9f3f86d9ddcfeddea3c83718f5b.zip
Add API access methods view
Diffstat (limited to 'gui/src')
-rw-r--r--gui/src/config.json1
-rw-r--r--gui/src/renderer/components/ApiAccessMethods.tsx316
-rw-r--r--gui/src/renderer/components/AppRouter.tsx2
-rw-r--r--gui/src/renderer/components/Settings.tsx20
-rw-r--r--gui/src/renderer/lib/api-access-methods.ts81
-rw-r--r--gui/src/renderer/lib/routes.ts1
-rw-r--r--gui/src/shared/localization-contexts.ts1
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'