import React, { useCallback } from 'react';
import { sprintf } from 'sprintf-js';
import { colors } from '../../config.json';
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 { formatHtml } from '../lib/html-formatter';
import accountActions from '../redux/account/actions';
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';
export default function LoginContainer() {
const { openUrl, login, clearAccountHistory, createNewAccount } = useAppContext();
const { resetLoginError, updateAccountNumber } = useActions(accountActions);
const { accountNumber, accountHistory, status } = useSelector((state) => state.account);
const tunnelState = useSelector((state) => state.connection.status);
const blockWhenDisconnected = useSelector((state) => state.settings.blockWhenDisconnected);
const showBlockMessage = tunnelState.state === 'error' || blockWhenDisconnected;
const isPerformingPostUpgrade = useSelector(
(state) => state.userInterface.isPerformingPostUpgrade,
);
return (
);
}
interface IProps {
accountNumber?: AccountNumber;
accountHistory?: AccountNumber;
loginState: LoginState;
showBlockMessage: boolean;
openExternalLink: (type: string) => void;
login: (accountNumber: AccountNumber) => void;
resetLoginError: () => void;
updateAccountNumber: (accountNumber: AccountNumber) => void;
clearAccountHistory: () => Promise;
createNewAccount: () => void;
isPerformingPostUpgrade?: boolean;
}
interface IState {
isActive: boolean;
}
const MIN_ACCOUNT_NUMBER_LENGTH = 10;
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.accountNumberValid()) {
this.props.login(this.props.accountNumber!);
}
};
private onInputChange = (accountNumber: string) => {
// reset error when user types in the new account number
if (this.shouldResetLoginError) {
this.shouldResetLoginError = false;
this.props.resetLoginError();
}
this.props.updateAccountNumber(accountNumber);
};
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 { accountNumber } = this.props;
return this.allowInteraction() && (accountNumber === undefined || accountNumber.length === 0);
}
private accountNumberValid(): boolean {
const { accountNumber } = this.props;
return accountNumber !== undefined && accountNumber.length >= MIN_ACCOUNT_NUMBER_LENGTH;
}
private shouldShowAccountHistory() {
return this.allowInteraction() && this.props.accountHistory !== undefined;
}
private onSelectAccountFromHistory = (accountNumber: string) => {
this.props.updateAccountNumber(accountNumber);
this.props.login(accountNumber);
};
private onClearAccountHistory = () => {
void this.clearAccountHistory();
};
private async clearAccountHistory() {
try {
await this.props.clearAccountHistory();
// TODO: Remove account from memory
} catch {
// TODO: Show error
}
}
private createLoginForm() {
const allowInteraction = this.allowInteraction();
const allowLogin = allowInteraction && this.accountNumberValid();
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?: AccountNumber;
onSelect: (value: AccountNumber) => void;
onRemove: (value: AccountNumber) => void;
}
function AccountDropdown(props: IAccountDropdownProps) {
const accountNumber = props.item;
if (!accountNumber) {
return null;
}
const label = formatAccountNumber(accountNumber);
return (
);
}
interface IAccountDropdownItemProps {
label: string;
value: AccountNumber;
onRemove: (value: AccountNumber) => void;
onSelect: (value: AccountNumber) => 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}
);
}