diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2020-04-06 18:06:32 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2020-04-06 18:06:32 +0200 |
| commit | faaa5d91fe49f7b104d21dd868ad649b26fd0f98 (patch) | |
| tree | cb855987a10e2d546fa46d7d632d282217a6386e | |
| parent | 6157a809ad6eb5cdb06b7fcaea9549b3f100f07a (diff) | |
| parent | e09e863cc858f8d04a1963c629328f77ae383f51 (diff) | |
| download | mullvadvpn-faaa5d91fe49f7b104d21dd868ad649b26fd0f98.tar.xz mullvadvpn-faaa5d91fe49f7b104d21dd868ad649b26fd0f98.zip | |
Merge branch 'add-submit-voucher-function'
| -rw-r--r-- | CHANGELOG.md | 1 | ||||
| -rw-r--r-- | gui/src/main/daemon-rpc.ts | 25 | ||||
| -rw-r--r-- | gui/src/main/index.ts | 3 | ||||
| -rw-r--r-- | gui/src/renderer/app.tsx | 5 | ||||
| -rw-r--r-- | gui/src/renderer/components/ExpiredAccountErrorView.tsx | 69 | ||||
| -rw-r--r-- | gui/src/renderer/components/RedeemVoucher.tsx | 203 | ||||
| -rw-r--r-- | gui/src/renderer/components/RedeemVoucherStyles.tsx | 43 | ||||
| -rw-r--r-- | gui/src/renderer/containers/RedeemVoucherContainer.tsx | 19 | ||||
| -rw-r--r-- | gui/src/shared/daemon-rpc-types.ts | 9 | ||||
| -rw-r--r-- | gui/src/shared/ipc-event-channel.ts | 6 |
10 files changed, 380 insertions, 3 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index ee7ba2e05a..5ee1e188f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Line wrap the file at 100 chars. Th ## [Unreleased] ### Added - Add possibility to create account in the desktop app. +- Add possibility to pay with voucher in the desktop app. #### Android - Add WireGuard MTU setting. diff --git a/gui/src/main/daemon-rpc.ts b/gui/src/main/daemon-rpc.ts index 02b9deceec..5db6f621c2 100644 --- a/gui/src/main/daemon-rpc.ts +++ b/gui/src/main/daemon-rpc.ts @@ -12,6 +12,8 @@ import { KeygenEvent, RelaySettingsUpdate, TunnelState, + VoucherErrorCode, + VoucherResponse, } from '../shared/daemon-rpc-types'; import { CommunicationError, InvalidAccountError, NoDaemonError } from './errors'; import JsonRpcClient, { @@ -227,6 +229,10 @@ const accountDataSchema = partialObject({ expiry: string, }); +const voucherResponseSchema = partialObject({ + new_expiry: string, +}); + const tunnelStateSchema = oneOf( object({ state: enumeration('disconnecting'), @@ -443,6 +449,25 @@ export class DaemonRpc { } } + public async submitVoucher(voucherCode: string): Promise<VoucherResponse> { + try { + const response = await this.transport.send('submit_voucher', voucherCode); + const new_expiry = validate(voucherResponseSchema, response).new_expiry; + return { type: 'success', new_expiry }; + } catch (error) { + if (error instanceof JsonRpcRemoteError) { + switch (error.code) { + case VoucherErrorCode.Invalid: + return { type: 'invalid' }; + case VoucherErrorCode.AlreadyUsed: + return { type: 'already_used' }; + } + } + } + + return { type: 'error' }; + } + public async getRelayLocations(): Promise<IRelayList> { const response = await this.transport.send('get_relay_locations'); try { diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts index 28956d8c88..2c3d71e628 100644 --- a/gui/src/main/index.ts +++ b/gui/src/main/index.ts @@ -995,6 +995,9 @@ class ApplicationMain { IpcMainEventChannel.account.handleLogin((token: AccountToken) => this.login(token)); IpcMainEventChannel.account.handleLogout(() => this.logout()); IpcMainEventChannel.account.handleWwwAuthToken(() => this.daemonRpc.getWwwAuthToken()); + IpcMainEventChannel.account.handleSubmitVoucher((voucherCode: string) => + this.daemonRpc.submitVoucher(voucherCode), + ); IpcMainEventChannel.accountHistory.handleRemoveItem(async (token: AccountToken) => { await this.daemonRpc.removeAccountFromHistory(token); diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx index f47e4c58bf..82858ad3d9 100644 --- a/gui/src/renderer/app.tsx +++ b/gui/src/renderer/app.tsx @@ -43,6 +43,7 @@ import { RelaySettings, RelaySettingsUpdate, TunnelState, + VoucherResponse, } from '../shared/daemon-rpc-types'; interface IPreferredLocaleDescriptor { @@ -272,6 +273,10 @@ export default class AppRenderer { } } + public submitVoucher(voucherCode: string): Promise<VoucherResponse> { + return IpcRendererEventChannel.account.submitVoucher(voucherCode); + } + public async connectTunnel(): Promise<void> { const state = this.tunnelState.state; diff --git a/gui/src/renderer/components/ExpiredAccountErrorView.tsx b/gui/src/renderer/components/ExpiredAccountErrorView.tsx index 7f50d7340f..68f0c9c30b 100644 --- a/gui/src/renderer/components/ExpiredAccountErrorView.tsx +++ b/gui/src/renderer/components/ExpiredAccountErrorView.tsx @@ -5,6 +5,7 @@ import { links } from '../../config.json'; import AccountExpiry from '../../shared/account-expiry'; import { AccountToken } from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; +import RedeemVoucherContainer from '../containers/RedeemVoucherContainer'; import { LoginState } from '../redux/account/reducers'; import AccountTokenLabel from './AccountTokenLabel'; import * as AppButton from './AppButton'; @@ -12,6 +13,11 @@ import * as Cell from './Cell'; import styles from './ExpiredAccountErrorViewStyles'; import ImageView from './ImageView'; import { ModalAlert, ModalAlertType } from './Modal'; +import { + RedeemVoucherInput, + RedeemVoucherResponse, + RedeemVoucherSubmitButton, +} from './RedeemVoucher'; export enum RecoveryAction { openBrowser, @@ -33,6 +39,8 @@ interface IExpiredAccountErrorViewProps { interface IExpiredAccountErrorViewState { showBlockWhenDisconnectedAlert: boolean; + showRedeemVoucherAlert: boolean; + redeemingVoucher: boolean; } export default class ExpiredAccountErrorView extends Component< @@ -41,6 +49,8 @@ export default class ExpiredAccountErrorView extends Component< > { public state: IExpiredAccountErrorViewState = { showBlockWhenDisconnectedAlert: false, + showRedeemVoucherAlert: false, + redeemingVoucher: false, }; public componentDidUpdate() { @@ -64,8 +74,15 @@ export default class ExpiredAccountErrorView extends Component< )} {this.renderExternalPaymentButton()} + + <AppButton.GreenButton + disabled={this.getRecoveryAction() === RecoveryAction.disconnect} + onPress={this.onOpenRedeemVoucherAlert}> + {messages.pgettext('connect-view', 'Redeem voucher')} + </AppButton.GreenButton> </View> + {this.state.showRedeemVoucherAlert && this.renderRedeemVoucherAlert()} {this.state.showBlockWhenDisconnectedAlert && this.renderBlockWhenDisconnectedAlert()} </View> ); @@ -149,7 +166,7 @@ export default class ExpiredAccountErrorView extends Component< <AppButton.BlockingButton disabled={this.getRecoveryAction() === RecoveryAction.disconnect} onPress={this.onOpenExternalPayment}> - <AppButton.GreenButton> + <AppButton.GreenButton style={styles.button}> <AppButton.Label>{buttonText}</AppButton.Label> <AppButton.Icon source="icon-extLink" height={16} width={16} /> </AppButton.GreenButton> @@ -157,8 +174,30 @@ export default class ExpiredAccountErrorView extends Component< ); } - private isNewAccount() { - return this.props.loginState.type === 'ok' && this.props.loginState.method === 'new_account'; + private renderRedeemVoucherAlert() { + return ( + <RedeemVoucherContainer + onSubmit={this.onVoucherSubmit} + onSuccess={this.props.hideWelcomeView} + onFailure={this.onVoucherResponse}> + <ModalAlert + buttons={[ + <RedeemVoucherSubmitButton key="submit" />, + <AppButton.BlueButton + key="cancel" + disabled={this.state.redeemingVoucher} + onPress={this.onCloseRedeemVoucherAlert}> + {messages.pgettext('connect-view', 'Cancel')} + </AppButton.BlueButton>, + ]}> + <Text style={styles.fieldLabel}> + {messages.pgettext('connect-view', 'Enter voucher code')} + </Text> + <RedeemVoucherInput /> + <RedeemVoucherResponse /> + </ModalAlert> + </RedeemVoucherContainer> + ); } private renderBlockWhenDisconnectedAlert() { @@ -195,6 +234,10 @@ export default class ExpiredAccountErrorView extends Component< ); } + private isNewAccount() { + return this.props.loginState.type === 'ok' && this.props.loginState.method === 'new_account'; + } + private onOpenExternalPayment = async (): Promise<void> => { if (this.getRecoveryAction() === RecoveryAction.disableBlockedWhenDisconnected) { this.setState({ showBlockWhenDisconnectedAlert: true }); @@ -215,6 +258,26 @@ export default class ExpiredAccountErrorView extends Component< } } + private onOpenRedeemVoucherAlert = () => { + if (this.getRecoveryAction() === RecoveryAction.disableBlockedWhenDisconnected) { + this.setState({ showBlockWhenDisconnectedAlert: true }); + } else { + this.setState({ showRedeemVoucherAlert: true }); + } + }; + + private onCloseRedeemVoucherAlert = () => { + this.setState({ showRedeemVoucherAlert: false }); + }; + + private onVoucherSubmit = () => { + this.setState({ redeemingVoucher: true }); + }; + + private onVoucherResponse = () => { + this.setState({ redeemingVoucher: false }); + }; + private onCloseBlockWhenDisconnectedInstructions = () => { this.setState({ showBlockWhenDisconnectedAlert: false }); }; diff --git a/gui/src/renderer/components/RedeemVoucher.tsx b/gui/src/renderer/components/RedeemVoucher.tsx new file mode 100644 index 0000000000..453200ab7c --- /dev/null +++ b/gui/src/renderer/components/RedeemVoucher.tsx @@ -0,0 +1,203 @@ +import * as React from 'react'; +import { Component, Text, TextInput, View } from 'reactxp'; +import { colors } from '../../config.json'; +import { VoucherResponse } from '../../shared/daemon-rpc-types'; +import { messages } from '../../shared/gettext'; +import * as AppButton from './AppButton'; +import ImageView from './ImageView'; +import styles from './RedeemVoucherStyles'; + +interface IRedeemVoucherContextValue { + onSubmit: () => void; + value: string; + setValue: (value: string) => void; + valueValid: boolean; + submitting: boolean; + response?: VoucherResponse; +} + +const contextProviderMissingError = new Error('<RedeemVoucherContext.Provider> is missing'); + +const RedeemVoucherContext = React.createContext<IRedeemVoucherContextValue>({ + onSubmit() { + throw contextProviderMissingError; + }, + get value(): string { + throw contextProviderMissingError; + }, + setValue(_) { + throw contextProviderMissingError; + }, + get valueValid(): boolean { + throw contextProviderMissingError; + }, + get submitting(): boolean { + throw contextProviderMissingError; + }, + get response(): VoucherResponse { + throw contextProviderMissingError; + }, +}); + +interface IRedeemVoucherProps { + submitVoucher: (voucherCode: string) => Promise<VoucherResponse>; + updateAccountExpiry: (expiry: string) => void; + onSubmit?: () => void; + onSuccess?: () => void; + onFailure?: () => void; + children?: React.ReactNode; +} + +interface IRedeemVoucherState { + value: string; + submitting: boolean; + response?: VoucherResponse; +} + +export class RedeemVoucher extends Component<IRedeemVoucherProps, IRedeemVoucherState> { + public state = { + value: '', + submitting: false, + response: undefined, + }; + + public render() { + return ( + <RedeemVoucherContext.Provider + value={{ + onSubmit: this.onSubmit, + value: this.state.value, + setValue: this.setValue, + valueValid: RedeemVoucher.isValueValid(this.state.value), + submitting: this.state.submitting, + response: this.state.response, + }}> + {this.props.children} + </RedeemVoucherContext.Provider> + ); + } + + private setValue = (value: string) => { + this.setState({ value }); + }; + + private static isValueValid(value: string): boolean { + return value.length >= 16; + } + + private onSubmit = async () => { + if (!RedeemVoucher.isValueValid(this.state.value)) { + return; + } + + this.setState({ submitting: true }); + + if (this.props.onSubmit) { + this.props.onSubmit(); + } + + const response = await this.props.submitVoucher(this.state.value); + + if (response.type === 'success') { + this.setState({ value: '', submitting: false, response }); + this.props.updateAccountExpiry(response.new_expiry); + if (this.props.onSuccess) { + this.props.onSuccess(); + } + } else { + this.setState({ submitting: false, response }); + if (this.props.onFailure) { + this.props.onFailure(); + } + } + }; +} + +export class RedeemVoucherInput extends Component { + public render() { + return ( + <RedeemVoucherContext.Consumer> + {(context) => ( + <View> + <TextInput + style={styles.textInput} + value={context.value} + placeholder={'XXXX-XXXX-XXXX-XXXX'} + placeholderTextColor={colors.blue40} + autoCorrect={false} + onChangeText={context.setValue} + onSubmitEditing={context.onSubmit} + /> + </View> + )} + </RedeemVoucherContext.Consumer> + ); + } +} + +export class RedeemVoucherResponse extends Component { + public render() { + return ( + <RedeemVoucherContext.Consumer> + {(context) => { + if (context.submitting) { + return ( + <ImageView source="icon-spinner" style={styles.spinner} height={20} width={20} /> + ); + } + + if (context.response) { + switch (context.response.type) { + case 'success': + return ( + <Text style={styles.redeemVoucherResponseSuccess}> + {messages.pgettext('redeem-voucher-view', 'Voucher was successfully redeemed.')} + </Text> + ); + case 'invalid': + return ( + <Text style={styles.redeemVoucherResponseError}> + {messages.pgettext('redeem-voucher-view', 'Voucher code is invalid.')} + </Text> + ); + case 'already_used': + return ( + <Text style={styles.redeemVoucherResponseError}> + {messages.pgettext( + 'redeem-voucher-view', + 'Voucher code has already been used.', + )} + </Text> + ); + case 'error': + return ( + <Text style={styles.redeemVoucherResponseError}> + {messages.pgettext('redeem-voucher-view', 'An error occured.')} + </Text> + ); + } + } + + return <View style={styles.redeemVoucherResponseEmpty} />; + }} + </RedeemVoucherContext.Consumer> + ); + } +} + +export class RedeemVoucherSubmitButton extends Component { + public render() { + return ( + <RedeemVoucherContext.Consumer> + {(context) => ( + <AppButton.GreenButton + key="cancel" + disabled={!context.valueValid || context.submitting} + onPress={context.onSubmit}> + {messages.pgettext('redeem-voucher-view', 'Redeem')} + </AppButton.GreenButton> + )} + </RedeemVoucherContext.Consumer> + ); + } +} diff --git a/gui/src/renderer/components/RedeemVoucherStyles.tsx b/gui/src/renderer/components/RedeemVoucherStyles.tsx new file mode 100644 index 0000000000..00eea8340d --- /dev/null +++ b/gui/src/renderer/components/RedeemVoucherStyles.tsx @@ -0,0 +1,43 @@ +import { Styles } from 'reactxp'; +import { colors } from '../../config.json'; + +export default { + textInput: Styles.createTextInputStyle({ + flex: 1, + overflow: 'hidden', + paddingTop: 14, + paddingLeft: 14, + paddingRight: 14, + paddingBottom: 14, + fontFamily: 'Open Sans', + fontSize: 13, + fontWeight: '600', + lineHeight: 26, + color: colors.blue, + backgroundColor: colors.white, + borderRadius: 4, + }), + redeemVoucherResponseSuccess: Styles.createTextStyle({ + marginTop: 8, + fontFamily: 'Open Sans', + fontSize: 13, + fontWeight: '600', + lineHeight: 20, + color: colors.green, + }), + redeemVoucherResponseError: Styles.createTextStyle({ + marginTop: 8, + fontFamily: 'Open Sans', + fontSize: 13, + fontWeight: '800', + lineHeight: 20, + color: colors.red, + }), + redeemVoucherResponseEmpty: Styles.createViewStyle({ + height: 20, + marginTop: 8, + }), + spinner: Styles.createViewStyle({ + marginTop: 8, + }), +}; diff --git a/gui/src/renderer/containers/RedeemVoucherContainer.tsx b/gui/src/renderer/containers/RedeemVoucherContainer.tsx new file mode 100644 index 0000000000..1aeee8c719 --- /dev/null +++ b/gui/src/renderer/containers/RedeemVoucherContainer.tsx @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { RedeemVoucher } from '../components/RedeemVoucher'; +import withAppContext, { IAppContext } from '../context'; +import { ReduxDispatch } from '../redux/store'; +import accountActions from '../redux/account/actions'; + +const mapDispatchToProps = (dispatch: ReduxDispatch, props: IAppContext) => { + const account = bindActionCreators(accountActions, dispatch); + + return { + submitVoucher: (voucherCode: string) => { + return props.app.submitVoucher(voucherCode); + }, + updateAccountExpiry: account.updateAccountExpiry, + }; +}; + +export default withAppContext(connect(null, mapDispatchToProps)(RedeemVoucher)); diff --git a/gui/src/shared/daemon-rpc-types.ts b/gui/src/shared/daemon-rpc-types.ts index ac1e7584f6..661250c5f5 100644 --- a/gui/src/shared/daemon-rpc-types.ts +++ b/gui/src/shared/daemon-rpc-types.ts @@ -320,6 +320,15 @@ export interface ISocketAddress { port: number; } +export type VoucherResponse = + | { type: 'success'; new_expiry: string } + | { type: 'invalid' | 'already_used' | 'error' }; + +export enum VoucherErrorCode { + Invalid = -400, + AlreadyUsed = -401, +} + export function parseSocketAddress(socketAddrStr: string): ISocketAddress { const re = new RegExp(/(.+):(\d+)$/); const matches = socketAddrStr.match(re); diff --git a/gui/src/shared/ipc-event-channel.ts b/gui/src/shared/ipc-event-channel.ts index 48f7b71bdc..b6e788e72e 100644 --- a/gui/src/shared/ipc-event-channel.ts +++ b/gui/src/shared/ipc-event-channel.ts @@ -18,6 +18,7 @@ import { KeygenEvent, RelaySettingsUpdate, TunnelState, + VoucherResponse, } from './daemon-rpc-types'; export interface IAppStateSnapshot { @@ -110,6 +111,7 @@ interface IAccountHandlers extends ISender<IAccountData | undefined> { handleLogin(fn: (token: AccountToken) => Promise<void>): void; handleLogout(fn: () => Promise<void>): void; handleWwwAuthToken(fn: () => Promise<string>): void; + handleSubmitVoucher(fn: (voucherCode: string) => Promise<VoucherResponse>): void; } interface IAccountMethods extends IReceiver<IAccountData | undefined> { @@ -117,6 +119,7 @@ interface IAccountMethods extends IReceiver<IAccountData | undefined> { login(token: AccountToken): Promise<void>; logout(): Promise<void>; getWwwAuthToken(): Promise<string>; + submitVoucher(voucherCode: string): Promise<VoucherResponse>; } interface IAccountHistoryHandlers extends ISender<AccountToken[]> { @@ -193,6 +196,7 @@ const DO_LOGIN = 'do-login'; const DO_LOGOUT = 'do-logout'; const DO_GET_WWW_AUTH_TOKEN = 'do-get-www-auth-token'; const ACCOUNT_DATA_CHANGED = 'account-data-changed'; +const REDEEM_VOUCHER = 'redeem-voucher'; const AUTO_START_CHANGED = 'auto-start-changed'; const SET_AUTO_START = 'set-auto-start'; @@ -287,6 +291,7 @@ export class IpcRendererEventChannel { login: requestSender(DO_LOGIN), logout: requestSender(DO_LOGOUT), getWwwAuthToken: requestSender(DO_GET_WWW_AUTH_TOKEN), + submitVoucher: requestSender(REDEEM_VOUCHER), }; public static accountHistory: IAccountHistoryMethods = { @@ -383,6 +388,7 @@ export class IpcMainEventChannel { handleLogin: requestHandler(DO_LOGIN), handleLogout: requestHandler(DO_LOGOUT), handleWwwAuthToken: requestHandler(DO_GET_WWW_AUTH_TOKEN), + handleSubmitVoucher: requestHandler<VoucherResponse>(REDEEM_VOUCHER), }; public static accountHistory: IAccountHistoryHandlers = { |
