summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2021-06-28 12:12:35 +0200
committerOskar Nyberg <oskar@mullvad.net>2021-06-28 12:12:35 +0200
commit4d95c0a019c2d7bfb2c3ca4951febfded93e9758 (patch)
tree1cb7b54c080a865e597102e16b1b9b32e4ca3505
parent09a3e447f47591643032fbf988dd9017db629b25 (diff)
parentb51b561baabebf3210f936eddc90390488e8819b (diff)
downloadmullvadvpn-4d95c0a019c2d7bfb2c3ca4951febfded93e9758.tar.xz
mullvadvpn-4d95c0a019c2d7bfb2c3ca4951febfded93e9758.zip
Merge branch 'new-account-flow'
-rw-r--r--CHANGELOG.md1
-rw-r--r--gui/src/config.json1
-rw-r--r--gui/src/main/account-data-cache.ts8
-rw-r--r--gui/src/main/index.ts23
-rw-r--r--gui/src/renderer/app.tsx13
-rw-r--r--gui/src/renderer/components/AppButton.tsx23
-rw-r--r--gui/src/renderer/components/Connect.tsx44
-rw-r--r--gui/src/renderer/components/ExpiredAccountAddTime.tsx274
-rw-r--r--gui/src/renderer/components/ExpiredAccountErrorView.tsx90
-rw-r--r--gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx13
-rw-r--r--gui/src/renderer/components/HeaderBar.tsx33
-rw-r--r--gui/src/renderer/components/MainView.tsx28
-rw-r--r--gui/src/renderer/components/RedeemVoucher.tsx42
-rw-r--r--gui/src/renderer/containers/ExpiredAccountErrorViewContainer.tsx15
-rw-r--r--gui/src/renderer/lib/transition-rule.ts12
-rw-r--r--gui/src/renderer/redux/account/actions.ts4
-rw-r--r--gui/src/renderer/redux/account/reducers.ts5
-rw-r--r--gui/src/renderer/routes.tsx24
-rw-r--r--gui/src/renderer/transitions.ts12
-rw-r--r--gui/src/shared/daemon-rpc-types.ts1
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;