diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2019-03-07 14:27:54 +0100 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2019-03-07 14:27:54 +0100 |
| commit | e7a4e8584efe74ef34b9d330f9d4212a4ab48f05 (patch) | |
| tree | 825e1e15c35724894eef58d00209f908bd40d9b2 | |
| parent | 1bc7df90fb7f3ed06ec9ba6c97da92156ac8a0fe (diff) | |
| parent | f3dd59eaab04488e0a4d2b4950e4f83b6f4a7dbf (diff) | |
| download | mullvadvpn-e7a4e8584efe74ef34b9d330f9d4212a4ab48f05.tar.xz mullvadvpn-e7a4e8584efe74ef34b9d330f9d4212a4ab48f05.zip | |
Merge branch 'fix-buy-button'
26 files changed, 711 insertions, 426 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 8986bc1516..a8f477ed12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ Line wrap the file at 100 chars. Th ## [Unreleased] ### Added - Integrate initial Shadowsocks proxy support. Accessible via CLI. +- Improve "Out of time" view button leading to the account website by unlocking internet access + before opening the browser ### Fixed - Fix the potential reconnect loop in GUI, triggered by the timeout when receiving @@ -33,6 +35,8 @@ Line wrap the file at 100 chars. Th - Fix some notifications not appearing depending on how the window is shown and hidden while the tunnel state changes. - Fix DNS when using IPv6. +- Fix the bug when the "Out of time" view remained visible, even when the app managed to reconnect + the VPN tunnel after a successful credit top-up. #### Linux - Fix startup failure when network device with a hardware address that's not a MAC address is diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot index 1a1905326d..45e54ac4a6 100644 --- a/gui/locales/messages.pot +++ b/gui/locales/messages.pot @@ -24,7 +24,7 @@ msgstr "" #. The remaining time left on the account displayed across the app. #. Available placeholders: #. %(duration)s - a localized remaining time (in minutes, hours, or days) until the account expiry -#: src/renderer/lib/account-expiry.ts:27 +#: src/renderer/lib/account-expiry.ts:31 msgctxt "account-expiry" msgid "%(duration)s left" msgstr "" @@ -55,7 +55,7 @@ msgctxt "account-view" msgid "COPIED TO CLIPBOARD!" msgstr "" -#: src/renderer/components/Account.tsx:91 +#: src/renderer/components/Account.tsx:101 msgctxt "account-view" msgid "Currently unavailable" msgstr "" @@ -65,7 +65,7 @@ msgctxt "account-view" msgid "Log out" msgstr "" -#: src/renderer/components/Account.tsx:100 +#: src/renderer/components/Account.tsx:93 msgctxt "account-view" msgid "OUT OF TIME" msgstr "" @@ -144,22 +144,22 @@ msgctxt "advanced-settings-view" msgid "Unless connected, always block all network traffic, even when you've disconnected or quit the app." msgstr "" -#: src/renderer/lib/auth-failure.ts:41 +#: src/renderer/lib/auth-failure.ts:80 msgctxt "auth-failure" msgid "Account authentication failed." msgstr "" -#: src/renderer/lib/auth-failure.ts:34 +#: src/renderer/lib/auth-failure.ts:74 msgctxt "auth-failure" msgid "This account has too many simultaneous connections. Disconnect another device or try connecting again shortly." msgstr "" -#: src/renderer/lib/auth-failure.ts:28 +#: src/renderer/lib/auth-failure.ts:68 msgctxt "auth-failure" msgid "You have no more VPN time left on this account. Please log in on our website to buy more credit." msgstr "" -#: src/renderer/lib/auth-failure.ts:22 +#: src/renderer/lib/auth-failure.ts:62 msgctxt "auth-failure" msgid "You've logged in with an account number that is not valid. Please log out and try another one." msgstr "" @@ -174,67 +174,78 @@ msgctxt "connect-container" msgid "%(city)s (%(hostname)s)" msgstr "" -#: src/renderer/components/Connect.tsx:60 +#: src/renderer/components/ExpiredAccountErrorView.tsx:136 +#: src/renderer/components/ExpiredAccountErrorView.tsx:157 msgctxt "connect-view" -msgid "Buy more time, so you can continue using the internet securely" +msgid "Buy more credit" msgstr "" -#: src/renderer/components/Connect.tsx:67 +#: src/renderer/components/ExpiredAccountErrorView.tsx:114 msgctxt "connect-view" -msgid "Offline" +msgid "Disconnect and buy more credit" msgstr "" -#: src/renderer/components/Connect.tsx:58 +#: src/renderer/components/ExpiredAccountErrorView.tsx:78 msgctxt "connect-view" msgid "Out of time" msgstr "" -#: src/renderer/components/Connect.tsx:69 +#: src/renderer/components/ExpiredAccountErrorView.tsx:150 +msgctxt "connect-view" +msgid "You have no more VPN time left on this account. Before you can buy more credit on our website, you first need to turn off the app's \"Block when disconnected\" option under Advanced settings." +msgstr "" + +#: src/renderer/components/ExpiredAccountErrorView.tsx:129 +msgctxt "connect-view" +msgid "You have no more VPN time left on this account. Please log in on our website to buy more credit." +msgstr "" + +#: src/renderer/components/ExpiredAccountErrorView.tsx:106 msgctxt "connect-view" -msgid "Your internet connection will be secured when you get back online" +msgid "You have no more VPN time left on this account. To buy more credit on our website, you will need to access the Internet with an unsecured connection." msgstr "" -#: src/renderer/components/NotificationArea.tsx:275 +#: src/renderer/components/NotificationArea.tsx:281 msgctxt "in-app-notifications" msgid "ACCOUNT CREDIT EXPIRES SOON" msgstr "" -#: src/renderer/components/NotificationArea.tsx:194 +#: src/renderer/components/NotificationArea.tsx:200 msgctxt "in-app-notifications" msgid "BLOCKING INTERNET" msgstr "" -#: src/renderer/components/NotificationArea.tsx:49 +#: src/renderer/components/NotificationArea.tsx:48 msgctxt "in-app-notifications" msgid "Could not configure IPv6, please enable it on your system or disable it in the app" msgstr "" -#: src/renderer/components/NotificationArea.tsx:54 +#: src/renderer/components/NotificationArea.tsx:53 msgctxt "in-app-notifications" msgid "Failed to apply firewall rules. The device might currently be unsecured" msgstr "" -#: src/renderer/components/NotificationArea.tsx:59 +#: src/renderer/components/NotificationArea.tsx:58 msgctxt "in-app-notifications" msgid "Failed to set system DNS server" msgstr "" -#: src/renderer/components/NotificationArea.tsx:61 +#: src/renderer/components/NotificationArea.tsx:60 msgctxt "in-app-notifications" msgid "Failed to start tunnel connection" msgstr "" -#: src/renderer/components/NotificationArea.tsx:182 +#: src/renderer/components/NotificationArea.tsx:188 msgctxt "in-app-notifications" msgid "FAILURE - UNSECURED" msgstr "" -#: src/renderer/components/NotificationArea.tsx:209 +#: src/renderer/components/NotificationArea.tsx:215 msgctxt "in-app-notifications" msgid "Inconsistent internal version information, please restart the app" msgstr "" -#: src/renderer/components/NotificationArea.tsx:206 +#: src/renderer/components/NotificationArea.tsx:212 msgctxt "in-app-notifications" msgid "INCONSISTENT VERSION" msgstr "" @@ -242,32 +253,32 @@ msgstr "" #. The in-app banner displayed to the user when the app update is available. #. Available placeholders: #. %(version)s - the newest available version of the app -#: src/renderer/components/NotificationArea.tsx:256 +#: src/renderer/components/NotificationArea.tsx:262 msgctxt "in-app-notifications" msgid "Install Mullvad VPN (%(version)s) to stay up to date" msgstr "" -#: src/renderer/components/NotificationArea.tsx:63 +#: src/renderer/components/NotificationArea.tsx:62 msgctxt "in-app-notifications" msgid "No relay server matches the current settings" msgstr "" -#: src/renderer/components/NotificationArea.tsx:65 +#: src/renderer/components/NotificationArea.tsx:64 msgctxt "in-app-notifications" msgid "This device is offline, no tunnels can be established" msgstr "" -#: src/renderer/components/NotificationArea.tsx:70 +#: src/renderer/components/NotificationArea.tsx:69 msgctxt "in-app-notifications" msgid "Unable to detect a working TAP adapter on this device. If you've disabled it, enable it again. Otherwise, please reinstall the app" msgstr "" -#: src/renderer/components/NotificationArea.tsx:223 +#: src/renderer/components/NotificationArea.tsx:229 msgctxt "in-app-notifications" msgid "UNSUPPORTED VERSION" msgstr "" -#: src/renderer/components/NotificationArea.tsx:249 +#: src/renderer/components/NotificationArea.tsx:255 msgctxt "in-app-notifications" msgid "UPDATE AVAILABLE" msgstr "" @@ -275,7 +286,7 @@ msgstr "" #. The in-app banner displayed to the user when the running app becomes unsupported. #. Available placeholders: #. %(version)s - the newest available version of the app -#: src/renderer/components/NotificationArea.tsx:230 +#: src/renderer/components/NotificationArea.tsx:236 msgctxt "in-app-notifications" msgid "You are running an unsupported app version. Please upgrade to %(version)s now to ensure your security" msgstr "" @@ -461,63 +472,63 @@ msgctxt "select-location-view" msgid "While connected, your real location is masked with a private and secure location in the selected region" msgstr "" -#: src/renderer/components/Settings.tsx:101 +#: src/renderer/components/Settings.tsx:104 msgctxt "settings-view" msgid "Account" msgstr "" -#: src/renderer/components/Settings.tsx:115 +#: src/renderer/components/Settings.tsx:119 msgctxt "settings-view" msgid "Advanced" msgstr "" -#: src/renderer/components/Settings.tsx:161 +#: src/renderer/components/Settings.tsx:165 msgctxt "settings-view" msgid "App version" msgstr "" -#: src/renderer/components/Settings.tsx:182 +#: src/renderer/components/Settings.tsx:186 msgctxt "settings-view" msgid "FAQs & Guides" msgstr "" -#: src/renderer/components/Settings.tsx:127 +#: src/renderer/components/Settings.tsx:131 msgctxt "settings-view" msgid "Inconsistent internal version information, please restart the app." msgstr "" -#: src/renderer/components/Settings.tsx:95 +#: src/renderer/components/Settings.tsx:98 msgctxt "settings-view" msgid "OUT OF TIME" msgstr "" -#: src/renderer/components/Settings.tsx:110 +#: src/renderer/components/Settings.tsx:114 msgctxt "settings-view" msgid "Preferences" msgstr "" -#: src/renderer/components/Settings.tsx:79 +#: src/renderer/components/Settings.tsx:80 msgctxt "settings-view" msgid "Quit app" msgstr "" -#: src/renderer/components/Settings.tsx:177 +#: src/renderer/components/Settings.tsx:181 msgctxt "settings-view" msgid "Report a problem" msgstr "" -#: src/renderer/components/Settings.tsx:57 +#: src/renderer/components/Settings.tsx:58 msgctxt "settings-view" msgid "Settings" msgstr "" -#: src/renderer/components/Settings.tsx:132 +#: src/renderer/components/Settings.tsx:136 msgctxt "settings-view" msgid "Update available, download to remain safe." msgstr "" #. Title label in navigation bar -#: src/renderer/components/Settings.tsx:49 +#: src/renderer/components/Settings.tsx:50 msgctxt "settings-view-nav" msgid "Settings" msgstr "" diff --git a/gui/package.json b/gui/package.json index d85f78c4dd..c04a64fc69 100644 --- a/gui/package.json +++ b/gui/package.json @@ -14,13 +14,13 @@ "dependencies": { "JSONStream": "^1.3.5", "connected-react-router": "^5.0.1", - "d3-geo-projection": "^2.4.1", + "d3-geo-projection": "^2.6.0", "electron-log": "^2.2.8", "gettext-parser": "^3.1.0", "history": "^4.6.1", "jsonrpc-lite": "^2.0.1", "mkdirp": "^0.5.1", - "moment": "^2.20.1", + "moment": "^2.24.0", "node-gettext": "^2.0.0", "rbush": "^2.0.2", "react": "^16.5.0", diff --git a/gui/src/main/errors.ts b/gui/src/main/errors.ts index f13b99e3e9..85adf965a5 100644 --- a/gui/src/main/errors.ts +++ b/gui/src/main/errors.ts @@ -4,12 +4,6 @@ export class NoCreditError extends Error { } } -export class NoInternetError extends Error { - constructor() { - super('Internet connectivity is currently unavailable'); - } -} - export class NoDaemonError extends Error { constructor() { super('Could not connect to Mullvad daemon'); diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx index d039b24a43..770f148c77 100644 --- a/gui/src/renderer/app.tsx +++ b/gui/src/renderer/app.tsx @@ -25,10 +25,10 @@ import { IWindowShapeParameters } from '../main/window-controller'; import { loadTranslations } from '../shared/gettext'; import { IGuiSettingsState } from '../shared/gui-settings-state'; import { IpcRendererEventChannel } from '../shared/ipc-event-channel'; +import AccountExpiry from './lib/account-expiry'; import { AccountToken, - ConnectionConfig, IAccountData, ILocation, IRelayList, @@ -41,42 +41,39 @@ import { export default class AppRenderer { private memoryHistory = createMemoryHistory(); private reduxStore = configureStore(this.memoryHistory); - private reduxActions: { [key: string]: any }; + private reduxActions = { + account: bindActionCreators(accountActions, this.reduxStore.dispatch), + connection: bindActionCreators(connectionActions, this.reduxStore.dispatch), + settings: bindActionCreators(settingsActions, this.reduxStore.dispatch), + version: bindActionCreators(versionActions, this.reduxStore.dispatch), + userInterface: bindActionCreators(userInterfaceActions, this.reduxStore.dispatch), + history: bindActionCreators( + { + push: pushHistory, + replace: replaceHistory, + }, + this.reduxStore.dispatch, + ), + }; private accountDataCache = new AccountDataCache( (accountToken) => { return IpcRendererEventChannel.account.getData(accountToken); }, (accountData) => { - const expiry = accountData ? accountData.expiry : null; - this.reduxActions.account.updateAccountExpiry(expiry); + this.setAccountExpiry(accountData && accountData.expiry); }, ); private tunnelState: TunnelStateTransition; private settings: ISettings; private guiSettings: IGuiSettingsState; + private accountExpiry?: AccountExpiry; private connectedToDaemon = false; private autoConnected = false; private doingLogin = false; private loginTimer?: NodeJS.Timeout; constructor() { - const dispatch = this.reduxStore.dispatch; - this.reduxActions = { - account: bindActionCreators(accountActions, dispatch), - connection: bindActionCreators(connectionActions, dispatch), - settings: bindActionCreators(settingsActions, dispatch), - version: bindActionCreators(versionActions, dispatch), - userInterface: bindActionCreators(userInterfaceActions, dispatch), - history: bindActionCreators( - { - push: pushHistory, - replace: replaceHistory, - }, - dispatch, - ), - }; - ipcRenderer.on( 'update-window-shape', (_event: Electron.Event, shapeParams: IWindowShapeParameters) => { @@ -106,6 +103,11 @@ export default class AppRenderer { IpcRendererEventChannel.tunnel.listen((newState: TunnelStateTransition) => { this.setTunnelState(newState); + this.updateBlockedState(newState, this.settings.blockWhenDisconnected); + + if (this.accountExpiry) { + this.detectStaleAccountExpiry(newState, this.accountExpiry); + } }); IpcRendererEventChannel.settings.listen((newSettings: ISettings) => { @@ -113,6 +115,7 @@ export default class AppRenderer { this.setSettings(newSettings); this.handleAccountChange(oldSettings.accountToken, newSettings.accountToken); + this.updateBlockedState(this.tunnelState, newSettings.blockWhenDisconnected); }); IpcRendererEventChannel.location.listen((newLocation: ILocation) => { @@ -147,8 +150,9 @@ export default class AppRenderer { this.guiSettings = initialState.guiSettings; this.setAccountHistory(initialState.accountHistory); - this.setTunnelState(initialState.tunnelState); this.setSettings(initialState.settings); + this.setTunnelState(initialState.tunnelState); + this.updateBlockedState(initialState.tunnelState, initialState.settings.blockWhenDisconnected); if (initialState.location) { this.setLocation(initialState.location); @@ -174,7 +178,12 @@ export default class AppRenderer { public renderView() { return ( <Provider store={this.reduxStore}> - <ConnectedRouter history={this.memoryHistory}>{makeRoutes({ app: this })}</ConnectedRouter> + <ConnectedRouter history={this.memoryHistory}> + {makeRoutes({ + app: this, + locale: remote.app.getLocale(), + })} + </ConnectedRouter> </Provider> ); } @@ -246,7 +255,7 @@ export default class AppRenderer { // connect only if tunnel is disconnected or blocked. if (state === 'disconnecting' || state === 'disconnected' || state === 'blocked') { // switch to the connecting state ahead of time to make the app look more responsive - this.reduxActions.connection.connecting(null); + this.reduxActions.connection.connecting(); return IpcRendererEventChannel.tunnel.connect(); } @@ -316,57 +325,60 @@ export default class AppRenderer { const actions = this.reduxActions; if ('normal' in relaySettings) { - const payload: { [key: string]: any } = {}; const normal = relaySettings.normal; const tunnel = normal.tunnel; const location = normal.location; - payload.location = location === 'any' ? 'any' : location.only; + const relayLocation = location === 'any' ? 'any' : location.only; if (tunnel === 'any') { - payload.port = 'any'; - payload.protocol = 'any'; + actions.settings.updateRelay({ + normal: { + location: relayLocation, + port: 'any', + protocol: 'any', + }, + }); } else { const constraints = tunnel.only; + if ('openvpn' in constraints) { const { port, protocol } = constraints.openvpn; - payload.port = port === 'any' ? port : port.only; - payload.protocol = protocol === 'any' ? protocol : protocol.only; - } - if ('wireguard' in constraints) { + actions.settings.updateRelay({ + normal: { + location: relayLocation, + port: port === 'any' ? port : port.only, + protocol: protocol === 'any' ? protocol : protocol.only, + }, + }); + } else if ('wireguard' in constraints) { const { port } = constraints.wireguard; - payload.port = port === 'any' ? port : port.only; - payload.protocol = 'udp'; + + actions.settings.updateRelay({ + normal: { + location: relayLocation, + port: port === 'any' ? port : port.only, + protocol: 'udp', + }, + }); } } - - actions.settings.updateRelay({ - normal: payload, - }); } else if ('customTunnelEndpoint' in relaySettings) { const customTunnelEndpoint = relaySettings.customTunnelEndpoint; - const host = customTunnelEndpoint.host; - const config: ConnectionConfig = customTunnelEndpoint.config; + const config = customTunnelEndpoint.config; - let port = 0; - let protocol = 'udp'; if ('openvpn' in config) { - port = config.openvpn.endpoint.port; - protocol = config.openvpn.endpoint.protocol; - } - - if ('wireguard' in config) { + actions.settings.updateRelay({ + customTunnelEndpoint: { + host: customTunnelEndpoint.host, + port: config.openvpn.endpoint.port, + protocol: config.openvpn.endpoint.protocol, + }, + }); + } else if ('wireguard' in config) { // TODO: handle wireguard } - - actions.settings.updateRelay({ - customTunnelEndpoint: { - host, - port, - protocol, - }, - }); } } @@ -475,6 +487,31 @@ export default class AppRenderer { } } + private updateBlockedState(tunnelState: TunnelStateTransition, blockWhenDisconnected: boolean) { + const actions = this.reduxActions.connection; + switch (tunnelState.state) { + case 'connecting': + actions.updateBlockState(true); + break; + + case 'connected': + actions.updateBlockState(false); + break; + + case 'disconnected': + actions.updateBlockState(blockWhenDisconnected); + break; + + case 'disconnecting': + actions.updateBlockState(true); + break; + + case 'blocked': + actions.updateBlockState(tunnelState.details.reason !== 'set_firewall_policy_error'); + break; + } + } + private handleAccountChange(oldAccount?: string, newAccount?: string) { if (oldAccount && !newAccount) { this.accountDataCache.invalidate(); @@ -532,6 +569,22 @@ export default class AppRenderer { this.reduxActions.settings.updateGuiSettings(guiSettings); } + private setAccountExpiry(expiry?: string) { + this.accountExpiry = expiry ? new AccountExpiry(expiry, remote.app.getLocale()) : undefined; + this.reduxActions.account.updateAccountExpiry(expiry); + } + + private detectStaleAccountExpiry( + tunnelState: TunnelStateTransition, + accountExpiry: AccountExpiry, + ) { + // It's likely that the account expiry is stale if the daemon managed to establish the tunnel. + if (tunnelState.state === 'connected' && accountExpiry.hasExpired()) { + log.info('Detected the stale account expiry.'); + this.accountDataCache.invalidate(); + } + } + private storeAutoStart(autoStart: boolean) { this.reduxActions.settings.updateAutoStart(autoStart); } diff --git a/gui/src/renderer/components/Account.tsx b/gui/src/renderer/components/Account.tsx index aaa3b637c9..7864bfa0da 100644 --- a/gui/src/renderer/components/Account.tsx +++ b/gui/src/renderer/components/Account.tsx @@ -1,7 +1,7 @@ -import moment from 'moment'; import * as React from 'react'; import { Component, Text, View } from 'reactxp'; import { pgettext } from '../../shared/gettext'; +import AccountExpiry from '../lib/account-expiry'; import styles from './AccountStyles'; import * as AppButton from './AppButton'; import ClipboardLabel from './ClipboardLabel'; @@ -85,33 +85,21 @@ export default class Account extends Component<IProps> { } function FormattedAccountExpiry(props: { expiry?: string; locale: string }) { - if (!props.expiry) { + if (props.expiry) { + const expiry = new AccountExpiry(props.expiry, props.locale); + + if (expiry.hasExpired()) { + return ( + <Text style={styles.account__out_of_time}>{pgettext('account-view', 'OUT OF TIME')}</Text> + ); + } else { + return <Text style={styles.account__row_value}>{expiry.formattedDate()}</Text>; + } + } else { return ( <Text style={styles.account__row_value}> {pgettext('account-view', 'Currently unavailable')} </Text> ); } - - const expiry = moment(props.expiry); - - if (expiry.isSameOrBefore(moment())) { - return ( - <Text style={styles.account__out_of_time}>{pgettext('account-view', 'OUT OF TIME')}</Text> - ); - } - - const formatOptions = { - day: 'numeric', - month: 'long', - year: 'numeric', - hour: 'numeric', - minute: 'numeric', - }; - - return ( - <Text style={styles.account__row_value}> - {expiry.toDate().toLocaleString(props.locale, formatOptions)} - </Text> - ); } diff --git a/gui/src/renderer/components/AppButton.tsx b/gui/src/renderer/components/AppButton.tsx index 17aafb7fc1..34a7f46e04 100644 --- a/gui/src/renderer/components/AppButton.tsx +++ b/gui/src/renderer/components/AppButton.tsx @@ -1,16 +1,53 @@ import * as React from 'react'; -import { Button, Component, Text, Types } from 'reactxp'; +import { Button, Component, Styles, Text, Types, UserInterface, View } from 'reactxp'; import { colors } from '../../config.json'; import styles from './AppButtonStyles'; import ImageView from './ImageView'; +const ButtonContext = React.createContext({ + textAdjustment: 0, + textRef: React.createRef<PrivateLabel>(), +}); + interface ILabelProps { children?: React.ReactText; } +interface IPrivateLabelProps { + textAdjustment: number; + children?: React.ReactText; +} + +class PrivateLabel extends Component<IPrivateLabelProps> { + public render() { + const { textAdjustment, children } = this.props; + const textAdjustmentStyle = Styles.createViewStyle( + { + paddingRight: textAdjustment > 0 ? textAdjustment : 0, + paddingLeft: textAdjustment < 0 ? Math.abs(textAdjustment) : 0, + }, + false, + ); + + return ( + <View style={[styles.labelContainer, textAdjustmentStyle]}> + <Text style={styles.label}>{children}</Text> + </View> + ); + } +} + export class Label extends Component<ILabelProps> { public render() { - return <Text style={styles.label}>{this.props.children}</Text>; + return ( + <ButtonContext.Consumer> + {(context) => ( + <PrivateLabel ref={context.textRef} textAdjustment={context.textAdjustment}> + {this.props.children} + </PrivateLabel> + )} + </ButtonContext.Consumer> + ); } } @@ -28,7 +65,6 @@ export class Icon extends Component<IIconProps> { width={this.props.width} height={this.props.height} tintColor={colors.white} - style={styles.icon} /> ); } @@ -43,52 +79,107 @@ interface IProps { interface IState { hovered: boolean; + textAdjustment: number; } class BaseButton extends Component<IProps, IState> { - public state: IState = { hovered: false }; - - public backgroundStyle = (): Types.ButtonStyleRuleSet => { - throw new Error('Implement backgroundStyle in subclasses.'); + public state: IState = { + hovered: false, + textAdjustment: 0, }; - public onHoverStart = () => (!this.props.disabled ? this.setState({ hovered: true }) : null); - public onHoverEnd = () => (!this.props.disabled ? this.setState({ hovered: false }) : null); + + private containerRef = React.createRef<View>(); + private textViewRef = React.createRef<PrivateLabel>(); + + public componentDidMount() { + this.forceUpdateTextAdjustment(); + } public render() { const { children, style, ...otherProps } = this.props; return ( - <Button - {...otherProps} - style={[styles.common, this.backgroundStyle(), style]} - onHoverStart={this.onHoverStart} - onHoverEnd={this.onHoverEnd}> - {React.Children.map(children, (child) => - typeof child === 'string' ? <Label>{child as string}</Label> : child, - )} - </Button> + <ButtonContext.Provider + value={{ + textAdjustment: this.state.textAdjustment, + textRef: this.textViewRef, + }}> + <Button + {...otherProps} + style={[styles.common, this.backgroundStyle(), style]} + onHoverStart={this.onHoverStart} + onHoverEnd={this.onHoverEnd}> + <View style={styles.content} ref={this.containerRef} onLayout={this.onLayout}> + {React.Children.map(children, (child) => + typeof child === 'string' ? <Label>{child as string}</Label> : child, + )} + </View> + </Button> + </ButtonContext.Provider> ); } + + protected backgroundStyle = (): Types.ButtonStyleRuleSet => { + throw new Error('Implement backgroundStyle in subclasses.'); + }; + protected onHoverStart = () => (!this.props.disabled ? this.setState({ hovered: true }) : null); + protected onHoverEnd = () => (!this.props.disabled ? this.setState({ hovered: false }) : null); + + private async forceUpdateTextAdjustment() { + const containerView = this.containerRef.current; + if (containerView) { + const containerLayout = await UserInterface.measureLayoutRelativeToAncestor( + containerView, + this, + ); + + this.updateTextAdjustment(containerLayout); + } + } + + private async updateTextAdjustment(containerLayout: Types.LayoutInfo) { + const labelView = this.textViewRef.current; + + if (labelView) { + // calculate the title layout frame + const labelLayout = await UserInterface.measureLayoutRelativeToAncestor(labelView, this); + + // calculate the remaining space at the right hand side + const trailingSpace = containerLayout.width - (labelLayout.x + labelLayout.width); + + // calculate text adjustment + const textAdjustment = labelLayout.x - trailingSpace; + + // re-render the view with the new text adjustment if it changed + if (this.state.textAdjustment !== textAdjustment) { + this.setState({ textAdjustment }); + } + } + } + + private onLayout = async (containerLayout: Types.ViewOnLayoutEvent) => { + this.updateTextAdjustment(containerLayout); + }; } export class RedButton extends BaseButton { - public backgroundStyle = () => (this.state.hovered ? styles.redHover : styles.red); + protected backgroundStyle = () => (this.state.hovered ? styles.redHover : styles.red); } export class GreenButton extends BaseButton { - public backgroundStyle = () => (this.state.hovered ? styles.greenHover : styles.green); + protected backgroundStyle = () => (this.state.hovered ? styles.greenHover : styles.green); } export class BlueButton extends BaseButton { - public backgroundStyle = () => (this.state.hovered ? styles.blueHover : styles.blue); + protected backgroundStyle = () => (this.state.hovered ? styles.blueHover : styles.blue); } export class TransparentButton extends BaseButton { - public backgroundStyle = () => + protected backgroundStyle = () => this.state.hovered ? styles.transparentHover : styles.transparent; } export class RedTransparentButton extends BaseButton { - public backgroundStyle = () => + protected backgroundStyle = () => this.state.hovered ? styles.redTransparentHover : styles.redTransparent; } diff --git a/gui/src/renderer/components/AppButtonStyles.tsx b/gui/src/renderer/components/AppButtonStyles.tsx index c5cc6dfc6b..055ce9b031 100644 --- a/gui/src/renderer/components/AppButtonStyles.tsx +++ b/gui/src/renderer/components/AppButtonStyles.tsx @@ -32,31 +32,26 @@ export default { redTransparentHover: Styles.createButtonStyle({ backgroundColor: colors.red45, }), - icon: Styles.createViewStyle({ - position: 'absolute', - alignSelf: 'flex-end', - right: 8, - marginLeft: 8, - }), common: Styles.createViewStyle({ cursor: 'default', - paddingTop: 9, - paddingLeft: 9, - paddingRight: 9, - paddingBottom: 9, borderRadius: 4, + }), + content: Styles.createViewStyle({ + flex: 1, + flexDirection: 'row', + alignItems: 'center', + padding: 9, + }), + labelContainer: Styles.createViewStyle({ flex: 1, - flexDirection: 'column', - alignContent: 'center', - justifyContent: 'center', }), label: Styles.createTextStyle({ - alignSelf: 'center', fontFamily: 'DINPro', fontSize: 20, fontWeight: '900', lineHeight: 26, flex: 1, color: colors.white, + textAlign: 'center', }), }; diff --git a/gui/src/renderer/components/Connect.tsx b/gui/src/renderer/components/Connect.tsx index 4d68c55897..6d10400a30 100644 --- a/gui/src/renderer/components/Connect.tsx +++ b/gui/src/renderer/components/Connect.tsx @@ -1,11 +1,12 @@ import * as React from 'react'; -import { Component, View } from 'reactxp'; +import { Component, Styles, View } from 'reactxp'; import { links } from '../../config.json'; -import { NoCreditError, NoInternetError } from '../../main/errors'; import { ITunnelEndpoint, parseSocketAddress } from '../../shared/daemon-rpc-types'; -import { pgettext } from '../../shared/gettext'; -import * as AppButton from './AppButton'; -import styles from './ConnectStyles'; +import AccountExpiry from '../lib/account-expiry'; +import { AuthFailureKind, parseAuthFailure } from '../lib/auth-failure'; +import { IConnectionReduxState } from '../redux/connection/reducers'; +import { IVersionReduxState } from '../redux/version/reducers'; +import ExpiredAccountErrorView, { RecoveryAction } from './ExpiredAccountErrorView'; import { Brand, HeaderBarStyle, SettingsBarButton } from './HeaderBar'; import ImageView from './ImageView'; import { Container, Header, Layout } from './Layout'; @@ -13,10 +14,6 @@ import Map, { MarkerStyle, ZoomLevel } from './Map'; import NotificationArea from './NotificationArea'; import TunnelControl, { IRelayInAddress, IRelayOutAddress } from './TunnelControl'; -import AccountExpiry from '../lib/account-expiry'; -import { IConnectionReduxState } from '../redux/connection/reducers'; -import { IVersionReduxState } from '../redux/version/reducers'; - interface IProps { connection: IConnectionReduxState; version: IVersionReduxState; @@ -34,68 +31,123 @@ interface IProps { type MarkerOrSpinner = 'marker' | 'spinner'; -export default class Connect extends Component<IProps> { - public render() { - const error = this.checkForErrors(); - const child = error ? this.renderError(error) : this.renderMap(); +const styles = { + connect: Styles.createViewStyle({ + flex: 1, + }), + map: Styles.createViewStyle({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + // @ts-ignore + zIndex: 0, + }), + body: Styles.createViewStyle({ + flex: 1, + paddingTop: 0, + paddingLeft: 24, + paddingRight: 24, + paddingBottom: 0, + marginTop: 186, + }), + container: Styles.createViewStyle({ + flex: 1, + flexDirection: 'column', + position: 'relative' /* need this for z-index to work to cover the map */, + // @ts-ignore + zIndex: 1, + }), + statusIcon: Styles.createViewStyle({ + position: 'absolute', + alignSelf: 'center', + width: 60, + height: 60, + marginTop: 94, + }), + notificationArea: Styles.createViewStyle({ + position: 'absolute', + left: 0, + top: 0, + right: 0, + }), +}; + +interface IState { + isAccountExpired: boolean; +} +export default class Connect extends Component<IProps, IState> { + constructor(props: IProps) { + super(props); + + this.state = { + isAccountExpired: this.checkAccountExpired(props, false), + }; + } + + public componentDidUpdate() { + this.updateAccountExpired(); + } + + public render() { return ( <Layout> <Header barStyle={this.headerBarStyle()}> <Brand /> <SettingsBarButton onPress={this.props.onSettings} /> </Header> - <Container>{child}</Container> + <Container> + {this.state.isAccountExpired ? this.renderExpiredAccountView() : this.renderMap()} + </Container> </Layout> ); } - public renderError(error: Error) { - let title = ''; - let message = ''; + private updateAccountExpired() { + const nextAccountExpired = this.checkAccountExpired(this.props, this.state.isAccountExpired); - if (error instanceof NoCreditError) { - title = pgettext('connect-view', 'Out of time'); - - message = pgettext( - 'connect-view', - 'Buy more time, so you can continue using the internet securely', - ); + if (nextAccountExpired !== this.state.isAccountExpired) { + this.setState({ + isAccountExpired: nextAccountExpired, + }); } + } - if (error instanceof NoInternetError) { - title = pgettext('connect-view', 'Offline'); + private checkAccountExpired(props: IProps, prevAccountExpired: boolean): boolean { + const tunnelState = props.connection.status; - message = pgettext( - 'connect-view', - 'Your internet connection will be secured when you get back online', - ); + // Blocked with auth failure / expired account + if ( + tunnelState.state === 'blocked' && + tunnelState.details.reason === 'auth_failed' && + parseAuthFailure(tunnelState.details.details).kind === AuthFailureKind.expiredAccount + ) { + return true; } - const { isBlocked } = this.props.connection; + // Use the account expiry to deduce the account state + if (this.props.accountExpiry) { + return this.props.accountExpiry.hasExpired(); + } + + // Do not assume that the account hasn't expired if the expiry is not available at the moment + // instead return the last known state. + return prevAccountExpired; + } + private renderExpiredAccountView() { return ( - <View style={styles.connect}> - <View style={styles.status_icon}> - <ImageView source="icon-fail" height={60} width={60} /> - </View> - <View style={styles.body}> - <View style={styles.error_title}>{title}</View> - <View style={styles.error_message}>{message}</View> - {error instanceof NoCreditError ? ( - <View> - <AppButton.GreenButton disabled={isBlocked} onPress={this.handleBuyMorePress}> - <AppButton.Label>Buy more time</AppButton.Label> - <AppButton.Icon source="icon-extLink" height={16} width={16} /> - </AppButton.GreenButton> - </View> - ) : null} - </View> - </View> + <ExpiredAccountErrorView + blockWhenDisconnected={this.props.blockWhenDisconnected} + isBlocked={this.props.connection.isBlocked} + action={this.handleExpiredAccountRecovery} + /> ); } - public renderMap() { + private renderMap() { const status = this.props.connection.status; const relayOutAddress: IRelayOutAddress = { @@ -112,7 +164,7 @@ export default class Connect extends Component<IProps> { <View style={styles.container}> {/* show spinner when connecting */} {this.showMarkerOrSpinner() === 'spinner' ? ( - <View style={styles.status_icon}> + <View style={styles.statusIcon}> <ImageView source="icon-spinner" height={60} width={60} /> </View> ) : null} @@ -133,7 +185,7 @@ export default class Connect extends Component<IProps> { /> <NotificationArea - style={styles.notification_area} + style={styles.notificationArea} tunnelState={this.props.connection.status} version={this.props.version} accountExpiry={this.props.accountExpiry} @@ -145,8 +197,23 @@ export default class Connect extends Component<IProps> { ); } - private handleBuyMorePress = () => { - this.props.onExternalLink(links.purchase); + private handleExpiredAccountRecovery = async (recoveryAction: RecoveryAction) => { + switch (recoveryAction) { + case RecoveryAction.disableBlockedWhenDisconnected: + break; + + case RecoveryAction.openBrowser: + this.props.onExternalLink(links.purchase); + break; + + case RecoveryAction.disconnectAndOpenBrowser: + try { + await this.props.onDisconnect(); + this.props.onExternalLink(links.purchase); + } catch (error) { + // no-op + } + } }; private headerBarStyle(): HeaderBarStyle { @@ -177,20 +244,6 @@ export default class Connect extends Component<IProps> { } } - private checkForErrors(): Error | undefined { - // Offline? - if (!this.props.connection.isOnline) { - return new NoInternetError(); - } - - // No credit? - if (this.props.accountExpiry && this.props.accountExpiry.hasExpired()) { - return new NoCreditError(); - } - - return undefined; - } - private getMapProps(): Map['props'] { const { longitude, diff --git a/gui/src/renderer/components/ConnectStyles.tsx b/gui/src/renderer/components/ConnectStyles.tsx deleted file mode 100644 index 645541000b..0000000000 --- a/gui/src/renderer/components/ConnectStyles.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Styles } from 'reactxp'; -import { colors } from '../../config.json'; - -export default { - connect: Styles.createViewStyle({ - flex: 1, - }), - map: Styles.createViewStyle({ - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - // @ts-ignore - zIndex: 0, - }), - body: Styles.createViewStyle({ - paddingTop: 0, - paddingLeft: 24, - paddingRight: 24, - paddingBottom: 0, - marginTop: 186, - flex: 1, - }), - container: Styles.createViewStyle({ - flexDirection: 'column', - flex: 1, - position: 'relative' /* need this for z-index to work to cover map */, - // @ts-ignore - zIndex: 1, - }), - status_icon: Styles.createViewStyle({ - position: 'absolute', - alignSelf: 'center', - width: 60, - height: 60, - marginTop: 94, - }), - notification_area: Styles.createViewStyle({ - position: 'absolute', - left: 0, - top: 0, - right: 0, - }), - error_title: Styles.createTextStyle({ - fontFamily: 'DINPro', - fontSize: 32, - fontWeight: '900', - lineHeight: 40, - color: colors.white, - marginBottom: 8, - }), - error_message: Styles.createTextStyle({ - fontFamily: 'Open Sans', - fontSize: 13, - lineHeight: 20, - fontWeight: '600', - color: colors.white, - marginBottom: 24, - }), -}; diff --git a/gui/src/renderer/components/ExpiredAccountErrorView.tsx b/gui/src/renderer/components/ExpiredAccountErrorView.tsx new file mode 100644 index 0000000000..3c8abd4223 --- /dev/null +++ b/gui/src/renderer/components/ExpiredAccountErrorView.tsx @@ -0,0 +1,164 @@ +import * as React from 'react'; +import { Component, Styles, View } from 'reactxp'; +import { colors } from '../../config.json'; +import { pgettext } from '../../shared/gettext'; +import * as AppButton from './AppButton'; +import ImageView from './ImageView'; + +export enum RecoveryAction { + openBrowser, + disconnectAndOpenBrowser, + disableBlockedWhenDisconnected, +} + +interface IProps { + isBlocked: boolean; + blockWhenDisconnected: boolean; + action: (recoveryAction: RecoveryAction) => void; +} + +interface IState { + recoveryAction: RecoveryAction; +} + +const styles = { + container: Styles.createViewStyle({ + flex: 1, + paddingTop: 94, + }), + body: Styles.createViewStyle({ + flex: 1, + paddingHorizontal: 24, + }), + title: Styles.createTextStyle({ + fontFamily: 'DINPro', + fontSize: 32, + fontWeight: '900', + lineHeight: 40, + color: colors.white, + marginBottom: 8, + }), + message: Styles.createTextStyle({ + fontFamily: 'Open Sans', + fontSize: 13, + lineHeight: 20, + fontWeight: '600', + color: colors.white, + marginBottom: 24, + }), + statusIcon: Styles.createViewStyle({ + alignSelf: 'center', + width: 60, + height: 60, + marginBottom: 32, + }), +}; + +export default class ExpiredAccountErrorView extends Component<IProps, IState> { + public static getDerivedStateFromProps(props: IProps): IState { + const { blockWhenDisconnected, isBlocked } = props; + + if (blockWhenDisconnected && isBlocked) { + return { recoveryAction: RecoveryAction.disableBlockedWhenDisconnected }; + } else if (!blockWhenDisconnected && isBlocked) { + return { recoveryAction: RecoveryAction.disconnectAndOpenBrowser }; + } else { + return { recoveryAction: RecoveryAction.openBrowser }; + } + } + public state: IState = { recoveryAction: RecoveryAction.openBrowser }; + + public render() { + return ( + <View style={styles.container}> + <View style={styles.statusIcon}> + <ImageView source="icon-fail" height={60} width={60} /> + </View> + <View style={styles.body}> + <View style={styles.title}>{pgettext('connect-view', 'Out of time')}</View> + {this.renderContent()} + </View> + </View> + ); + } + + private renderContent() { + switch (this.state.recoveryAction) { + case RecoveryAction.disconnectAndOpenBrowser: + return <DisconnectAndOpenBrowserContentView actionHandler={this.handleAction} />; + case RecoveryAction.openBrowser: + return <OpenBrowserContentView actionHandler={this.handleAction} />; + case RecoveryAction.disableBlockedWhenDisconnected: + return <DisableBlockWhenDisconnectedContentView />; + } + } + + private handleAction = () => { + this.props.action(this.state.recoveryAction); + }; +} + +class DisconnectAndOpenBrowserContentView extends Component<{ actionHandler: () => void }> { + public render() { + return ( + <View> + <View style={styles.message}> + {pgettext( + 'connect-view', + 'You have no more VPN time left on this account. To buy more credit on our website, you will need to access the Internet with an unsecured connection.', + )} + </View> + <View> + <AppButton.RedButton onPress={this.props.actionHandler}> + <AppButton.Label> + {pgettext('connect-view', 'Disconnect and buy more credit')} + </AppButton.Label> + <AppButton.Icon source="icon-extLink" height={16} width={16} /> + </AppButton.RedButton> + </View> + </View> + ); + } +} + +class OpenBrowserContentView extends Component<{ actionHandler: () => void }> { + public render() { + return ( + <View> + <View style={styles.message}> + {pgettext( + 'connect-view', + 'You have no more VPN time left on this account. Please log in on our website to buy more credit.', + )} + </View> + <View> + <AppButton.GreenButton onPress={this.props.actionHandler}> + <AppButton.Label>{pgettext('connect-view', 'Buy more credit')}</AppButton.Label> + <AppButton.Icon source="icon-extLink" height={16} width={16} /> + </AppButton.GreenButton> + </View> + </View> + ); + } +} + +class DisableBlockWhenDisconnectedContentView extends Component { + public render() { + return ( + <View> + <View style={styles.message}> + {pgettext( + 'connect-view', + 'You have no more VPN time left on this account. Before you can buy more credit on our website, you first need to turn off the app\'s "Block when disconnected" option under Advanced settings.', + )} + </View> + <View> + <AppButton.GreenButton disabled={true}> + <AppButton.Label>{pgettext('connect-view', 'Buy more credit')}</AppButton.Label> + <AppButton.Icon source="icon-extLink" height={16} width={16} /> + </AppButton.GreenButton> + </View> + </View> + ); + } +} diff --git a/gui/src/renderer/components/NotificationArea.tsx b/gui/src/renderer/components/NotificationArea.tsx index 543ba90edf..033067df8a 100644 --- a/gui/src/renderer/components/NotificationArea.tsx +++ b/gui/src/renderer/components/NotificationArea.tsx @@ -16,7 +16,7 @@ import { import { BlockReason, TunnelStateTransition } from '../../shared/daemon-rpc-types'; import AccountExpiry from '../lib/account-expiry'; -import { AuthFailureError } from '../lib/auth-failure'; +import { parseAuthFailure } from '../lib/auth-failure'; import { IVersionReduxState } from '../redux/version/reducers'; interface IProps { @@ -42,9 +42,8 @@ type State = NotificationAreaPresentation & { function getBlockReasonMessage(blockReason: BlockReason): string { switch (blockReason.reason) { - case 'auth_failed': { - return new AuthFailureError(blockReason.details).message; - } + case 'auth_failed': + return parseAuthFailure(blockReason.details).message; case 'ipv6_unavailable': return pgettext( 'in-app-notifications', @@ -150,7 +149,14 @@ export default class NotificationArea extends Component<IProps, State> { }; } - if (accountExpiry && accountExpiry.willHaveExpiredIn(moment().add(3, 'days'))) { + if ( + accountExpiry && + accountExpiry.willHaveExpiredAt( + moment() + .add(3, 'days') + .toDate(), + ) + ) { return { visible: true, type: 'expires-soon', diff --git a/gui/src/renderer/components/Settings.tsx b/gui/src/renderer/components/Settings.tsx index 610de163af..146cfa05b7 100644 --- a/gui/src/renderer/components/Settings.tsx +++ b/gui/src/renderer/components/Settings.tsx @@ -22,6 +22,7 @@ import { LoginState } from '../redux/account/reducers'; export interface IProps { loginState: LoginState; accountExpiry?: string; + expiryLocale: string; appVersion: string; consistentVersion: boolean; upToDateVersion: boolean; @@ -88,7 +89,9 @@ export default class Settings extends Component<IProps> { return null; } - const expiry = this.props.accountExpiry ? new AccountExpiry(this.props.accountExpiry) : null; + const expiry = this.props.accountExpiry + ? new AccountExpiry(this.props.accountExpiry, this.props.expiryLocale) + : null; const isOutOfTime = expiry ? expiry.hasExpired() : false; const formattedExpiry = expiry ? expiry.remainingTime().toUpperCase() : ''; diff --git a/gui/src/renderer/containers/AccountPage.tsx b/gui/src/renderer/containers/AccountPage.tsx index 33dad1e1cb..9edd159e1c 100644 --- a/gui/src/renderer/containers/AccountPage.tsx +++ b/gui/src/renderer/containers/AccountPage.tsx @@ -1,5 +1,5 @@ import { goBack } from 'connected-react-router'; -import { remote, shell } from 'electron'; +import { shell } from 'electron'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import { links } from '../../config.json'; @@ -8,10 +8,10 @@ import Account from '../components/Account'; import { IReduxState, ReduxDispatch } from '../redux/store'; import { ISharedRouteProps } from '../routes'; -const mapStateToProps = (state: IReduxState) => ({ +const mapStateToProps = (state: IReduxState, props: ISharedRouteProps) => ({ accountToken: state.account.accountToken, accountExpiry: state.account.expiry, - expiryLocale: remote.app.getLocale(), + expiryLocale: props.locale, isOffline: state.connection.isBlocked, }); const mapDispatchToProps = (dispatch: ReduxDispatch, props: ISharedRouteProps) => { diff --git a/gui/src/renderer/containers/ConnectPage.tsx b/gui/src/renderer/containers/ConnectPage.tsx index cb78c2e8b3..dbbf898457 100644 --- a/gui/src/renderer/containers/ConnectPage.tsx +++ b/gui/src/renderer/containers/ConnectPage.tsx @@ -69,9 +69,11 @@ function getRelayName( } } -const mapStateToProps = (state: IReduxState) => { +const mapStateToProps = (state: IReduxState, props: ISharedRouteProps) => { return { - accountExpiry: state.account.expiry ? new AccountExpiry(state.account.expiry) : undefined, + accountExpiry: state.account.expiry + ? new AccountExpiry(state.account.expiry, props.locale) + : undefined, selectedRelayName: getRelayName(state.settings.relaySettings, state.settings.relayLocations), connection: state.connection, version: state.version, @@ -101,9 +103,9 @@ const mapDispatchToProps = (dispatch: ReduxDispatch, props: ISharedRouteProps) = log.error(`Failed to connect the tunnel: ${error.message}`); } }, - onDisconnect: () => { + onDisconnect: async () => { try { - props.app.disconnectTunnel(); + await props.app.disconnectTunnel(); } catch (error) { log.error(`Failed to disconnect the tunnel: ${error.message}`); } diff --git a/gui/src/renderer/containers/SettingsPage.tsx b/gui/src/renderer/containers/SettingsPage.tsx index da4a10cdb2..e8a9c8ee9a 100644 --- a/gui/src/renderer/containers/SettingsPage.tsx +++ b/gui/src/renderer/containers/SettingsPage.tsx @@ -7,9 +7,10 @@ import Settings from '../components/Settings'; import { IReduxState, ReduxDispatch } from '../redux/store'; import { ISharedRouteProps } from '../routes'; -const mapStateToProps = (state: IReduxState) => ({ +const mapStateToProps = (state: IReduxState, props: ISharedRouteProps) => ({ loginState: state.account.status, accountExpiry: state.account.expiry, + expiryLocale: props.locale, appVersion: state.version.current, consistentVersion: state.version.consistent, upToDateVersion: state.version.upToDate, diff --git a/gui/src/renderer/lib/account-expiry.ts b/gui/src/renderer/lib/account-expiry.ts index e781caeffe..5a8e5e8ec2 100644 --- a/gui/src/renderer/lib/account-expiry.ts +++ b/gui/src/renderer/lib/account-expiry.ts @@ -5,16 +5,20 @@ import { pgettext } from '../../shared/gettext'; export default class AccountExpiry { private expiry: moment.Moment; - constructor(expiry: string) { - this.expiry = moment(expiry); + constructor(isoString: string, locale: string) { + this.expiry = moment(isoString).locale(locale); } public hasExpired(): boolean { - return this.willHaveExpiredIn(moment()); + return this.willHaveExpiredAt(new Date()); } - public willHaveExpiredIn(time: moment.Moment): boolean { - return this.expiry.isSameOrBefore(time); + public willHaveExpiredAt(date: Date): boolean { + return this.expiry.isSameOrBefore(date); + } + + public formattedDate(): string { + return this.expiry.format('L LTS'); } public remainingTime(): string { diff --git a/gui/src/renderer/lib/auth-failure.ts b/gui/src/renderer/lib/auth-failure.ts index 7ac9b4b527..ae381d5677 100644 --- a/gui/src/renderer/lib/auth-failure.ts +++ b/gui/src/renderer/lib/auth-failure.ts @@ -1,4 +1,3 @@ -import log from 'electron-log'; import { pgettext } from '../../shared/gettext'; export enum AuthFailureKind { @@ -8,75 +7,41 @@ export enum AuthFailureKind { unknown, } -export class AuthFailureError extends Error { - private kindValue: AuthFailureKind; - private unknownErrorMessage?: string; - - get kind(): AuthFailureKind { - return this.kindValue; - } - - get message(): string { - switch (this.kindValue) { - case AuthFailureKind.invalidAccount: - return pgettext( - 'auth-failure', - "You've logged in with an account number that is not valid. Please log out and try another one.", - ); - - case AuthFailureKind.expiredAccount: - return pgettext( - 'auth-failure', - 'You have no more VPN time left on this account. Please log in on our website to buy more credit.', - ); - - case AuthFailureKind.tooManyConnections: - return pgettext( - 'auth-failure', - 'This account has too many simultaneous connections. Disconnect another device or try connecting again shortly.', - ); - - case AuthFailureKind.unknown: - return ( - this.unknownErrorMessage || pgettext('auth-failure', 'Account authentication failed.') - ); - } - } - - constructor(reason?: string) { - super(); - - if (!reason) { - log.error('Received invalid auth_failed reason: ', reason); - - this.kindValue = AuthFailureKind.unknown; - return; - } +interface IAuthFailure { + kind: AuthFailureKind; + message: string; +} - const results = /^\[(\w+)\]\s*(.*)$/.exec(reason); +export function parseAuthFailure(rawFailureMessage?: string): IAuthFailure { + if (rawFailureMessage) { + const results = /^\[(\w+)\]\s*(.*)$/.exec(rawFailureMessage); if (results && results.length === 3) { - const rawReasonId = results[1]; - const kindValue = rawReasonIdToFailureKind(rawReasonId); - - if (kindValue === AuthFailureKind.unknown) { - log.error(`Received unknown auth_failed message id - ${rawReasonId}`); - } + const kind = parseRawFailureKind(results[1]); + const message = + kind === AuthFailureKind.unknown ? rawFailureMessage : messageForFailureKind(kind); - this.kindValue = kindValue; - this.unknownErrorMessage = results[2]; + return { + kind, + message, + }; } else { - log.error(`Received invalid auth_failed message - "${reason}"`); - - this.kindValue = AuthFailureKind.unknown; - this.unknownErrorMessage = reason; + return { + kind: AuthFailureKind.unknown, + message: rawFailureMessage, + }; } + } else { + return { + kind: AuthFailureKind.unknown, + message: messageForFailureKind(AuthFailureKind.unknown), + }; } } -function rawReasonIdToFailureKind(id: string): AuthFailureKind { +function parseRawFailureKind(failureId: string): AuthFailureKind { // These strings should match up with mullvad-types/src/auth_failed.rs - switch (id) { + switch (failureId) { case 'INVALID_ACCOUNT': return AuthFailureKind.invalidAccount; @@ -90,3 +55,28 @@ function rawReasonIdToFailureKind(id: string): AuthFailureKind { return AuthFailureKind.unknown; } } + +function messageForFailureKind(kind: AuthFailureKind): string { + switch (kind) { + case AuthFailureKind.invalidAccount: + return pgettext( + 'auth-failure', + "You've logged in with an account number that is not valid. Please log out and try another one.", + ); + + case AuthFailureKind.expiredAccount: + return pgettext( + 'auth-failure', + 'You have no more VPN time left on this account. Please log in on our website to buy more credit.', + ); + + case AuthFailureKind.tooManyConnections: + return pgettext( + 'auth-failure', + 'This account has too many simultaneous connections. Disconnect another device or try connecting again shortly.', + ); + + case AuthFailureKind.unknown: + return pgettext('auth-failure', 'Account authentication failed.'); + } +} diff --git a/gui/src/renderer/redux/account/actions.ts b/gui/src/renderer/redux/account/actions.ts index 8dba590737..e7f6ade346 100644 --- a/gui/src/renderer/redux/account/actions.ts +++ b/gui/src/renderer/redux/account/actions.ts @@ -34,7 +34,7 @@ interface IUpdateAccountHistoryAction { interface IUpdateAccountExpiryAction { type: 'UPDATE_ACCOUNT_EXPIRY'; - expiry: string; + expiry?: string; } export type AccountAction = @@ -93,7 +93,7 @@ function updateAccountHistory(accountHistory: AccountToken[]): IUpdateAccountHis }; } -function updateAccountExpiry(expiry: string): IUpdateAccountExpiryAction { +function updateAccountExpiry(expiry?: string): IUpdateAccountExpiryAction { return { type: 'UPDATE_ACCOUNT_EXPIRY', expiry, diff --git a/gui/src/renderer/redux/connection/actions.ts b/gui/src/renderer/redux/connection/actions.ts index ff34b20e11..b84e85f8e6 100644 --- a/gui/src/renderer/redux/connection/actions.ts +++ b/gui/src/renderer/redux/connection/actions.ts @@ -34,12 +34,9 @@ interface INewLocationAction { newLocation: ILocation; } -interface IOnlineAction { - type: 'ONLINE'; -} - -interface IOfflineAction { - type: 'OFFLINE'; +interface IUpdateBlockStateAction { + type: 'UPDATE_BLOCK_STATE'; + isBlocked: boolean; } export type ConnectionAction = @@ -49,8 +46,7 @@ export type ConnectionAction = | IDisconnectedAction | IDisconnectingAction | IBlockedAction - | IOnlineAction - | IOfflineAction; + | IUpdateBlockStateAction; function connecting(tunnelEndpoint?: ITunnelEndpoint): IConnectingAction { return { @@ -93,25 +89,19 @@ function newLocation(location: ILocation): INewLocationAction { }; } -function online(): IOnlineAction { - return { - type: 'ONLINE', - }; -} - -function offline(): IOfflineAction { +function updateBlockState(isBlocked: boolean): IUpdateBlockStateAction { return { - type: 'OFFLINE', + type: 'UPDATE_BLOCK_STATE', + isBlocked, }; } export default { newLocation, + updateBlockState, connecting, connected, disconnected, disconnecting, blocked, - online, - offline, }; diff --git a/gui/src/renderer/redux/connection/reducers.ts b/gui/src/renderer/redux/connection/reducers.ts index af92e0fd6f..a0918f53ee 100644 --- a/gui/src/renderer/redux/connection/reducers.ts +++ b/gui/src/renderer/redux/connection/reducers.ts @@ -3,7 +3,6 @@ import { ReduxAction } from '../store'; export interface IConnectionReduxState { status: TunnelStateTransition; - isOnline: boolean; isBlocked: boolean; ip?: Ip; hostname?: string; @@ -15,7 +14,6 @@ export interface IConnectionReduxState { const initialState: IConnectionReduxState = { status: { state: 'disconnected' }, - isOnline: true, isBlocked: false, ip: undefined, hostname: undefined, @@ -33,43 +31,39 @@ export default function( case 'NEW_LOCATION': return { ...state, ...action.newLocation }; + case 'UPDATE_BLOCK_STATE': + return { ...state, isBlocked: action.isBlocked }; + case 'CONNECTING': return { ...state, status: { state: 'connecting', details: action.tunnelEndpoint }, - isBlocked: true, }; case 'CONNECTED': return { ...state, status: { state: 'connected', details: action.tunnelEndpoint }, - isBlocked: false, }; case 'DISCONNECTED': - return { ...state, status: { state: 'disconnected' }, isBlocked: false }; + return { + ...state, + status: { state: 'disconnected' }, + }; case 'DISCONNECTING': return { ...state, status: { state: 'disconnecting', details: action.afterDisconnect }, - isBlocked: true, }; case 'BLOCKED': return { ...state, status: { state: 'blocked', details: action.reason }, - isBlocked: action.reason.reason !== 'set_firewall_policy_error', }; - case 'ONLINE': - return { ...state, isOnline: true }; - - case 'OFFLINE': - return { ...state, isOnline: false }; - default: return state; } diff --git a/gui/src/renderer/redux/settings/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts index 60a610ece0..ca02ab0db2 100644 --- a/gui/src/renderer/redux/settings/reducers.ts +++ b/gui/src/renderer/redux/settings/reducers.ts @@ -21,7 +21,6 @@ export type RelaySettingsRedux = export interface IRelayLocationRelayRedux { hostname: string; ipv4AddrIn: string; - ipv4AddrExit: string; includeInCountry: boolean; weight: number; } diff --git a/gui/src/renderer/routes.tsx b/gui/src/renderer/routes.tsx index 7aaf15d1e7..a31dd0de4a 100644 --- a/gui/src/renderer/routes.tsx +++ b/gui/src/renderer/routes.tsx @@ -16,6 +16,7 @@ import { getTransitionProps } from './transitions'; export interface ISharedRouteProps { app: App; + locale: string; } type CustomRouteProps = { diff --git a/gui/test/auth-failure.spec.ts b/gui/test/auth-failure.spec.ts index 6bb643c345..eaa08c4a1e 100644 --- a/gui/test/auth-failure.spec.ts +++ b/gui/test/auth-failure.spec.ts @@ -1,27 +1,27 @@ import { expect } from 'chai'; import { it, describe } from 'mocha'; -import { AuthFailureError, AuthFailureKind } from '../src/renderer/lib/auth-failure'; +import { parseAuthFailure, AuthFailureKind } from '../src/renderer/lib/auth-failure'; describe('auth_failed parsing', () => { it('invalid line parsing works', () => { - const auth_msg = new AuthFailureError('invalid auth_failed message'); - expect(auth_msg.kind).to.be.equal(AuthFailureKind.unknown); - expect(auth_msg.message).to.be.equal('invalid auth_failed message'); + const authFailure = parseAuthFailure('invalid auth_failed message'); + expect(authFailure.kind).to.be.equal(AuthFailureKind.unknown); + expect(authFailure.message).to.be.equal('invalid auth_failed message'); }); it('valid unknown works', () => { - const auth_msg = new AuthFailureError('[valid_unknown] Message'); - expect(auth_msg.kind).to.be.equal(AuthFailureKind.unknown); - expect(auth_msg.message).to.be.equal('Message'); + const authFailure = parseAuthFailure('[valid_unknown] Message'); + expect(authFailure.kind).to.be.equal(AuthFailureKind.unknown); + expect(authFailure.message).to.be.equal('Message'); }); it('valid known works', () => { - const auth_msg = new AuthFailureError('[INVALID_ACCOUNT] Invalid account'); - expect(auth_msg.kind).to.be.equal(AuthFailureKind.invalidAccount); + const authFailure = parseAuthFailure('[INVALID_ACCOUNT] Invalid account'); + expect(authFailure.kind).to.be.equal(AuthFailureKind.invalidAccount); }); it('empty message works', () => { - const auth_msg = new AuthFailureError('[INVALID_ACCOUNT]'); - expect(auth_msg.kind).to.be.equal(AuthFailureKind.invalidAccount); + const authFailure = parseAuthFailure('[INVALID_ACCOUNT]'); + expect(authFailure.kind).to.be.equal(AuthFailureKind.invalidAccount); }); }); diff --git a/gui/test/components/NotificationArea.spec.tsx b/gui/test/components/NotificationArea.spec.tsx index c23a739535..54aae1f8a6 100644 --- a/gui/test/components/NotificationArea.spec.tsx +++ b/gui/test/components/NotificationArea.spec.tsx @@ -19,6 +19,7 @@ describe('components/NotificationArea', () => { moment() .add(1, 'year') .format(), + 'en', ); it('handles disconnecting state', () => { @@ -176,6 +177,7 @@ describe('components/NotificationArea', () => { moment() .add(2, 'days') .format(), + 'en', ); const component = shallow( <NotificationArea diff --git a/gui/yarn.lock b/gui/yarn.lock index 66b1215a63..cf08832cf7 100644 --- a/gui/yarn.lock +++ b/gui/yarn.lock @@ -1277,14 +1277,15 @@ d3-geo-projection@1.2.2: d3-array "1" d3-geo "^1.1.0" -d3-geo-projection@^2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/d3-geo-projection/-/d3-geo-projection-2.4.1.tgz#9d0f3b296aeb4e6a9194a438b5cd04f70866092f" - integrity sha512-tDoYNK3OPlyeoY0Y6ZQ7v02Y2pGg4PTzh7GoNaHnLksIggInHiwXcbiziGHkjBoLbE2bQp9iL2OPsn+n+ZO/dg== +d3-geo-projection@^2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/d3-geo-projection/-/d3-geo-projection-2.6.0.tgz#a0acf97be5e6da251e7b0564268b58e0e6a1dc28" + integrity sha512-snlzliB5q8SgzvYG2iVp4wRM+qmWbCY5nO2sWIbChRFiR3PD3NayOgNVTtdBoC70eY3QWI9z6dmzy8Qi7q0IQA== dependencies: commander "2" d3-array "1" d3-geo "^1.10.0" + resolve "^1.1.10" d3-geo@1.6.3: version "1.6.3" @@ -3362,10 +3363,10 @@ mock-socket@^8.0.5: dependencies: url-parse "^1.2.0" -moment@^2.20.1: - version "2.22.2" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66" - integrity sha1-PCV/mDn8DpP/UxSWMiOeuQeD/2Y= +moment@^2.24.0: + version "2.24.0" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b" + integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg== moo@^0.4.3: version "0.4.3" @@ -4483,7 +4484,7 @@ resolve-url@^0.2.1: resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= -resolve@^1.3.2: +resolve@^1.1.10, resolve@^1.3.2: version "1.10.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.10.0.tgz#3bdaaeaf45cc07f375656dfd2e54ed0810b101ba" integrity sha512-3sUr9aq5OfSg2S9pNtPA9hL1FVEAjvfOC4leW0SNf/mpnaakz2a9femSd6LqAww2RaFctwyf1lCqnTHuF1rxDg== |
