summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorTobias Järvelöv <tobias.jarvelov@mullvad.net>2025-09-11 09:35:06 +0200
committerTobias Järvelöv <tobias.jarvelov@mullvad.net>2025-09-11 09:35:06 +0200
commite7de4693adc78878d2686f228b5c91c6bb9bc319 (patch)
tree256c461cbb139d8d21061b4bd4632458d1c097f1
parent8e7b6dcdf884f5f2b3876fd4d88f82797a50d850 (diff)
parentcf026ee0306eee2a9d6c3afe3fde0735565b383c (diff)
downloadmullvadvpn-e7de4693adc78878d2686f228b5c91c6bb9bc319.tar.xz
mullvadvpn-e7de4693adc78878d2686f228b5c91c6bb9bc319.zip
Merge branch 'add-login-button-des-2396'
-rw-r--r--desktop/packages/mullvad-vpn/locales/messages.pot21
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx12
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/NotificationSubtitle.tsx6
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/login/LoginStyles.tsx50
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/login/LoginView.tsx (renamed from desktop/packages/mullvad-vpn/src/renderer/components/views/login/Login.tsx)169
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/login/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/link/Link.tsx45
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/link/components/LinkIcon.tsx2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/link/components/LinkText.tsx5
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/link/hooks/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/link/hooks/use-hover-color.ts10
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/link/hooks/use-state-colors.ts24
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/view/components/Container.tsx2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/mocked/login.spec.ts28
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/login/login-route-object-model.ts12
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/login/selectors.ts5
17 files changed, 234 insertions, 161 deletions
diff --git a/desktop/packages/mullvad-vpn/locales/messages.pot b/desktop/packages/mullvad-vpn/locales/messages.pot
index bdc7ffe220..3eafee0b0e 100644
--- a/desktop/packages/mullvad-vpn/locales/messages.pot
+++ b/desktop/packages/mullvad-vpn/locales/messages.pot
@@ -1484,8 +1484,9 @@ msgctxt "login-view"
msgid "Checking account number"
msgstr ""
+#. Text in button that allows user to create a new account.
msgctxt "login-view"
-msgid "Create account"
+msgid "Create a new account"
msgstr ""
#. Button which confirms the action to create a new account.
@@ -1508,10 +1509,6 @@ msgid "Do you want to remove the saved account number?"
msgstr ""
msgctxt "login-view"
-msgid "Don’t have an account number?"
-msgstr ""
-
-msgctxt "login-view"
msgid "Enter your account number"
msgstr ""
@@ -1547,6 +1544,7 @@ msgctxt "login-view"
msgid "Logging in..."
msgstr ""
+#. Label for the login button.
msgctxt "login-view"
msgid "Login"
msgstr ""
@@ -1555,6 +1553,13 @@ msgctxt "login-view"
msgid "Login failed"
msgstr ""
+#. Text shown between two horizontal lines above the "create account" button.
+#. In this context it is used to separate the users alternative of logging in
+#. or creating a new account, "Login or Create a new account".
+msgctxt "login-view"
+msgid "Or"
+msgstr ""
+
#. This is a warning message shown when the app is blocking the users
#. internet connection while logged out.
msgctxt "login-view"
@@ -2965,6 +2970,9 @@ msgstr ""
msgid "Create"
msgstr ""
+msgid "Create account"
+msgstr ""
+
msgid "Create new list"
msgstr ""
@@ -3019,6 +3027,9 @@ msgstr ""
msgid "Dismiss"
msgstr ""
+msgid "Don’t have an account number?"
+msgstr ""
+
msgid "Edit custom lists"
msgstr ""
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx
index 16fa8bcbda..eb40349089 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx
@@ -3,7 +3,6 @@ import { Route, Switch } from 'react-router';
import { RoutePath } from '../../shared/routes';
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';
@@ -34,7 +33,14 @@ import Support from './Support';
import TooManyDevices from './TooManyDevices';
import UdpOverTcp from './UdpOverTcp';
import UserInterfaceSettings from './UserInterfaceSettings';
-import { AppInfoView, AppUpgradeView, ChangelogView, LaunchView, SettingsView } from './views';
+import {
+ AppInfoView,
+ AppUpgradeView,
+ ChangelogView,
+ LaunchView,
+ LoginView,
+ SettingsView,
+} from './views';
import VpnSettings from './VpnSettings';
import WireguardSettings from './WireguardSettings';
@@ -50,7 +56,7 @@ export default function AppRouter() {
<Focus ref={focusRef}>
<Switch key={currentLocation.key} location={currentLocation}>
<Route exact path={RoutePath.launch} component={LaunchView} />
- <Route exact path={RoutePath.login} component={LoginPage} />
+ <Route exact path={RoutePath.login} component={LoginView} />
<Route exact path={RoutePath.tooManyDevices} component={TooManyDevices} />
<Route exact path={RoutePath.deviceRevoked} component={DeviceRevokedView} />
<Route exact path={RoutePath.main} component={MainView} />
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/NotificationSubtitle.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/NotificationSubtitle.tsx
index 204da6fabc..837b0701cf 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/NotificationSubtitle.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/NotificationSubtitle.tsx
@@ -32,11 +32,7 @@ const formatSubtitle = (subtitle: InAppNotificationSubtitle) => {
);
case 'run-function':
return (
- <StyledLink
- forwardedAs="button"
- color="white"
- variant="labelTiny"
- {...subtitle.action.button}>
+ <StyledLink forwardedAs="button" variant="labelTiny" {...subtitle.action.button}>
<StyledLink.Text>{content}</StyledLink.Text>
</StyledLink>
);
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/index.ts
index 2ce9d17013..c307b7eb71 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/views/index.ts
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/index.ts
@@ -1,5 +1,6 @@
export * from './app-info';
export * from './app-upgrade';
export * from './launch';
+export * from './login';
export * from './changelog';
export * from './settings';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/login/LoginStyles.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/login/LoginStyles.tsx
index 54bdeab8b4..f7aa67d824 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/views/login/LoginStyles.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/login/LoginStyles.tsx
@@ -1,10 +1,9 @@
import styled from 'styled-components';
-import { Icon } from '../../../lib/components';
+import { Icon, Layout } from '../../../lib/components';
import { colors, spacings } from '../../../lib/foundations';
-import { hugeText, largeText, measurements, smallText, tinyText } from '../../common-styles';
+import { hugeText, largeText, smallText, tinyText } from '../../common-styles';
import FormattableTextInput from '../../FormattableTextInput';
-import { Footer } from '../../Layout';
export const StyledAccountDropdownContainer = styled.ul({
display: 'flex',
@@ -46,46 +45,23 @@ export const StyledAccountDropdownItemIconButton = styled.button({
justifyContent: 'center',
});
-export const StyledTopInfo = styled.div({
- display: 'flex',
- justifyContent: 'center',
- flex: 1,
-});
-
-export const StyledFooter = styled(Footer)<{ $show: boolean }>((props) => ({
- position: 'relative',
- width: '100%',
- bottom: 0,
- transform: `translateY(${props.$show ? 0 : 100}%)`,
- backgroundColor: colors.darkBlue,
- transition: 'transform 250ms ease-in-out',
-}));
-
export const StyledStatusIcon = styled.div({
display: 'flex',
alignSelf: 'end',
flex: 0,
- marginBottom: '30px',
justifyContent: 'center',
+ marginTop: spacings.large,
height: '48px',
minHeight: '48px',
});
-export const StyledLoginForm = styled.div({
- display: 'flex',
- flex: '0 1 225px',
- flexDirection: 'column',
- overflow: 'visible',
- padding: `0 ${measurements.horizontalViewMargin}`,
-});
-
interface IStyledAccountInputGroupProps {
$editable: boolean;
$active: boolean;
$error: boolean;
}
-export const StyledAccountInputGroup = styled.form<IStyledAccountInputGroupProps>((props) => ({
+export const StyledAccountInputGroup = styled.div<IStyledAccountInputGroupProps>((props) => ({
borderWidth: '2px',
borderStyle: 'solid',
borderRadius: '8px',
@@ -100,17 +76,6 @@ export const StyledAccountInputBackdrop = styled.div({
borderColor: colors.darkBlue,
});
-export const StyledInputButton = styled.button<{ $visible: boolean }>((props) => ({
- display: 'flex',
- borderWidth: 0,
- width: '48px',
- alignItems: 'center',
- justifyContent: 'center',
- opacity: props.$visible ? 1 : 0,
- transition: 'opacity 250ms ease-in-out',
- backgroundColor: colors.green,
-}));
-
export const StyledDropdownSpacer = styled.div({
height: 1,
backgroundColor: colors.darkBlue,
@@ -142,7 +107,6 @@ export const StyledBlockMessageContainer = styled.div({
alignSelf: 'start',
backgroundColor: colors.darkBlue,
borderRadius: '8px',
- margin: '5px 16px 10px',
padding: '16px',
});
@@ -156,3 +120,9 @@ export const StyledBlockMessage = styled.div(tinyText, {
color: colors.white,
marginBottom: '10px',
});
+
+export const StyledLine = styled(Layout)`
+ height: 1px;
+ width: 100%;
+ background-color: ${colors.whiteAlpha20};
+`;
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/login/Login.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/login/LoginView.tsx
index d085ea2b54..87de6e1a61 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/views/login/Login.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/login/LoginView.tsx
@@ -14,10 +14,13 @@ import {
Flex,
Icon,
Label,
- LabelTiny,
+ Link,
Spinner,
+ Text,
TitleMedium,
} from '../../../lib/components';
+import { FlexColumn } from '../../../lib/components/flex-column';
+import { View } from '../../../lib/components/view';
import { colors } from '../../../lib/foundations';
import { formatHtml } from '../../../lib/html-formatter';
import { IconBadge } from '../../../lib/icon-badge';
@@ -26,7 +29,6 @@ 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 {
@@ -40,17 +42,12 @@ import {
StyledBlockMessageContainer,
StyledBlockTitle,
StyledDropdownSpacer,
- StyledFooter,
StyledInput,
- StyledInputButton,
- StyledInputSubmitIcon,
- StyledLoginForm,
+ StyledLine,
StyledStatusIcon,
- StyledTitle,
- StyledTopInfo,
} from './LoginStyles';
-export default function LoginContainer() {
+export function LoginView() {
const { openUrl, login, clearAccountHistory, createNewAccount } = useAppContext();
const { resetLoginError, updateAccountNumber } = useActions(accountActions);
@@ -136,26 +133,46 @@ class Login extends React.Component<IProps, IState> {
public render() {
const allowInteraction = this.allowInteraction();
-
return (
- <Layout>
+ <View>
<AppMainHeader>
<AppMainHeader.SettingsButton disabled={!allowInteraction} />
</AppMainHeader>
- <Container>
- <StyledTopInfo>
- {this.props.showBlockMessage ? <BlockMessage /> : this.getStatusIcon()}
- </StyledTopInfo>
-
- <StyledLoginForm>
- <StyledTitle aria-live="polite">{this.formTitle()}</StyledTitle>
+ <View.Container size="4" $justifyContent="center" $padding={{ bottom: 'large' }}>
+ <FlexColumn $gap="medium">
+ <Flex $flex={1} $justifyContent="center">
+ {this.props.showBlockMessage ? <BlockMessage /> : this.getStatusIcon()}
+ </Flex>
- {this.createLoginForm()}
- </StyledLoginForm>
+ <FlexColumn
+ $gap="large"
+ $margin={{ horizontal: 'small' }}
+ $justifyContent="center"
+ $flexGrow={1}>
+ <FlexColumn $gap="small">
+ <Text as="h1" variant="titleBig" aria-live="polite">
+ {this.formTitle()}
+ </Text>
- <StyledFooter $show={allowInteraction}>{this.createFooter()}</StyledFooter>
- </Container>
- </Layout>
+ {this.createLoginForm()}
+ </FlexColumn>
+ <Flex $justifyContent="center">
+ <StyledLine $margin={{ vertical: 'small', right: 'small' }} />
+ <Text variant="labelTiny">
+ {
+ // TRANSLATORS: Text shown between two horizontal lines above the "create account" button.
+ // TRANSLATORS: In this context it is used to separate the users alternative of logging in
+ // TRANSLATORS: or creating a new account, "Login or Create a new account".
+ messages.pgettext('login-view', 'Or')
+ }
+ </Text>
+ <StyledLine $margin={{ vertical: 'small', left: 'small' }} />
+ </Flex>
+ </FlexColumn>
+ {this.createFooter()}
+ </FlexColumn>
+ </View.Container>
+ </View>
);
}
@@ -354,59 +371,60 @@ class Login extends React.Component<IProps, IState> {
return (
<>
- <Flex $flexDirection="column" $gap="small">
- <Label htmlFor={inputId} data-testid="subtitle">
+ <Flex $flexDirection="column" $gap="tiny">
+ <Label htmlFor={inputId} variant="labelTiny" color="whiteAlpha60" 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
+ <form onSubmit={this.onSubmit}>
+ <FlexColumn $gap="large">
+ <StyledAccountInputGroup
+ $active={allowInteraction && this.state.isActive}
+ $editable={allowInteraction}
+ $error={hasError}>
+ <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"
+ />
+ </StyledAccountInputBackdrop>
+ <Accordion expanded={this.shouldShowAccountHistory()}>
+ <StyledAccountDropdownContainer>
+ <AccountDropdown
+ item={this.props.accountHistory}
+ onSelect={this.onSelectAccountFromHistory}
+ onRemove={this.onClearAccountHistory}
+ />
+ </StyledAccountDropdownContainer>
+ </Accordion>
+ </StyledAccountInputGroup>
+ <Button
type="submit"
- $visible={allowLogin}
+ variant="success"
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
+ <Button.Text>
+ {
+ // TRANSLATORS: Label for the login button.
+ messages.pgettext('login-view', 'Login')
}
- 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>
+ </Button.Text>
+ </Button>
+ </FlexColumn>
+ </form>
</Flex>
<ClearAccountHistoryDialog
@@ -421,13 +439,15 @@ class Login extends React.Component<IProps, IState> {
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.onCreateNewAccount} disabled={!this.allowCreateAccount()}>
- <Button.Text>{messages.pgettext('login-view', 'Create account')}</Button.Text>
- </Button>
+ <Flex $flexDirection="column" $gap="small" $alignItems="center">
+ <Link as="button" onClick={this.onCreateNewAccount} disabled={!this.allowCreateAccount()}>
+ <Link.Text>
+ {
+ // TRANSLATORS: Text in button that allows user to create a new account.
+ messages.pgettext('login-view', 'Create a new account')
+ }
+ </Link.Text>
+ </Link>
</Flex>
<CreateAccountDialog
visible={this.state.createAccountDialogVisible}
@@ -511,6 +531,7 @@ function AccountDropdownItem({ label, onRemove, onSelect, value }: AccountDropdo
<Box $height="48px" $width="48px" center>
<StyledAccountDropdownItemIconButton
onClick={handleRemove}
+ type="button"
aria-controls={itemId}
aria-label={sprintf(
// TRANSLATORS: This is used by screenreaders to communicate the "x" button next to a saved account number.
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/login/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/login/index.ts
new file mode 100644
index 0000000000..0ded5f7ba6
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/login/index.ts
@@ -0,0 +1 @@
+export * from './LoginView';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/link/Link.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/link/Link.tsx
index 5e1bef8d5f..747eff06ed 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/link/Link.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/link/Link.tsx
@@ -1,26 +1,25 @@
import React from 'react';
import styled, { css } from 'styled-components';
-import { Colors, colors, Radius, Typography } from '../../foundations';
-import { TransientProps } from '../../types';
+import { Colors, colors, Typography } from '../../foundations';
+import { PolymorphicProps } from '../../types';
import { LinkIcon, LinkText, StyledIcon as StyledLinkIcon, StyledLinkText } from './components';
-import { useHoverColor } from './hooks';
+import { useStateColors } from './hooks';
import { LinkProvider } from './LinkContext';
type LinkBaseProps = {
variant?: Typography;
color?: Colors;
- onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
};
-export type LinkProps = LinkBaseProps &
- Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof LinkBaseProps>;
+export type LinkProps<T extends React.ElementType = 'a'> = PolymorphicProps<T, LinkBaseProps>;
-const StyledLink = styled.a<
- TransientProps<LinkProps> & {
- $hoverColor?: Colors;
- }
->(({ $hoverColor }) => {
+const StyledLink = styled.a<{
+ $hoverColor: Colors;
+ $activeColor: Colors;
+}>(({ $hoverColor, $activeColor }) => {
+ const hoverColor = colors[$hoverColor];
+ const activeColor = colors[$activeColor];
return css`
cursor: default;
text-decoration: none;
@@ -28,17 +27,22 @@ const StyledLink = styled.a<
width: fit-content;
&&:hover > ${StyledLinkText} {
- text-decoration-line: underline;
- text-underline-offset: 2px;
- color: ${$hoverColor};
+ color: ${hoverColor};
+ }
+
+ &&:active > ${StyledLinkText} {
+ color: ${activeColor};
}
&&:focus-visible > ${StyledLinkText} {
- border-radius: ${Radius.radius4};
outline: 2px solid ${colors.white};
outline-offset: 2px;
}
+ &&:disabled > ${StyledLinkText} {
+ color: ${colors.whiteAlpha40};
+ }
+
> ${StyledLinkIcon}:first-child:not(:only-child) {
margin-right: 2px;
}
@@ -48,11 +52,16 @@ const StyledLink = styled.a<
`;
});
-function Link({ color, variant, children, ...props }: LinkProps) {
- const hoverColor = useHoverColor(color);
+function Link<T extends React.ElementType = 'a'>({
+ color = 'chalk',
+ variant,
+ children,
+ ...props
+}: LinkProps<T>) {
+ const { hover, active } = useStateColors(color);
return (
<LinkProvider variant={variant} color={color}>
- <StyledLink $hoverColor={hoverColor} {...props}>
+ <StyledLink $hoverColor={hover} $activeColor={active} draggable={false} {...props}>
{children}
</StyledLink>
</LinkProvider>
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/link/components/LinkIcon.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/link/components/LinkIcon.tsx
index 3a82f11a4d..6b4ac8f4f0 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/link/components/LinkIcon.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/link/components/LinkIcon.tsx
@@ -11,5 +11,5 @@ export const StyledIcon = styled(Icon)`
`;
export function LinkIcon({ ...props }: LinkIconProps) {
- return <StyledIcon size="small" {...props} />;
+ return <StyledIcon size="small" color="chalk" {...props} />;
}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/link/components/LinkText.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/link/components/LinkText.tsx
index c8c1ff8baa..26acb87e15 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/link/components/LinkText.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/link/components/LinkText.tsx
@@ -5,7 +5,10 @@ import { useLinkContext } from '../LinkContext';
export type LinkTextProps = TextProps;
-export const StyledLinkText = styled(Text)``;
+export const StyledLinkText = styled(Text)`
+ text-decoration-line: underline;
+ text-underline-offset: 2px;
+`;
export function LinkText(props: LinkTextProps) {
const { variant, color } = useLinkContext();
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/link/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/link/hooks/index.ts
index 4f17d940a4..700c211e5c 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/link/hooks/index.ts
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/link/hooks/index.ts
@@ -1 +1 @@
-export * from './use-hover-color';
+export * from './use-state-colors';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/link/hooks/use-hover-color.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/link/hooks/use-hover-color.ts
deleted file mode 100644
index f62f26a6fe..0000000000
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/link/hooks/use-hover-color.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { Colors } from '../../../foundations';
-
-export const useHoverColor = (color: Colors | undefined) => {
- switch (color) {
- case 'whiteAlpha60':
- return 'white';
- default:
- return undefined;
- }
-};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/link/hooks/use-state-colors.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/link/hooks/use-state-colors.ts
new file mode 100644
index 0000000000..b1a7426e14
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/link/hooks/use-state-colors.ts
@@ -0,0 +1,24 @@
+import { Colors } from '../../../foundations';
+
+const colorMap: Record<
+ Extract<Colors, 'chalk'> | 'default',
+ {
+ hover: Colors;
+ active: Colors;
+ }
+> = {
+ chalk: { hover: 'whiteAlpha60', active: 'whiteAlpha20' },
+ default: { hover: 'whiteAlpha60', active: 'whiteAlpha20' },
+} as const;
+
+export const useStateColors = (
+ color: Colors | undefined,
+): {
+ hover: Colors;
+ active: Colors;
+} => {
+ if (color === 'chalk') {
+ return colorMap[color];
+ }
+ return colorMap.default;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/view/components/Container.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/view/components/Container.tsx
index 69d2f5291b..3425a70397 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/view/components/Container.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/view/components/Container.tsx
@@ -16,7 +16,7 @@ const sizes: Record<'3' | '4', string> = {
const StyledFlex = styled(Flex)<{ $size: string }>((props) => ({
width: props.$size,
- margin: 'auto',
+ margin: '0 auto',
}));
export function Container({ size = '4', ...props }: ContainerProps) {
diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/login.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/login.spec.ts
index fb868a639e..b7e0801ceb 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/mocked/login.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/login.spec.ts
@@ -10,7 +10,7 @@ let routes: RoutesObjectModel;
test.describe.configure({ mode: 'parallel' });
-test.describe('Clear account history warnings', () => {
+test.describe('Login view', () => {
const startup = async () => {
({ page, util } = await startMockedApp());
routes = new RoutesObjectModel(page, util);
@@ -42,6 +42,32 @@ test.describe('Clear account history warnings', () => {
await util.ipc.accountHistory[''].notify('1234123412341234');
};
+ test('Should try to login when clicking login button', async () => {
+ await routes.login.fillAccountNumber('1234 1234 1234 1234');
+
+ await Promise.all([util.ipc.account.login.expect(), routes.login.loginByPressingEnter()]);
+ const header = routes.login.selectors.header();
+ await expect(header).toHaveText('Logging in...');
+ await expect(routes.login.selectors.loginButton()).toBeDisabled();
+ });
+
+ test('Should try to login when pressing enter', async () => {
+ await routes.login.fillAccountNumber('1234 1234 1234 1234');
+
+ await Promise.all([util.ipc.account.login.expect(), routes.login.loginByPressingEnter()]);
+ const header = routes.login.selectors.header();
+ await expect(header).toHaveText('Logging in...');
+ await expect(routes.login.selectors.loginButton()).toBeDisabled();
+ });
+
+ test('Should disable login button when input is invalid', async () => {
+ const loginButton = routes.login.selectors.loginButton();
+ await expect(loginButton).toBeDisabled();
+
+ await routes.login.fillAccountNumber('1234 1234');
+ await expect(loginButton).toBeDisabled();
+ });
+
test('Should not warn about creating an account', async () => {
const accountHistoryItemButton = routes.login.getAccountHistoryItemButton();
await expect(accountHistoryItemButton).not.toBeVisible();
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
index 4dfe73a2a8..a866052551 100644
--- 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
@@ -18,6 +18,18 @@ export class LoginRouteObjectModel {
await this.utils.waitForRoute(RoutePath.login);
}
+ fillAccountNumber(accountNumber: string) {
+ return this.selectors.loginInput().fill(accountNumber);
+ }
+
+ async loginByPressingEnter() {
+ await this.selectors.loginInput().press('Enter');
+ }
+
+ async loginByClickingLoginButton() {
+ await this.selectors.loginButton().click();
+ }
+
async createNewAccount() {
await this.selectors.createNewAccountButton().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
index c49df8ce87..12ed0720db 100644
--- 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
@@ -1,7 +1,7 @@
import { Page } from 'playwright';
export const createSelectors = (page: Page) => ({
- createNewAccountButton: () => page.getByRole('button', { name: 'Create account' }),
+ createNewAccountButton: () => page.getByRole('button', { name: 'Create a new account' }),
createNewAccountMessage: () => page.getByText('Do you want to create a new account?'),
confirmCreateNewAccountButton: () => page.getByRole('button', { name: 'Create new account' }),
@@ -12,4 +12,7 @@ export const createSelectors = (page: Page) => ({
confirmClearAccountHistoryButton: () => page.getByRole('button', { name: 'Remove' }),
cancelDialogButton: () => page.getByRole('button', { name: 'Cancel' }),
+ loginInput: () => page.getByLabel('Enter your account number'),
+ loginButton: () => page.getByRole('button', { name: 'Login', exact: true }),
+ header: () => page.getByRole('heading', { level: 1 }),
});