diff options
| author | Hank <hank@mullvad.net> | 2022-10-17 07:50:08 +0200 |
|---|---|---|
| committer | Hank <hank@mullvad.net> | 2022-10-17 07:50:08 +0200 |
| commit | ed1f1b1abbc7f54e842fd52b7d24afb56adad891 (patch) | |
| tree | 5c6f2937997b284c691773edb23cb75207c8d546 /gui/src | |
| parent | 169121cd2a078fd89abb01672464658c47fe48f5 (diff) | |
| parent | a63aa570f4b97cf65405840f9d757ea2173e958b (diff) | |
| download | mullvadvpn-ed1f1b1abbc7f54e842fd52b7d24afb56adad891.tar.xz mullvadvpn-ed1f1b1abbc7f54e842fd52b7d24afb56adad891.zip | |
Merge branch 'expired-account-error-view-component'
Diffstat (limited to 'gui/src')
| -rw-r--r-- | gui/src/renderer/components/ExpiredAccountErrorView.tsx | 451 | ||||
| -rw-r--r-- | gui/src/renderer/components/MainView.tsx | 4 | ||||
| -rw-r--r-- | gui/src/renderer/containers/ExpiredAccountErrorViewContainer.tsx | 44 |
3 files changed, 257 insertions, 242 deletions
diff --git a/gui/src/renderer/components/ExpiredAccountErrorView.tsx b/gui/src/renderer/components/ExpiredAccountErrorView.tsx index 2d4fcdb216..8cfcdb1d46 100644 --- a/gui/src/renderer/components/ExpiredAccountErrorView.tsx +++ b/gui/src/renderer/components/ExpiredAccountErrorView.tsx @@ -1,10 +1,13 @@ -import * as React from 'react'; +import { createContext, ReactNode, useCallback, useContext, useMemo, useState } from 'react'; import { sprintf } from 'sprintf-js'; import { links } from '../../config.json'; -import { AccountToken, TunnelState } from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; -import { LoginState } from '../redux/account/reducers'; +import log from '../../shared/logging'; +import { useAppContext } from '../context'; +import { useHistory } from '../lib/history'; +import { RoutePath } from '../lib/routes'; +import { useSelector } from '../redux/store'; import * as AppButton from './AppButton'; import { AriaDescribed, AriaDescription, AriaDescriptionGroup } from './AriaGroup'; import * as Cell from './cell'; @@ -26,230 +29,286 @@ import ImageView from './ImageView'; import { Footer, Layout } from './Layout'; import { ModalAlert, ModalAlertType, ModalMessage } from './Modal'; -export enum RecoveryAction { +enum RecoveryAction { openBrowser, disconnect, disableBlockedWhenDisconnected, } -interface IExpiredAccountErrorViewProps { - isBlocked: boolean; - blockWhenDisconnected: boolean; - accountToken?: AccountToken; - loginState: LoginState; - tunnelState: TunnelState; - onExternalLinkWithAuth: (url: string) => Promise<void>; - onDisconnect: () => Promise<void>; - setBlockWhenDisconnected: (value: boolean) => void; - navigateToRedeemVoucher: () => void; +export default function ExpiredAccountErrorView() { + return ( + <ExpiredAccountContextProvider> + <ExpiredAccountErrorViewComponent /> + </ExpiredAccountContextProvider> + ); } -interface IExpiredAccountErrorViewState { - showBlockWhenDisconnectedAlert: boolean; +function ExpiredAccountErrorViewComponent() { + const history = useHistory(); + const { disconnectTunnel } = useAppContext(); + + const connection = useSelector((state) => state.connection); + + const { recoveryAction } = useRecoveryAction(); + const isNewAccount = useIsNewAccount(); + + const headerBarStyle = isNewAccount + ? HeaderBarStyle.default + : calculateHeaderBarStyle(connection.status); + + const onDisconnect = useCallback(async () => { + try { + await disconnectTunnel(); + } catch (e) { + const error = e as Error; + log.error(`Failed to disconnect the tunnel: ${error.message}`); + } + }, []); + + const navigateToRedeemVoucher = useCallback(() => { + history.push(RoutePath.redeemVoucher); + }, [history.push]); + + return ( + <Layout> + <StyledHeader barStyle={headerBarStyle} /> + <StyledCustomScrollbars fillContainer> + <StyledContainer> + <StyledBody>{isNewAccount ? <WelcomeView /> : <Content />}</StyledBody> + + <Footer> + <AppButton.ButtonGroup> + {recoveryAction === RecoveryAction.disconnect && ( + <AppButton.BlockingButton onClick={onDisconnect}> + <AppButton.RedButton> + {messages.pgettext('connect-view', 'Disconnect')} + </AppButton.RedButton> + </AppButton.BlockingButton> + )} + + <ExternalPaymentButton /> + + <AppButton.GreenButton onClick={navigateToRedeemVoucher}> + {messages.pgettext('connect-view', 'Redeem voucher')} + </AppButton.GreenButton> + </AppButton.ButtonGroup> + </Footer> + + <BlockWhenDisconnectedAlert /> + </StyledContainer> + </StyledCustomScrollbars> + </Layout> + ); } -export default class ExpiredAccountErrorView extends React.Component< - IExpiredAccountErrorViewProps, - IExpiredAccountErrorViewState -> { - public state: IExpiredAccountErrorViewState = { - showBlockWhenDisconnectedAlert: false, - }; +function WelcomeView() { + const account = useSelector((state) => state.account); + const { recoveryMessage } = useRecoveryAction(); - public render() { - const headerBarStyle = - this.props.loginState.type === 'ok' && this.props.loginState.method === 'new_account' - ? HeaderBarStyle.default - : calculateHeaderBarStyle(this.props.tunnelState); + return ( + <> + <StyledTitle>{messages.pgettext('connect-view', 'Congrats!')}</StyledTitle> + <StyledAccountTokenMessage> + {messages.pgettext('connect-view', 'Here’s your account number. Save it!')} + <StyledAccountTokenContainer> + <StyledAccountTokenLabel accountToken={account.accountToken || ''} obscureValue={false} /> + </StyledAccountTokenContainer> + </StyledAccountTokenMessage> - return ( - <Layout> - <StyledHeader barStyle={headerBarStyle} /> - <StyledCustomScrollbars fillContainer> - <StyledContainer> - <StyledBody>{this.renderContent()}</StyledBody> + <StyledMessage> + {sprintf('%(introduction)s %(recoveryMessage)s', { + introduction: messages.pgettext( + 'connect-view', + 'To start using the app, you first need to add time to your account.', + ), + recoveryMessage, + })} + </StyledMessage> + </> + ); +} - <Footer> - <AppButton.ButtonGroup> - {this.getRecoveryAction() === RecoveryAction.disconnect && ( - <AppButton.BlockingButton onClick={this.props.onDisconnect}> - <AppButton.RedButton> - {messages.pgettext('connect-view', 'Disconnect')} - </AppButton.RedButton> - </AppButton.BlockingButton> - )} +function Content() { + const { recoveryMessage } = useRecoveryAction(); - {this.renderExternalPaymentButton()} + return ( + <> + <StyledStatusIcon> + <ImageView source="icon-fail" height={60} width={60} /> + </StyledStatusIcon> + <StyledTitle>{messages.pgettext('connect-view', 'Out of time')}</StyledTitle> + <StyledMessage> + {sprintf('%(introduction)s %(recoveryMessage)s', { + introduction: messages.pgettext( + 'connect-view', + 'You have no more VPN time left on this account.', + ), + recoveryMessage, + })} + </StyledMessage> + </> + ); +} - <AppButton.GreenButton onClick={this.props.navigateToRedeemVoucher}> - {messages.pgettext('connect-view', 'Redeem voucher')} - </AppButton.GreenButton> - </AppButton.ButtonGroup> - </Footer> +function ExternalPaymentButton() { + const { setShowBlockWhenDisconnectedAlert } = useExpiredAccountContext(); + const { recoveryAction } = useRecoveryAction(); + const { openLinkWithAuth } = useAppContext(); + const isNewAccount = useIsNewAccount(); - {this.renderBlockWhenDisconnectedAlert()} - </StyledContainer> - </StyledCustomScrollbars> - </Layout> - ); - } + const buttonText = isNewAccount + ? messages.gettext('Buy credit') + : messages.gettext('Buy more credit'); - private renderContent() { - if (this.isNewAccount()) { - return this.renderWelcomeView(); + const onOpenExternalPayment = useCallback(async () => { + if (recoveryAction === RecoveryAction.disableBlockedWhenDisconnected) { + setShowBlockWhenDisconnectedAlert(true); + } else { + await openLinkWithAuth(links.purchase); } + }, []); - return ( - <> - <StyledStatusIcon> - <ImageView source="icon-fail" height={60} width={60} /> - </StyledStatusIcon> - <StyledTitle>{messages.pgettext('connect-view', 'Out of time')}</StyledTitle> - <StyledMessage> - {sprintf('%(introduction)s %(recoveryMessage)s', { - introduction: messages.pgettext( - 'connect-view', - 'You have no more VPN time left on this account.', - ), - recoveryMessage: this.getRecoveryActionMessage(), - })} - </StyledMessage> - </> - ); - } + return ( + <AppButton.BlockingButton + disabled={recoveryAction === RecoveryAction.disconnect} + onClick={onOpenExternalPayment}> + <AriaDescriptionGroup> + <AriaDescribed> + <AppButton.GreenButton> + <AppButton.Label>{buttonText}</AppButton.Label> + <AriaDescription> + <AppButton.Icon + source="icon-extLink" + height={16} + width={16} + aria-label={messages.pgettext('accessibility', 'Opens externally')} + /> + </AriaDescription> + </AppButton.GreenButton> + </AriaDescribed> + </AriaDescriptionGroup> + </AppButton.BlockingButton> + ); +} - private renderWelcomeView() { - return ( - <> - <StyledTitle>{messages.pgettext('connect-view', 'Congrats!')}</StyledTitle> - <StyledAccountTokenMessage> - {messages.pgettext('connect-view', 'Here’s your account number. Save it!')} - <StyledAccountTokenContainer> - <StyledAccountTokenLabel - accountToken={this.props.accountToken || ''} - obscureValue={false} - /> - </StyledAccountTokenContainer> - </StyledAccountTokenMessage> +function BlockWhenDisconnectedAlert() { + const { + showBlockWhenDisconnectedAlert, + setShowBlockWhenDisconnectedAlert, + } = useExpiredAccountContext(); + const { setBlockWhenDisconnected } = useAppContext(); + const blockWhenDisconnected = useSelector((state) => state.settings.blockWhenDisconnected); - <StyledMessage> - {sprintf('%(introduction)s %(recoveryMessage)s', { - introduction: messages.pgettext( - 'connect-view', - 'To start using the app, you first need to add time to your account.', - ), - recoveryMessage: this.getRecoveryActionMessage(), - })} - </StyledMessage> - </> - ); - } + const onCloseBlockWhenDisconnectedInstructions = useCallback(() => { + setShowBlockWhenDisconnectedAlert(false); + }, []); + + const onChange = useCallback(async (blockWhenDisconnected: boolean) => { + try { + await setBlockWhenDisconnected(blockWhenDisconnected); + } catch (e) { + const error = e as Error; + log.error('Failed to update block when disconnected', error.message); + } + }, []); - private getRecoveryActionMessage() { - switch (this.getRecoveryAction()) { - case RecoveryAction.openBrowser: - case RecoveryAction.disableBlockedWhenDisconnected: - return messages.pgettext( + return ( + <ModalAlert + isOpen={showBlockWhenDisconnectedAlert} + type={ModalAlertType.caution} + buttons={[ + <AppButton.BlueButton key="cancel" onClick={onCloseBlockWhenDisconnectedInstructions}> + {messages.gettext('Close')} + </AppButton.BlueButton>, + ]} + close={onCloseBlockWhenDisconnectedInstructions}> + <ModalMessage> + {messages.pgettext( 'connect-view', - 'Either buy credit on our website or redeem a voucher.', - ); - case RecoveryAction.disconnect: - return messages.pgettext( + 'You need to disable "Lockdown mode" in order to access the Internet to add time.', + )} + </ModalMessage> + <ModalMessage> + {messages.pgettext( 'connect-view', - 'To add more, you will need to disconnect and access the Internet with an unsecure connection.', - ); - } - } + 'Remember, turning it off will allow network traffic while the VPN is disconnected until you turn it back on under Advanced settings.', + )} + </ModalMessage> + <StyledModalCellContainer> + <Cell.Label>{messages.pgettext('vpn-settings-view', 'Lockdown mode')}</Cell.Label> + <Cell.Switch isOn={blockWhenDisconnected} onChange={onChange} /> + </StyledModalCellContainer> + </ModalAlert> + ); +} - private renderExternalPaymentButton() { - const buttonText = this.isNewAccount() - ? messages.gettext('Buy credit') - : messages.gettext('Buy more credit'); +type ExpiredAccountContextType = { + setShowBlockWhenDisconnectedAlert: (val: boolean) => void; + showBlockWhenDisconnectedAlert: boolean; +}; - return ( - <AppButton.BlockingButton - disabled={this.getRecoveryAction() === RecoveryAction.disconnect} - onClick={this.onOpenExternalPayment}> - <AriaDescriptionGroup> - <AriaDescribed> - <AppButton.GreenButton> - <AppButton.Label>{buttonText}</AppButton.Label> - <AriaDescription> - <AppButton.Icon - source="icon-extLink" - height={16} - width={16} - aria-label={messages.pgettext('accessibility', 'Opens externally')} - /> - </AriaDescription> - </AppButton.GreenButton> - </AriaDescribed> - </AriaDescriptionGroup> - </AppButton.BlockingButton> - ); - } +const ExpiredAccountContext = createContext<ExpiredAccountContextType | undefined>(undefined); - private renderBlockWhenDisconnectedAlert() { - return ( - <ModalAlert - isOpen={this.state.showBlockWhenDisconnectedAlert} - type={ModalAlertType.caution} - buttons={[ - <AppButton.BlueButton - key="cancel" - onClick={this.onCloseBlockWhenDisconnectedInstructions}> - {messages.gettext('Close')} - </AppButton.BlueButton>, - ]} - close={this.onCloseBlockWhenDisconnectedInstructions}> - <ModalMessage> - {messages.pgettext( - 'connect-view', - 'You need to disable "Lockdown mode" in order to access the Internet to add time.', - )} - </ModalMessage> - <ModalMessage> - {messages.pgettext( - 'connect-view', - 'Remember, turning it off will allow network traffic while the VPN is disconnected until you turn it back on under Advanced settings.', - )} - </ModalMessage> - <StyledModalCellContainer> - <Cell.Label>{messages.pgettext('vpn-settings-view', 'Lockdown mode')}</Cell.Label> - <Cell.Switch - isOn={this.props.blockWhenDisconnected} - onChange={this.props.setBlockWhenDisconnected} - /> - </StyledModalCellContainer> - </ModalAlert> +const ExpiredAccountContextProvider = ({ children }: { children: ReactNode }) => { + const [showBlockWhenDisconnectedAlert, setShowBlockWhenDisconnectedAlert] = useState(false); + + const value: ExpiredAccountContextType = useMemo( + () => ({ + setShowBlockWhenDisconnectedAlert, + showBlockWhenDisconnectedAlert, + }), + [setShowBlockWhenDisconnectedAlert, showBlockWhenDisconnectedAlert], + ); + return <ExpiredAccountContext.Provider value={value}>{children}</ExpiredAccountContext.Provider>; +}; + +const useExpiredAccountContext = () => { + const context = useContext(ExpiredAccountContext); + if (!context) { + throw new Error( + 'useExpiredAccountContext must be used within an ExpiredAccountContextProvider', ); } - private isNewAccount() { - return this.props.loginState.type === 'ok' && this.props.loginState.method === 'new_account'; - } + return context; +}; - private onOpenExternalPayment = async (): Promise<void> => { - if (this.getRecoveryAction() === RecoveryAction.disableBlockedWhenDisconnected) { - this.setState({ showBlockWhenDisconnectedAlert: true }); - } else { - await this.props.onExternalLinkWithAuth(links.purchase); - } - }; +const useRecoveryAction = () => { + const isBlocked = useSelector((state) => state.connection.isBlocked); + const blockWhenDisconnected = useSelector((state) => state.settings.blockWhenDisconnected); - private getRecoveryAction() { - const { blockWhenDisconnected, isBlocked } = this.props; + let recoveryAction: RecoveryAction; - if (blockWhenDisconnected && isBlocked) { - return RecoveryAction.disableBlockedWhenDisconnected; - } else if (!blockWhenDisconnected && isBlocked) { - return RecoveryAction.disconnect; - } else { - return RecoveryAction.openBrowser; - } + if (blockWhenDisconnected && isBlocked) { + recoveryAction = RecoveryAction.disableBlockedWhenDisconnected; + } else if (!blockWhenDisconnected && isBlocked) { + recoveryAction = RecoveryAction.disconnect; + } else { + recoveryAction = RecoveryAction.openBrowser; } - private onCloseBlockWhenDisconnectedInstructions = () => { - this.setState({ showBlockWhenDisconnectedAlert: false }); - }; -} + let recoveryMessage: string; + + switch (recoveryAction) { + case RecoveryAction.openBrowser: + case RecoveryAction.disableBlockedWhenDisconnected: + recoveryMessage = messages.pgettext( + 'connect-view', + 'Either buy credit on our website or redeem a voucher.', + ); + break; + case RecoveryAction.disconnect: + recoveryMessage = messages.pgettext( + 'connect-view', + 'To add more, you will need to disconnect and access the Internet with an unsecure connection.', + ); + break; + } + + return { recoveryAction, recoveryMessage }; +}; + +const useIsNewAccount = () => { + const account = useSelector((state) => state.account); + return account.status.type === 'ok' && account.status.method === 'new_account'; +}; diff --git a/gui/src/renderer/components/MainView.tsx b/gui/src/renderer/components/MainView.tsx index 33f5f92ad5..cc2419ddfd 100644 --- a/gui/src/renderer/components/MainView.tsx +++ b/gui/src/renderer/components/MainView.tsx @@ -2,10 +2,10 @@ import { useEffect, useState } from 'react'; import { hasExpired } from '../../shared/account-expiry'; import Connect from '../components/Connect'; -import ExpiredAccountErrorViewContainer from '../containers/ExpiredAccountErrorViewContainer'; import { useHistory } from '../lib/history'; import { RoutePath } from '../lib/routes'; import { useSelector } from '../redux/store'; +import ExpiredAccountErrorView from './ExpiredAccountErrorView'; type ExpiryData = { show: false } | { show: true; expiry: string | undefined }; @@ -37,7 +37,7 @@ export default function MainView() { }, [showAccountExpired, accountHasExpired]); if (showAccountExpired.show) { - return <ExpiredAccountErrorViewContainer />; + return <ExpiredAccountErrorView />; } else { return <Connect />; } diff --git a/gui/src/renderer/containers/ExpiredAccountErrorViewContainer.tsx b/gui/src/renderer/containers/ExpiredAccountErrorViewContainer.tsx deleted file mode 100644 index 14cde1d3db..0000000000 --- a/gui/src/renderer/containers/ExpiredAccountErrorViewContainer.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { connect } from 'react-redux'; - -import log from '../../shared/logging'; -import ExpiredAccountErrorView from '../components/ExpiredAccountErrorView'; -import withAppContext, { IAppContext } from '../context'; -import { IHistoryProps, withHistory } from '../lib/history'; -import { RoutePath } from '../lib/routes'; -import { IReduxState, ReduxDispatch } from '../redux/store'; - -const mapStateToProps = (state: IReduxState) => ({ - accountToken: state.account.accountToken, - loginState: state.account.status, - tunnelState: state.connection.status, - isBlocked: state.connection.isBlocked, - blockWhenDisconnected: state.settings.blockWhenDisconnected, -}); -const mapDispatchToProps = (_dispatch: ReduxDispatch, props: IHistoryProps & IAppContext) => { - return { - onExternalLinkWithAuth: (url: string) => props.app.openLinkWithAuth(url), - onDisconnect: async () => { - try { - await props.app.disconnectTunnel(); - } catch (e) { - const error = e as Error; - log.error(`Failed to disconnect the tunnel: ${error.message}`); - } - }, - setBlockWhenDisconnected: async (blockWhenDisconnected: boolean) => { - try { - await props.app.setBlockWhenDisconnected(blockWhenDisconnected); - } catch (e) { - const error = e as Error; - log.error('Failed to update block when disconnected', error.message); - } - }, - navigateToRedeemVoucher: () => { - props.history.push(RoutePath.redeemVoucher); - }, - }; -}; - -export default withAppContext( - withHistory(connect(mapStateToProps, mapDispatchToProps)(ExpiredAccountErrorView)), -); |
