diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2021-06-28 12:12:35 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2021-06-28 12:12:35 +0200 |
| commit | 4d95c0a019c2d7bfb2c3ca4951febfded93e9758 (patch) | |
| tree | 1cb7b54c080a865e597102e16b1b9b32e4ca3505 | |
| parent | 09a3e447f47591643032fbf988dd9017db629b25 (diff) | |
| parent | b51b561baabebf3210f936eddc90390488e8819b (diff) | |
| download | mullvadvpn-4d95c0a019c2d7bfb2c3ca4951febfded93e9758.tar.xz mullvadvpn-4d95c0a019c2d7bfb2c3ca4951febfded93e9758.zip | |
Merge branch 'new-account-flow'
20 files changed, 528 insertions, 138 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index b94fe8d10d..22f36fb8d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Line wrap the file at 100 chars. Th ### Changed - Only use the account history file to store the last used account. +- Updated the out of time-view and new account-view to make it more user friendly. ### Fixed - Fix link to download page not always using the beta URL when it should. diff --git a/gui/src/config.json b/gui/src/config.json index 59a214b604..112391c360 100644 --- a/gui/src/config.json +++ b/gui/src/config.json @@ -4,6 +4,7 @@ "purchase": "https://mullvad.net/account/", "manageKeys": "https://mullvad.net/account/ports/", "faq": "https://mullvad.net/help/tag/mullvad-app/", + "privacyGuide": "https://mullvad.net/help/first-steps-towards-online-privacy/", "download": "https://mullvad.net/download/", "betaDownload": "https://mullvad.net/download/beta" }, diff --git a/gui/src/main/account-data-cache.ts b/gui/src/main/account-data-cache.ts index 7d3c59e5ef..8b81a29472 100644 --- a/gui/src/main/account-data-cache.ts +++ b/gui/src/main/account-data-cache.ts @@ -1,5 +1,5 @@ import { closeToExpiry, hasExpired } from '../shared/account-expiry'; -import { AccountToken, IAccountData } from '../shared/daemon-rpc-types'; +import { AccountToken, IAccountData, VoucherResponse } from '../shared/daemon-rpc-types'; import { DateComponent, dateByAddingComponent } from '../shared/date-helper'; import log from '../shared/logging'; import consumePromise from '../shared/promise'; @@ -66,6 +66,12 @@ export default class AccountDataCache { }); } + public handleVoucherResponse(accountToken: AccountToken, voucherResponse: VoucherResponse) { + if (accountToken === this.currentAccount && voucherResponse.type === 'success') { + this.setValue({ expiry: voucherResponse.newExpiry }); + } + } + private setValue(value: IAccountData) { this.expiresAt = new Date(Date.now() + 60 * 1000); // 60s expiration this.updateHandler(value); diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts index d86a6cba53..1785794818 100644 --- a/gui/src/main/index.ts +++ b/gui/src/main/index.ts @@ -196,10 +196,16 @@ class ApplicationMain { return this.daemonRpc.getAccountData(accountToken); }, (accountData) => { - this.accountData = accountData; + this.accountData = accountData && { + ...accountData, + previousExpiry: + accountData.expiry !== this.accountData?.expiry + ? this.accountData?.expiry + : this.accountData?.previousExpiry, + }; if (this.windowController) { - IpcMainEventChannel.account.notify(this.windowController.webContents, accountData); + IpcMainEventChannel.account.notify(this.windowController.webContents, this.accountData); } this.handleAccountExpiry(); @@ -1124,9 +1130,16 @@ class ApplicationMain { IpcMainEventChannel.account.handleLogin((token: AccountToken) => this.login(token)); IpcMainEventChannel.account.handleLogout(() => this.logout()); IpcMainEventChannel.account.handleGetWwwAuthToken(() => this.daemonRpc.getWwwAuthToken()); - IpcMainEventChannel.account.handleSubmitVoucher((voucherCode: string) => - this.daemonRpc.submitVoucher(voucherCode), - ); + IpcMainEventChannel.account.handleSubmitVoucher(async (voucherCode: string) => { + const currentAccountToken = this.settings.accountToken; + const response = await this.daemonRpc.submitVoucher(voucherCode); + + if (currentAccountToken) { + this.accountDataCache.handleVoucherResponse(currentAccountToken, response); + } + + return response; + }); IpcMainEventChannel.accountHistory.handleClear(async () => { await this.daemonRpc.clearAccountHistory(); diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx index 9fa0e4a456..9e1555e4ec 100644 --- a/gui/src/renderer/app.tsx +++ b/gui/src/renderer/app.tsx @@ -145,7 +145,7 @@ export default class AppRenderer { }); IpcRendererEventChannel.account.listen((newAccountData?: IAccountData) => { - this.setAccountExpiry(newAccountData && newAccountData.expiry); + this.setAccountExpiry(newAccountData?.expiry, newAccountData?.previousExpiry); }); IpcRendererEventChannel.accountHistory.listen((newAccountHistory?: AccountToken) => { @@ -219,7 +219,10 @@ export default class AppRenderer { initialState.translations.relayLocations, ); - this.setAccountExpiry(initialState.accountData && initialState.accountData.expiry); + this.setAccountExpiry( + initialState.accountData?.expiry, + initialState.accountData?.previousExpiry, + ); this.handleAccountChange(undefined, initialState.settings.accountToken); this.setAccountHistory(initialState.accountHistory); this.setSettings(initialState.settings); @@ -620,7 +623,7 @@ export default class AppRenderer { private resetNavigation() { if (this.connectedToDaemon) { if (this.settings.accountToken) { - this.history.resetWithIfDifferent('/connect'); + this.history.resetWithIfDifferent('/main'); } else { this.history.resetWithIfDifferent('/login'); } @@ -829,8 +832,8 @@ export default class AppRenderer { this.reduxActions.settings.updateGuiSettings(guiSettings); } - private setAccountExpiry(expiry?: string) { - this.reduxActions.account.updateAccountExpiry(expiry); + private setAccountExpiry(expiry?: string, previousExpiry?: string) { + this.reduxActions.account.updateAccountExpiry(expiry, previousExpiry); } private storeAutoStart(autoStart: boolean) { diff --git a/gui/src/renderer/components/AppButton.tsx b/gui/src/renderer/components/AppButton.tsx index 352d70d9e9..d424e8b54e 100644 --- a/gui/src/renderer/components/AppButton.tsx +++ b/gui/src/renderer/components/AppButton.tsx @@ -191,3 +191,26 @@ export const RedTransparentButton = styled(BaseButton)({ backgroundColor: colors.red80, }, }); + +const StyledButtonWrapper = styled.div({ + display: 'flex', + flexDirection: 'column', + flex: 0, + ':not(:last-child)': { + marginBottom: '18px', + }, +}); + +interface IButtonGroupProps { + children: React.ReactElement[]; +} + +export function ButtonGroup(props: IButtonGroupProps) { + return ( + <> + {React.Children.map(props.children, (button, index) => ( + <StyledButtonWrapper key={index}>{button}</StyledButtonWrapper> + ))} + </> + ); +} diff --git a/gui/src/renderer/components/Connect.tsx b/gui/src/renderer/components/Connect.tsx index a6654ad8c0..e48abf8d96 100644 --- a/gui/src/renderer/components/Connect.tsx +++ b/gui/src/renderer/components/Connect.tsx @@ -1,15 +1,13 @@ import * as React from 'react'; import styled from 'styled-components'; import { hasExpired } from '../../shared/account-expiry'; -import ExpiredAccountErrorViewContainer from '../containers/ExpiredAccountErrorViewContainer'; import NotificationArea from '../components/NotificationArea'; import { AuthFailureKind, parseAuthFailure } from '../../shared/auth-failure'; import { LoginState } from '../redux/account/reducers'; import { IConnectionReduxState } from '../redux/connection/reducers'; -import { FocusFallback } from './Focus'; -import { Brand, HeaderBarStyle, HeaderBarSettingsButton } from './HeaderBar'; +import { calculateHeaderBarStyle, DefaultHeaderBar } from './HeaderBar'; import ImageView from './ImageView'; -import { Container, Header, Layout } from './Layout'; +import { Container, Layout } from './Layout'; import Map, { MarkerStyle, ZoomLevel } from './Map'; import { ModalContainer } from './Modal'; import TunnelControl from './TunnelControl'; @@ -89,21 +87,8 @@ export default class Connect extends React.Component<IProps, IState> { return ( <ModalContainer> <Layout> - <Header barStyle={this.headerBarStyle()}> - <FocusFallback> - <Brand /> - </FocusFallback> - <HeaderBarSettingsButton /> - </Header> - <StyledContainer> - {this.state.isAccountExpired || - (this.props.loginState.type === 'ok' && - this.props.loginState.method === 'new_account') ? ( - <ExpiredAccountErrorViewContainer /> - ) : ( - this.renderMap() - )} - </StyledContainer> + <DefaultHeaderBar barStyle={calculateHeaderBarStyle(this.props.connection.status)} /> + <StyledContainer>{this.renderMap()}</StyledContainer> </Layout> </ModalContainer> ); @@ -171,27 +156,6 @@ export default class Connect extends React.Component<IProps, IState> { ); } - private headerBarStyle(): HeaderBarStyle { - const { status } = this.props.connection; - switch (status.state) { - case 'disconnected': - return HeaderBarStyle.error; - case 'connecting': - case 'connected': - return HeaderBarStyle.success; - case 'error': - return !status.details.blockFailure ? HeaderBarStyle.success : HeaderBarStyle.error; - case 'disconnecting': - switch (status.details) { - case 'block': - case 'reconnect': - return HeaderBarStyle.success; - case 'nothing': - return HeaderBarStyle.error; - } - } - } - private getMapProps(): Map['props'] { const { longitude, diff --git a/gui/src/renderer/components/ExpiredAccountAddTime.tsx b/gui/src/renderer/components/ExpiredAccountAddTime.tsx new file mode 100644 index 0000000000..9831430d4e --- /dev/null +++ b/gui/src/renderer/components/ExpiredAccountAddTime.tsx @@ -0,0 +1,274 @@ +import React, { useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { useHistory } from 'react-router'; +import { sprintf } from 'sprintf-js'; +import styled from 'styled-components'; +import { links, colors } from '../../config.json'; +import { formatRelativeDate } from '../../shared/date-helper'; +import { messages } from '../../shared/gettext'; +import { useAppContext } from '../context'; +import useActions from '../lib/actionsHook'; +import History from '../lib/history'; +import account from '../redux/account/actions'; +import { IReduxState } from '../redux/store'; +import * as AppButton from './AppButton'; +import { AriaDescribed, AriaDescription, AriaDescriptionGroup } from './AriaGroup'; +import { bigText } from './common-styles'; +import CustomScrollbars from './CustomScrollbars'; +import { calculateHeaderBarStyle, DefaultHeaderBar, HeaderBarStyle } from './HeaderBar'; +import ImageView from './ImageView'; +import { Container, Layout } from './Layout'; +import { + RedeemVoucherContainer, + RedeemVoucherInput, + RedeemVoucherResponse, + RedeemVoucherSubmitButton, +} from './RedeemVoucher'; + +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', + paddingBottom: 'auto', +}); + +export const StyledFooter = styled.div({ + display: 'flex', + flexDirection: 'column', + flex: 0, + padding: '18px 22px 22px', +}); + +export const StyledTitle = styled.span(bigText, { + lineHeight: '38px', + marginBottom: '8px', +}); + +export const StyledLabel = styled.span({ + fontFamily: 'Open Sans', + fontSize: '13px', + fontWeight: 600, + lineHeight: '20px', + color: colors.white, + marginBottom: '9px', +}); + +export const StyledRedeemVoucherInput = styled(RedeemVoucherInput)({ + flex: 0, +}); + +export const StyledStatusIcon = styled.div({ + alignSelf: 'center', + width: '60px', + height: '60px', + marginBottom: '18px', +}); + +export function VoucherInput() { + const history = useHistory(); + + const onSuccess = useCallback(() => { + history.push('/main/voucher/success'); + }, [history]); + + const navigateBack = useCallback(() => { + history.goBack(); + }, [history]); + + return ( + <Layout> + <HeaderBar /> + <StyledCustomScrollbars fillContainer> + <StyledContainer> + <RedeemVoucherContainer onSuccess={onSuccess}> + <StyledBody> + <StyledTitle>{messages.pgettext('connect-view', 'Redeem voucher')}</StyledTitle> + <StyledLabel>{messages.pgettext('connect-view', 'Enter voucher code')}</StyledLabel> + <StyledRedeemVoucherInput /> + <RedeemVoucherResponse disableSuccessMessage /> + </StyledBody> + + <StyledFooter> + <AppButton.ButtonGroup> + <RedeemVoucherSubmitButton /> + <AppButton.BlueButton onClick={navigateBack}> + {messages.gettext('Cancel')} + </AppButton.BlueButton> + </AppButton.ButtonGroup> + </StyledFooter> + </RedeemVoucherContainer> + </StyledContainer> + </StyledCustomScrollbars> + </Layout> + ); +} + +export function VoucherVerificationSuccess() { + return ( + <TimeAdded title={messages.pgettext('connect-view', 'Voucher was successfully redeemed')} /> + ); +} + +interface ITimeAddedProps { + title?: string; +} + +export function TimeAdded(props: ITimeAddedProps) { + const history = useHistory(); + const finish = useFinishedCallback(); + const accountData = useSelector((state: IReduxState) => state.account); + const isNewAccount = useSelector( + (state: IReduxState) => + state.account.status.type === 'ok' && state.account.status.method === 'new_account', + ); + + const navigateToSetupFinished = useCallback(() => { + if (isNewAccount) { + history.push('/main/setup-finished'); + } else { + finish(); + } + }, [history, finish]); + + const duration = + (accountData.expiry && + accountData.previousExpiry && + formatRelativeDate(accountData.expiry, accountData.previousExpiry)) ?? + ''; + + return ( + <Layout> + <HeaderBar /> + <StyledCustomScrollbars fillContainer> + <StyledContainer> + <StyledBody> + <StyledStatusIcon> + <ImageView source="icon-success" height={60} width={60} /> + </StyledStatusIcon> + <StyledTitle> + {props.title ?? messages.pgettext('connect-view', 'Time was successfully added')} + </StyledTitle> + <StyledLabel> + {sprintf( + messages.pgettext('connect-view', '%(duration)s was added to your account.'), + { duration }, + )} + </StyledLabel> + </StyledBody> + + <StyledFooter> + <AppButton.BlueButton onClick={navigateToSetupFinished}> + {messages.gettext('Next')} + </AppButton.BlueButton> + </StyledFooter> + </StyledContainer> + </StyledCustomScrollbars> + </Layout> + ); +} + +export function SetupFinished() { + const finish = useFinishedCallback(); + const { openUrl } = useAppContext(); + + const openPrivacyLink = useCallback(() => openUrl(links.privacyGuide), [openUrl]); + + return ( + <Layout> + <HeaderBar /> + <StyledCustomScrollbars fillContainer> + <StyledContainer> + <StyledBody> + <StyledTitle>{messages.pgettext('connect-view', "You're all set!")}</StyledTitle> + <StyledLabel> + {messages.pgettext( + 'connect-view', + 'Go ahead and start using the app to begin reclaiming your online privacy.', + )} + </StyledLabel> + <StyledLabel> + {messages.pgettext( + 'connect-view', + 'To continue your journey as a privacy ninja, visit our website to pick up other privacy-friendly habits and tools.', + )} + </StyledLabel> + </StyledBody> + + <StyledFooter> + <AppButton.ButtonGroup> + <AriaDescriptionGroup> + <AriaDescribed> + <AppButton.BlueButton onClick={openPrivacyLink}> + <AppButton.Label> + {messages.pgettext('connect-view', 'Learn about privacy')} + </AppButton.Label> + <AriaDescription> + <AppButton.Icon + height={16} + width={16} + source="icon-extLink" + aria-label={messages.pgettext('accessibility', 'Opens externally')} + /> + </AriaDescription> + </AppButton.BlueButton> + </AriaDescribed> + </AriaDescriptionGroup> + <AppButton.GreenButton onClick={finish}> + {messages.pgettext('connect-view', 'Start using the app')} + </AppButton.GreenButton> + </AppButton.ButtonGroup> + </StyledFooter> + </StyledContainer> + </StyledCustomScrollbars> + </Layout> + ); +} + +function HeaderBar() { + const isNewAccount = useSelector( + (state: IReduxState) => + state.account.status.type === 'ok' && state.account.status.method === 'new_account', + ); + const tunnelState = useSelector((state: IReduxState) => state.connection.status); + const headerBarStyle = isNewAccount + ? HeaderBarStyle.default + : calculateHeaderBarStyle(tunnelState); + + return <StyledHeader barStyle={headerBarStyle} />; +} + +function useFinishedCallback() { + const { loggedIn } = useActions(account); + + const history = useHistory() as History; + const isNewAccount = useSelector( + (state: IReduxState) => + state.account.status.type === 'ok' && state.account.status.method === 'new_account', + ); + + const callback = useCallback(() => { + // Changes login method from "new_account" to "existing_account" + if (isNewAccount) { + loggedIn(); + } + + history.resetWith('/main'); + }, [isNewAccount, loggedIn, history]); + + return callback; +} diff --git a/gui/src/renderer/components/ExpiredAccountErrorView.tsx b/gui/src/renderer/components/ExpiredAccountErrorView.tsx index a01993787b..04d9f96156 100644 --- a/gui/src/renderer/components/ExpiredAccountErrorView.tsx +++ b/gui/src/renderer/components/ExpiredAccountErrorView.tsx @@ -1,8 +1,7 @@ import * as React from 'react'; import { sprintf } from 'sprintf-js'; import { links } from '../../config.json'; -import { hasExpired } from '../../shared/account-expiry'; -import { AccountToken } from '../../shared/daemon-rpc-types'; +import { AccountToken, TunnelState } from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; import { LoginState } from '../redux/account/reducers'; import * as AppButton from './AppButton'; @@ -18,14 +17,16 @@ import { StyledCustomScrollbars, StyledDisconnectButton, StyledFooter, + StyledHeader, StyledMessage, StyledModalCellContainer, StyledStatusIcon, StyledTitle, } from './ExpiredAccountErrorViewStyles'; +import { calculateHeaderBarStyle, HeaderBarStyle } from './HeaderBar'; import ImageView from './ImageView'; -import { ModalAlert, ModalAlertType, ModalMessage } from './Modal'; -import { RedeemVoucherContainer, RedeemVoucherAlert } from './RedeemVoucher'; +import { Layout } from './Layout'; +import { ModalAlert, ModalAlertType, ModalContainer, ModalMessage } from './Modal'; export enum RecoveryAction { openBrowser, @@ -37,17 +38,16 @@ interface IExpiredAccountErrorViewProps { isBlocked: boolean; blockWhenDisconnected: boolean; accountToken?: AccountToken; - accountExpiry?: string; loginState: LoginState; - hideWelcomeView: () => void; + tunnelState: TunnelState; onExternalLinkWithAuth: (url: string) => Promise<void>; onDisconnect: () => Promise<void>; setBlockWhenDisconnected: (value: boolean) => void; + navigateToRedeemVoucher: () => void; } interface IExpiredAccountErrorViewState { showBlockWhenDisconnectedAlert: boolean; - showRedeemVoucherAlert: boolean; } export default class ExpiredAccountErrorView extends React.Component< @@ -56,43 +56,45 @@ export default class ExpiredAccountErrorView extends React.Component< > { public state: IExpiredAccountErrorViewState = { showBlockWhenDisconnectedAlert: false, - showRedeemVoucherAlert: false, }; - public componentDidUpdate() { - if (this.props.accountExpiry && !hasExpired(this.props.accountExpiry)) { - this.props.hideWelcomeView(); - } - } - public render() { + const headerBarStyle = + this.props.loginState.type === 'ok' && this.props.loginState.method === 'new_account' + ? HeaderBarStyle.default + : calculateHeaderBarStyle(this.props.tunnelState); + return ( - <StyledCustomScrollbars fillContainer> - <StyledContainer> - <StyledBody>{this.renderContent()}</StyledBody> + <ModalContainer> + <Layout> + <StyledHeader barStyle={headerBarStyle} /> + <StyledCustomScrollbars fillContainer> + <StyledContainer> + <StyledBody>{this.renderContent()}</StyledBody> - <StyledFooter> - {this.getRecoveryAction() === RecoveryAction.disconnect && ( - <AppButton.BlockingButton onClick={this.props.onDisconnect}> - <StyledDisconnectButton> - {messages.pgettext('connect-view', 'Disconnect')} - </StyledDisconnectButton> - </AppButton.BlockingButton> - )} + <StyledFooter> + {this.getRecoveryAction() === RecoveryAction.disconnect && ( + <AppButton.BlockingButton onClick={this.props.onDisconnect}> + <StyledDisconnectButton> + {messages.pgettext('connect-view', 'Disconnect')} + </StyledDisconnectButton> + </AppButton.BlockingButton> + )} - {this.renderExternalPaymentButton()} + {this.renderExternalPaymentButton()} - <AppButton.GreenButton - disabled={this.getRecoveryAction() === RecoveryAction.disconnect} - onClick={this.onOpenRedeemVoucherAlert}> - {messages.pgettext('connect-view', 'Redeem voucher')} - </AppButton.GreenButton> - </StyledFooter> + <AppButton.GreenButton + disabled={this.getRecoveryAction() === RecoveryAction.disconnect} + onClick={this.props.navigateToRedeemVoucher}> + {messages.pgettext('connect-view', 'Redeem voucher')} + </AppButton.GreenButton> + </StyledFooter> - {this.state.showRedeemVoucherAlert && this.renderRedeemVoucherAlert()} - {this.state.showBlockWhenDisconnectedAlert && this.renderBlockWhenDisconnectedAlert()} - </StyledContainer> - </StyledCustomScrollbars> + {this.state.showBlockWhenDisconnectedAlert && this.renderBlockWhenDisconnectedAlert()} + </StyledContainer> + </StyledCustomScrollbars> + </Layout> + </ModalContainer> ); } @@ -188,14 +190,6 @@ export default class ExpiredAccountErrorView extends React.Component< ); } - private renderRedeemVoucherAlert() { - return ( - <RedeemVoucherContainer onSuccess={this.props.hideWelcomeView}> - <RedeemVoucherAlert onClose={this.onCloseRedeemVoucherAlert} /> - </RedeemVoucherContainer> - ); - } - private renderBlockWhenDisconnectedAlert() { return ( <ModalAlert @@ -255,14 +249,6 @@ export default class ExpiredAccountErrorView extends React.Component< } } - private onOpenRedeemVoucherAlert = () => { - this.setState({ showRedeemVoucherAlert: true }); - }; - - private onCloseRedeemVoucherAlert = () => { - this.setState({ showRedeemVoucherAlert: false }); - }; - private onCloseBlockWhenDisconnectedInstructions = () => { this.setState({ showBlockWhenDisconnectedAlert: false }); }; diff --git a/gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx b/gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx index 3559d3b8ad..8872bbb312 100644 --- a/gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx +++ b/gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx @@ -5,6 +5,12 @@ import * as AppButton from './AppButton'; import * as Cell from './cell'; import { bigText, smallText } from './common-styles'; import CustomScrollbars from './CustomScrollbars'; +import { DefaultHeaderBar } from './HeaderBar'; +import { Container } from './Layout'; + +export const StyledHeader = styled(DefaultHeaderBar)({ + flex: 0, +}); export const StyledAccountTokenLabel = styled(AccountTokenLabel)({ fontFamily: 'Open Sans', @@ -31,12 +37,10 @@ export const StyledCustomScrollbars = styled(CustomScrollbars)({ flex: 1, }); -export const StyledContainer = styled.div({ - display: 'flex', - flexDirection: 'column', - flex: 1, +export const StyledContainer = styled(Container)({ paddingTop: '22px', minHeight: '100%', + backgroundColor: colors.darkBlue, }); export const StyledBody = styled.div({ @@ -51,7 +55,6 @@ export const StyledFooter = styled.div({ flexDirection: 'column', flex: 0, padding: '18px 22px 22px', - backgroundColor: colors.darkBlue, }); export const StyledTitle = styled.span(bigText, { diff --git a/gui/src/renderer/components/HeaderBar.tsx b/gui/src/renderer/components/HeaderBar.tsx index bdf7d51924..b89811913e 100644 --- a/gui/src/renderer/components/HeaderBar.tsx +++ b/gui/src/renderer/components/HeaderBar.tsx @@ -3,8 +3,10 @@ import { useSelector } from 'react-redux'; import { useHistory } from 'react-router'; import styled from 'styled-components'; import { colors } from '../../config.json'; +import { TunnelState } from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; import { IReduxState } from '../redux/store'; +import { FocusFallback } from './Focus'; import ImageView from './ImageView'; export enum HeaderBarStyle { @@ -118,3 +120,34 @@ export function HeaderBarSettingsButton() { </HeaderBarSettingsButtonContainer> ); } + +export function DefaultHeaderBar(props: IHeaderBarProps) { + return ( + <HeaderBar {...props}> + <FocusFallback> + <Brand /> + </FocusFallback> + <HeaderBarSettingsButton /> + </HeaderBar> + ); +} + +export function calculateHeaderBarStyle(tunnelState: TunnelState): HeaderBarStyle { + switch (tunnelState.state) { + case 'disconnected': + return HeaderBarStyle.error; + case 'connecting': + case 'connected': + return HeaderBarStyle.success; + case 'error': + return !tunnelState.details.blockFailure ? HeaderBarStyle.success : HeaderBarStyle.error; + case 'disconnecting': + switch (tunnelState.details) { + case 'block': + case 'reconnect': + return HeaderBarStyle.success; + case 'nothing': + return HeaderBarStyle.error; + } + } +} diff --git a/gui/src/renderer/components/MainView.tsx b/gui/src/renderer/components/MainView.tsx new file mode 100644 index 0000000000..9698321940 --- /dev/null +++ b/gui/src/renderer/components/MainView.tsx @@ -0,0 +1,28 @@ +import React, { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { useHistory } from 'react-router'; +import { hasExpired } from '../../shared/account-expiry'; +import { IReduxState } from '../redux/store'; +import ConnectPage from '../containers/ConnectPage'; +import ExpiredAccountErrorViewContainer from '../containers/ExpiredAccountErrorViewContainer'; + +export default function MainView() { + const history = useHistory(); + const accountExpiry = useSelector((state: IReduxState) => state.account.expiry); + const accountHasExpired = accountExpiry && hasExpired(accountExpiry); + const isNewAccount = useSelector( + (state: IReduxState) => + state.account.status.type === 'ok' && state.account.status.method === 'new_account', + ); + const [showAccountExpired, setShowAccountExpired] = useState(isNewAccount || accountHasExpired); + + useEffect(() => { + if (accountHasExpired) { + setShowAccountExpired(true); + } else if (showAccountExpired && !accountHasExpired) { + history.push('/main/time-added'); + } + }, [showAccountExpired, accountHasExpired]); + + return showAccountExpired ? <ExpiredAccountErrorViewContainer /> : <ConnectPage />; +} diff --git a/gui/src/renderer/components/RedeemVoucher.tsx b/gui/src/renderer/components/RedeemVoucher.tsx index f8c0d19b83..b878a94c4f 100644 --- a/gui/src/renderer/components/RedeemVoucher.tsx +++ b/gui/src/renderer/components/RedeemVoucher.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useContext, useState } from 'react'; import { VoucherResponse } from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; -import { useScheduler } from '../../shared/scheduler'; import { useAppContext } from '../context'; import useActions from '../lib/actionsHook'; import accountActions from '../redux/account/actions'; @@ -60,7 +59,6 @@ interface IRedeemVoucherProps { export function RedeemVoucherContainer(props: IRedeemVoucherProps) { const { onSubmit, onSuccess, onFailure } = props; - const closeScheduler = useScheduler(); const { submitVoucher } = useAppContext(); const { updateAccountExpiry } = useActions(accountActions); @@ -75,18 +73,21 @@ export function RedeemVoucherContainer(props: IRedeemVoucherProps) { return; } + const submitTimestamp = Date.now(); setSubmitting(true); onSubmit?.(); const response = await submitVoucher(value); + // Show the spinner for at least half a second if it isn't successful. + const submitDuration = Date.now() - submitTimestamp; + if (response.type !== 'success' && submitDuration < 500) { + await new Promise((resolve) => setTimeout(resolve, 500 - submitDuration)); + } + setSubmitting(false); setResponse(response); if (response.type === 'success') { - setValue(''); - closeScheduler.schedule(() => { - updateAccountExpiry(response.newExpiry); - onSuccess?.(); - }, 1000); + onSuccess?.(); } else { onFailure?.(); } @@ -100,7 +101,11 @@ export function RedeemVoucherContainer(props: IRedeemVoucherProps) { ); } -export function RedeemVoucherInput() { +interface IRedeemVoucherInputProps { + className?: string; +} + +export function RedeemVoucherInput(props: IRedeemVoucherInputProps) { const { value, setValue, onSubmit, submitting, response } = useContext(RedeemVoucherContext); const disabled = submitting || response?.type === 'success'; @@ -122,6 +127,7 @@ export function RedeemVoucherInput() { return ( <StyledInput + className={props.className} allowedCharacters="[A-Z0-9]" separator="-" uppercaseOnly @@ -137,7 +143,11 @@ export function RedeemVoucherInput() { ); } -export function RedeemVoucherResponse() { +interface IRedeemVoucherResponseProps { + disableSuccessMessage?: boolean; +} + +export function RedeemVoucherResponse(props: IRedeemVoucherResponseProps) { const { response, submitting } = useContext(RedeemVoucherContext); if (submitting) { @@ -147,11 +157,15 @@ export function RedeemVoucherResponse() { if (response) { switch (response.type) { case 'success': - return ( - <StyledSuccessResponse> - {messages.pgettext('redeem-voucher-view', 'Voucher was successfully redeemed.')} - </StyledSuccessResponse> - ); + if (props.disableSuccessMessage) { + return <StyledEmptyResponse />; + } else { + return ( + <StyledSuccessResponse> + {messages.pgettext('redeem-voucher-view', 'Voucher was successfully redeemed.')} + </StyledSuccessResponse> + ); + } case 'invalid': return ( <StyledErrorResponse> diff --git a/gui/src/renderer/containers/ExpiredAccountErrorViewContainer.tsx b/gui/src/renderer/containers/ExpiredAccountErrorViewContainer.tsx index 8b01b5e996..6297a17f42 100644 --- a/gui/src/renderer/containers/ExpiredAccountErrorViewContainer.tsx +++ b/gui/src/renderer/containers/ExpiredAccountErrorViewContainer.tsx @@ -1,24 +1,20 @@ import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; +import { RouteComponentProps, withRouter } from 'react-router'; import log from '../../shared/logging'; import ExpiredAccountErrorView from '../components/ExpiredAccountErrorView'; -import accountActions from '../redux/account/actions'; import withAppContext, { IAppContext } from '../context'; import { IReduxState, ReduxDispatch } from '../redux/store'; const mapStateToProps = (state: IReduxState) => ({ accountToken: state.account.accountToken, - accountExpiry: state.account.expiry, loginState: state.account.status, + tunnelState: state.connection.status, isBlocked: state.connection.isBlocked, blockWhenDisconnected: state.settings.blockWhenDisconnected, }); -const mapDispatchToProps = (dispatch: ReduxDispatch, props: IAppContext) => { - const account = bindActionCreators(accountActions, dispatch); +const mapDispatchToProps = (_dispatch: ReduxDispatch, props: RouteComponentProps & IAppContext) => { return { - // Changes login method from "new_account" to "existing_account" - hideWelcomeView: () => account.loggedIn(), onExternalLinkWithAuth: (url: string) => props.app.openLinkWithAuth(url), onDisconnect: async () => { try { @@ -34,9 +30,12 @@ const mapDispatchToProps = (dispatch: ReduxDispatch, props: IAppContext) => { log.error('Failed to update block when disconnected', e.message); } }, + navigateToRedeemVoucher: () => { + props.history.push('/main/voucher/redeem'); + }, }; }; export default withAppContext( - connect(mapStateToProps, mapDispatchToProps)(ExpiredAccountErrorView), + withRouter(connect(mapStateToProps, mapDispatchToProps)(ExpiredAccountErrorView)), ); diff --git a/gui/src/renderer/lib/transition-rule.ts b/gui/src/renderer/lib/transition-rule.ts index ae0e2ad5b7..91d5cd6d4c 100644 --- a/gui/src/renderer/lib/transition-rule.ts +++ b/gui/src/renderer/lib/transition-rule.ts @@ -1,3 +1,5 @@ +import { Action } from 'history'; + export interface ITransitionDescriptor { name: string; duration: number; @@ -16,15 +18,19 @@ export interface ITransitionMatch { export default class TransitionRule { constructor(private from: string | null, private to: string, private fork: ITransitionFork) {} - public match(fromRoute: string | null, toRoute: string): ITransitionMatch | null { - if ((!this.from || this.from === fromRoute) && this.to === toRoute) { + public match( + fromRoute: string | null, + toRoute: string, + action?: Action, + ): ITransitionMatch | null { + if (action !== 'POP' && (!this.from || this.from === fromRoute) && this.to === toRoute) { return { direction: 'forward', descriptor: this.fork.forward, }; } - if ((!this.from || this.from === toRoute) && this.to === fromRoute) { + if (action !== 'PUSH' && (!this.from || this.from === toRoute) && this.to === fromRoute) { return { direction: 'backward', descriptor: this.fork.backward, diff --git a/gui/src/renderer/redux/account/actions.ts b/gui/src/renderer/redux/account/actions.ts index b8fbe94d39..4a9790df00 100644 --- a/gui/src/renderer/redux/account/actions.ts +++ b/gui/src/renderer/redux/account/actions.ts @@ -50,6 +50,7 @@ interface IUpdateAccountHistoryAction { interface IUpdateAccountExpiryAction { type: 'UPDATE_ACCOUNT_EXPIRY'; expiry?: string; + previousExpiry?: string; } export type AccountAction = @@ -132,10 +133,11 @@ function updateAccountHistory(accountHistory?: AccountToken): IUpdateAccountHist }; } -function updateAccountExpiry(expiry?: string): IUpdateAccountExpiryAction { +function updateAccountExpiry(expiry?: string, previousExpiry?: string): IUpdateAccountExpiryAction { return { type: 'UPDATE_ACCOUNT_EXPIRY', expiry, + previousExpiry, }; } diff --git a/gui/src/renderer/redux/account/reducers.ts b/gui/src/renderer/redux/account/reducers.ts index 53bc55db1b..6b68fef9cf 100644 --- a/gui/src/renderer/redux/account/reducers.ts +++ b/gui/src/renderer/redux/account/reducers.ts @@ -10,6 +10,7 @@ export interface IAccountReduxState { accountToken?: AccountToken; accountHistory?: AccountToken; expiry?: string; // ISO8601 + previousExpiry?: string; // ISO8601 status: LoginState; } @@ -17,6 +18,7 @@ const initialState: IAccountReduxState = { accountToken: undefined, accountHistory: undefined, expiry: undefined, + previousExpiry: undefined, status: { type: 'none' }, }; @@ -48,6 +50,7 @@ export default function ( status: { type: 'none' }, accountToken: undefined, expiry: undefined, + previousExpiry: undefined, }; case 'RESET_LOGIN_ERROR': return { @@ -70,6 +73,7 @@ export default function ( status: { type: 'ok', method: 'new_account' }, accountToken: action.token, expiry: action.expiry, + previousExpiry: undefined, }; case 'UPDATE_ACCOUNT_TOKEN': return { @@ -85,6 +89,7 @@ export default function ( return { ...state, expiry: action.expiry, + previousExpiry: action.previousExpiry, }; } diff --git a/gui/src/renderer/routes.tsx b/gui/src/renderer/routes.tsx index 37c9851daa..fe49469092 100644 --- a/gui/src/renderer/routes.tsx +++ b/gui/src/renderer/routes.tsx @@ -1,13 +1,14 @@ +import { Action } from 'history'; import * as React from 'react'; import { Route, RouteComponentProps, Switch, withRouter } from 'react-router'; import Launch from './components/Launch'; import KeyboardNavigation from './components/KeyboardNavigation'; +import MainView from './components/MainView'; import Focus, { IFocusHandle } from './components/Focus'; import LinuxSplitTunnelingSettings from './components/LinuxSplitTunnelingSettings'; import TransitionContainer, { TransitionView } from './components/TransitionContainer'; import AccountPage from './containers/AccountPage'; import AdvancedSettingsPage from './containers/AdvancedSettingsPage'; -import ConnectPage from './containers/ConnectPage'; import LoginPage from './containers/LoginPage'; import PlatformWindowContainer from './containers/PlatformWindowContainer'; import PreferencesPage from './containers/PreferencesPage'; @@ -18,10 +19,17 @@ import SupportPage from './containers/SupportPage'; import WireguardKeysPage from './containers/WireguardKeysPage'; import History from './lib/history'; import { getTransitionProps } from './transitions'; +import { + SetupFinished, + TimeAdded, + VoucherInput, + VoucherVerificationSuccess, +} from './components/ExpiredAccountAddTime'; interface IAppRoutesState { previousLocation?: RouteComponentProps['location']; currentLocation: RouteComponentProps['location']; + action?: Action; } class AppRoutes extends React.Component<RouteComponentProps, IAppRoutesState> { @@ -41,10 +49,11 @@ class AppRoutes extends React.Component<RouteComponentProps, IAppRoutesState> { // React throttles updates, so it's impossible to capture the intermediate navigation without // listening to the history directly. this.unobserveHistory = (this.props.history as History).listen( - (location, _action, affectedEntries) => { + (location, action, affectedEntries) => { this.setState({ previousLocation: affectedEntries[0], currentLocation: location, + action, }); }, ); @@ -61,6 +70,7 @@ class AppRoutes extends React.Component<RouteComponentProps, IAppRoutesState> { const transitionProps = getTransitionProps( this.state.previousLocation ? this.state.previousLocation.pathname : null, location.pathname, + this.state.action, ); return ( @@ -72,7 +82,15 @@ class AppRoutes extends React.Component<RouteComponentProps, IAppRoutesState> { <Switch key={location.key} location={location}> <Route exact={true} path="/" component={Launch} /> <Route exact={true} path="/login" component={LoginPage} /> - <Route exact={true} path="/connect" component={ConnectPage} /> + <Route exact={true} path="/main" component={MainView} /> + <Route exact={true} path="/main/voucher/redeem" component={VoucherInput} /> + <Route + exact={true} + path="/main/voucher/success" + component={VoucherVerificationSuccess} + /> + <Route exact={true} path="/main/time-added" component={TimeAdded} /> + <Route exact={true} path="/main/setup-finished" component={SetupFinished} /> <Route exact={true} path="/settings" component={SettingsPage} /> <Route exact={true} path="/settings/language" component={SelectLanguagePage} /> <Route exact={true} path="/settings/account" component={AccountPage} /> diff --git a/gui/src/renderer/transitions.ts b/gui/src/renderer/transitions.ts index da1a45ac01..cddf8c57a2 100644 --- a/gui/src/renderer/transitions.ts +++ b/gui/src/renderer/transitions.ts @@ -1,3 +1,4 @@ +import { Action } from 'history'; import TransitionRule, { ITransitionDescriptor, ITransitionFork } from './lib/transition-rule'; export interface ITransitionGroupProps { @@ -47,6 +48,14 @@ const transitionRules = [ r('/settings/advanced', '/settings/advanced/wireguard-keys', transitions.push), r('/settings/advanced', '/settings/advanced/linux-split-tunneling', transitions.push), r('/settings', '/settings/support', transitions.push), + r('/main', '/main/voucher/redeem', transitions.push), + r('/main/voucher/redeem', '/main/voucher/success', transitions.push), + r('/main/voucher/success', '/main/setup-finished', transitions.push), + r('/main/voucher/success', '/main', transitions.push), + r('/main/time-added', '/main/setup-finished', transitions.push), + r('/main/time-added', '/main', transitions.push), + r('/main', '/main/time-added', transitions.push), + r('/main/setup-finished', '/main', transitions.push), r(null, '/settings', transitions.slide), r(null, '/select-location', transitions.slide), ]; @@ -60,6 +69,7 @@ const transitionRules = [ export function getTransitionProps( fromRoute: string | null, toRoute: string, + action?: Action, ): ITransitionGroupProps { // ignore initial transition and transition between the same routes if (!fromRoute || fromRoute === toRoute) { @@ -67,7 +77,7 @@ export function getTransitionProps( } for (const rule of transitionRules) { - const match = rule.match(fromRoute, toRoute); + const match = rule.match(fromRoute, toRoute, action); if (match) { return toTransitionGroupProps(match.descriptor); } diff --git a/gui/src/shared/daemon-rpc-types.ts b/gui/src/shared/daemon-rpc-types.ts index 92d08e8d5c..b0319369cf 100644 --- a/gui/src/shared/daemon-rpc-types.ts +++ b/gui/src/shared/daemon-rpc-types.ts @@ -1,5 +1,6 @@ export interface IAccountData { expiry: string; + previousExpiry?: string; } export type AccountToken = string; export type Ip = string; |
