import React, { useCallback } from 'react'; import { sprintf } from 'sprintf-js'; import { colors } from '../../config.json'; import { AccountDataError, AccountToken } from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; import { useAppContext } from '../context'; import { formatAccountToken } from '../lib/account'; import { formatHtml } from '../lib/html-formatter'; import { LoginState } from '../redux/account/reducers'; import { useSelector } from '../redux/store'; import Accordion from './Accordion'; import * as AppButton from './AppButton'; import { AriaControlGroup, AriaControlled, AriaControls } from './AriaGroup'; import { Brand, HeaderBarSettingsButton } from './HeaderBar'; import ImageView from './ImageView'; import { Container, Header, Layout } from './Layout'; import { StyledAccountDropdownContainer, StyledAccountDropdownItem, StyledAccountDropdownItemButton, StyledAccountDropdownItemButtonLabel, StyledAccountDropdownRemoveButton, StyledAccountDropdownRemoveIcon, StyledAccountInputBackdrop, StyledAccountInputGroup, StyledBlockMessage, StyledBlockMessageContainer, StyledBlockTitle, StyledDropdownSpacer, StyledFooter, StyledInput, StyledInputButton, StyledInputSubmitIcon, StyledLoginFooterPrompt, StyledLoginForm, StyledStatusIcon, StyledSubtitle, StyledTitle, StyledTopInfo, } from './LoginStyles'; interface IProps { accountToken?: AccountToken; accountHistory?: AccountToken; loginState: LoginState; showBlockMessage: boolean; openExternalLink: (type: string) => void; login: (accountToken: AccountToken) => void; resetLoginError: () => void; updateAccountToken: (accountToken: AccountToken) => void; clearAccountHistory: () => Promise; createNewAccount: () => void; isPerformingPostUpgrade?: boolean; } interface IState { isActive: boolean; } const MIN_ACCOUNT_TOKEN_LENGTH = 10; export default class Login extends React.Component { public state: IState = { isActive: true, }; private accountInput = React.createRef(); private shouldResetLoginError = false; constructor(props: IProps) { super(props); if (props.loginState.type === 'failed') { this.shouldResetLoginError = true; } } public componentDidUpdate(prevProps: IProps, _prevState: IState) { if ( this.props.loginState.type !== prevProps.loginState.type && this.props.loginState.type === 'failed' ) { this.shouldResetLoginError = true; // focus on login field when failed to log in this.accountInput.current?.focus(); } } public render() { const allowInteraction = this.allowInteraction(); return (
{this.props.showBlockMessage ? : this.getStatusIcon()} {this.formTitle()} {this.createLoginForm()} {this.createFooter()}
); } private onFocus = () => { this.setState({ isActive: true }); }; private onBlur = (e: React.FocusEvent) => { // restore focus if click happened within dropdown if (e.relatedTarget) { if (this.accountInput.current) { this.accountInput.current.focus(); } return; } this.setState({ isActive: false }); }; private onSubmit = (event?: React.FormEvent) => { event?.preventDefault(); if (this.accountTokenValid()) { this.props.login(this.props.accountToken!); } }; private onInputChange = (accountToken: string) => { // reset error when user types in the new account number if (this.shouldResetLoginError) { this.shouldResetLoginError = false; this.props.resetLoginError(); } this.props.updateAccountToken(accountToken); }; private formTitle() { if (this.props.isPerformingPostUpgrade) { return messages.pgettext('login-view', 'Upgrading...'); } switch (this.props.loginState.type) { case 'logging in': case 'too many devices': return this.props.loginState.method === 'existing_account' ? messages.pgettext('login-view', 'Logging in...') : messages.pgettext('login-view', 'Creating account...'); case 'failed': return this.props.loginState.method === 'existing_account' ? messages.pgettext('login-view', 'Login failed') : messages.pgettext('login-view', 'Error'); case 'ok': return this.props.loginState.method === 'existing_account' ? messages.pgettext('login-view', 'Logged in') : messages.pgettext('login-view', 'Account created'); default: return messages.pgettext('login-view', 'Login'); } } private formSubtitle() { if (this.props.isPerformingPostUpgrade) { return messages.pgettext('login-view', 'Finishing upgrade.'); } switch (this.props.loginState.type) { case 'failed': return this.props.loginState.method === 'existing_account' ? this.errorString(this.props.loginState.error) : messages.pgettext('login-view', 'Failed to create account'); case 'too many devices': return messages.pgettext('login-view', 'Too many devices'); case 'logging in': return this.props.loginState.method === 'existing_account' ? messages.pgettext('login-view', 'Checking account number') : messages.pgettext('login-view', 'Please wait'); case 'ok': return this.props.loginState.method === 'existing_account' ? messages.pgettext('login-view', 'Valid account number') : messages.pgettext('login-view', 'Logged in'); default: return messages.pgettext('login-view', 'Enter your account number'); } } private errorString(error: AccountDataError['error']): string { switch (error) { case 'invalid-account': // TRANSLATORS: Error message shown above login input when trying to login with a // TRANSLATORS: non-existent account number. return messages.pgettext('login-view', 'Invalid account number'); case 'too-many-devices': // TRANSLATORS: Error message shown above login input when trying to login to an account // TRANSLATORS: with too many registered devices. return messages.pgettext('login-view', 'Too many devices'); case 'list-devices': // TRANSLATORS: Error message shown above login input when trying to login but the app fails // TRANSLATORS: to fetch the list of registered devices. return messages.pgettext('login-view', 'Failed to fetch list of devices'); case 'communication': return 'api.mullvad.net is blocked, please check your firewall'; default: return messages.pgettext('login-view', 'Unknown error'); } } private getStatusIcon() { const statusIconPath = this.getStatusIconPath(); return ( {statusIconPath ? : null} ); } private getStatusIconPath(): string | undefined { if (this.props.isPerformingPostUpgrade) { return 'icon-spinner'; } switch (this.props.loginState.type) { case 'logging in': return 'icon-spinner'; case 'failed': return 'icon-fail'; case 'ok': return 'icon-success'; default: return undefined; } } private allowInteraction() { return ( !this.props.isPerformingPostUpgrade && this.props.loginState.type !== 'logging in' && this.props.loginState.type !== 'ok' && this.props.loginState.type !== 'too many devices' ); } private allowCreateAccount() { const { accountToken } = this.props; return this.allowInteraction() && (accountToken === undefined || accountToken.length === 0); } private accountTokenValid(): boolean { const { accountToken } = this.props; return accountToken !== undefined && accountToken.length >= MIN_ACCOUNT_TOKEN_LENGTH; } private shouldShowAccountHistory() { return this.allowInteraction() && this.props.accountHistory !== undefined; } private onSelectAccountFromHistory = (accountToken: string) => { this.props.updateAccountToken(accountToken); this.props.login(accountToken); }; private onClearAccountHistory = () => { void this.clearAccountHistory(); }; private async clearAccountHistory() { try { await this.props.clearAccountHistory(); // TODO: Remove account from memory } catch (error) { // TODO: Show error } } private createLoginForm() { const allowInteraction = this.allowInteraction(); const allowLogin = allowInteraction && this.accountTokenValid(); const hasError = this.props.loginState.type === 'failed' && this.props.loginState.method === 'existing_account'; return ( <> {this.formSubtitle()} ); } private createFooter() { return ( <> {messages.pgettext('login-view', 'Don’t have an account number?')} {messages.pgettext('login-view', 'Create account')} ); } } interface IAccountDropdownProps { item?: AccountToken; onSelect: (value: AccountToken) => void; onRemove: (value: AccountToken) => void; } function AccountDropdown(props: IAccountDropdownProps) { const token = props.item; if (!token) { return null; } const label = formatAccountToken(token); return ( ); } interface IAccountDropdownItemProps { label: string; value: AccountToken; onRemove: (value: AccountToken) => void; onSelect: (value: AccountToken) => void; } function AccountDropdownItem(props: IAccountDropdownItemProps) { const handleSelect = useCallback(() => { props.onSelect(props.value); }, [props.onSelect, props.value]); const handleRemove = useCallback( (event: React.MouseEvent) => { // Prevent login form from submitting event.preventDefault(); props.onRemove(props.value); }, [props.onRemove, props.value], ); return ( <> {props.label} ); } function BlockMessage() { const { setBlockWhenDisconnected, disconnectTunnel } = useAppContext(); const tunnelState = useSelector((state) => state.connection.status); const blockWhenDisconnected = useSelector((state) => state.settings.blockWhenDisconnected); const unlock = useCallback(() => { if (blockWhenDisconnected) { void setBlockWhenDisconnected(false); } if (tunnelState.state === 'error') { void disconnectTunnel(); } }, [blockWhenDisconnected, tunnelState, setBlockWhenDisconnected, disconnectTunnel]); const lockdownModeSettingName = messages.pgettext('vpn-settings-view', 'Lockdown mode'); const message = formatHtml( blockWhenDisconnected ? sprintf( // TRANSLATORS: This is a warning message shown when the app is blocking the users // TRANSLATORS: internet connection while logged out. // TRANSLATORS: Available placeholder: // TRANSLATORS: %(lockdownModeSettingName)s - The translation of "Lockdown mode" messages.pgettext( 'login-view', '%(lockdownModeSettingName)s is enabled. Disable it to unblock your connection.', ), { lockdownModeSettingName }, ) : // This makes the translator comment appear on it's own line. // TRANSLATORS: This is a warning message shown when the app is blocking the users // TRANSLATORS: internet connection while logged out. messages.pgettext('login-view', 'Our kill switch is currently blocking your connection.'), ); const buttonText = blockWhenDisconnected ? messages.gettext('Disable') : messages.gettext('Unblock'); return ( {messages.gettext('Blocking internet')} {message} {buttonText} ); }