diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2024-02-15 16:04:54 +0100 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2024-02-15 16:04:54 +0100 |
| commit | 110e037e84e233aac0796e1d18502640bc241ca3 (patch) | |
| tree | 4fe313d6321541cea6e467470292b50aa0f17618 | |
| parent | d42287a49451c6ab42efb0edc5e66a1375e28306 (diff) | |
| parent | 3edd6599b191c5c241abb86d8938164aac4c150d (diff) | |
| download | mullvadvpn-110e037e84e233aac0796e1d18502640bc241ca3.tar.xz mullvadvpn-110e037e84e233aac0796e1d18502640bc241ca3.zip | |
Merge branch 'add-server-ip-override-ui-des-422'
30 files changed, 845 insertions, 52 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index a5bfd14a62..7d1c96d57f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Line wrap the file at 100 chars. Th circumvent censorship by proxying API traffic. - Add confirmation dialog when deleting a custom list. - Add support for custom SOCKS5 OpenVPN bridges running locally. +- Add ability to import server IP overrides in GUI. #### Android - Add support for all screen orientations. diff --git a/gui/assets/images/icon-checkmark.svg b/gui/assets/images/icon-checkmark.svg new file mode 100644 index 0000000000..67773298f8 --- /dev/null +++ b/gui/assets/images/icon-checkmark.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 22 22"> + <path d="M2.683 6.69a1.581 1.581 0 0 0-2.222 0 1.549 1.549 0 0 0 0 2.2l6.286 6.233a1.581 1.581 0 0 0 2.222 0L21.54 2.66a1.549 1.549 0 0 0 0-2.2 1.581 1.581 0 0 0-2.222 0L7.857 11.821z" transform="translate(0 3.208)" style="fill:#44ad4d"/> +</svg> diff --git a/gui/assets/images/icon-cross.svg b/gui/assets/images/icon-cross.svg new file mode 100644 index 0000000000..0ac8215f62 --- /dev/null +++ b/gui/assets/images/icon-cross.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <path d="m12.7 10 6.75-6.747a1.907 1.907 0 0 0-2.7-2.7L10 7.3 3.25.556a1.907 1.907 0 0 0-2.7 2.7L7.3 10 .552 16.747a1.907 1.907 0 0 0 2.7 2.7L10 12.7l6.75 6.747a1.907 1.907 0 0 0 2.7-2.7L12.7 10z" transform="translate(2 2)" style="fill:#e34039"/> +</svg> diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot index ea04a3c6a8..7839a893ee 100644 --- a/gui/locales/messages.pot +++ b/gui/locales/messages.pot @@ -145,6 +145,9 @@ msgstr "" msgid "Got it!" msgstr "" +msgid "Import" +msgstr "" + msgid "IPv4" msgstr "" @@ -1324,6 +1327,77 @@ msgctxt "select-location-view" msgid "While connected, your traffic will be routed through two secure locations, the entry point and the exit point (needs to be two different VPN servers)." msgstr "" +msgctxt "settings-import" +msgid "Clear all overrides" +msgstr "" + +msgctxt "settings-import" +msgid "Clear all overrides?" +msgstr "" + +msgctxt "settings-import" +msgid "Clearing the imported overrides changes the server IPs, in the Select location view, back to default." +msgstr "" + +msgctxt "settings-import" +msgid "If you are having issues connecting to VPN servers, please contact support." +msgstr "" + +msgctxt "settings-import" +msgid "Import file" +msgstr "" + +msgctxt "settings-import" +msgid "Import files or text with new IP addresses for the servers in the Select location view." +msgstr "" + +msgctxt "settings-import" +msgid "Import of file %(fileName)s was successful, overrides are now active." +msgstr "" + +msgctxt "settings-import" +msgid "Import of file %(fileName)s was unsuccessful, please try again." +msgstr "" + +msgctxt "settings-import" +msgid "Import of text was successful, overrides are now active." +msgstr "" + +msgctxt "settings-import" +msgid "Import of text was unsuccessful, please try again." +msgstr "" + +msgctxt "settings-import" +msgid "IMPORT SUCCESSFUL" +msgstr "" + +#. Title label in navigation bar +msgctxt "settings-import" +msgid "Import via text" +msgstr "" + +msgctxt "settings-import" +msgid "NO OVERRIDES IMPORTED" +msgstr "" + +msgctxt "settings-import" +msgid "On some networks, where various types of censorship are being used, our server IP addresses are sometimes blocked." +msgstr "" + +msgctxt "settings-import" +msgid "OVERRIDES ACTIVE" +msgstr "" + +#. Title label in navigation bar. This is for a feature that lets +#. users import server IP settings. +msgctxt "settings-import" +msgid "Server IP override" +msgstr "" + +msgctxt "settings-import" +msgid "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." +msgstr "" + #. Navigation button to the 'API access methods' view msgctxt "settings-view" msgid "API access" @@ -1768,6 +1842,10 @@ msgctxt "vpn-settings-view" msgid "Malware" msgstr "" +msgctxt "vpn-settings-view" +msgid "Server IP override" +msgstr "" + #. Label for settings that enables block of social media. msgctxt "vpn-settings-view" msgid "Social media" diff --git a/gui/src/main/daemon-rpc.ts b/gui/src/main/daemon-rpc.ts index 6290aa7a92..703ad027cc 100644 --- a/gui/src/main/daemon-rpc.ts +++ b/gui/src/main/daemon-rpc.ts @@ -697,6 +697,14 @@ export class DaemonRpc { return result.getValue(); } + public async applyJsonSettings(settings: string): Promise<void> { + await this.callString(this.client.applyJsonSettings, settings); + } + + public async clearAllRelayOverrides(): Promise<void> { + await this.callEmpty(this.client.clearAllRelayOverrides); + } + private subscriptionId(): number { const current = this.nextSubscriptionId; this.nextSubscriptionId += 1; @@ -711,11 +719,14 @@ export class DaemonRpc { return Date.now() + CHANNEL_STATE_TIMEOUT; } - private callEmpty<R>(fn: CallFunctionArgument<Empty, R>): Promise<R> { + private callEmpty<R = Empty>(fn: CallFunctionArgument<Empty, R>): Promise<R> { return this.call<Empty, R>(fn, new Empty()); } - private callString<R>(fn: CallFunctionArgument<StringValue, R>, value?: string): Promise<R> { + private callString<R = Empty>( + fn: CallFunctionArgument<StringValue, R>, + value?: string, + ): Promise<R> { const googleString = new StringValue(); if (value !== undefined) { @@ -1164,6 +1175,7 @@ function convertFromSettings(settings: grpcTypes.Settings): ISettings | undefine const obfuscationSettings = convertFromObfuscationSettings(settingsObject.obfuscationSettings); const customLists = convertFromCustomListSettings(settings.getCustomLists()); const apiAccessMethods = convertFromApiAccessMethodSettings(settings.getApiAccessMethods()!); + const relayOverrides = settingsObject.relayOverridesList; return { ...settings.toObject(), bridgeState, @@ -1174,6 +1186,7 @@ function convertFromSettings(settings: grpcTypes.Settings): ISettings | undefine obfuscationSettings, customLists, apiAccessMethods, + relayOverrides, }; } diff --git a/gui/src/main/default-settings.ts b/gui/src/main/default-settings.ts index 7f7656a56a..e942a535b6 100644 --- a/gui/src/main/default-settings.ts +++ b/gui/src/main/default-settings.ts @@ -77,6 +77,7 @@ export function getDefaultSettings(): ISettings { }, customLists: [], apiAccessMethods: getDefaultApiAccessMethods(), + relayOverrides: [], }; } diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts index 635b0ad79e..92d51dba69 100644 --- a/gui/src/main/index.ts +++ b/gui/src/main/index.ts @@ -822,6 +822,9 @@ class ApplicationMain await shell.openExternal(url); } }); + IpcMainEventChannel.app.handleGetPathBaseName((filePath) => + Promise.resolve(path.basename(filePath)), + ); IpcMainEventChannel.navigation.handleSetHistory((history) => { this.navigationHistory = history; diff --git a/gui/src/main/settings.ts b/gui/src/main/settings.ts index 7c304f3e71..16996eebd9 100644 --- a/gui/src/main/settings.ts +++ b/gui/src/main/settings.ts @@ -1,3 +1,5 @@ +import fs from 'fs/promises'; + import BridgeSettingsBuilder from '../shared/bridge-settings-builder'; import { ISettings } from '../shared/daemon-rpc-types'; import { ICurrentAppVersionInfo } from '../shared/ipc-types'; @@ -90,6 +92,17 @@ export default class Settings implements Readonly<ISettings> { return this.daemonRpc.testCustomApiAccessMethod(method); }); + IpcMainEventChannel.settings.handleClearAllRelayOverrides(() => { + return this.daemonRpc.clearAllRelayOverrides(); + }); + IpcMainEventChannel.settings.handleImportText((text) => { + return this.daemonRpc.applyJsonSettings(text); + }); + IpcMainEventChannel.settings.handleImportFile(async (path) => { + const settings = await fs.readFile(path); + return this.daemonRpc.applyJsonSettings(settings.toString()); + }); + IpcMainEventChannel.guiSettings.handleSetEnableSystemNotifications((flag: boolean) => { this.guiSettings.enableSystemNotifications = flag; }); @@ -160,6 +173,9 @@ export default class Settings implements Readonly<ISettings> { public get apiAccessMethods() { return this.settingsValue.apiAccessMethods; } + public get relayOverrides() { + return this.settingsValue.relayOverrides; + } public get gui() { return this.guiSettings; diff --git a/gui/src/main/user-interface.ts b/gui/src/main/user-interface.ts index de902e2d7c..ac308adc01 100644 --- a/gui/src/main/user-interface.ts +++ b/gui/src/main/user-interface.ts @@ -69,6 +69,7 @@ export default class UserInterface implements WindowControllerDelegate { ...options, }); this.browsingFiles = false; + this.showWindow(); return response; }); diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx index 1a735e1ec5..5e2c574d79 100644 --- a/gui/src/renderer/app.tsx +++ b/gui/src/renderer/app.tsx @@ -345,6 +345,7 @@ export default class AppRenderer { public viewLog = (path: string) => IpcRendererEventChannel.problemReport.viewLog(path); public quit = () => IpcRendererEventChannel.app.quit(); public openUrl = (url: string) => IpcRendererEventChannel.app.openUrl(url); + public getPathBaseName = (path: string) => IpcRendererEventChannel.app.getPathBaseName(path); public showOpenDialog = (options: Electron.OpenDialogOptions) => IpcRendererEventChannel.app.showOpenDialog(options); public createCustomList = (name: string) => @@ -365,6 +366,9 @@ export default class AppRenderer { IpcRendererEventChannel.settings.testApiAccessMethodById(id); public testCustomApiAccessMethod = (method: CustomProxy) => IpcRendererEventChannel.settings.testCustomApiAccessMethod(method); + public importSettingsFile = (path: string) => IpcRendererEventChannel.settings.importFile(path); + public importSettingsText = (text: string) => IpcRendererEventChannel.settings.importText(text); + public clearAllRelayOverrides = () => IpcRendererEventChannel.settings.clearAllRelayOverrides(); public getMapData = () => IpcRendererEventChannel.map.getData(); public setAnimateMap = (displayMap: boolean): void => IpcRendererEventChannel.guiSettings.setAnimateMap(displayMap); @@ -812,6 +816,7 @@ export default class AppRenderer { reduxSettings.updateObfuscationSettings(newSettings.obfuscationSettings); reduxSettings.updateCustomLists(newSettings.customLists); reduxSettings.updateApiAccessMethods(newSettings.apiAccessMethods); + reduxSettings.updateRelayOverrides(newSettings.relayOverrides); this.setReduxRelaySettings(newSettings.relaySettings); this.setBridgeSettings(newSettings.bridgeSettings); diff --git a/gui/src/renderer/components/ApiAccessMethods.tsx b/gui/src/renderer/components/ApiAccessMethods.tsx index b5cffddc94..ccec1ce823 100644 --- a/gui/src/renderer/components/ApiAccessMethods.tsx +++ b/gui/src/renderer/components/ApiAccessMethods.tsx @@ -24,7 +24,13 @@ 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 { + NavigationBar, + NavigationContainer, + NavigationInfoButton, + NavigationItems, + TitleBarItem, +} from './NavigationBar'; import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader'; import { StyledContent, StyledNavigationScrollbars, StyledSettingsContent } from './SettingsStyles'; import { SmallButton, SmallButtonColor, SmallButtonGroup } from './SmallButton'; @@ -33,10 +39,6 @@ const StyledContextMenuButton = styled(Cell.Icon)({ marginRight: '8px', }); -const StyledTitleInfoButton = styled(InfoButton)({ - marginLeft: '12px', -}); - const StyledMethodInfoButton = styled(InfoButton)({ marginRight: '11px', }); @@ -90,31 +92,29 @@ export default function ApiAccessMethods() { messages.pgettext('navigation-bar', 'API access') } </TitleBarItem> + <NavigationInfoButton + 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.', + ), + ]} + /> </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> + <HeaderTitle>{messages.pgettext('navigation-bar', 'API access')}</HeaderTitle> <HeaderSubTitle> {messages.pgettext( 'api-access-methods-view', 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/InfoButton.tsx b/gui/src/renderer/components/InfoButton.tsx index 8807356d79..e17e7b87b8 100644 --- a/gui/src/renderer/components/InfoButton.tsx +++ b/gui/src/renderer/components/InfoButton.tsx @@ -18,6 +18,8 @@ const StyledInfoButton = styled.button({ interface IInfoIconProps { className?: string; size?: number; + tintColor?: string; + tintHoverColor?: string; } export function InfoIcon(props: IInfoIconProps) { @@ -25,21 +27,24 @@ export function InfoIcon(props: IInfoIconProps) { <ImageView source="icon-info" width={props.size ?? 18} - tintColor={colors.white} - tintHoverColor={colors.white80} + tintColor={props.tintColor ?? colors.white} + tintHoverColor={props.tintHoverColor ?? colors.white80} className={props.className} /> ); } -interface IInfoButtonProps extends React.HTMLAttributes<HTMLButtonElement> { +export interface IInfoButtonProps extends React.HTMLAttributes<HTMLButtonElement> { message?: string | Array<string>; children?: React.ReactNode; + title?: string; size?: number; + tintColor?: string; + tintHoverColor?: string; } export default function InfoButton(props: IInfoButtonProps) { - const { message, children, size, ...otherProps } = props; + const { message, children, size, tintColor, tintHoverColor, ...otherProps } = props; const [isOpen, show, hide] = useBoolean(false); return ( @@ -48,10 +53,11 @@ export default function InfoButton(props: IInfoButtonProps) { onClick={show} aria-label={messages.pgettext('accessibility', 'More information')} {...otherProps}> - <InfoIcon size={size} /> + <InfoIcon size={size} tintColor={tintColor} tintHoverColor={tintHoverColor} /> </StyledInfoButton> <ModalAlert isOpen={isOpen} + title={props.title} message={props.message} type={ModalAlertType.info} buttons={[ diff --git a/gui/src/renderer/components/NavigationBar.tsx b/gui/src/renderer/components/NavigationBar.tsx index d375fc34d6..ee316de783 100644 --- a/gui/src/renderer/components/NavigationBar.tsx +++ b/gui/src/renderer/components/NavigationBar.tsx @@ -1,11 +1,13 @@ import React, { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; +import styled from 'styled-components'; import { colors } from '../../config.json'; import { messages } from '../../shared/gettext'; import { useAppContext } from '../context'; -import { useHistory } from '../lib/history'; +import { transitions, useHistory } from '../lib/history'; import { useCombinedRefs } from '../lib/utilityHooks'; import CustomScrollbars, { CustomScrollbarsRef, IScrollEvent } from './CustomScrollbars'; +import InfoButton from './InfoButton'; import { BackActionContext } from './KeyboardNavigation'; import { StyledBackBarItemButton, @@ -185,7 +187,9 @@ export const TitleBarItem = React.memo(function TitleBarItemT(props: ITitleBarIt export function BackBarItem() { const history = useHistory(); - const backIcon = useMemo(() => history.length > 2, []); + // Compare the transition name with dismiss to infer wheter or not the view will slide + // horizontally or vertically and then use matching button. + const backIcon = useMemo(() => history.getPopTransition().name !== transitions.dismiss.name, []); const { parentBackAction } = useContext(BackActionContext); const iconSource = backIcon ? 'icon-back' : 'icon-close-down'; const ariaLabel = backIcon ? messages.gettext('Back') : messages.gettext('Close'); @@ -196,3 +200,21 @@ export function BackBarItem() { </StyledBackBarItemButton> ); } + +const navigationRightHandSideButton: React.CSSProperties = { + justifySelf: 'end', + borderWidth: 0, + padding: 0, + margin: 0, + cursor: 'default', + backgroundColor: 'transparent', +}; + +export const NavigationBarButton = styled.button({ ...navigationRightHandSideButton }); +export const NavigationInfoButton = styled(InfoButton).attrs({ + size: 24, + tintColor: colors.white40, + tintHoverColor: colors.white60, +})({ + ...navigationRightHandSideButton, +}); 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/components/select-location/SelectLocation.tsx b/gui/src/renderer/components/select-location/SelectLocation.tsx index e09823586f..6bf0fcff6f 100644 --- a/gui/src/renderer/components/select-location/SelectLocation.tsx +++ b/gui/src/renderer/components/select-location/SelectLocation.tsx @@ -18,6 +18,7 @@ import { BackAction } from '../KeyboardNavigation'; import { Layout, SettingsContainer } from '../Layout'; import { NavigationBar, + NavigationBarButton, NavigationContainer, NavigationItems, NavigationScrollbars, @@ -44,7 +45,6 @@ import { StyledClearFilterButton, StyledContent, StyledFilter, - StyledFilterIconButton, StyledFilterRow, StyledHeaderSubTitle, StyledNavigationBarAttachment, @@ -137,9 +137,7 @@ export default function SelectLocation() { } </TitleBarItem> - <StyledFilterIconButton - onClick={onViewFilter} - aria-label={messages.gettext('Filter')}> + <NavigationBarButton onClick={onViewFilter} aria-label={messages.gettext('Filter')}> <ImageView source="icon-filter-round" tintColor={colors.white40} @@ -147,7 +145,7 @@ export default function SelectLocation() { height={24} width={24} /> - </StyledFilterIconButton> + </NavigationBarButton> </NavigationItems> </NavigationBar> diff --git a/gui/src/renderer/components/select-location/SelectLocationStyles.tsx b/gui/src/renderer/components/select-location/SelectLocationStyles.tsx index a031287e5e..fd401c8e8c 100644 --- a/gui/src/renderer/components/select-location/SelectLocationStyles.tsx +++ b/gui/src/renderer/components/select-location/SelectLocationStyles.tsx @@ -22,15 +22,6 @@ export const StyledNavigationBarAttachment = styled.div({ padding: '0 16px 14px', }); -export const StyledFilterIconButton = styled.button({ - justifySelf: 'end', - borderWidth: 0, - padding: 0, - margin: 0, - cursor: 'default', - backgroundColor: 'transparent', -}); - export const StyledFilterRow = styled.div({ ...tinyText, color: colors.white, diff --git a/gui/src/renderer/lib/history.tsx b/gui/src/renderer/lib/history.tsx index af851a01d4..741c298da6 100644 --- a/gui/src/renderer/lib/history.tsx +++ b/gui/src/renderer/lib/history.tsx @@ -139,6 +139,13 @@ export default class History { return nextIndex >= 0 && nextIndex < this.entries.length; } + public getPopTransition(steps = 1) { + // The back transition should be based on the last view to be popped, i.e. the one with the + // lowest index. + const transition = this.entries[this.index - steps + 1].state.transition; + return oppositeTransition(transition); + } + // This returns this object casted as History from the History module. The difference between this // one and the one in the history module is that this one has stricter types for the paths. // Instead of accepting any string it's limited to the paths we actually support. But this history @@ -183,13 +190,13 @@ export default class History { private popImpl(n = 1): ITransitionSpecification | undefined { if (this.canGo(-n)) { + const transition = this.getPopTransition(n); + this.lastAction = 'POP'; this.index -= n; - - const transition = this.entries[this.index + 1].state.transition; this.entries = this.entries.slice(0, this.index + 1); - return oppositeTransition(transition); + return transition; } else { return undefined; } 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/renderer/redux/settings-import/actions.ts b/gui/src/renderer/redux/settings-import/actions.ts new file mode 100644 index 0000000000..f31af1c1c6 --- /dev/null +++ b/gui/src/renderer/redux/settings-import/actions.ts @@ -0,0 +1,40 @@ +export interface SaveSettingsImportFormAction { + type: 'SAVE_SETTINGS_IMPORT_FORM'; + value: string; + submit: boolean; +} + +export interface ClearSettingsImportFormAction { + type: 'CLEAR_SETTINGS_IMPORT_FORM'; +} + +export interface UnsetSubmitSettingsImportFormAction { + type: 'UNSET_SUBMIT_SETTINGS_IMPORT_FORM'; +} + +export type SettingsImportAction = + | SaveSettingsImportFormAction + | ClearSettingsImportFormAction + | UnsetSubmitSettingsImportFormAction; + +function saveSettingsImportForm(value: string, submit: boolean): SaveSettingsImportFormAction { + return { + type: 'SAVE_SETTINGS_IMPORT_FORM', + value, + submit, + }; +} + +function clearSettingsImportForm(): ClearSettingsImportFormAction { + return { + type: 'CLEAR_SETTINGS_IMPORT_FORM', + }; +} + +function unsetSubmitSettingsImportForm(): UnsetSubmitSettingsImportFormAction { + return { + type: 'UNSET_SUBMIT_SETTINGS_IMPORT_FORM', + }; +} + +export default { saveSettingsImportForm, clearSettingsImportForm, unsetSubmitSettingsImportForm }; diff --git a/gui/src/renderer/redux/settings-import/reducers.ts b/gui/src/renderer/redux/settings-import/reducers.ts new file mode 100644 index 0000000000..76908bc67a --- /dev/null +++ b/gui/src/renderer/redux/settings-import/reducers.ts @@ -0,0 +1,41 @@ +import { ReduxAction } from '../store'; + +export interface SettingsImportReduxState { + value: string; + submit: boolean; +} + +const initialState: SettingsImportReduxState = { + value: '', + submit: false, +}; + +export default function ( + state: SettingsImportReduxState = initialState, + action: ReduxAction, +): SettingsImportReduxState { + switch (action.type) { + case 'SAVE_SETTINGS_IMPORT_FORM': + return { + ...state, + value: action.value, + submit: action.submit, + }; + + case 'CLEAR_SETTINGS_IMPORT_FORM': + return { + ...state, + value: '', + submit: false, + }; + + case 'UNSET_SUBMIT_SETTINGS_IMPORT_FORM': + return { + ...state, + submit: false, + }; + + default: + return state; + } +} diff --git a/gui/src/renderer/redux/settings/actions.ts b/gui/src/renderer/redux/settings/actions.ts index 969f8b0a41..aa4e460e6f 100644 --- a/gui/src/renderer/redux/settings/actions.ts +++ b/gui/src/renderer/redux/settings/actions.ts @@ -7,6 +7,7 @@ import { IDnsOptions, IWireguardEndpointData, ObfuscationSettings, + RelayOverride, } from '../../../shared/daemon-rpc-types'; import { IGuiSettingsState } from '../../../shared/gui-settings-state'; import { BridgeSettingsRedux, IRelayLocationCountryRedux, RelaySettingsRedux } from './reducers'; @@ -116,6 +117,11 @@ export interface ISetCurrentApiAccessMethod { accessMethod: AccessMethodSetting; } +export interface ISetRelayOverrides { + type: 'SET_RELAY_OVERRIDES'; + relayOverrides: Array<RelayOverride>; +} + export type SettingsAction = | IUpdateGuiSettingsAction | IUpdateRelayAction @@ -137,7 +143,8 @@ export type SettingsAction = | ISetObfuscationSettings | ISetCustomLists | ISetApiAccessMethods - | ISetCurrentApiAccessMethod; + | ISetCurrentApiAccessMethod + | ISetRelayOverrides; function updateGuiSettings(guiSettings: IGuiSettingsState): IUpdateGuiSettingsAction { return { @@ -298,6 +305,13 @@ function updateCurrentApiAccessMethod(setting: AccessMethodSetting): ISetCurrent }; } +function updateRelayOverrides(relayOverrides: Array<RelayOverride>): ISetRelayOverrides { + return { + type: 'SET_RELAY_OVERRIDES', + relayOverrides, + }; +} + export default { updateGuiSettings, updateRelay, @@ -320,4 +334,5 @@ export default { updateCustomLists, updateApiAccessMethods, updateCurrentApiAccessMethod, + updateRelayOverrides, }; diff --git a/gui/src/renderer/redux/settings/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts index bb971f896a..07502ab280 100644 --- a/gui/src/renderer/redux/settings/reducers.ts +++ b/gui/src/renderer/redux/settings/reducers.ts @@ -16,6 +16,7 @@ import { ProxySettings, RelayEndpointType, RelayLocation, + RelayOverride, RelayProtocol, TunnelProtocol, } from '../../../shared/daemon-rpc-types'; @@ -112,6 +113,7 @@ export interface ISettingsReduxState { customLists: CustomLists; apiAccessMethods: ApiAccessMethodSettings; currentApiAccessMethod?: AccessMethodSetting; + relayOverrides: Array<RelayOverride>; } const initialState: ISettingsReduxState = { @@ -181,6 +183,7 @@ const initialState: ISettingsReduxState = { customLists: [], apiAccessMethods: getDefaultApiAccessMethods(), currentApiAccessMethod: undefined, + relayOverrides: [], }; export default function ( @@ -323,6 +326,12 @@ export default function ( currentApiAccessMethod: action.accessMethod, }; + case 'SET_RELAY_OVERRIDES': + return { + ...state, + relayOverrides: action.relayOverrides, + }; + default: return state; } diff --git a/gui/src/renderer/redux/store.ts b/gui/src/renderer/redux/store.ts index 9634774038..1617acce3d 100644 --- a/gui/src/renderer/redux/store.ts +++ b/gui/src/renderer/redux/store.ts @@ -9,6 +9,8 @@ import connectionActions, { ConnectionAction } from './connection/actions'; import connectionReducer, { IConnectionReduxState } from './connection/reducers'; import settingsActions, { SettingsAction } from './settings/actions'; import settingsReducer, { ISettingsReduxState } from './settings/reducers'; +import { SettingsImportAction } from './settings-import/actions'; +import settingsImportReducer, { SettingsImportReduxState } from './settings-import/reducers'; import supportActions, { SupportAction } from './support/actions'; import supportReducer, { ISupportReduxState } from './support/reducers'; import userInterfaceActions, { UserInterfaceAction } from './userinterface/actions'; @@ -23,6 +25,7 @@ export interface IReduxState { support: ISupportReduxState; version: IVersionReduxState; userInterface: IUserInterfaceReduxState; + settingsImport: SettingsImportReduxState; } export type ReduxAction = @@ -31,7 +34,8 @@ export type ReduxAction = | SettingsAction | SupportAction | VersionAction - | UserInterfaceAction; + | UserInterfaceAction + | SettingsImportAction; export type ReduxStore = ReturnType<typeof configureStore>; export type ReduxDispatch = Dispatch<ReduxAction>; @@ -43,6 +47,7 @@ export default function configureStore() { support: supportReducer, version: versionReducer, userInterface: userInterfaceReducer, + settingsImport: settingsImportReducer, }; const rootReducer = combineReducers(reducers); diff --git a/gui/src/shared/daemon-rpc-types.ts b/gui/src/shared/daemon-rpc-types.ts index f048549b7a..e24c124f4c 100644 --- a/gui/src/shared/daemon-rpc-types.ts +++ b/gui/src/shared/daemon-rpc-types.ts @@ -435,6 +435,7 @@ export interface ISettings { obfuscationSettings: ObfuscationSettings; customLists: CustomLists; apiAccessMethods: ApiAccessMethodSettings; + relayOverrides: Array<RelayOverride>; } export type BridgeState = 'auto' | 'on' | 'off'; @@ -539,6 +540,12 @@ export type ApiAccessMethodSettings = { custom: Array<AccessMethodSetting>; }; +export interface RelayOverride { + hostname: string; + ipv4AddrIn?: string; + ipv6AddrIn?: string; +} + export function parseSocketAddress(socketAddrStr: string): ISocketAddress { const re = new RegExp(/(.+):(\d+)$/); const matches = socketAddrStr.match(re); diff --git a/gui/src/shared/ipc-schema.ts b/gui/src/shared/ipc-schema.ts index 5257bb7b78..b94fe0a701 100644 --- a/gui/src/shared/ipc-schema.ts +++ b/gui/src/shared/ipc-schema.ts @@ -159,6 +159,7 @@ export const ipcSchema = { openUrl: invoke<string, void>(), showOpenDialog: invoke<Electron.OpenDialogOptions, Electron.OpenDialogReturnValue>(), showLaunchDaemonSettings: invoke<void, void>(), + getPathBaseName: invoke<string, string>(), }, tunnel: { '': notifyRenderer<TunnelState>(), @@ -168,6 +169,8 @@ export const ipcSchema = { }, settings: { '': notifyRenderer<ISettings>(), + importFile: invoke<string, void>(), + importText: invoke<string, void>(), apiAccessMethodSettingChange: notifyRenderer<AccessMethodSetting>(), setAllowLan: invoke<boolean, void>(), setShowBetaReleases: invoke<boolean, void>(), @@ -187,6 +190,7 @@ export const ipcSchema = { setApiAccessMethod: invoke<string, void>(), testApiAccessMethodById: invoke<string, boolean>(), testCustomApiAccessMethod: invoke<CustomProxy, boolean>(), + clearAllRelayOverrides: invoke<void, void>(), }, guiSettings: { '': notifyRenderer<IGuiSettingsState>(), 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' diff --git a/gui/test/e2e/installed/state-dependent/settings-import.spec.ts b/gui/test/e2e/installed/state-dependent/settings-import.spec.ts new file mode 100644 index 0000000000..6b37b36244 --- /dev/null +++ b/gui/test/e2e/installed/state-dependent/settings-import.spec.ts @@ -0,0 +1,116 @@ +import { expect, test } from '@playwright/test'; +import { Page } from 'playwright'; + +import { startInstalledApp } from '../installed-utils'; +import { TestUtils } from '../../utils'; +import { RoutePath } from '../../../../src/renderer/lib/routes'; + +const INVALID_JSON = 'invalid json'; +const VALID_JSON = ` +{ + "relay_overrides": [ + { + "hostname": "se-got-wg-001", + "ipv4_addr_in": "127.0.0.1" + } + ] +} +`; + +// This test expects the daemon to be logged in. + +let page: Page; +let util: TestUtils; + +test.beforeAll(async () => { + ({ page, util } = await startInstalledApp()); +}); + +test.afterAll(async () => { + await page.close(); +}); + +async function navigateToSettingsImport() { + await util.waitForNavigation(async () => await page.click('button[aria-label="Settings"]')); + await util.waitForNavigation(async () => await page.getByText('VPN settings').click()); + + expect( + await util.waitForNavigation(async () => await page.getByText('Server IP override').click()) + ).toEqual(RoutePath.settingsImport); + + const title = page.locator('h1') + await expect(title).toHaveText('Server IP override'); +} + +test('App should display no overrides', async () => { + await navigateToSettingsImport(); + await expect(page.getByTestId('status-title')).toHaveText('NO OVERRIDES IMPORTED'); + await expect(page.getByText('Clear all overrides')).toBeDisabled(); +}); + +test('App should fail to import text', async () => { + expect( + await util.waitForNavigation(async () => await page.getByText('Import via text').click()) + ).toEqual(RoutePath.settingsTextImport); + + await page.locator('textarea').fill(INVALID_JSON); + expect( + await util.waitForNavigation(async () => await page.click('button[aria-label="Save"]')) + ).toEqual(RoutePath.settingsImport); + + await expect(page.getByTestId('status-title')).toHaveText('NO OVERRIDES IMPORTED'); + await expect(page.getByTestId('status-subtitle')).toBeVisible(); + await expect(page.getByText('Clear all overrides')).toBeDisabled(); + await expect(page.getByTestId('status-subtitle')).not.toBeEmpty(); +}); + +test('App should succeed to import text', async () => { + expect( + await util.waitForNavigation(async () => await page.getByText('Import via text').click()) + ).toEqual(RoutePath.settingsTextImport); + + const textarea = page.locator('textarea'); + await expect(textarea).toHaveValue(INVALID_JSON); + await textarea.fill(VALID_JSON); + expect( + await util.waitForNavigation(async () => await page.click('button[aria-label="Save"]')) + ).toEqual(RoutePath.settingsImport); + + await expect(page.getByTestId('status-title')).toHaveText('IMPORT SUCCESSFUL'); + await expect(page.getByTestId('status-subtitle')).toBeVisible(); + await expect(page.getByText('Clear all overrides')).toBeEnabled(); + await expect(page.getByTestId('status-subtitle')).not.toBeEmpty(); + + await expect(page.getByTestId('status-title')).toHaveText('OVERRIDES ACTIVE'); + + expect( + await util.waitForNavigation(async () => await page.getByText('Import via text').click()) + ).toEqual(RoutePath.settingsTextImport); + + await expect(textarea).toHaveValue(''); + + expect( + await util.waitForNavigation(async () => await page.click('button[aria-label="Close"]')) + ).toEqual(RoutePath.settingsImport); +}); + +test('App should show active overrides', async () => { + expect( + await util.waitForNavigation(async () => await page.click('button[aria-label="Back"]')) + ).toEqual(RoutePath.vpnSettings); + expect( + await util.waitForNavigation(async () => await page.getByText('Server IP override').click()) + ).toEqual(RoutePath.settingsImport); + + await expect(page.getByTestId('status-title')).toHaveText('OVERRIDES ACTIVE'); + await expect(page.getByText('Clear all overrides')).toBeEnabled(); +}); + +test('App should clear overrides', async () => { + await page.getByText('Clear all overrides').click(); + await expect(page.getByText('Clear all overrides?')).toBeVisible(); + + await page.getByText(/^Clear$/).click(); + await expect(page.getByTestId('status-title')).toHaveText('NO OVERRIDES IMPORTED'); + await expect(page.getByText(/Clear all overrides$/)).toBeDisabled(); +}); |
