diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2022-03-14 13:59:05 +0100 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2022-03-14 13:59:05 +0100 |
| commit | 4ab205bd9add69264ccdfaaf8cf068515ceddb77 (patch) | |
| tree | 302357507a7e51ece04f563c5f7018d64adaccac /gui/src/renderer/components | |
| parent | 6459ae7beefcc5f13eb54254dfe402dd807c62fe (diff) | |
| parent | 55aa3418f8b7ec6f473fd22819f7e54cb432d097 (diff) | |
| download | mullvadvpn-4ab205bd9add69264ccdfaaf8cf068515ceddb77.tar.xz mullvadvpn-4ab205bd9add69264ccdfaaf8cf068515ceddb77.zip | |
Merge branch 'device-api-electron'
Diffstat (limited to 'gui/src/renderer/components')
| -rw-r--r-- | gui/src/renderer/components/Account.tsx | 99 | ||||
| -rw-r--r-- | gui/src/renderer/components/AccountStyles.tsx | 11 | ||||
| -rw-r--r-- | gui/src/renderer/components/AdvancedSettings.tsx | 67 | ||||
| -rw-r--r-- | gui/src/renderer/components/AdvancedSettingsStyles.tsx | 11 | ||||
| -rw-r--r-- | gui/src/renderer/components/AppRouter.tsx | 6 | ||||
| -rw-r--r-- | gui/src/renderer/components/DeviceRevokedView.tsx | 95 | ||||
| -rw-r--r-- | gui/src/renderer/components/ExpiredAccountAddTime.tsx | 6 | ||||
| -rw-r--r-- | gui/src/renderer/components/KeyboardNavigation.tsx | 16 | ||||
| -rw-r--r-- | gui/src/renderer/components/Login.tsx | 9 | ||||
| -rw-r--r-- | gui/src/renderer/components/MainView.tsx | 21 | ||||
| -rw-r--r-- | gui/src/renderer/components/NotificationArea.tsx | 8 | ||||
| -rw-r--r-- | gui/src/renderer/components/TooManyDevices.tsx | 317 | ||||
| -rw-r--r-- | gui/src/renderer/components/WireguardKeys.tsx | 345 | ||||
| -rw-r--r-- | gui/src/renderer/components/WireguardKeysStyles.tsx | 59 | ||||
| -rw-r--r-- | gui/src/renderer/components/WireguardSettings.tsx | 10 |
15 files changed, 580 insertions, 500 deletions
diff --git a/gui/src/renderer/components/Account.tsx b/gui/src/renderer/components/Account.tsx index 833a06d1c2..4a84e3c110 100644 --- a/gui/src/renderer/components/Account.tsx +++ b/gui/src/renderer/components/Account.tsx @@ -9,6 +9,8 @@ import { AccountRowLabel, AccountRows, AccountRowValue, + DeviceRowValue, + StyledSpinnerContainer, StyledBuyCreditButton, StyledContainer, StyledRedeemVoucherButton, @@ -17,24 +19,36 @@ 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 { + deviceName?: string; accountToken?: AccountToken; 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(); } @@ -63,6 +77,13 @@ export default class Account extends React.Component<IProps> { <AccountRows> <AccountRow> <AccountRowLabel> + {messages.pgettext('device-management', 'Device name')} + </AccountRowLabel> + <DeviceRowValue>{this.props.deviceName}</DeviceRowValue> + </AccountRow> + + <AccountRow> + <AccountRowLabel> {messages.pgettext('account-view', 'Account number')} </AccountRowLabel> <AccountRowValue @@ -105,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 0e403231da..549b0cda7d 100644 --- a/gui/src/renderer/components/AccountStyles.tsx +++ b/gui/src/renderer/components/AccountStyles.tsx @@ -44,10 +44,21 @@ export const AccountRowValue = styled(AccountRowText)(normalText, { color: colors.white, }); +export const DeviceRowValue = styled(AccountRowValue)({ + textTransform: 'capitalize', +}); + 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/AdvancedSettings.tsx b/gui/src/renderer/components/AdvancedSettings.tsx index 8ecf6c0052..596a638f51 100644 --- a/gui/src/renderer/components/AdvancedSettings.tsx +++ b/gui/src/renderer/components/AdvancedSettings.tsx @@ -1,12 +1,8 @@ import * as React from 'react'; -import { sprintf } from 'sprintf-js'; import { TunnelProtocol } from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; -import { WgKeyState } from '../redux/settings/reducers'; import { StyledNavigationScrollbars, - StyledNoWireguardKeyError, - StyledNoWireguardKeyErrorContainer, StyledSelectorForFooter, StyledTunnelProtocolContainer, } from './AdvancedSettingsStyles'; @@ -28,7 +24,6 @@ interface IProps { enableIpv6: boolean; blockWhenDisconnected: boolean; tunnelProtocol?: TunnelProtocol; - wireguardKeyState: WgKeyState; setEnableIpv6: (value: boolean) => void; setBlockWhenDisconnected: (value: boolean) => void; setTunnelProtocol: (value: OptionalTunnelProtocol) => void; @@ -49,9 +44,28 @@ export default class AdvancedSettings extends React.Component<IProps, IState> { private blockWhenDisconnectedRef = React.createRef<Switch>(); - public render() { - const hasWireguardKey = this.props.wireguardKeyState.type === 'key-set'; + private tunnelProtocolItems: Array<ISelectorItem<OptionalTunnelProtocol>>; + + public constructor(props: IProps) { + super(props); + + this.tunnelProtocolItems = [ + { + label: messages.gettext('Automatic'), + value: undefined, + }, + { + label: messages.pgettext('advanced-settings-view', 'WireGuard'), + value: 'wireguard', + }, + { + label: messages.pgettext('advanced-settings-view', 'OpenVPN'), + value: 'openvpn', + }, + ]; + } + public render() { return ( <BackAction action={this.props.onClose}> <Layout> @@ -141,22 +155,10 @@ export default class AdvancedSettings extends React.Component<IProps, IState> { <StyledTunnelProtocolContainer> <StyledSelectorForFooter title={messages.pgettext('advanced-settings-view', 'Tunnel protocol')} - values={this.tunnelProtocolItems(hasWireguardKey)} + values={this.tunnelProtocolItems} value={this.props.tunnelProtocol} onSelect={this.onSelectTunnelProtocol} /> - {!hasWireguardKey && ( - <StyledNoWireguardKeyErrorContainer> - <AriaDescription> - <StyledNoWireguardKeyError> - {messages.pgettext( - 'advanced-settings-view', - 'To enable WireGuard, generate a key under the "WireGuard key" setting below.', - )} - </StyledNoWireguardKeyError> - </AriaDescription> - </StyledNoWireguardKeyErrorContainer> - )} </StyledTunnelProtocolContainer> </AriaInputGroup> @@ -191,31 +193,6 @@ export default class AdvancedSettings extends React.Component<IProps, IState> { ); } - private tunnelProtocolItems = ( - hasWireguardKey: boolean, - ): Array<ISelectorItem<OptionalTunnelProtocol>> => { - return [ - { - label: messages.gettext('Automatic'), - value: undefined, - }, - { - label: hasWireguardKey - ? messages.pgettext('advanced-settings-view', 'WireGuard') - : sprintf('%(label)s (%(error)s)', { - label: messages.pgettext('advanced-settings-view', 'WireGuard'), - error: messages.pgettext('advanced-settings-view', 'missing key'), - }), - value: 'wireguard', - disabled: !hasWireguardKey, - }, - { - label: messages.pgettext('advanced-settings-view', 'OpenVPN'), - value: 'openvpn', - }, - ]; - }; - private renderConfirmBlockWhenDisconnectedAlert = () => { return ( <ModalAlert diff --git a/gui/src/renderer/components/AdvancedSettingsStyles.tsx b/gui/src/renderer/components/AdvancedSettingsStyles.tsx index bdc84701c6..f7a87b5311 100644 --- a/gui/src/renderer/components/AdvancedSettingsStyles.tsx +++ b/gui/src/renderer/components/AdvancedSettingsStyles.tsx @@ -1,6 +1,4 @@ import styled from 'styled-components'; -import { colors } from '../../config.json'; -import * as Cell from './cell'; import { NavigationScrollbars } from './NavigationBar'; import Selector from './cell/Selector'; @@ -19,12 +17,3 @@ export const StyledTunnelProtocolContainer = styled(StyledSelectorContainer)({ export const StyledNavigationScrollbars = styled(NavigationScrollbars)({ flex: 1, }); - -export const StyledNoWireguardKeyErrorContainer = styled(Cell.Footer)({ - paddingBottom: 0, -}); - -export const StyledNoWireguardKeyError = styled(Cell.FooterText)({ - fontWeight: 700, - color: colors.red, -}); diff --git a/gui/src/renderer/components/AppRouter.tsx b/gui/src/renderer/components/AppRouter.tsx index f72de697e3..46b322785a 100644 --- a/gui/src/renderer/components/AppRouter.tsx +++ b/gui/src/renderer/components/AppRouter.tsx @@ -16,7 +16,6 @@ import SelectLanguagePage from '../containers/SelectLanguagePage'; import SelectLocationPage from '../containers/SelectLocationPage'; import SettingsPage from '../containers/SettingsPage'; import SupportPage from '../containers/SupportPage'; -import WireguardKeysPage from '../containers/WireguardKeysPage'; import WireguardSettingsPage from '../containers/WireguardSettingsPage'; import { IHistoryProps, ITransitionSpecification, transitions, withHistory } from '../lib/history'; import { @@ -27,6 +26,8 @@ import { } from './ExpiredAccountAddTime'; import { RoutePath } from '../lib/routes'; import FilterByProvider from './FilterByProvider'; +import TooManyDevices from './TooManyDevices'; +import { DeviceRevokedView } from './DeviceRevokedView'; interface IAppRoutesState { currentLocation: IHistoryProps['history']['location']; @@ -77,6 +78,8 @@ class AppRouter extends React.Component<IHistoryProps, IAppRoutesState> { <Switch key={location.key} location={location}> <Route exact path={RoutePath.launch} component={Launch} /> <Route exact path={RoutePath.login} component={LoginPage} /> + <Route exact path={RoutePath.tooManyDevices} component={TooManyDevices} /> + <Route exact path={RoutePath.deviceRevoked} component={DeviceRevokedView} /> <Route exact path={RoutePath.main} component={MainView} /> <Route exact path={RoutePath.redeemVoucher} component={VoucherInput} /> <Route @@ -92,7 +95,6 @@ class AppRouter extends React.Component<IHistoryProps, IAppRoutesState> { <Route exact path={RoutePath.preferences} component={PreferencesPage} /> <Route exact path={RoutePath.advancedSettings} component={AdvancedSettingsPage} /> <Route exact path={RoutePath.wireguardSettings} component={WireguardSettingsPage} /> - <Route exact path={RoutePath.wireguardKeys} component={WireguardKeysPage} /> <Route exact path={RoutePath.openVpnSettings} component={OpenVPNSettingsPage} /> <Route exact path={RoutePath.splitTunneling} component={SplitTunnelingSettings} /> <Route exact path={RoutePath.support} component={SupportPage} /> diff --git a/gui/src/renderer/components/DeviceRevokedView.tsx b/gui/src/renderer/components/DeviceRevokedView.tsx new file mode 100644 index 0000000000..6a51694af1 --- /dev/null +++ b/gui/src/renderer/components/DeviceRevokedView.tsx @@ -0,0 +1,95 @@ +import styled from 'styled-components'; +import { colors } from '../../config.json'; +import { messages } from '../../shared/gettext'; +import { useAppContext } from '../context'; +import { useSelector } from '../redux/store'; +import * as AppButton from './AppButton'; +import CustomScrollbars from './CustomScrollbars'; +import { calculateHeaderBarStyle, DefaultHeaderBar } from './HeaderBar'; +import { Container } from './Layout'; +import ImageView from './ImageView'; +import { Layout } from './Layout'; +import { bigText, smallText } from './common-styles'; + +export const StyledHeader = styled(DefaultHeaderBar)({ + flex: 0, +}); + +export const StyledCustomScrollbars = styled(CustomScrollbars)({ + flex: 1, +}); + +export const StyledContainer = styled(Container)({ + paddingTop: '22px', + minHeight: '100%', + backgroundColor: colors.darkBlue, +}); + +export const StyledBody = styled.div({ + display: 'flex', + flexDirection: 'column', + flex: 1, + padding: '0 22px', +}); + +export const StyledFooter = styled.div({ + display: 'flex', + flexDirection: 'column', + flex: 0, + padding: '18px 22px 22px', +}); + +export const StyledStatusIcon = styled.div({ + alignSelf: 'center', + width: '60px', + height: '60px', + marginBottom: '18px', +}); + +export const StyledTitle = styled.span(bigText, { + lineHeight: '38px', + marginBottom: '8px', + color: colors.white, +}); + +export const StyledMessage = styled.span(smallText, { + marginBottom: '20px', + color: colors.white, +}); + +export function DeviceRevokedView() { + const { leaveRevokedDevice } = useAppContext(); + const tunnelState = useSelector((state) => state.connection.status); + + const Button = tunnelState.state === 'disconnected' ? AppButton.GreenButton : AppButton.RedButton; + + return ( + <Layout> + <StyledHeader barStyle={calculateHeaderBarStyle(tunnelState)} /> + <StyledCustomScrollbars fillContainer> + <StyledContainer> + <StyledBody> + <StyledStatusIcon> + <ImageView source="icon-fail" height={60} width={60} /> + </StyledStatusIcon> + <StyledTitle> + {messages.pgettext('device-management', 'Device is inactive')} + </StyledTitle> + <StyledMessage> + {messages.pgettext( + 'device-management', + 'You have removed this device from your list of active devices. To connect with this device again, log in.', + )} + </StyledMessage> + </StyledBody> + + <StyledFooter> + <Button onClick={leaveRevokedDevice}> + {messages.pgettext('device-management', 'Go to login')} + </Button> + </StyledFooter> + </StyledContainer> + </StyledCustomScrollbars> + </Layout> + ); +} diff --git a/gui/src/renderer/components/ExpiredAccountAddTime.tsx b/gui/src/renderer/components/ExpiredAccountAddTime.tsx index fa98b1df59..47939b8c65 100644 --- a/gui/src/renderer/components/ExpiredAccountAddTime.tsx +++ b/gui/src/renderer/components/ExpiredAccountAddTime.tsx @@ -273,7 +273,7 @@ function HeaderBar() { } function useFinishedCallback() { - const { loggedIn } = useActions(account); + const { accountSetupFinished } = useActions(account); const history = useHistory(); const isNewAccount = useSelector( @@ -283,11 +283,11 @@ function useFinishedCallback() { const callback = useCallback(() => { // Changes login method from "new_account" to "existing_account" if (isNewAccount) { - loggedIn(); + accountSetupFinished(); } history.reset(RoutePath.main, undefined, transitions.push); - }, [isNewAccount, loggedIn, history]); + }, [isNewAccount, accountSetupFinished, history]); return callback; } diff --git a/gui/src/renderer/components/KeyboardNavigation.tsx b/gui/src/renderer/components/KeyboardNavigation.tsx index 4cfba89452..6b3851a49f 100644 --- a/gui/src/renderer/components/KeyboardNavigation.tsx +++ b/gui/src/renderer/components/KeyboardNavigation.tsx @@ -1,5 +1,7 @@ import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { useLocation } from 'react-router'; import { useHistory } from '../lib/history'; +import { disableDismissForRoutes, RoutePath } from '../lib/routes'; interface IKeyboardNavigationProps { children: React.ReactElement; @@ -9,18 +11,22 @@ interface IKeyboardNavigationProps { export default function KeyboardNavigation(props: IKeyboardNavigationProps) { const history = useHistory(); const [backAction, setBackAction] = useState<IBackActionConfiguration>(); + const location = useLocation(); const handleKeyDown = useCallback( (event: KeyboardEvent) => { if (event.key === 'Escape') { - if (event.shiftKey) { - history.dismiss(true); - } else { - backAction?.action(); + const path = location.pathname as RoutePath; + if (!disableDismissForRoutes.includes(path)) { + if (event.shiftKey) { + history.dismiss(true); + } else { + backAction?.action(); + } } } }, - [history.dismiss, backAction], + [history.dismiss, backAction, location.pathname], ); useEffect(() => { diff --git a/gui/src/renderer/components/Login.tsx b/gui/src/renderer/components/Login.tsx index 31afee88b0..ab93e1d6ac 100644 --- a/gui/src/renderer/components/Login.tsx +++ b/gui/src/renderer/components/Login.tsx @@ -151,6 +151,7 @@ export default class Login extends React.Component<IProps, IState> { private formTitle() { 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...'); @@ -173,6 +174,8 @@ export default class Login extends React.Component<IProps, IState> { return this.props.loginState.method === 'existing_account' ? this.props.loginState.error.message || messages.pgettext('login-view', 'Unknown 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') @@ -209,7 +212,11 @@ export default class Login extends React.Component<IProps, IState> { } private allowInteraction() { - return this.props.loginState.type !== 'logging in' && this.props.loginState.type !== 'ok'; + return ( + this.props.loginState.type !== 'logging in' && + this.props.loginState.type !== 'ok' && + this.props.loginState.type !== 'too many devices' + ); } private allowCreateAccount() { diff --git a/gui/src/renderer/components/MainView.tsx b/gui/src/renderer/components/MainView.tsx index c7a8851a90..56878fb521 100644 --- a/gui/src/renderer/components/MainView.tsx +++ b/gui/src/renderer/components/MainView.tsx @@ -1,7 +1,6 @@ import { useEffect, useState } from 'react'; -import { useSelector } from 'react-redux'; import { hasExpired } from '../../shared/account-expiry'; -import { IReduxState } from '../redux/store'; +import { useSelector } from '../redux/store'; import ConnectPage from '../containers/ConnectPage'; import ExpiredAccountErrorViewContainer from '../containers/ExpiredAccountErrorViewContainer'; import { useHistory } from '../lib/history'; @@ -9,13 +8,15 @@ import { RoutePath } from '../lib/routes'; export default function MainView() { const history = useHistory(); - const accountExpiry = useSelector((state: IReduxState) => state.account.expiry); - const accountHasExpired = accountExpiry && hasExpired(accountExpiry); + const accountExpiry = useSelector((state) => state.account.expiry); + const accountHasExpired = accountExpiry !== undefined && hasExpired(accountExpiry); const isNewAccount = useSelector( - (state: IReduxState) => - state.account.status.type === 'ok' && state.account.status.method === 'new_account', + (state) => state.account.status.type === 'ok' && state.account.status.method === 'new_account', + ); + + const [showAccountExpired, setShowAccountExpired] = useState<boolean>( + isNewAccount || accountHasExpired, ); - const [showAccountExpired, setShowAccountExpired] = useState(isNewAccount || accountHasExpired); useEffect(() => { if (accountHasExpired) { @@ -25,5 +26,9 @@ export default function MainView() { } }, [showAccountExpired, accountHasExpired]); - return showAccountExpired ? <ExpiredAccountErrorViewContainer /> : <ConnectPage />; + if (showAccountExpired) { + return <ExpiredAccountErrorViewContainer />; + } else { + return <ConnectPage />; + } } diff --git a/gui/src/renderer/components/NotificationArea.tsx b/gui/src/renderer/components/NotificationArea.tsx index 1f79d8b90d..de8b547087 100644 --- a/gui/src/renderer/components/NotificationArea.tsx +++ b/gui/src/renderer/components/NotificationArea.tsx @@ -9,7 +9,6 @@ import { InAppNotificationProvider, InconsistentVersionNotificationProvider, NotificationAction, - NoValidKeyNotificationProvider, ReconnectingNotificationProvider, UnsupportedVersionNotificationProvider, UpdateAvailableNotificationProvider, @@ -38,12 +37,6 @@ export default function NotificationArea(props: IProps) { const blockWhenDisconnected = useSelector( (state: IReduxState) => state.settings.blockWhenDisconnected, ); - const tunnelProtocol = useSelector((state: IReduxState) => - 'normal' in state.settings.relaySettings - ? state.settings.relaySettings.normal.tunnelProtocol - : undefined, - ); - const wireGuardKey = useSelector((state: IReduxState) => state.settings.wireguardKeyState); const hasExcludedApps = useSelector( (state: IReduxState) => state.settings.splitTunneling && state.settings.splitTunnelingApplications.length > 0, @@ -58,7 +51,6 @@ export default function NotificationArea(props: IProps) { hasExcludedApps, }), new ErrorNotificationProvider({ tunnelState, accountExpiry, hasExcludedApps }), - new NoValidKeyNotificationProvider({ tunnelProtocol, wireGuardKey }), new InconsistentVersionNotificationProvider({ consistent: version.consistent }), new UnsupportedVersionNotificationProvider(version), ]; diff --git a/gui/src/renderer/components/TooManyDevices.tsx b/gui/src/renderer/components/TooManyDevices.tsx new file mode 100644 index 0000000000..b743dd2aec --- /dev/null +++ b/gui/src/renderer/components/TooManyDevices.tsx @@ -0,0 +1,317 @@ +import { useCallback, useEffect } from 'react'; +import { sprintf } from 'sprintf-js'; +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'; +import { bigText } from './common-styles'; +import CustomScrollbars from './CustomScrollbars'; +import { Brand, HeaderBarSettingsButton } from './HeaderBar'; +import ImageView from './ImageView'; +import { Header, Layout, SettingsContainer } from './Layout'; +import List from './List'; +import { ModalAlert, ModalAlertType, ModalContainer, ModalMessage } from './Modal'; + +const StyledCustomScrollbars = styled(CustomScrollbars)({ + flex: 1, +}); + +const StyledContainer = styled(SettingsContainer)({ + paddingTop: '14px', + minHeight: '100%', +}); + +const StyledBody = styled.div({ + display: 'flex', + flexDirection: 'column', + flex: 1, + paddingBottom: 'auto', +}); + +const StyledFooter = styled.div({ + display: 'flex', + flexDirection: 'column', + flex: 0, + padding: '18px 22px 22px', +}); + +const StyledStatusIcon = styled.div({ + alignSelf: 'center', + width: '60px', + height: '60px', + marginBottom: '18px', +}); + +const StyledTitle = styled.span(bigText, { + lineHeight: '38px', + margin: '0 22px 8px', + color: colors.white, +}); + +const StyledLabel = styled.span({ + fontFamily: 'Open Sans', + fontSize: '13px', + fontWeight: 600, + lineHeight: '20px', + color: colors.white, + margin: '0 22px 18px', +}); + +const StyledDeviceList = styled(Cell.CellButtonGroup)({ + marginBottom: 0, + flex: '0 0', +}); + +const StyledSpacer = styled.div({ + flex: '1', +}); + +const StyledCellContainer = styled(Cell.Container)({ + marginBottom: '1px', +}); + +const StyledDeviceName = styled(Cell.Label)({ + textTransform: 'capitalize', +}); + +const StyledRemoveDeviceButton = styled.button({ + cursor: 'default', + padding: 0, + marginLeft: 8, + backgroundColor: 'transparent', + border: 'none', +}); + +export default function TooManyDevices() { + const history = useHistory(); + const { fetchDevices, removeDevice, login, cancelLogin } = useAppContext(); + const accountToken = useSelector((state) => state.account.accountToken)!; + const devices = useSelector((state) => state.account.devices); + + const onRemoveDevice = useCallback( + async (deviceId: string) => { + await removeDevice({ accountToken, deviceId }); + }, + [removeDevice, accountToken], + ); + + const continueLogin = useCallback(() => login(accountToken), [login, accountToken]); + const cancel = useCallback(() => { + cancelLogin(); + history.reset(RoutePath.login, transitions.pop); + }, [history.reset, cancelLogin]); + + useEffect(() => void fetchDevices(accountToken), []); + + const iconSource = getIconSource(devices); + const title = getTitle(devices); + const subtitle = getSubtitle(devices); + + return ( + <ModalContainer> + <Layout> + <Header> + <Brand /> + <HeaderBarSettingsButton /> + </Header> + <StyledCustomScrollbars fillContainer> + <StyledContainer> + <StyledBody> + <StyledStatusIcon> + <ImageView key={iconSource} source={iconSource} height={60} width={60} /> + </StyledStatusIcon> + {devices !== undefined && ( + <> + <StyledTitle>{title}</StyledTitle> + <StyledLabel>{subtitle}</StyledLabel> + <DeviceList devices={devices} onRemoveDevice={onRemoveDevice} /> + </> + )} + </StyledBody> + + {devices !== undefined && ( + <StyledFooter> + <AppButton.ButtonGroup> + <AppButton.GreenButton onClick={continueLogin} disabled={devices.length === 5}> + { + // TRANSLATORS: Button for continuing login process. + messages.pgettext('device-management', 'Continue with login') + } + </AppButton.GreenButton> + <AppButton.BlueButton onClick={cancel}> + {messages.gettext('Back')} + </AppButton.BlueButton> + </AppButton.ButtonGroup> + </StyledFooter> + )} + </StyledContainer> + </StyledCustomScrollbars> + </Layout> + </ModalContainer> + ); +} + +interface IDeviceListProps { + devices: Array<IDevice>; + onRemoveDevice: (deviceId: string) => Promise<void>; +} + +function DeviceList(props: IDeviceListProps) { + return ( + <StyledSpacer> + <StyledDeviceList> + <List items={props.devices} getKey={getDeviceKey}> + {(device) => <Device device={device} onRemove={props.onRemoveDevice} />} + </List> + </StyledDeviceList> + </StyledSpacer> + ); +} + +const getDeviceKey = (device: IDevice): string => device.id; + +interface IDeviceProps { + device: IDevice; + onRemove: (deviceId: string) => Promise<void>; +} + +function Device(props: IDeviceProps) { + const [confirmationVisible, showConfirmation, hideConfirmation] = useBoolean(false); + const [deleting, setDeleting] = useBoolean(false); + + const onRemove = useCallback(async () => { + await props.onRemove(props.device.id); + hideConfirmation(); + setDeleting(); + }, [props.onRemove, props.device.id, hideConfirmation, setDeleting]); + + const capitalizedDeviceName = capitalizeEveryWord(props.device.name); + + return ( + <> + <StyledCellContainer> + <StyledDeviceName aria-hidden>{props.device.name}</StyledDeviceName> + <StyledRemoveDeviceButton + onClick={showConfirmation} + aria-label={sprintf( + // TRANSLATORS: Button action description provided to accessibility tools such as screen + // TRANSLATORS: readers. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(deviceName)s - The device name to remove. + messages.pgettext('accessibility', 'Remove device named %(deviceName)s'), + { deviceName: props.device.name }, + )}> + <ImageView + source="icon-close" + tintColor={colors.white40} + tintHoverColor={colors.white60} + /> + </StyledRemoveDeviceButton> + </StyledCellContainer> + <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**?', + ), + { deviceName: capitalizedDeviceName }, + ), + )} + </ModalMessage> + {props.device.ports && props.device.ports.length > 0 && ( + <ModalMessage> + { + // TRANSLATORS: Further information about consequences of logging out device. + messages.pgettext( + 'device-management', + 'This will delete all forwarded ports. Local settings will be saved.', + ) + } + </ModalMessage> + )} + </> + )} + </ModalAlert> + </> + ); +} + +function getIconSource(devices?: Array<IDevice>): string { + if (devices) { + if (devices.length === 5) { + return 'icon-fail'; + } else { + return 'icon-success'; + } + } else { + return 'icon-spinner'; + } +} + +function getTitle(devices?: Array<IDevice>): string | undefined { + if (devices) { + if (devices.length === 5) { + // TRANSLATORS: Page title informing user that the login failed due to too many registered + // TRANSLATORS: devices on account. + return messages.pgettext('device-management', 'Too many devices'); + } else { + // TRANSLATORS: Page title informing user that enough devices has been removed to continue + // TRANSLATORS: login process. + return messages.pgettext('device-management', 'Super!'); + } + } else { + return undefined; + } +} + +function getSubtitle(devices?: Array<IDevice>): string | undefined { + if (devices) { + if (devices.length === 5) { + return messages.pgettext( + 'device-management', + 'You have too many active devices. Please log out of at least one by removing it from the list below. You can find the corresponding nickname under the device’s Account settings.', + ); + } else { + return messages.pgettext( + 'device-management', + 'You can now continue logging in on this device.', + ); + } + } else { + return undefined; + } +} diff --git a/gui/src/renderer/components/WireguardKeys.tsx b/gui/src/renderer/components/WireguardKeys.tsx deleted file mode 100644 index 1a4dab38a6..0000000000 --- a/gui/src/renderer/components/WireguardKeys.tsx +++ /dev/null @@ -1,345 +0,0 @@ -import * as React from 'react'; -import { sprintf } from 'sprintf-js'; -import { TunnelState } from '../../shared/daemon-rpc-types'; -import { formatRelativeDate } from '../../shared/date-helper'; -import { messages } from '../../shared/gettext'; -import log from '../../shared/logging'; -import { IWgKey, WgKeyState } from '../redux/settings/reducers'; -import * as AppButton from './AppButton'; -import { AriaDescribed, AriaDescription, AriaDescriptionGroup } from './AriaGroup'; -import ClipboardLabel from './ClipboardLabel'; -import ImageView from './ImageView'; -import { BackAction } from './KeyboardNavigation'; -import { Layout } from './Layout'; -import { NavigationBar, NavigationContainer, NavigationItems, TitleBarItem } from './NavigationBar'; -import SettingsHeader, { HeaderTitle } from './SettingsHeader'; -import { - StyledButtonRow, - StyledContainer, - StyledContent, - StyledLastButtonRow, - StyledMessage, - StyledMessages, - StyledNavigationScrollbars, - StyledRow, - StyledRowLabel, - StyledRowLabelSpacer, - StyledRowValue, -} from './WireguardKeysStyles'; - -export interface IProps { - keyState: WgKeyState; - isOffline: boolean; - tunnelState: TunnelState; - windowFocused: boolean; - - onClose: () => void; - onGenerateKey: () => void; - onReplaceKey: (old: IWgKey) => void; - onVerifyKey: (publicKey: IWgKey) => void; - onVisitWebsiteKey: () => Promise<void>; -} - -export interface IState { - recentlyGeneratedKey: boolean; - userHasInitiatedVerification: boolean; - ageOfKeyString: string; -} - -export default class WireguardKeys extends React.Component<IProps, IState> { - public state = { - recentlyGeneratedKey: false, - userHasInitiatedVerification: false, - ageOfKeyString: WireguardKeys.ageOfKeyString(this.props.keyState), - }; - - private keyAgeUpdateInterval?: number; - - public static getDerivedStateFromProps(props: IProps) { - return { - ageOfKeyString: WireguardKeys.ageOfKeyString(props.keyState), - }; - } - - public componentDidMount() { - this.verifyKey(); - this.keyAgeUpdateInterval = window.setInterval(this.setAgeOfKeyStringState, 60 * 1000); - } - - public componentWillUnmount() { - clearInterval(this.keyAgeUpdateInterval); - } - - public componentDidUpdate(prevProps: IProps) { - const prevKey = - prevProps.keyState.type === 'key-set' ? prevProps.keyState.key.publicKey : undefined; - const key = - this.props.keyState.type === 'key-set' ? this.props.keyState.key.publicKey : undefined; - if (this.props.tunnelState.state === 'connected' && key !== undefined && key != prevKey) { - this.setState({ recentlyGeneratedKey: true }); - } - - if ( - this.state.recentlyGeneratedKey && - prevProps.tunnelState.state !== 'connected' && - this.props.tunnelState.state === 'connected' - ) { - this.setState({ recentlyGeneratedKey: false }); - } - } - - public render() { - return ( - <BackAction action={this.props.onClose}> - <Layout> - <StyledContainer> - <NavigationContainer> - <NavigationBar> - <NavigationItems> - <TitleBarItem> - { - // TRANSLATORS: Title label in navigation bar - messages.pgettext('wireguard-keys-nav', 'WireGuard key') - } - </TitleBarItem> - </NavigationItems> - </NavigationBar> - - <StyledNavigationScrollbars fillContainer> - <StyledContent> - <SettingsHeader> - <HeaderTitle> - {messages.pgettext('wireguard-keys-nav', 'WireGuard key')} - </HeaderTitle> - </SettingsHeader> - - <StyledRow> - <StyledRowLabel> - <span>{messages.pgettext('wireguard-key-view', 'Public key')}</span> - <StyledRowLabelSpacer /> - <span>{this.keyValidityLabel()}</span> - </StyledRowLabel> - - <StyledRowValue>{this.getKeyText()}</StyledRowValue> - </StyledRow> - <StyledRow> - <StyledRowLabel> - {messages.pgettext('wireguard-key-view', 'Key generated')} - </StyledRowLabel> - <StyledRowValue>{this.state.ageOfKeyString}</StyledRowValue> - </StyledRow> - - <StyledMessages>{this.getStatusMessage()}</StyledMessages> - - <StyledButtonRow>{this.getGenerateButton()}</StyledButtonRow> - <StyledButtonRow> - <AppButton.BlueButton - disabled={this.isVerifyButtonDisabled()} - onClick={this.handleVerifyKeyPress}> - <AppButton.Label> - {messages.pgettext('wireguard-key-view', 'Verify key')} - </AppButton.Label> - </AppButton.BlueButton> - </StyledButtonRow> - <StyledLastButtonRow> - <AppButton.BlockingButton - disabled={this.props.isOffline} - onClick={this.props.onVisitWebsiteKey}> - <AriaDescriptionGroup> - <AriaDescribed> - <AppButton.BlueButton> - <AppButton.Label> - {messages.pgettext('wireguard-key-view', 'Manage keys')} - </AppButton.Label> - <AriaDescription> - <AppButton.Icon - source="icon-extLink" - height={16} - width={16} - aria-label={messages.pgettext('accessibility', 'Opens externally')} - /> - </AriaDescription> - </AppButton.BlueButton> - </AriaDescribed> - </AriaDescriptionGroup> - </AppButton.BlockingButton> - </StyledLastButtonRow> - </StyledContent> - </StyledNavigationScrollbars> - </NavigationContainer> - </StyledContainer> - </Layout> - </BackAction> - ); - } - - private isVerifyButtonDisabled(): boolean { - return this.props.keyState.type !== 'key-set'; - } - - private handleVerifyKeyPress = () => { - this.setState({ userHasInitiatedVerification: true }); - this.verifyKey(); - }; - - private verifyKey() { - switch (this.props.keyState.type) { - case 'key-set': { - const key = this.props.keyState.key; - this.props.onVerifyKey(key); - break; - } - default: - log.error(`onVerifyKey called from invalid state - ${this.props.keyState.type}`); - } - } - - /// Action button can either generate or verify a key - private getGenerateButton() { - let buttonText = messages.pgettext('wireguard-key-view', 'Generate key'); - const regenerateText = messages.pgettext('wireguard-key-view', 'Regenerate key'); - - let disabled = false; - let generateKey = this.props.onGenerateKey; - switch (this.props.keyState.type) { - case 'key-set': { - buttonText = regenerateText; - const key = this.props.keyState.key; - generateKey = () => this.props.onReplaceKey(key); - break; - } - case 'being-verified': - disabled = true; - buttonText = regenerateText; - break; - case 'being-replaced': - case 'being-generated': - disabled = true; - buttonText = messages.pgettext('wireguard-key-view', 'Generating key'); - } - - return ( - <AppButton.GreenButton disabled={disabled} onClick={generateKey}> - <AppButton.Label>{buttonText}</AppButton.Label> - </AppButton.GreenButton> - ); - } - - private getKeyText() { - switch (this.props.keyState.type) { - case 'being-verified': - case 'key-set': { - // mimicking the truncating of the key from website - const publicKey = this.props.keyState.key.publicKey; - return ( - <StyledRowValue title={this.props.keyState.key.publicKey}> - <ClipboardLabel - value={publicKey} - displayValue={publicKey.substring(0, 20) + '...'} - obscureValue={false} - /> - </StyledRowValue> - ); - } - case 'being-replaced': - case 'being-generated': - return <ImageView source="icon-spinner" height={19} width={19} />; - default: - return ( - <StyledRowValue>{messages.pgettext('wireguard-key-view', 'No key set')}</StyledRowValue> - ); - } - } - - private keyValidityLabel() { - const keyStateType = this.props.keyState.type; - if (keyStateType === 'being-verified' && this.state.userHasInitiatedVerification) { - return <ImageView source="icon-spinner" height={20} width={20} />; - } else if (this.props.keyState.type === 'key-set') { - const valid = this.props.keyState.key.valid; - const show = this.state.userHasInitiatedVerification || valid === false; - return show && valid !== undefined ? ( - <StyledMessage success={valid}> - {valid - ? messages.pgettext('wireguard-key-view', 'Key is valid') - : messages.pgettext('wireguard-key-view', 'Key is invalid')} - </StyledMessage> - ) : null; - } else { - return null; - } - } - - private static ageOfKeyString(keyState: WgKeyState): string { - switch (keyState.type) { - case 'key-set': - case 'being-verified': { - const createdDate = Math.min(Date.parse(keyState.key.created), Date.now()); - return formatRelativeDate(new Date(), createdDate, true); - } - default: - return '-'; - } - } - - private setAgeOfKeyStringState = () => { - this.setState({ - ageOfKeyString: WireguardKeys.ageOfKeyString(this.props.keyState), - }); - }; - - private getStatusMessage() { - if (this.props.isOffline && this.state.recentlyGeneratedKey) { - return ( - <StyledMessage success={this.state.recentlyGeneratedKey}> - {messages.pgettext('wireguard-key-view', 'Reconnecting with new WireGuard key...')} - </StyledMessage> - ); - } else { - let message = ''; - switch (this.props.keyState.type) { - case 'key-set': { - const key = this.props.keyState.key; - if (key.replacementFailure) { - switch (key.replacementFailure) { - case 'too_many_keys': - message = this.formatKeygenFailure('too-many-keys'); - break; - case 'generation_failure': - message = this.formatKeygenFailure('generation-failure'); - break; - } - } else if (key.verificationFailed) { - message = messages.pgettext('wireguard-key-view', 'Key verification failed'); - } - - break; - } - case 'too-many-keys': - case 'generation-failure': - message = this.formatKeygenFailure(this.props.keyState.type); - break; - } - - return <StyledMessage success={false}>{message}</StyledMessage>; - } - } - - private formatKeygenFailure(failure: 'too-many-keys' | 'generation-failure'): string { - switch (failure) { - case 'too-many-keys': - // TRANSLATORS: "%(manage)" is replaced with the text in the "Manage keys" button. - return sprintf( - messages.pgettext( - 'wireguard-key-view', - 'Unable to regenerate key: you already have the maximum number of keys. To generate a new key, you first need to revoke one under “Manage keys.”', - ), - { manage: messages.pgettext('wireguard-key-view', 'Manage keys') }, - ); - case 'generation-failure': - return messages.pgettext('wireguard-key-view', 'Failed to generate a key'); - default: - return failure; - } - } -} diff --git a/gui/src/renderer/components/WireguardKeysStyles.tsx b/gui/src/renderer/components/WireguardKeysStyles.tsx deleted file mode 100644 index cf443b2fa5..0000000000 --- a/gui/src/renderer/components/WireguardKeysStyles.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import styled from 'styled-components'; -import { colors } from '../../config.json'; -import { normalText, smallText, tinyText } from './common-styles'; -import { Container } from './Layout'; -import { NavigationScrollbars } from './NavigationBar'; - -export const StyledNavigationScrollbars = styled(NavigationScrollbars)({ - flex: 1, -}); - -export const StyledContainer = styled(Container)({ - backgroundColor: colors.darkBlue, -}); - -export const StyledContent = styled.div({ - display: 'flex', - flexDirection: 'column', - flex: 1, -}); - -export const StyledMessages = styled.div({ - padding: '0 22px 20px', - flex: 1, -}); - -export const StyledMessage = styled.span(smallText, (props: { success: boolean }) => ({ - fontWeight: props.success ? 600 : 700, - color: props.success ? colors.green : colors.red, -})); - -export const StyledRow = styled.div({ - display: 'flex', - flexDirection: 'column', - padding: '0 22px', - marginBottom: '20px', -}); - -export const StyledButtonRow = styled(StyledRow)({ - marginBottom: '18px', -}); - -export const StyledLastButtonRow = styled(StyledButtonRow)({ - marginBottom: '22px', -}); - -export const StyledRowLabel = styled.span(tinyText, { - color: colors.white60, - lineHeight: '20px', - marginBottom: '5px', -}); - -export const StyledRowLabelSpacer = styled.div({ - flex: 1, -}); - -export const StyledRowValue = styled.span(normalText, { - fontWeight: 600, - color: colors.white, -}); diff --git a/gui/src/renderer/components/WireguardSettings.tsx b/gui/src/renderer/components/WireguardSettings.tsx index f799cff335..8bec012986 100644 --- a/gui/src/renderer/components/WireguardSettings.tsx +++ b/gui/src/renderer/components/WireguardSettings.tsx @@ -51,7 +51,6 @@ interface IProps { setWireguardMultihop: (value: boolean) => void; setWireguardPort: (port?: number) => void; setWireguardIpVersion: (ipVersion?: IpVersion) => void; - onViewWireguardKeys: () => void; onClose: () => void; } @@ -202,15 +201,6 @@ export default class WireguardSettings extends React.Component<IProps, IState> { </Cell.Footer> </AriaInputGroup> - <Cell.CellButtonGroup> - <Cell.CellButton onClick={this.props.onViewWireguardKeys}> - <Cell.Label> - {messages.pgettext('wireguard-settings-view', 'WireGuard key')} - </Cell.Label> - <Cell.Icon height={12} width={7} source="icon-chevron" /> - </Cell.CellButton> - </Cell.CellButtonGroup> - <AriaInputGroup> <Cell.Container> <AriaLabel> |
