summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2020-04-06 18:06:32 +0200
committerOskar Nyberg <oskar@mullvad.net>2020-04-06 18:06:32 +0200
commitfaaa5d91fe49f7b104d21dd868ad649b26fd0f98 (patch)
treecb855987a10e2d546fa46d7d632d282217a6386e
parent6157a809ad6eb5cdb06b7fcaea9549b3f100f07a (diff)
parente09e863cc858f8d04a1963c629328f77ae383f51 (diff)
downloadmullvadvpn-faaa5d91fe49f7b104d21dd868ad649b26fd0f98.tar.xz
mullvadvpn-faaa5d91fe49f7b104d21dd868ad649b26fd0f98.zip
Merge branch 'add-submit-voucher-function'
-rw-r--r--CHANGELOG.md1
-rw-r--r--gui/src/main/daemon-rpc.ts25
-rw-r--r--gui/src/main/index.ts3
-rw-r--r--gui/src/renderer/app.tsx5
-rw-r--r--gui/src/renderer/components/ExpiredAccountErrorView.tsx69
-rw-r--r--gui/src/renderer/components/RedeemVoucher.tsx203
-rw-r--r--gui/src/renderer/components/RedeemVoucherStyles.tsx43
-rw-r--r--gui/src/renderer/containers/RedeemVoucherContainer.tsx19
-rw-r--r--gui/src/shared/daemon-rpc-types.ts9
-rw-r--r--gui/src/shared/ipc-event-channel.ts6
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 = {