diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2021-11-26 14:19:08 +0100 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2022-03-14 13:58:44 +0100 |
| commit | ebfdf9289aff8d85266161b17f490bc0a661ffcf (patch) | |
| tree | 07cd905c535564dfc3b35a63856ec21359aad658 /gui | |
| parent | 9916f3444c1d15a6d9ad5caba3555e1c0e1a704c (diff) | |
| download | mullvadvpn-ebfdf9289aff8d85266161b17f490bc0a661ffcf.tar.xz mullvadvpn-ebfdf9289aff8d85266161b17f490bc0a661ffcf.zip | |
Add log out confirmation dialog
Diffstat (limited to 'gui')
| -rw-r--r-- | gui/src/main/daemon-rpc.ts | 16 | ||||
| -rw-r--r-- | gui/src/main/index.ts | 4 | ||||
| -rw-r--r-- | gui/src/renderer/app.tsx | 6 | ||||
| -rw-r--r-- | gui/src/renderer/components/Account.tsx | 92 | ||||
| -rw-r--r-- | gui/src/renderer/components/AccountStyles.tsx | 7 | ||||
| -rw-r--r-- | gui/src/renderer/components/TooManyDevices.tsx | 70 | ||||
| -rw-r--r-- | gui/src/renderer/containers/AccountPage.tsx | 9 | ||||
| -rw-r--r-- | gui/src/renderer/redux/account/actions.ts | 24 | ||||
| -rw-r--r-- | gui/src/renderer/redux/account/reducers.ts | 13 | ||||
| -rw-r--r-- | gui/src/shared/daemon-rpc-types.ts | 1 | ||||
| -rw-r--r-- | gui/src/shared/ipc-schema.ts | 1 | ||||
| -rw-r--r-- | gui/src/shared/string-helpers.ts | 4 |
12 files changed, 208 insertions, 39 deletions
diff --git a/gui/src/main/daemon-rpc.ts b/gui/src/main/daemon-rpc.ts index bb90d77d52..ff0c81a707 100644 --- a/gui/src/main/daemon-rpc.ts +++ b/gui/src/main/daemon-rpc.ts @@ -516,7 +516,7 @@ export class DaemonRpc { accountToken, ); - return response.toObject().devicesList; + return response.getDevicesList().map(convertFromDevice); } public async removeDevice(deviceRemoval: IDeviceRemoval): Promise<void> { @@ -1376,16 +1376,26 @@ function convertFromDeviceEvent(deviceEvent: grpcTypes.DeviceEvent): IDeviceEven } function convertFromDeviceConfig(deviceConfig?: grpcTypes.DeviceConfig): DeviceConfig { + const device = deviceConfig?.getDevice(); return ( deviceConfig && { accountToken: deviceConfig.getAccountToken(), - device: deviceConfig.getDevice()?.toObject(), + device: device ? convertFromDevice(device) : undefined, } ); } function convertFromDeviceRemoval(deviceRemoval: grpcTypes.RemoveDeviceEvent): Array<IDevice> { - return deviceRemoval.getNewDeviceListList().map((device) => device.toObject()); + return deviceRemoval.getNewDeviceListList().map(convertFromDevice); +} + +function convertFromDevice(device: grpcTypes.Device): IDevice { + const asObject = device.toObject(); + + return { + ...asObject, + ports: asObject.portsList.map((port) => port.id), + }; } function ensureExists<T>(value: T | undefined, errorMessage: string): T { diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts index 9bb988a199..b1f7f44b8a 100644 --- a/gui/src/main/index.ts +++ b/gui/src/main/index.ts @@ -1312,6 +1312,10 @@ class ApplicationMain { }); IpcMainEventChannel.account.handleUpdateData(() => this.updateAccountData()); + IpcMainEventChannel.account.handleGetDevice(async () => { + const deviceConfig = await this.daemonRpc.getDevice(); + return deviceConfig?.device; + }); IpcMainEventChannel.account.handleListDevices((accountToken: AccountToken) => { return this.daemonRpc.listDevices(accountToken); }); diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx index 47a58fc4dd..9f3bb70a04 100644 --- a/gui/src/renderer/app.tsx +++ b/gui/src/renderer/app.tsx @@ -334,6 +334,10 @@ export default class AppRenderer { IpcRendererEventChannel.account.updateData(); } + public getDevice = (): Promise<IDevice | undefined> => { + return IpcRendererEventChannel.account.getDevice(); + }; + public fetchDevices = async (accountToken: AccountToken): Promise<Array<IDevice>> => { const devices = await IpcRendererEventChannel.account.listDevices(accountToken); this.reduxActions.account.updateDevices(devices); @@ -783,7 +787,7 @@ export default class AppRenderer { if (oldAccount && !newAccount) { this.loginScheduler.cancel(); - if (newDeviceEvent.remote) { + if (!this.reduxStore.getState().account.loggingOut && newDeviceEvent.remote) { reduxAccount.deviceRevoked(); } else { reduxAccount.loggedOut(); diff --git a/gui/src/renderer/components/Account.tsx b/gui/src/renderer/components/Account.tsx index a612f2a07e..4a84e3c110 100644 --- a/gui/src/renderer/components/Account.tsx +++ b/gui/src/renderer/components/Account.tsx @@ -10,6 +10,7 @@ import { AccountRows, AccountRowValue, DeviceRowValue, + StyledSpinnerContainer, StyledBuyCreditButton, StyledContainer, StyledRedeemVoucherButton, @@ -18,10 +19,12 @@ import AccountTokenLabel from './AccountTokenLabel'; import * as AppButton from './AppButton'; import { AriaDescribed, AriaDescription, AriaDescriptionGroup } from './AriaGroup'; import { Layout } from './Layout'; +import { ModalAlert, ModalAlertType, ModalMessage } from './Modal'; import { NavigationBar, NavigationItems, TitleBarItem } from './NavigationBar'; import SettingsHeader, { HeaderTitle } from './SettingsHeader'; -import { AccountToken } from '../../shared/daemon-rpc-types'; +import { AccountToken, IDevice } from '../../shared/daemon-rpc-types'; +import ImageView from './ImageView'; import { BackAction } from './KeyboardNavigation'; interface IProps { @@ -30,13 +33,22 @@ interface IProps { accountExpiry?: string; expiryLocale: string; isOffline: boolean; + prepareLogout: () => void; + cancelLogout: () => void; onLogout: () => void; onClose: () => void; onBuyMore: () => Promise<void>; updateAccountData: () => void; + getDevice: () => Promise<IDevice | undefined>; } -export default class Account extends React.Component<IProps> { +interface IState { + logoutDialogState: 'hidden' | 'checking-ports' | 'confirm'; +} + +export default class Account extends React.Component<IProps, IState> { + public state: IState = { logoutDialogState: 'hidden' }; + public componentDidMount() { this.props.updateAccountData(); } @@ -65,7 +77,7 @@ export default class Account extends React.Component<IProps> { <AccountRows> <AccountRow> <AccountRowLabel> - {messages.pgettext('account-view', 'Device name')} + {messages.pgettext('device-management', 'Device name')} </AccountRowLabel> <DeviceRowValue>{this.props.deviceName}</DeviceRowValue> </AccountRow> @@ -114,16 +126,88 @@ export default class Account extends React.Component<IProps> { <StyledRedeemVoucherButton /> - <AppButton.RedButton onClick={this.props.onLogout}> + <AppButton.RedButton onClick={this.onTryLogout}> {messages.pgettext('account-view', 'Log out')} </AppButton.RedButton> </AccountFooter> </AccountContainer> </StyledContainer> + + {this.renderLogoutDialog()} </Layout> </BackAction> ); } + + private renderLogoutDialog() { + const modalType = + this.state.logoutDialogState === 'checking-ports' ? undefined : ModalAlertType.warning; + + const message = + this.state.logoutDialogState === 'checking-ports' ? ( + <StyledSpinnerContainer> + <ImageView source="icon-spinner" width={60} height={60} /> + </StyledSpinnerContainer> + ) : ( + <ModalMessage> + { + // TRANSLATORS: This is is a further explanation of what happens when logging out. + messages.pgettext( + 'device-management', + 'The ports forwarded to this device will be deleted if you log out.', + ) + } + </ModalMessage> + ); + + const buttons = + this.state.logoutDialogState === 'checking-ports' + ? [] + : [ + <AppButton.RedButton key="logout" onClick={this.props.onLogout}> + { + // TRANSLATORS: Confirmation button when logging out + messages.pgettext('device-management', 'Log out anyway') + } + </AppButton.RedButton>, + <AppButton.BlueButton key="back" onClick={this.cancelLogout}> + {messages.gettext('Back')} + </AppButton.BlueButton>, + ]; + + return ( + <ModalAlert + isOpen={this.state.logoutDialogState !== 'hidden'} + type={modalType} + buttons={buttons}> + {message} + </ModalAlert> + ); + } + + private onTryLogout = async () => { + this.setState({ logoutDialogState: 'checking-ports' }); + this.props.prepareLogout(); + + const device = await this.props.getDevice(); + if (device === undefined) { + this.onHideLogoutConfirmationDialog(); + } else if (device.ports !== undefined && device.ports.length > 0) { + this.setState({ logoutDialogState: 'confirm' }); + } else { + this.props.onLogout(); + this.onHideLogoutConfirmationDialog(); + } + }; + + private cancelLogout = () => { + this.props.cancelLogout(); + this.onHideLogoutConfirmationDialog(); + }; + + private onHideLogoutConfirmationDialog = () => { + this.setState({ logoutDialogState: 'hidden' }); + }; } function FormattedAccountExpiry(props: { expiry?: string; locale: string }) { diff --git a/gui/src/renderer/components/AccountStyles.tsx b/gui/src/renderer/components/AccountStyles.tsx index 34ce600fde..549b0cda7d 100644 --- a/gui/src/renderer/components/AccountStyles.tsx +++ b/gui/src/renderer/components/AccountStyles.tsx @@ -52,6 +52,13 @@ export const AccountOutOfTime = styled(AccountRowValue)({ color: colors.red, }); +export const StyledSpinnerContainer = styled.div({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '8px 0', +}); + export const AccountFooter = styled.div({ display: 'flex', flexDirection: 'column', diff --git a/gui/src/renderer/components/TooManyDevices.tsx b/gui/src/renderer/components/TooManyDevices.tsx index 56ecbd99c1..b743dd2aec 100644 --- a/gui/src/renderer/components/TooManyDevices.tsx +++ b/gui/src/renderer/components/TooManyDevices.tsx @@ -4,10 +4,12 @@ import styled from 'styled-components'; import { colors } from '../../config.json'; import { IDevice } from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; +import { capitalizeEveryWord } from '../../shared/string-helpers'; import { useAppContext } from '../context'; import { transitions, useHistory } from '../lib/history'; import { RoutePath } from '../lib/routes'; import { useBoolean } from '../lib/utilityHooks'; +import { formatMarkdown } from '../markdown-formatter'; import { useSelector } from '../redux/store'; import * as AppButton from './AppButton'; import * as Cell from './cell'; @@ -192,6 +194,8 @@ function Device(props: IDeviceProps) { setDeleting(); }, [props.onRemove, props.device.id, hideConfirmation, setDeleting]); + const capitalizedDeviceName = capitalizeEveryWord(props.device.name); + return ( <> <StyledCellContainer> @@ -213,36 +217,42 @@ function Device(props: IDeviceProps) { /> </StyledRemoveDeviceButton> </StyledCellContainer> - {confirmationVisible && ( - <ModalAlert - type={ModalAlertType.warning} - iconColor={colors.red} - buttons={[ - <AppButton.RedButton key="remove" onClick={onRemove} disabled={deleting}> - { - // TRANSLATORS: Confirmation button when logging out other device. - messages.pgettext('device-management', 'Yes, log out device') - } - </AppButton.RedButton>, - <AppButton.BlueButton key="back" onClick={hideConfirmation} disabled={deleting}> - {messages.gettext('Back')} - </AppButton.BlueButton>, - ]} - close={hideConfirmation}> - {deleting ? ( - <ImageView source="icon-spinner" /> - ) : ( - <> - <ModalMessage> - {sprintf( + <ModalAlert + isOpen={confirmationVisible} + type={ModalAlertType.warning} + iconColor={colors.red} + buttons={[ + <AppButton.RedButton key="remove" onClick={onRemove} disabled={deleting}> + { + // TRANSLATORS: Confirmation button when logging out other device. + messages.pgettext('device-management', 'Yes, log out device') + } + </AppButton.RedButton>, + <AppButton.BlueButton key="back" onClick={hideConfirmation} disabled={deleting}> + {messages.gettext('Back')} + </AppButton.BlueButton>, + ]} + close={hideConfirmation}> + {deleting ? ( + <ImageView source="icon-spinner" /> + ) : ( + <> + <ModalMessage> + {formatMarkdown( + sprintf( // TRANSLATORS: Text displayed above button which logs out another device. + // TRANSLATORS: The text enclosed in "**" will appear bold. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(deviceName)s - The name of the device to log out. messages.pgettext( 'device-management', - 'Are you sure you want to log out of %(deviceName)s?', + 'Are you sure you want to log out of **%(deviceName)s**?', ), - { deviceName: props.device.name }, - )} - </ModalMessage> + { deviceName: capitalizedDeviceName }, + ), + )} + </ModalMessage> + {props.device.ports && props.device.ports.length > 0 && ( <ModalMessage> { // TRANSLATORS: Further information about consequences of logging out device. @@ -252,10 +262,10 @@ function Device(props: IDeviceProps) { ) } </ModalMessage> - </> - )} - </ModalAlert> - )} + )} + </> + )} + </ModalAlert> </> ); } diff --git a/gui/src/renderer/containers/AccountPage.tsx b/gui/src/renderer/containers/AccountPage.tsx index a5251500d0..2309c614fb 100644 --- a/gui/src/renderer/containers/AccountPage.tsx +++ b/gui/src/renderer/containers/AccountPage.tsx @@ -1,10 +1,12 @@ import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; import { links } from '../../config.json'; import Account from '../components/Account'; import withAppContext, { IAppContext } from '../context'; import { IHistoryProps, withHistory } from '../lib/history'; import { IReduxState, ReduxDispatch } from '../redux/store'; +import accountActions from '../redux/account/actions'; const mapStateToProps = (state: IReduxState) => ({ deviceName: state.account.deviceName, @@ -13,8 +15,12 @@ const mapStateToProps = (state: IReduxState) => ({ expiryLocale: state.userInterface.locale, isOffline: state.connection.isBlocked, }); -const mapDispatchToProps = (_dispatch: ReduxDispatch, props: IHistoryProps & IAppContext) => { +const mapDispatchToProps = (dispatch: ReduxDispatch, props: IHistoryProps & IAppContext) => { + const account = bindActionCreators(accountActions, dispatch); + return { + prepareLogout: () => account.prepareLogout(), + cancelLogout: () => account.cancelLogout(), onLogout: () => { void props.app.logout(); }, @@ -23,6 +29,7 @@ const mapDispatchToProps = (_dispatch: ReduxDispatch, props: IHistoryProps & IAp }, onBuyMore: () => props.app.openLinkWithAuth(links.purchase), updateAccountData: () => props.app.updateAccountData(), + getDevice: () => props.app.getDevice(), }; }; diff --git a/gui/src/renderer/redux/account/actions.ts b/gui/src/renderer/redux/account/actions.ts index 5bc3e84aa9..355acc4a35 100644 --- a/gui/src/renderer/redux/account/actions.ts +++ b/gui/src/renderer/redux/account/actions.ts @@ -21,6 +21,14 @@ interface ILoginTooManyDevicesAction { error: Error; } +interface IPrepareLogoutAction { + type: 'PREPARE_LOG_OUT'; +} + +interface ICancelLogoutAction { + type: 'CANCEL_LOGOUT'; +} + interface ILoggedOutAction { type: 'LOGGED_OUT'; } @@ -78,6 +86,8 @@ export type AccountAction = | ILoggedInAction | ILoginFailedAction | ILoginTooManyDevicesAction + | IPrepareLogoutAction + | ICancelLogoutAction | ILoggedOutAction | IResetLoginErrorAction | IDeviceRevokedAction @@ -119,6 +129,18 @@ function loginTooManyDevices(error: Error): ILoginTooManyDevicesAction { }; } +function prepareLogout(): IPrepareLogoutAction { + return { + type: 'PREPARE_LOG_OUT', + }; +} + +function cancelLogout(): ICancelLogoutAction { + return { + type: 'CANCEL_LOGOUT', + }; +} + function loggedOut(): ILoggedOutAction { return { type: 'LOGGED_OUT', @@ -196,6 +218,8 @@ export default { loggedIn, loginFailed, loginTooManyDevices, + prepareLogout, + cancelLogout, loggedOut, resetLoginError, deviceRevoked, diff --git a/gui/src/renderer/redux/account/reducers.ts b/gui/src/renderer/redux/account/reducers.ts index 5be640e98b..00f2ef7bb5 100644 --- a/gui/src/renderer/redux/account/reducers.ts +++ b/gui/src/renderer/redux/account/reducers.ts @@ -13,6 +13,7 @@ export interface IAccountReduxState { accountHistory?: AccountToken; expiry?: string; // ISO8601 status: LoginState; + loggingOut: boolean; } const initialState: IAccountReduxState = { @@ -22,6 +23,7 @@ const initialState: IAccountReduxState = { accountHistory: undefined, expiry: undefined, status: { type: 'none', deviceRevoked: false }, + loggingOut: false, }; export default function ( @@ -53,12 +55,23 @@ export default function ( ...state, status: { type: 'too many devices', method: 'existing_account', error: action.error }, }; + case 'PREPARE_LOG_OUT': + return { + ...state, + loggingOut: true, + }; + case 'CANCEL_LOGOUT': + return { + ...state, + loggingOut: false, + }; case 'LOGGED_OUT': return { ...state, status: { type: 'none', deviceRevoked: false }, accountToken: undefined, expiry: undefined, + loggingOut: false, }; case 'RESET_LOGIN_ERROR': return { diff --git a/gui/src/shared/daemon-rpc-types.ts b/gui/src/shared/daemon-rpc-types.ts index 7840a68d26..9f4e02baa0 100644 --- a/gui/src/shared/daemon-rpc-types.ts +++ b/gui/src/shared/daemon-rpc-types.ts @@ -337,6 +337,7 @@ export type DeviceConfig = export interface IDevice { id: string; name: string; + ports?: Array<string>; } export interface IDeviceRemoval { diff --git a/gui/src/shared/ipc-schema.ts b/gui/src/shared/ipc-schema.ts index 62dd206c4c..89dd0b37b6 100644 --- a/gui/src/shared/ipc-schema.ts +++ b/gui/src/shared/ipc-schema.ts @@ -176,6 +176,7 @@ export const ipcSchema = { getWwwAuthToken: invoke<void, string>(), submitVoucher: invoke<string, VoucherResponse>(), updateData: send<void>(), + getDevice: invoke<void, IDevice | undefined>(), listDevices: invoke<AccountToken, Array<IDevice>>(), removeDevice: invoke<IDeviceRemoval, void>(), }, diff --git a/gui/src/shared/string-helpers.ts b/gui/src/shared/string-helpers.ts index 30a2ac9d58..c69ebddfcf 100644 --- a/gui/src/shared/string-helpers.ts +++ b/gui/src/shared/string-helpers.ts @@ -1,3 +1,7 @@ export function capitalize(inputString: string): string { return inputString.charAt(0).toUpperCase() + inputString.slice(1); } + +export function capitalizeEveryWord(inputString: string): string { + return inputString.split(' ').map(capitalize).join(' '); +} |
