diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2024-02-14 15:12:06 +0100 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2024-02-15 16:04:29 +0100 |
| commit | 50254837d60956ee95cd9e1dc57674fcd05faf80 (patch) | |
| tree | 732eb09c04fdcebeaa7ce036ceb4726f4bf92575 | |
| parent | 8ab10682e57bf4f42f4a789ee8566af69e5b161f (diff) | |
| download | mullvadvpn-50254837d60956ee95cd9e1dc57674fcd05faf80.tar.xz mullvadvpn-50254837d60956ee95cd9e1dc57674fcd05faf80.zip | |
Add IP override views
| -rw-r--r-- | gui/src/renderer/components/AppRouter.tsx | 4 | ||||
| -rw-r--r-- | gui/src/renderer/components/SettingsImport.tsx | 304 | ||||
| -rw-r--r-- | gui/src/renderer/components/SettingsTextImport.tsx | 82 | ||||
| -rw-r--r-- | gui/src/renderer/components/VpnSettings.tsx | 15 | ||||
| -rw-r--r-- | gui/src/renderer/lib/routes.ts | 2 | ||||
| -rw-r--r-- | gui/src/shared/localization-contexts.ts | 1 |
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' |
