summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--desktop/packages/mullvad-vpn/locales/messages.pot42
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/login/ClearAccountHistoryDialog.tsx48
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/login/CreateAccountDialog.tsx47
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/login/Login.tsx (renamed from desktop/packages/mullvad-vpn/src/renderer/components/Login.tsx)209
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/login/LoginStyles.tsx (renamed from desktop/packages/mullvad-vpn/src/renderer/components/LoginStyles.tsx)10
-rw-r--r--desktop/packages/mullvad-vpn/src/shared/ipc-helpers.ts18
-rw-r--r--desktop/packages/mullvad-vpn/src/shared/ipc-schema.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/mocked/login.spec.ts103
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/mocked/mocked-utils.ts25
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/login/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/login/login-route-object-model.ts56
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/login/selectors.ts15
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/routes-object-model.ts3
14 files changed, 484 insertions, 98 deletions
diff --git a/desktop/packages/mullvad-vpn/locales/messages.pot b/desktop/packages/mullvad-vpn/locales/messages.pot
index 360a6a8482..bdc7ffe220 100644
--- a/desktop/packages/mullvad-vpn/locales/messages.pot
+++ b/desktop/packages/mullvad-vpn/locales/messages.pot
@@ -237,6 +237,10 @@ msgstr ""
msgid "Reconnect"
msgstr ""
+#. Button label in confirmation dialog that confirms a remove action.
+msgid "Remove"
+msgstr ""
+
msgid "Required"
msgstr ""
@@ -1484,10 +1488,25 @@ msgctxt "login-view"
msgid "Create account"
msgstr ""
+#. Button which confirms the action to create a new account.
+msgctxt "login-view"
+msgid "Create new account"
+msgstr ""
+
msgctxt "login-view"
msgid "Creating account..."
msgstr ""
+#. Text that asks the user if they really want to create a new account.
+msgctxt "login-view"
+msgid "Do you want to create a new account?"
+msgstr ""
+
+#. Text that asks the user if they really want to remove the saved account.
+msgctxt "login-view"
+msgid "Do you want to remove the saved account number?"
+msgstr ""
+
msgctxt "login-view"
msgid "Don’t have an account number?"
msgstr ""
@@ -1546,6 +1565,12 @@ msgctxt "login-view"
msgid "Please wait"
msgstr ""
+#. Text that informs the user about the consequences of clearing the saved
+#. account number.
+msgctxt "login-view"
+msgid "Removing the saved account number from this device cannot be undone."
+msgstr ""
+
#. Error message shown above login input when trying to login to an account
#. with too many registered devices.
msgctxt "login-view"
@@ -1564,6 +1589,11 @@ msgctxt "login-view"
msgid "Valid account number"
msgstr ""
+#. Text that informs the users about consequences of creating a new account.
+msgctxt "login-view"
+msgid "You already have a saved account number, by creating a new account the saved account number will be removed from this device. This cannot be undone."
+msgstr ""
+
#. Title label in navigation bar
msgctxt "navigation-bar"
msgid "API access"
@@ -2935,9 +2965,6 @@ msgstr ""
msgid "Create"
msgstr ""
-msgid "Create new account"
-msgstr ""
-
msgid "Create new list"
msgstr ""
@@ -2992,9 +3019,6 @@ msgstr ""
msgid "Dismiss"
msgstr ""
-msgid "Do you want to create a new account?"
-msgstr ""
-
msgid "Edit custom lists"
msgstr ""
@@ -3205,9 +3229,6 @@ msgstr ""
msgid "Recursion limit"
msgstr ""
-msgid "Remove"
-msgstr ""
-
msgid "Remove %1$s from %2$s"
msgstr ""
@@ -3400,9 +3421,6 @@ msgstr ""
msgid "WireGuard port"
msgstr ""
-msgid "You already have a saved account number, by creating a new account the saved account number will be removed from this device. This cannot be undone."
-msgstr ""
-
msgid "\"%s\" was created"
msgstr ""
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx
index 492199c76f..16fa8bcbda 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx
@@ -2,8 +2,8 @@ import { useCallback, useRef } from 'react';
import { Route, Switch } from 'react-router';
import { RoutePath } from '../../shared/routes';
-import LoginPage from '../components/Login';
import SelectLocation from '../components/select-location/SelectLocationContainer';
+import LoginPage from '../components/views/login/Login';
import { useViewTransitions } from '../lib/transition-hooks';
import Account from './Account';
import ApiAccessMethods from './ApiAccessMethods';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/login/ClearAccountHistoryDialog.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/login/ClearAccountHistoryDialog.tsx
new file mode 100644
index 0000000000..b2c3e370d5
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/login/ClearAccountHistoryDialog.tsx
@@ -0,0 +1,48 @@
+import { messages } from '../../../../shared/gettext';
+import { Button } from '../../../lib/components';
+import { ModalAlert, ModalAlertType, ModalMessage } from '../../Modal';
+
+interface Props {
+ visible: boolean;
+ onConfirm: () => void;
+ onHide: () => void;
+}
+
+export default function ClearAccountHistoryDialog(props: Props) {
+ return (
+ <ModalAlert
+ isOpen={props.visible}
+ type={ModalAlertType.caution}
+ buttons={[
+ <Button variant="destructive" key="confirm" onClick={props.onConfirm}>
+ <Button.Text>
+ {
+ // TRANSLATORS: Button label in confirmation dialog that confirms a remove action.
+ messages.gettext('Remove')
+ }
+ </Button.Text>
+ </Button>,
+ <Button key="back" onClick={props.onHide}>
+ <Button.Text>{messages.gettext('Cancel')}</Button.Text>
+ </Button>,
+ ]}
+ close={props.onHide}>
+ <ModalMessage>
+ {
+ // TRANSLATORS: Text that informs the user about the consequences of clearing the saved
+ // TRANSLATORS: account number.
+ messages.pgettext(
+ 'login-view',
+ 'Removing the saved account number from this device cannot be undone.',
+ )
+ }
+ </ModalMessage>
+ <ModalMessage>
+ {
+ // TRANSLATORS: Text that asks the user if they really want to remove the saved account.
+ messages.pgettext('login-view', 'Do you want to remove the saved account number?')
+ }
+ </ModalMessage>
+ </ModalAlert>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/login/CreateAccountDialog.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/login/CreateAccountDialog.tsx
new file mode 100644
index 0000000000..a416989e46
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/login/CreateAccountDialog.tsx
@@ -0,0 +1,47 @@
+import { messages } from '../../../../shared/gettext';
+import { Button } from '../../../lib/components';
+import { ModalAlert, ModalAlertType, ModalMessage } from '../../Modal';
+
+interface Props {
+ visible: boolean;
+ onConfirm: () => void;
+ onHide: () => void;
+}
+
+export default function ClearAccountHistoryDialog(props: Props) {
+ return (
+ <ModalAlert
+ isOpen={props.visible}
+ type={ModalAlertType.caution}
+ buttons={[
+ <Button key="confirm" onClick={props.onConfirm}>
+ <Button.Text>
+ {
+ // TRANSLATORS: Button which confirms the action to create a new account.
+ messages.pgettext('login-view', 'Create new account')
+ }
+ </Button.Text>
+ </Button>,
+ <Button key="back" onClick={props.onHide}>
+ <Button.Text>{messages.gettext('Cancel')}</Button.Text>
+ </Button>,
+ ]}
+ close={props.onHide}>
+ <ModalMessage>
+ {
+ // TRANSLATORS: Text that informs the users about consequences of creating a new account.
+ messages.pgettext(
+ 'login-view',
+ 'You already have a saved account number, by creating a new account the saved account number will be removed from this device. This cannot be undone.',
+ )
+ }
+ </ModalMessage>
+ <ModalMessage>
+ {
+ // TRANSLATORS: Text that asks the user if they really want to create a new account.
+ messages.pgettext('login-view', 'Do you want to create a new account?')
+ }
+ </ModalMessage>
+ </ModalAlert>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/Login.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/login/Login.tsx
index 8662c669f9..d085ea2b54 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/Login.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/login/Login.tsx
@@ -2,22 +2,33 @@ import React, { useCallback } from 'react';
import { sprintf } from 'sprintf-js';
import styled from 'styled-components';
-import { Url } from '../../shared/constants';
-import { AccountDataError, AccountNumber } from '../../shared/daemon-rpc-types';
-import { messages } from '../../shared/gettext';
-import { useAppContext } from '../context';
-import { formatAccountNumber } from '../lib/account';
-import useActions from '../lib/actionsHook';
-import { Box, Button, Flex, Icon, Label, LabelTiny, Spinner, TitleMedium } from '../lib/components';
-import { colors } from '../lib/foundations';
-import { formatHtml } from '../lib/html-formatter';
-import { IconBadge } from '../lib/icon-badge';
-import accountActions from '../redux/account/actions';
-import { LoginState } from '../redux/account/reducers';
-import { useSelector } from '../redux/store';
-import Accordion from './Accordion';
-import { AppMainHeader } from './app-main-header';
-import { Container, Layout } from './Layout';
+import { Url } from '../../../../shared/constants';
+import { AccountDataError, AccountNumber } from '../../../../shared/daemon-rpc-types';
+import { messages } from '../../../../shared/gettext';
+import { useAppContext } from '../../../context';
+import { formatAccountNumber } from '../../../lib/account';
+import useActions from '../../../lib/actionsHook';
+import {
+ Box,
+ Button,
+ Flex,
+ Icon,
+ Label,
+ LabelTiny,
+ Spinner,
+ TitleMedium,
+} from '../../../lib/components';
+import { colors } from '../../../lib/foundations';
+import { formatHtml } from '../../../lib/html-formatter';
+import { IconBadge } from '../../../lib/icon-badge';
+import accountActions from '../../../redux/account/actions';
+import { LoginState } from '../../../redux/account/reducers';
+import { useSelector } from '../../../redux/store';
+import Accordion from '../../Accordion';
+import { AppMainHeader } from '../../app-main-header';
+import { Container, Layout } from '../../Layout';
+import ClearAccountHistoryDialog from './ClearAccountHistoryDialog';
+import CreateAccountDialog from './CreateAccountDialog';
import {
StyledAccountDropdownContainer,
StyledAccountDropdownItem,
@@ -87,6 +98,8 @@ interface IProps {
interface IState {
isActive: boolean;
+ clearAccountHistoryDialogVisible: boolean;
+ createAccountDialogVisible: boolean;
}
const MIN_ACCOUNT_NUMBER_LENGTH = 10;
@@ -94,6 +107,8 @@ const MIN_ACCOUNT_NUMBER_LENGTH = 10;
class Login extends React.Component<IProps, IState> {
public state: IState = {
isActive: true,
+ clearAccountHistoryDialogVisible: false,
+ createAccountDialogVisible: false,
};
private accountInput = React.createRef<HTMLInputElement>();
@@ -290,9 +305,18 @@ class Login extends React.Component<IProps, IState> {
};
private onClearAccountHistory = () => {
+ this.setState({ clearAccountHistoryDialogVisible: true });
+ };
+
+ private onConfirmClearAccountHistory = () => {
+ this.hideClearAccountHistoryDialog();
void this.clearAccountHistory();
};
+ private hideClearAccountHistoryDialog = () => {
+ this.setState({ clearAccountHistoryDialogVisible: false });
+ };
+
private async clearAccountHistory() {
try {
await this.props.clearAccountHistory();
@@ -303,6 +327,23 @@ class Login extends React.Component<IProps, IState> {
}
}
+ private onCreateNewAccount = () => {
+ if (this.props.accountHistory !== undefined) {
+ this.setState({ createAccountDialogVisible: true });
+ } else {
+ this.onConfirmCreateNewAccount();
+ }
+ };
+
+ private onConfirmCreateNewAccount = () => {
+ this.props.createNewAccount();
+ this.hideCreateAccountDialog();
+ };
+
+ private hideCreateAccountDialog = () => {
+ this.setState({ createAccountDialogVisible: false });
+ };
+
private createLoginForm() {
const inputId = 'account-number-input';
const allowInteraction = this.allowInteraction();
@@ -312,72 +353,88 @@ class Login extends React.Component<IProps, IState> {
this.props.loginState.method === 'existing_account';
return (
- <Flex $flexDirection="column" $gap="small">
- <Label htmlFor={inputId} data-testid="subtitle">
- {this.formSubtitle()}
- </Label>
- <StyledAccountInputGroup
- $active={allowInteraction && this.state.isActive}
- $editable={allowInteraction}
- $error={hasError}
- onSubmit={this.onSubmit}>
- <StyledAccountInputBackdrop>
- <StyledInput
- id={inputId}
- allowedCharacters="[0-9]"
- separator=" "
- groupLength={4}
- placeholder="0000 0000 0000 0000"
- value={this.props.accountNumber || ''}
- disabled={!allowInteraction}
- onFocus={this.onFocus}
- onBlur={this.onBlur}
- handleChange={this.onInputChange}
- autoFocus={true}
- ref={this.accountInput}
- aria-autocomplete="list"
- />
- <StyledInputButton
- type="submit"
- $visible={allowLogin}
- disabled={!allowLogin}
- aria-label={
- // TRANSLATORS: This is used by screenreaders to communicate the login button.
- messages.pgettext('accessibility', 'Login')
- }>
- <StyledInputSubmitIcon
- $visible={
- this.props.loginState.type !== 'logging in' && !this.props.isPerformingPostUpgrade
- }
- icon="chevron-right"
- size="large"
+ <>
+ <Flex $flexDirection="column" $gap="small">
+ <Label htmlFor={inputId} data-testid="subtitle">
+ {this.formSubtitle()}
+ </Label>
+ <StyledAccountInputGroup
+ $active={allowInteraction && this.state.isActive}
+ $editable={allowInteraction}
+ $error={hasError}
+ onSubmit={this.onSubmit}>
+ <StyledAccountInputBackdrop>
+ <StyledInput
+ id={inputId}
+ allowedCharacters="[0-9]"
+ separator=" "
+ groupLength={4}
+ placeholder="0000 0000 0000 0000"
+ value={this.props.accountNumber || ''}
+ disabled={!allowInteraction}
+ onFocus={this.onFocus}
+ onBlur={this.onBlur}
+ handleChange={this.onInputChange}
+ autoFocus={true}
+ ref={this.accountInput}
+ aria-autocomplete="list"
/>
- </StyledInputButton>
- </StyledAccountInputBackdrop>
- <Accordion expanded={this.shouldShowAccountHistory()}>
- <StyledAccountDropdownContainer>
- <AccountDropdown
- item={this.props.accountHistory}
- onSelect={this.onSelectAccountFromHistory}
- onRemove={this.onClearAccountHistory}
- />
- </StyledAccountDropdownContainer>
- </Accordion>
- </StyledAccountInputGroup>
- </Flex>
+ <StyledInputButton
+ type="submit"
+ $visible={allowLogin}
+ disabled={!allowLogin}
+ aria-label={
+ // TRANSLATORS: This is used by screenreaders to communicate the login button.
+ messages.pgettext('accessibility', 'Login')
+ }>
+ <StyledInputSubmitIcon
+ $visible={
+ this.props.loginState.type !== 'logging in' &&
+ !this.props.isPerformingPostUpgrade
+ }
+ icon="chevron-right"
+ size="large"
+ />
+ </StyledInputButton>
+ </StyledAccountInputBackdrop>
+ <Accordion expanded={this.shouldShowAccountHistory()}>
+ <StyledAccountDropdownContainer>
+ <AccountDropdown
+ item={this.props.accountHistory}
+ onSelect={this.onSelectAccountFromHistory}
+ onRemove={this.onClearAccountHistory}
+ />
+ </StyledAccountDropdownContainer>
+ </Accordion>
+ </StyledAccountInputGroup>
+ </Flex>
+
+ <ClearAccountHistoryDialog
+ visible={this.state.clearAccountHistoryDialogVisible}
+ onConfirm={this.onConfirmClearAccountHistory}
+ onHide={this.hideClearAccountHistoryDialog}
+ />
+ </>
);
}
private createFooter() {
return (
- <Flex $flexDirection="column" $gap="small">
- <LabelTiny color="whiteAlpha60">
- {messages.pgettext('login-view', 'Don’t have an account number?')}
- </LabelTiny>
- <Button onClick={this.props.createNewAccount} disabled={!this.allowCreateAccount()}>
- <Button.Text>{messages.pgettext('login-view', 'Create account')}</Button.Text>
- </Button>
- </Flex>
+ <>
+ <Flex $flexDirection="column" $gap="small">
+ <LabelTiny color="whiteAlpha60">
+ {messages.pgettext('login-view', 'Don’t have an account number?')}
+ </LabelTiny>
+ <Button onClick={this.onCreateNewAccount} disabled={!this.allowCreateAccount()}>
+ <Button.Text>{messages.pgettext('login-view', 'Create account')}</Button.Text>
+ </Button>
+ </Flex>
+ <CreateAccountDialog
+ visible={this.state.createAccountDialogVisible}
+ onConfirm={this.onConfirmCreateNewAccount}
+ onHide={this.hideCreateAccountDialog}
+ />
+ </>
);
}
}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/LoginStyles.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/login/LoginStyles.tsx
index 68824f564c..54bdeab8b4 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/LoginStyles.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/login/LoginStyles.tsx
@@ -1,10 +1,10 @@
import styled from 'styled-components';
-import { Icon } from '../lib/components';
-import { colors, spacings } from '../lib/foundations';
-import { hugeText, largeText, measurements, smallText, tinyText } from './common-styles';
-import FormattableTextInput from './FormattableTextInput';
-import { Footer } from './Layout';
+import { Icon } from '../../../lib/components';
+import { colors, spacings } from '../../../lib/foundations';
+import { hugeText, largeText, measurements, smallText, tinyText } from '../../common-styles';
+import FormattableTextInput from '../../FormattableTextInput';
+import { Footer } from '../../Layout';
export const StyledAccountDropdownContainer = styled.ul({
display: 'flex',
diff --git a/desktop/packages/mullvad-vpn/src/shared/ipc-helpers.ts b/desktop/packages/mullvad-vpn/src/shared/ipc-helpers.ts
index c49b7df9b8..db219f4535 100644
--- a/desktop/packages/mullvad-vpn/src/shared/ipc-helpers.ts
+++ b/desktop/packages/mullvad-vpn/src/shared/ipc-helpers.ts
@@ -61,6 +61,11 @@ export type IpcRenderer<S extends Schema> = {
};
};
+// Transforms the provided schema into containing only the event keys.
+export type IpcEvents<S> = {
+ [G in keyof S]: { [C in keyof S[G]]: string };
+};
+
// Preforms the transformation of the main event channel in accordance with the above types.
export function createIpcMain<S extends Schema>(
schema: S,
@@ -99,10 +104,15 @@ export function createIpcRenderer<S extends Schema>(
});
}
-function createIpc<S extends Schema, T, R extends IpcMain<S> | IpcRenderer<S>>(
- ipc: S,
- fn: (event: string, key: string, spec: AnyIpcCall) => [newKey: string, newValue: T],
-): R {
+export function createIpcEvents<S extends Schema>(schema: S): IpcEvents<S> {
+ return createIpc(schema, (event, key) => [key, event]);
+}
+
+export function createIpc<
+ S extends Schema,
+ T,
+ R extends IpcMain<S> | IpcRenderer<S> | IpcEvents<S>,
+>(ipc: S, fn: (event: string, key: string, spec: AnyIpcCall) => [newKey: string, newValue: T]): R {
return Object.fromEntries(
Object.entries(ipc).map(([groupKey, group]) => {
const newGroup = Object.fromEntries(
diff --git a/desktop/packages/mullvad-vpn/src/shared/ipc-schema.ts b/desktop/packages/mullvad-vpn/src/shared/ipc-schema.ts
index 48148d9498..9a9580fc13 100644
--- a/desktop/packages/mullvad-vpn/src/shared/ipc-schema.ts
+++ b/desktop/packages/mullvad-vpn/src/shared/ipc-schema.ts
@@ -82,6 +82,8 @@ export interface IAppStateSnapshot {
isMacOs13OrNewer: boolean;
}
+export type IpcSchema = typeof ipcSchema;
+
// The different types of requests are:
// * send<ArgumentType>(), which is used for one-way communication from the renderer process to the
// main process. The main channel will have a property named 'handle<PropertyName>' and the
diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/login.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/login.spec.ts
new file mode 100644
index 0000000000..46f2dcd6a0
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/login.spec.ts
@@ -0,0 +1,103 @@
+import { expect, test } from '@playwright/test';
+import { Page } from 'playwright';
+
+import { DeviceEvent } from '../../../src/shared/daemon-rpc-types';
+import { RoutesObjectModel } from '../route-object-models';
+import { MockedTestUtils, startMockedApp } from './mocked-utils';
+
+let page: Page;
+let util: MockedTestUtils;
+let routes: RoutesObjectModel;
+
+test.describe.configure({ mode: 'parallel' });
+
+test.describe('Clear account history warnings', () => {
+ const startup = async () => {
+ ({ page, util } = await startMockedApp());
+ routes = new RoutesObjectModel(page, util);
+ };
+
+ const logout = async () => {
+ await util.sendMockIpcResponse<DeviceEvent>({
+ channel: util.ipcEvents.account.device,
+ response: { type: 'logged out', deviceState: { type: 'logged out' } },
+ });
+
+ await routes.login.waitForRoute();
+ };
+
+ test.beforeAll(async () => {
+ await startup();
+ await routes.main.waitForRoute();
+ });
+
+ test.beforeEach(async () => {
+ await logout();
+ });
+
+ test.afterAll(async () => {
+ await page.close();
+ });
+
+ const setAccountHistory = async () => {
+ await util.sendMockIpcResponse({
+ channel: util.ipcEvents.accountHistory[''],
+ response: '1234123412341234',
+ });
+ };
+
+ test('Should not warn about creating an account', async () => {
+ const accountHistoryItemButton = routes.login.getAccountHistoryItemButton();
+ await expect(accountHistoryItemButton).not.toBeVisible();
+
+ await Promise.all([
+ util.expectIpcCall(util.ipcEvents.account.create),
+ routes.login.createNewAccount(),
+ ]);
+ });
+
+ test('Should warn about creating an account', async () => {
+ await setAccountHistory();
+
+ const confirmationMessage = routes.login.getCreateNewAccountConfirmationMessage();
+ await expect(confirmationMessage).not.toBeVisible();
+ await routes.login.createNewAccount();
+ await expect(confirmationMessage).toBeVisible();
+ await routes.login.cancelCreateNewAccount();
+ await expect(confirmationMessage).not.toBeVisible();
+
+ await routes.login.createNewAccount();
+
+ await Promise.all([
+ util.expectIpcCall(util.ipcEvents.account.create),
+ routes.login.confirmCreateNewAccount(),
+ ]);
+ });
+
+ test('Should warn about clearing account history', async () => {
+ await setAccountHistory();
+
+ const accountHistoryItemButton = routes.login.getAccountHistoryItemButton();
+ await expect(accountHistoryItemButton).toBeVisible();
+
+ const confirmationMessage = routes.login.getClearAccountHistoryConfirmationMessage();
+ await expect(confirmationMessage).not.toBeVisible();
+ await routes.login.clearAccountHistory();
+ await expect(confirmationMessage).toBeVisible();
+ await routes.login.cancelClearAccountHistory();
+ await expect(confirmationMessage).not.toBeVisible();
+ await expect(accountHistoryItemButton).toBeVisible();
+
+ await routes.login.clearAccountHistory();
+ await Promise.all([
+ util.expectIpcCall(util.ipcEvents.accountHistory.clear),
+ routes.login.confirmClearAccountHistory(),
+ ]);
+
+ await util.sendMockIpcResponse({
+ channel: util.ipcEvents.accountHistory[''],
+ response: undefined,
+ });
+ await expect(accountHistoryItemButton).not.toBeVisible();
+ });
+});
diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/mocked-utils.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/mocked-utils.ts
index 171cc1cc50..0290536418 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/mocked/mocked-utils.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/mocked-utils.ts
@@ -1,5 +1,7 @@
import { ElectronApplication } from 'playwright';
+import { createIpcEvents, IpcEvents } from '../../../src/shared/ipc-helpers';
+import { IpcSchema, ipcSchema } from '../../../src/shared/ipc-schema';
import { startApp, TestUtils } from '../utils';
// This option can be removed in the future when/if we're able to tun the tests with the sandbox
@@ -13,6 +15,8 @@ interface StartMockedAppResponse extends Awaited<ReturnType<typeof startApp>> {
export interface MockedTestUtils extends TestUtils {
mockIpcHandle: MockIpcHandle;
sendMockIpcResponse: SendMockIpcResponse;
+ expectIpcCall: ExpectIpcCall;
+ ipcEvents: IpcEvents<IpcSchema>;
}
export const startMockedApp = async (): Promise<StartMockedAppResponse> => {
@@ -27,6 +31,7 @@ export const startMockedApp = async (): Promise<StartMockedAppResponse> => {
const startAppResult = await startApp({ args });
const mockIpcHandle = generateMockIpcHandle(startAppResult.app);
const sendMockIpcResponse = generateSendMockIpcResponse(startAppResult.app);
+ const expectIpcCall = generateExpectIpcCall(startAppResult.app);
return {
...startAppResult,
@@ -34,6 +39,9 @@ export const startMockedApp = async (): Promise<StartMockedAppResponse> => {
...startAppResult.util,
mockIpcHandle,
sendMockIpcResponse,
+ expectIpcCall,
+
+ ipcEvents: createIpcEvents(ipcSchema),
},
};
};
@@ -83,3 +91,20 @@ export const generateSendMockIpcResponse = (electronApp: ElectronApplication) =>
);
};
};
+
+export type ExpectIpcCall = ReturnType<typeof generateExpectIpcCall>;
+
+export const generateExpectIpcCall = (electronApp: ElectronApplication) => {
+ return <T>(channel: string): Promise<T> => {
+ return electronApp.evaluate(
+ ({ ipcMain }, { channel }) => {
+ return new Promise<T>((resolve) => {
+ ipcMain.handleOnce(channel, (_event, arg) => {
+ resolve(arg);
+ });
+ });
+ },
+ { channel },
+ );
+ };
+};
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/login/index.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/login/index.ts
new file mode 100644
index 0000000000..2bee8b20be
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/login/index.ts
@@ -0,0 +1,2 @@
+export * from './login-route-object-model';
+export * from './selectors';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/login/login-route-object-model.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/login/login-route-object-model.ts
new file mode 100644
index 0000000000..4dfe73a2a8
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/login/login-route-object-model.ts
@@ -0,0 +1,56 @@
+import { Page } from 'playwright';
+
+import { RoutePath } from '../../../../src/shared/routes';
+import { TestUtils } from '../../utils';
+import { createSelectors } from './selectors';
+
+export class LoginRouteObjectModel {
+ readonly selectors: ReturnType<typeof createSelectors>;
+
+ constructor(
+ private readonly page: Page,
+ private readonly utils: TestUtils,
+ ) {
+ this.selectors = createSelectors(this.page);
+ }
+
+ async waitForRoute() {
+ await this.utils.waitForRoute(RoutePath.login);
+ }
+
+ async createNewAccount() {
+ await this.selectors.createNewAccountButton().click();
+ }
+
+ getCreateNewAccountConfirmationMessage() {
+ return this.selectors.createNewAccountMessage();
+ }
+
+ async confirmCreateNewAccount() {
+ await this.selectors.confirmCreateNewAccountButton().click();
+ }
+
+ async cancelCreateNewAccount() {
+ await this.selectors.cancelDialogButton().click();
+ }
+
+ async clearAccountHistory() {
+ await this.selectors.clearAccountHistory().click();
+ }
+
+ getAccountHistoryItemButton() {
+ return this.selectors.accountHistoryItemButton();
+ }
+
+ getClearAccountHistoryConfirmationMessage() {
+ return this.selectors.clearAccountHistoryMessage();
+ }
+
+ async confirmClearAccountHistory() {
+ await this.selectors.confirmClearAccountHistoryButton().click();
+ }
+
+ async cancelClearAccountHistory() {
+ await this.selectors.cancelDialogButton().click();
+ }
+}
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/login/selectors.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/login/selectors.ts
new file mode 100644
index 0000000000..c49df8ce87
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/login/selectors.ts
@@ -0,0 +1,15 @@
+import { Page } from 'playwright';
+
+export const createSelectors = (page: Page) => ({
+ createNewAccountButton: () => page.getByRole('button', { name: 'Create account' }),
+ createNewAccountMessage: () => page.getByText('Do you want to create a new account?'),
+ confirmCreateNewAccountButton: () => page.getByRole('button', { name: 'Create new account' }),
+
+ accountHistoryItemButton: () => page.getByRole('button', { name: 'Login with account number' }),
+ clearAccountHistory: () => page.getByRole('button', { name: 'Forget account number' }),
+ clearAccountHistoryMessage: () =>
+ page.getByText('Do you want to remove the saved account number?'),
+ confirmClearAccountHistoryButton: () => page.getByRole('button', { name: 'Remove' }),
+
+ cancelDialogButton: () => page.getByRole('button', { name: 'Cancel' }),
+});
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/routes-object-model.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/routes-object-model.ts
index 2de9fa7d64..ec58260dd3 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/routes-object-model.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/routes-object-model.ts
@@ -4,6 +4,7 @@ import { TestUtils } from '../utils';
import { DaitaSettingsRouteObjectModel } from './daita-settings';
import { FilterRouteObjectModel } from './filter';
import { LaunchRouteObjectModel } from './launch';
+import { LoginRouteObjectModel } from './login';
import { MainRouteObjectModel } from './main';
import { MultihopSettingsRouteObjectModel } from './multihop-settings';
import { SelectLanguageRouteObjectModel } from './select-language';
@@ -17,6 +18,7 @@ import { WireguardSettingsRouteObjectModel } from './wireguard-settings';
export class RoutesObjectModel {
readonly main: MainRouteObjectModel;
readonly launch: LaunchRouteObjectModel;
+ readonly login: LoginRouteObjectModel;
readonly settings: SettingsRouteObjectModel;
readonly userInterfaceSettings: UserInterfaceSettingsRouteObjectModel;
readonly selectLanguage: SelectLanguageRouteObjectModel;
@@ -32,6 +34,7 @@ export class RoutesObjectModel {
this.selectLanguage = new SelectLanguageRouteObjectModel(page, utils);
this.main = new MainRouteObjectModel(page, utils);
this.launch = new LaunchRouteObjectModel(page, utils);
+ this.login = new LoginRouteObjectModel(page, utils);
this.settings = new SettingsRouteObjectModel(page, utils);
this.userInterfaceSettings = new UserInterfaceSettingsRouteObjectModel(page, utils);
this.filter = new FilterRouteObjectModel(page, utils);