summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2024-02-14 15:12:06 +0100
committerOskar Nyberg <oskar@mullvad.net>2024-02-15 16:04:29 +0100
commit50254837d60956ee95cd9e1dc57674fcd05faf80 (patch)
tree732eb09c04fdcebeaa7ce036ceb4726f4bf92575
parent8ab10682e57bf4f42f4a789ee8566af69e5b161f (diff)
downloadmullvadvpn-50254837d60956ee95cd9e1dc57674fcd05faf80.tar.xz
mullvadvpn-50254837d60956ee95cd9e1dc57674fcd05faf80.zip
Add IP override views
-rw-r--r--gui/src/renderer/components/AppRouter.tsx4
-rw-r--r--gui/src/renderer/components/SettingsImport.tsx304
-rw-r--r--gui/src/renderer/components/SettingsTextImport.tsx82
-rw-r--r--gui/src/renderer/components/VpnSettings.tsx15
-rw-r--r--gui/src/renderer/lib/routes.ts2
-rw-r--r--gui/src/shared/localization-contexts.ts1
6 files changed, 408 insertions, 0 deletions
diff --git a/gui/src/renderer/components/AppRouter.tsx b/gui/src/renderer/components/AppRouter.tsx
index 6c45083290..9e4a13adc8 100644
--- a/gui/src/renderer/components/AppRouter.tsx
+++ b/gui/src/renderer/components/AppRouter.tsx
@@ -26,6 +26,8 @@ import OpenVpnSettings from './OpenVpnSettings';
import ProblemReport from './ProblemReport';
import SelectLanguage from './SelectLanguage';
import Settings from './Settings';
+import SettingsImport from './SettingsImport';
+import SettingsTextImport from './SettingsTextImport';
import SplitTunnelingSettings from './SplitTunnelingSettings';
import Support from './Support';
import TooManyDevices from './TooManyDevices';
@@ -83,6 +85,8 @@ 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.settingsImport} component={SettingsImport} />
+ <Route exact path={RoutePath.settingsTextImport} component={SettingsTextImport} />
<Route exact path={RoutePath.editApiAccessMethods} component={EditApiAccessMethod} />
<Route exact path={RoutePath.support} component={Support} />
<Route exact path={RoutePath.problemReport} component={ProblemReport} />
diff --git a/gui/src/renderer/components/SettingsImport.tsx b/gui/src/renderer/components/SettingsImport.tsx
new file mode 100644
index 0000000000..3f3cc5dd1f
--- /dev/null
+++ b/gui/src/renderer/components/SettingsImport.tsx
@@ -0,0 +1,304 @@
+import { useCallback, useState } from 'react';
+import { sprintf } from 'sprintf-js';
+import styled from 'styled-components';
+
+import { colors } from '../../config.json';
+import { messages } from '../../shared/gettext';
+import { useScheduler } from '../../shared/scheduler';
+import { useAppContext } from '../context';
+import useActions from '../lib/actionsHook';
+import { transitions, useHistory } from '../lib/history';
+import { RoutePath } from '../lib/routes';
+import { useAsyncEffect, useBoolean } from '../lib/utilityHooks';
+import settingsImportActions from '../redux/settings-import/actions';
+import { useSelector } from '../redux/store';
+import { measurements, normalText } from './common-styles';
+import { tinyText } from './common-styles';
+import ImageView from './ImageView';
+import { BackAction } from './KeyboardNavigation';
+import { Footer, Layout, SettingsContainer } from './Layout';
+import { ModalAlert, ModalAlertType } from './Modal';
+import {
+ NavigationBar,
+ NavigationInfoButton,
+ NavigationItems,
+ TitleBarItem,
+} from './NavigationBar';
+import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader';
+import { SmallButton, SmallButtonGrid } from './SmallButton';
+import { SmallButtonColor } from './SmallButton';
+
+const ContentContainer = styled.div({
+ display: 'flex',
+ flexDirection: 'column',
+ flex: 1,
+});
+
+const Content = styled.div({
+ display: 'flex',
+ flexDirection: 'column',
+ flex: 1,
+});
+
+const StyledSmallButtonGrid = styled(SmallButtonGrid)({
+ margin: `0 ${measurements.viewMargin}`,
+});
+
+type ImportStatus = { successful: boolean } & ({ type: 'file'; name: string } | { type: 'text' });
+
+export default function SettingsImport() {
+ const history = useHistory();
+ const {
+ clearAllRelayOverrides,
+ importSettingsFile,
+ importSettingsText,
+ showOpenDialog,
+ getPathBaseName,
+ } = useAppContext();
+ const { clearSettingsImportForm, unsetSubmitSettingsImportForm } = useActions(
+ settingsImportActions,
+ );
+
+ // Status of the text form which is used to for example submit it.
+ const textForm = useSelector((state) => state.settingsImport);
+
+ // "Clear" button will be disabled if there are no imported overrides.
+ const activeOverrides = useSelector((state) => state.settings.relayOverrides.length > 0);
+
+ const [clearDialogVisible, showClearDialog, hideClearDialog] = useBoolean();
+
+ // Keeps the status of the last import and is cleared 10 seconds after being set.
+ const [importStatus, setImportStatusImpl] = useState<ImportStatus>();
+ const importStatusResetScheduler = useScheduler();
+
+ const setImportStatus = useCallback((status?: ImportStatus) => {
+ // Cancel scheduled status clearing.
+ importStatusResetScheduler.cancel();
+ setImportStatusImpl(status);
+
+ // The status text should be cleared after 10 seconds.
+ if (status !== undefined) {
+ importStatusResetScheduler.schedule(() => setImportStatusImpl(undefined), 10_000);
+ }
+ }, []);
+
+ const confirmClear = useCallback(() => {
+ hideClearDialog();
+ void clearAllRelayOverrides();
+ setImportStatus(undefined);
+ }, []);
+
+ const navigateTextImport = useCallback(() => {
+ history.push(RoutePath.settingsTextImport, { transition: transitions.show });
+ }, [history]);
+
+ const importFile = useCallback(async () => {
+ const file = await showOpenDialog({
+ properties: ['openFile'],
+ buttonLabel: messages.gettext('Import'),
+ filters: [{ name: 'Mullvad settings file', extensions: ['json'] }],
+ });
+ const path = file.filePaths[0];
+ const name = await getPathBaseName(path);
+ try {
+ await importSettingsFile(path);
+ setImportStatus({ successful: true, type: 'file', name });
+ } catch {
+ setImportStatus({ successful: false, type: 'file', name });
+ }
+ }, []);
+
+ useAsyncEffect(async () => {
+ if (history.action === 'POP' && textForm.submit && textForm.value !== '') {
+ try {
+ await importSettingsText(textForm.value);
+ setImportStatus({ successful: true, type: 'text' });
+ clearSettingsImportForm();
+ } catch {
+ setImportStatus({ successful: false, type: 'text' });
+ unsetSubmitSettingsImportForm();
+ }
+ }
+ }, []);
+
+ return (
+ <BackAction action={history.pop}>
+ <Layout>
+ <SettingsContainer>
+ <NavigationBar>
+ <NavigationItems>
+ <TitleBarItem>
+ {
+ // TRANSLATORS: Title label in navigation bar. This is for a feature that lets
+ // TRANSLATORS: users import server IP settings.
+ messages.pgettext('settings-import', 'Server IP override')
+ }
+ </TitleBarItem>
+ <NavigationInfoButton
+ title={messages.pgettext('settings-import', 'Server IP override')}
+ message={[
+ messages.pgettext(
+ 'settings-import',
+ 'On some networks, where various types of censorship are being used, our server IP addresses are sometimes blocked.',
+ ),
+ messages.pgettext(
+ 'settings-import',
+ 'To circumvent this you can import a file or a text, provided by our support team, with new IP addresses that override the default addresses of the servers in the Select location view.',
+ ),
+ messages.pgettext(
+ 'settings-import',
+ 'If you are having issues connecting to VPN servers, please contact support.',
+ ),
+ ]}
+ />
+ </NavigationItems>
+ </NavigationBar>
+
+ <ContentContainer>
+ <SettingsHeader>
+ <HeaderTitle>
+ {messages.pgettext('settings-import', 'Server IP override')}
+ </HeaderTitle>
+ <HeaderSubTitle>
+ {messages.pgettext(
+ 'settings-import',
+ 'Import files or text with new IP addresses for the servers in the Select location view.',
+ )}
+ </HeaderSubTitle>
+ </SettingsHeader>
+
+ <Content>
+ <StyledSmallButtonGrid>
+ <SmallButton onClick={navigateTextImport}>
+ {messages.pgettext('settings-import', 'Import via text')}
+ </SmallButton>
+ <SmallButton onClick={importFile}>
+ {messages.pgettext('settings-import', 'Import file')}
+ </SmallButton>
+ </StyledSmallButtonGrid>
+
+ <SettingsImportStatus status={importStatus} />
+ </Content>
+
+ <Footer>
+ <SmallButton
+ onClick={showClearDialog}
+ color={SmallButtonColor.red}
+ disabled={!activeOverrides}>
+ {messages.pgettext('settings-import', 'Clear all overrides')}
+ </SmallButton>
+ </Footer>
+
+ <ModalAlert
+ isOpen={clearDialogVisible}
+ type={ModalAlertType.warning}
+ gridButtons={[
+ <SmallButton key="cancel" onClick={hideClearDialog}>
+ {messages.gettext('Cancel')}
+ </SmallButton>,
+ <SmallButton key="confirm" onClick={confirmClear} color={SmallButtonColor.red}>
+ {messages.gettext('Clear')}
+ </SmallButton>,
+ ]}
+ close={hideClearDialog}
+ title={messages.pgettext('settings-import', 'Clear all overrides?')}
+ message={messages.pgettext(
+ 'settings-import',
+ 'Clearing the imported overrides changes the server IPs, in the Select location view, back to default.',
+ )}
+ />
+ </ContentContainer>
+ </SettingsContainer>
+ </Layout>
+ </BackAction>
+ );
+}
+
+const StyledStatusContainer = styled.div({
+ display: 'flex',
+ flexDirection: 'column',
+ margin: `18px ${measurements.viewMargin}`,
+});
+
+const StyledStatusTitle = styled.div(normalText, {
+ display: 'flex',
+ alignItems: 'center',
+ fontWeight: 'bold',
+ lineHeight: '20px',
+ color: colors.white,
+});
+
+const StyledStatusImage = styled(ImageView)({
+ margin: '5px',
+});
+
+const StyledStatusSubTitle = styled.div(tinyText, {
+ color: colors.white60,
+});
+
+interface ImportStatusProps {
+ status?: ImportStatus;
+}
+
+// This component renders the status title, subtitle and icon depending on active overrides and
+// import result.
+function SettingsImportStatus(props: ImportStatusProps) {
+ const activeOverrides = useSelector((state) => state.settings.relayOverrides.length > 0);
+
+ let title;
+ if (props.status?.successful) {
+ title = messages.pgettext('settings-import', 'IMPORT SUCCESSFUL');
+ } else if (activeOverrides && props.status?.successful !== false) {
+ title = messages.pgettext('settings-import', 'OVERRIDES ACTIVE');
+ } else {
+ title = messages.pgettext('settings-import', 'NO OVERRIDES IMPORTED');
+ }
+
+ let icon = undefined;
+ let subtitle;
+ if (props.status !== undefined) {
+ icon = props.status.successful ? 'icon-checkmark' : 'icon-cross';
+
+ if (props.status.successful) {
+ subtitle =
+ props.status.type === 'file'
+ ? sprintf(
+ messages.pgettext(
+ 'settings-import',
+ 'Import of file %(fileName)s was successful, overrides are now active.',
+ ),
+ { fileName: props.status.name },
+ )
+ : messages.pgettext(
+ 'settings-import',
+ 'Import of text was successful, overrides are now active.',
+ );
+ } else {
+ subtitle =
+ props.status.type === 'file'
+ ? sprintf(
+ messages.pgettext(
+ 'settings-import',
+ 'Import of file %(fileName)s was unsuccessful, please try again.',
+ ),
+ { fileName: props.status.name },
+ )
+ : messages.pgettext(
+ 'settings-import',
+ 'Import of text was unsuccessful, please try again.',
+ );
+ }
+ }
+
+ return (
+ <StyledStatusContainer>
+ <StyledStatusTitle data-testid="status-title">
+ {title}
+ {icon !== undefined && <StyledStatusImage source={icon} width={13} />}
+ </StyledStatusTitle>
+ {subtitle !== undefined && (
+ <StyledStatusSubTitle data-testid="status-subtitle">{subtitle}</StyledStatusSubTitle>
+ )}
+ </StyledStatusContainer>
+ );
+}
diff --git a/gui/src/renderer/components/SettingsTextImport.tsx b/gui/src/renderer/components/SettingsTextImport.tsx
new file mode 100644
index 0000000000..7d51e7ac93
--- /dev/null
+++ b/gui/src/renderer/components/SettingsTextImport.tsx
@@ -0,0 +1,82 @@
+import { useCallback } from 'react';
+import styled from 'styled-components';
+
+import { colors } from '../../config.json';
+import { messages } from '../../shared/gettext';
+import useActions from '../lib/actionsHook';
+import { useHistory } from '../lib/history';
+import { useCombinedRefs, useStyledRef } from '../lib/utilityHooks';
+import settingsImportActions from '../redux/settings-import/actions';
+import { useSelector } from '../redux/store';
+import ImageView from './ImageView';
+import { BackAction } from './KeyboardNavigation';
+import { Layout, SettingsContainer } from './Layout';
+import { NavigationBar, NavigationBarButton, NavigationItems, TitleBarItem } from './NavigationBar';
+
+const StyledTextArea = styled.textarea({
+ width: '100%',
+ flex: 1,
+ padding: '13px',
+ color: colors.blue,
+});
+
+export default function SettingsTextImport() {
+ const history = useHistory();
+
+ const { saveSettingsImportForm } = useActions(settingsImportActions);
+ // The textarea value is saved in redux to make it persistent when leaving the view.
+ const initialValue = useSelector((state) => state.settingsImport.value);
+
+ const textareaRef = useStyledRef<HTMLTextAreaElement>();
+ const onTextareaLoad = useCallback((element?: HTMLTextAreaElement) => {
+ if (element) {
+ element.value = initialValue;
+ }
+ }, []);
+
+ const combinedTextAreaRef = useCombinedRefs(textareaRef, onTextareaLoad);
+
+ const save = useCallback(() => {
+ if (textareaRef.current?.value) {
+ saveSettingsImportForm(textareaRef.current.value, true);
+ }
+ history.pop();
+ }, [history]);
+
+ const back = useCallback(() => {
+ if (textareaRef.current) {
+ saveSettingsImportForm(textareaRef.current.value, false);
+ }
+ history.pop();
+ }, [history]);
+
+ return (
+ <BackAction action={back}>
+ <Layout>
+ <SettingsContainer>
+ <NavigationBar alwaysDisplayBarTitle>
+ <NavigationItems>
+ <TitleBarItem>
+ {
+ // TRANSLATORS: Title label in navigation bar
+ messages.pgettext('settings-import', 'Import via text')
+ }
+ </TitleBarItem>
+ <NavigationBarButton onClick={save} aria-label={messages.gettext('Save')}>
+ <ImageView
+ source="icon-check"
+ tintColor={colors.white40}
+ tintHoverColor={colors.white60}
+ height={24}
+ width={24}
+ />
+ </NavigationBarButton>
+ </NavigationItems>
+ </NavigationBar>
+
+ <StyledTextArea ref={combinedTextAreaRef} />
+ </SettingsContainer>
+ </Layout>
+ </BackAction>
+ );
+}
diff --git a/gui/src/renderer/components/VpnSettings.tsx b/gui/src/renderer/components/VpnSettings.tsx
index 06f69240b6..395d1ba183 100644
--- a/gui/src/renderer/components/VpnSettings.tsx
+++ b/gui/src/renderer/components/VpnSettings.tsx
@@ -123,6 +123,10 @@ export default function VpnSettings() {
<Cell.Group>
<CustomDnsSettings />
</Cell.Group>
+
+ <Cell.Group>
+ <IpOverrideButton />
+ </Cell.Group>
</StyledContent>
</NavigationScrollbars>
</NavigationContainer>
@@ -779,3 +783,14 @@ function OpenVpnSettingsButton() {
</Cell.CellNavigationButton>
);
}
+
+function IpOverrideButton() {
+ const history = useHistory();
+ const navigate = useCallback(() => history.push(RoutePath.settingsImport), [history]);
+
+ return (
+ <Cell.CellNavigationButton onClick={navigate}>
+ <Cell.Label>{messages.pgettext('vpn-settings-view', 'Server IP override')}</Cell.Label>
+ </Cell.CellNavigationButton>
+ );
+}
diff --git a/gui/src/renderer/lib/routes.ts b/gui/src/renderer/lib/routes.ts
index a7cf89d9ce..499157976a 100644
--- a/gui/src/renderer/lib/routes.ts
+++ b/gui/src/renderer/lib/routes.ts
@@ -18,6 +18,8 @@ export enum RoutePath {
openVpnSettings = '/settings/advanced/openvpn',
splitTunneling = '/settings/split-tunneling',
apiAccessMethods = '/settings/api-access-methods',
+ settingsImport = '/settings/settings-import',
+ settingsTextImport = '/settings/settings-import/text-import',
editApiAccessMethods = '/settings/api-access-methods/edit/:id?',
support = '/settings/support',
problemReport = '/settings/support/problem-report',
diff --git a/gui/src/shared/localization-contexts.ts b/gui/src/shared/localization-contexts.ts
index 081d5a622f..71a663def0 100644
--- a/gui/src/shared/localization-contexts.ts
+++ b/gui/src/shared/localization-contexts.ts
@@ -31,6 +31,7 @@ export type LocalizationContexts =
| 'split-tunneling-view'
| 'split-tunneling-nav'
| 'api-access-methods-view'
+ | 'settings-import'
| 'support-view'
| 'select-language-nav'
| 'tray-icon-context-menu'