diff options
Diffstat (limited to 'gui/src/shared')
| -rw-r--r-- | gui/src/shared/account-expiry.ts | 10 | ||||
| -rw-r--r-- | gui/src/shared/auth-failure.ts | 81 | ||||
| -rw-r--r-- | gui/src/shared/notifications/accountExpiry.ts | 56 | ||||
| -rw-r--r-- | gui/src/shared/notifications/blockWhenDisconnected.ts | 40 | ||||
| -rw-r--r-- | gui/src/shared/notifications/connected.ts | 32 | ||||
| -rw-r--r-- | gui/src/shared/notifications/connecting.ts | 51 | ||||
| -rw-r--r-- | gui/src/shared/notifications/disconnected.ts | 16 | ||||
| -rw-r--r-- | gui/src/shared/notifications/error.ts | 125 | ||||
| -rw-r--r-- | gui/src/shared/notifications/inconsistentVersion.ts | 41 | ||||
| -rw-r--r-- | gui/src/shared/notifications/notification.ts | 41 | ||||
| -rw-r--r-- | gui/src/shared/notifications/reconnecting.ts | 30 | ||||
| -rw-r--r-- | gui/src/shared/notifications/unsupportedVersion.ts | 65 | ||||
| -rw-r--r-- | gui/src/shared/notifications/updateAvailable.ts | 37 |
13 files changed, 623 insertions, 2 deletions
diff --git a/gui/src/shared/account-expiry.ts b/gui/src/shared/account-expiry.ts index 44c62a613e..d6acb0de91 100644 --- a/gui/src/shared/account-expiry.ts +++ b/gui/src/shared/account-expiry.ts @@ -40,15 +40,21 @@ export default class AccountExpiry { } } - public remainingTime(): string { + public remainingTime(shouldCapitalizeFirstLetter?: boolean): string { const duration = this.durationUntilExpiry(); - return sprintf( + const remaining = sprintf( // TRANSLATORS: The remaining time left on the account displayed across the app. // TRANSLATORS: Available placeholders: // TRANSLATORS: %(duration)s - a localized remaining time (in minutes, hours, or days) until the account expiry messages.pgettext('account-expiry', '%(duration)s left'), { duration }, ); + + return shouldCapitalizeFirstLetter ? capitalizeFirstLetter(remaining) : remaining; } } + +function capitalizeFirstLetter(inputString: string): string { + return inputString.charAt(0).toUpperCase() + inputString.slice(1); +} diff --git a/gui/src/shared/auth-failure.ts b/gui/src/shared/auth-failure.ts new file mode 100644 index 0000000000..bbd990bf89 --- /dev/null +++ b/gui/src/shared/auth-failure.ts @@ -0,0 +1,81 @@ +import { messages } from './gettext'; + +export enum AuthFailureKind { + invalidAccount, + expiredAccount, + tooManyConnections, + unknown, +} + +interface IAuthFailure { + kind: AuthFailureKind; + message: string; +} + +export function parseAuthFailure(rawFailureMessage?: string): IAuthFailure { + if (rawFailureMessage) { + const results = /^\[(\w+)\]\s*(.*)$/.exec(rawFailureMessage); + + if (results && results.length === 3) { + const kind = parseRawFailureKind(results[1]); + const message = kind === AuthFailureKind.unknown ? results[2] : messageForFailureKind(kind); + + return { + kind, + message, + }; + } else { + return { + kind: AuthFailureKind.unknown, + message: rawFailureMessage, + }; + } + } else { + return { + kind: AuthFailureKind.unknown, + message: messageForFailureKind(AuthFailureKind.unknown), + }; + } +} + +function parseRawFailureKind(failureId: string): AuthFailureKind { + // These strings should match up with mullvad-types/src/auth_failed.rs + switch (failureId) { + case 'INVALID_ACCOUNT': + return AuthFailureKind.invalidAccount; + + case 'EXPIRED_ACCOUNT': + return AuthFailureKind.expiredAccount; + + case 'TOO_MANY_CONNECTIONS': + return AuthFailureKind.tooManyConnections; + + default: + return AuthFailureKind.unknown; + } +} + +function messageForFailureKind(kind: AuthFailureKind): string { + switch (kind) { + case AuthFailureKind.invalidAccount: + return messages.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 messages.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 messages.pgettext( + 'auth-failure', + 'This account has too many simultaneous connections. Disconnect another device or try connecting again shortly.', + ); + + case AuthFailureKind.unknown: + return messages.pgettext('auth-failure', 'Account authentication failed.'); + } +} diff --git a/gui/src/shared/notifications/accountExpiry.ts b/gui/src/shared/notifications/accountExpiry.ts new file mode 100644 index 0000000000..b749fb55c1 --- /dev/null +++ b/gui/src/shared/notifications/accountExpiry.ts @@ -0,0 +1,56 @@ +import moment from 'moment'; +import { sprintf } from 'sprintf-js'; +import { links } from '../../config.json'; +import { messages } from '../../shared/gettext'; +import AccountExpiry from '../account-expiry'; +import { + InAppNotification, + InAppNotificationProvider, + SystemNotification, + SystemNotificationProvider, +} from './notification'; + +interface AccountExpiryContext { + accountExpiry: AccountExpiry; + tooSoon?: boolean; +} + +export class AccountExpiryNotificationProvider + implements InAppNotificationProvider, SystemNotificationProvider { + public constructor(private context: AccountExpiryContext) {} + + public mayDisplay() { + return ( + !this.context.accountExpiry.hasExpired() && + this.context.accountExpiry.willHaveExpiredAt(moment().add(3, 'days').toDate()) && + !this.context.tooSoon + ); + } + + public getSystemNotification(): SystemNotification { + const message = sprintf( + // TRANSLATORS: The system notification displayed to the user when the account credit is close to expiry. + // TRANSLATORS: Available placeholder: + // TRANSLATORS: %(duration)s - remaining time, e.g. "2 days" + messages.pgettext('notifications', 'Account credit expires in %(duration)s'), + { + duration: this.context.accountExpiry.remainingTime(), + }, + ); + + return { + message, + critical: true, + action: { type: 'open-url', url: links.purchase, withAuth: true }, + }; + } + + public getInAppNotification(): InAppNotification { + return { + indicator: 'warning', + title: messages.pgettext('in-app-notifications', 'ACCOUNT CREDIT EXPIRES SOON'), + subtitle: this.context.accountExpiry.remainingTime(true), + action: { type: 'open-url', url: links.purchase, withAuth: true }, + }; + } +} diff --git a/gui/src/shared/notifications/blockWhenDisconnected.ts b/gui/src/shared/notifications/blockWhenDisconnected.ts new file mode 100644 index 0000000000..16b65c0672 --- /dev/null +++ b/gui/src/shared/notifications/blockWhenDisconnected.ts @@ -0,0 +1,40 @@ +import { messages } from '../../shared/gettext'; +import { TunnelState } from '../daemon-rpc-types'; +import { + InAppNotification, + InAppNotificationProvider, + SystemNotificationProvider, +} from './notification'; + +interface BlockWhenDisconnectedNotificationContext { + tunnelState: TunnelState; + blockWhenDisconnected: boolean; +} + +export class BlockWhenDisconnectedNotificationProvider + implements InAppNotificationProvider, SystemNotificationProvider { + public constructor(private context: BlockWhenDisconnectedNotificationContext) {} + + public mayDisplay() { + return ( + (this.context.tunnelState.state === 'disconnecting' || + this.context.tunnelState.state === 'disconnected') && + this.context.blockWhenDisconnected + ); + } + + public getSystemNotification() { + return { + message: messages.pgettext('notifications', 'Blocking internet'), + critical: false, + }; + } + + public getInAppNotification(): InAppNotification { + return { + indicator: 'error', + title: messages.pgettext('in-app-notifications', 'BLOCKING INTERNET'), + subtitle: messages.pgettext('in-app-notifications', '"Always require VPN" is enabled.'), + }; + } +} diff --git a/gui/src/shared/notifications/connected.ts b/gui/src/shared/notifications/connected.ts new file mode 100644 index 0000000000..07f7f26ee9 --- /dev/null +++ b/gui/src/shared/notifications/connected.ts @@ -0,0 +1,32 @@ +import { sprintf } from 'sprintf-js'; +import { messages } from '../../shared/gettext'; +import { TunnelState } from '../daemon-rpc-types'; +import { SystemNotificationProvider } from './notification'; + +export class ConnectedNotificationProvider implements SystemNotificationProvider { + public constructor(private context: TunnelState) {} + + public mayDisplay = () => this.context.state === 'connected'; + + public getSystemNotification() { + if (this.context.state === 'connected') { + let message = messages.pgettext('notifications', 'Secured'); + const location = this.context.details.location?.hostname; + if (location) { + // TRANSLATORS: The message showed when a server has been connected to. + // TRANSLATORS: Available placeholder: + // TRANSLATORS: %(location) - name of the server location we're connected to (e.g. "se-got-003") + message = sprintf(messages.pgettext('notifications', 'Connected to %(location)s'), { + location, + }); + } + + return { + message, + critical: false, + }; + } else { + return undefined; + } + } +} diff --git a/gui/src/shared/notifications/connecting.ts b/gui/src/shared/notifications/connecting.ts new file mode 100644 index 0000000000..ac8c049c67 --- /dev/null +++ b/gui/src/shared/notifications/connecting.ts @@ -0,0 +1,51 @@ +import { sprintf } from 'sprintf-js'; +import { messages } from '../../shared/gettext'; +import { TunnelState } from '../daemon-rpc-types'; +import { + InAppNotification, + InAppNotificationProvider, + SystemNotificationProvider, +} from './notification'; + +interface ConnectingNotificationContext { + tunnelState: TunnelState; + reconnecting?: boolean; +} + +export class ConnectingNotificationProvider + implements SystemNotificationProvider, InAppNotificationProvider { + public constructor(private context: ConnectingNotificationContext) {} + + public mayDisplay() { + return this.context.tunnelState.state === 'connecting' && !this.context.reconnecting; + } + + public getSystemNotification() { + if (this.context.tunnelState.state === 'connecting') { + let message = messages.pgettext('notifications', 'Connecting'); + const location = this.context.tunnelState.details?.location?.hostname; + if (location) { + // TRANSLATORS: The message showed when a server is being connected to. + // TRANSLATORS: Available placeholder: + // TRANSLATORS: %(location) - name of the server location we're connecting to (e.g. "se-got-003") + message = sprintf(messages.pgettext('notifications', 'Connecting to %(location)s'), { + location, + }); + } + + return { + message, + critical: false, + }; + } else { + return undefined; + } + } + + public getInAppNotification(): InAppNotification { + return { + indicator: 'error', + title: messages.pgettext('in-app-notifications', 'BLOCKING INTERNET'), + }; + } +} diff --git a/gui/src/shared/notifications/disconnected.ts b/gui/src/shared/notifications/disconnected.ts new file mode 100644 index 0000000000..52d6ba96db --- /dev/null +++ b/gui/src/shared/notifications/disconnected.ts @@ -0,0 +1,16 @@ +import { messages } from '../../shared/gettext'; +import { TunnelState } from '../daemon-rpc-types'; +import { SystemNotificationProvider } from './notification'; + +export class DisconnectedNotificationProvider implements SystemNotificationProvider { + public constructor(private context: TunnelState) {} + + public mayDisplay = () => this.context.state === 'disconnected'; + + public getSystemNotification() { + return { + message: messages.pgettext('notifications', 'Unsecured'), + critical: false, + }; + } +} diff --git a/gui/src/shared/notifications/error.ts b/gui/src/shared/notifications/error.ts new file mode 100644 index 0000000000..a4ba97c81d --- /dev/null +++ b/gui/src/shared/notifications/error.ts @@ -0,0 +1,125 @@ +import { parseAuthFailure } from '../auth-failure'; +import { IErrorState, TunnelState, TunnelParameterError } from '../daemon-rpc-types'; +import { messages } from '../gettext'; +import { + InAppNotification, + InAppNotificationProvider, + SystemNotificationProvider, +} from './notification'; + +export class ErrorNotificationProvider + implements SystemNotificationProvider, InAppNotificationProvider { + public constructor(private context: TunnelState) {} + + public mayDisplay = () => this.context.state === 'error'; + + public getSystemNotification() { + return this.context.state === 'error' + ? { + message: getSystemNotificationMessage(this.context), + critical: !this.context.details.isBlocking, + } + : undefined; + } + + public getInAppNotification(): InAppNotification | undefined { + return this.context.state === 'error' + ? { + indicator: 'error', + title: this.context.details.isBlocking + ? messages.pgettext('in-app-notifications', 'BLOCKING INTERNET') + : messages.pgettext('in-app-notifications', 'YOU MIGHT BE LEAKING NETWORK TRAFFIC'), + subtitle: getInAppNotificationSubtitle(this.context), + } + : undefined; + } +} + +function getSystemNotificationMessage(tunnelState: { state: 'error'; details: IErrorState }) { + if (!tunnelState.details.isBlocking) { + return messages.pgettext('notifications', 'Critical error (your attention is required)'); + } else if ( + tunnelState.details.cause.reason === 'tunnel_parameter_error' && + tunnelState.details.cause.details === 'no_wireguard_key' + ) { + return messages.pgettext('notifications', 'Blocking internet: Valid WireGuard key is missing'); + } else { + return messages.pgettext('notifications', 'Blocking internet'); + } +} + +function getInAppNotificationSubtitle(tunnelState: { state: 'error'; details: IErrorState }) { + if (!tunnelState.details.isBlocking) { + return messages.pgettext( + 'in-app-notifications', + 'Failed to block all network traffic. Please troubleshoot or report the problem to us.', + ); + } else { + const blockReason = tunnelState.details.cause; + switch (blockReason.reason) { + case 'auth_failed': + return parseAuthFailure(blockReason.details).message; + case 'ipv6_unavailable': + return messages.pgettext( + 'in-app-notifications', + 'Could not configure IPv6, please enable it on your system or disable it in the app', + ); + case 'set_firewall_policy_error': { + let extraMessage = null; + switch (process.platform) { + case 'linux': + extraMessage = messages.pgettext('in-app-notifications', 'Your kernel may be outdated'); + break; + case 'win32': + extraMessage = messages.pgettext( + 'in-app-notifications', + 'This might be caused by third party security software', + ); + break; + } + return `${messages.pgettext( + 'in-app-notifications', + 'Failed to apply firewall rules. The device might currently be unsecured', + )}${extraMessage ? '. ' + extraMessage : ''}`; + } + case 'set_dns_error': + return messages.pgettext('in-app-notifications', 'Failed to set system DNS server'); + case 'start_tunnel_error': + return messages.pgettext('in-app-notifications', 'Failed to start tunnel connection'); + case 'tunnel_parameter_error': + return getTunnelParameterMessage(blockReason.details); + case 'is_offline': + return messages.pgettext( + 'in-app-notifications', + 'This device is offline, no tunnels can be established', + ); + case 'tap_adapter_problem': + return messages.pgettext( + 'in-app-notifications', + "Unable to detect a working TAP adapter on this device. If you've disabled it, enable it again. Otherwise, please reinstall the app", + ); + } + } +} + +function getTunnelParameterMessage(err: TunnelParameterError): string { + switch (err) { + /// TODO: once bridge constraints can be set, add a more descriptive error message + case 'no_matching_bridge_relay': + case 'no_matching_relay': + return messages.pgettext( + 'in-app-notifications', + 'No relay server matches the current settings. You can try changing the location or the relay settings.', + ); + case 'no_wireguard_key': + return messages.pgettext( + 'in-app-notifications', + 'Valid WireGuard key is missing. Manage keys under Advanced settings.', + ); + case 'custom_tunnel_host_resultion_error': + return messages.pgettext( + 'in-app-notifications', + 'Failed to resolve host of custom tunnel. Consider changing the settings', + ); + } +} diff --git a/gui/src/shared/notifications/inconsistentVersion.ts b/gui/src/shared/notifications/inconsistentVersion.ts new file mode 100644 index 0000000000..94c33bd925 --- /dev/null +++ b/gui/src/shared/notifications/inconsistentVersion.ts @@ -0,0 +1,41 @@ +import { messages } from '../../shared/gettext'; +import { + InAppNotification, + InAppNotificationProvider, + SystemNotification, + SystemNotificationProvider, +} from './notification'; + +interface InconsistentVersionNotificationContext { + consistent: boolean; +} + +export class InconsistentVersionNotificationProvider + implements SystemNotificationProvider, InAppNotificationProvider { + public constructor(private context: InconsistentVersionNotificationContext) {} + + public mayDisplay = () => !this.context.consistent; + + public getSystemNotification(): SystemNotification { + return { + message: messages.pgettext( + 'notifications', + 'Inconsistent internal version information, please restart the app', + ), + critical: true, + presentOnce: { value: true, name: this.constructor.name }, + suppressInDevelopment: true, + }; + } + + public getInAppNotification(): InAppNotification { + return { + indicator: 'error', + title: messages.pgettext('in-app-notifications', 'INCONSISTENT VERSION'), + subtitle: messages.pgettext( + 'in-app-notifications', + 'Inconsistent internal version information, please restart the app', + ), + }; + } +} diff --git a/gui/src/shared/notifications/notification.ts b/gui/src/shared/notifications/notification.ts new file mode 100644 index 0000000000..e7d89e65bb --- /dev/null +++ b/gui/src/shared/notifications/notification.ts @@ -0,0 +1,41 @@ +export type NotificationAction = { type: 'open-url'; url: string; withAuth?: boolean }; + +export type InAppNotificationIndicatorType = 'success' | 'warning' | 'error'; + +interface NotificationProvider { + mayDisplay(): boolean; +} + +export interface SystemNotification { + message: string; + critical: boolean; + presentOnce?: { value: boolean; name: string }; + suppressInDevelopment?: boolean; + action?: NotificationAction; +} + +export interface InAppNotification { + indicator: InAppNotificationIndicatorType; + title: string; + subtitle?: string; + action?: NotificationAction; +} + +export interface SystemNotificationProvider extends NotificationProvider { + getSystemNotification(): SystemNotification | undefined; +} + +export interface InAppNotificationProvider extends NotificationProvider { + getInAppNotification(): InAppNotification | undefined; +} + +export * from './accountExpiry'; +export * from './blockWhenDisconnected'; +export * from './connected'; +export * from './connecting'; +export * from './disconnected'; +export * from './error'; +export * from './inconsistentVersion'; +export * from './reconnecting'; +export * from './unsupportedVersion'; +export * from './updateAvailable'; diff --git a/gui/src/shared/notifications/reconnecting.ts b/gui/src/shared/notifications/reconnecting.ts new file mode 100644 index 0000000000..2b328aeab2 --- /dev/null +++ b/gui/src/shared/notifications/reconnecting.ts @@ -0,0 +1,30 @@ +import { messages } from '../../shared/gettext'; +import { TunnelState } from '../daemon-rpc-types'; +import { + InAppNotification, + InAppNotificationProvider, + SystemNotificationProvider, +} from './notification'; + +export class ReconnectingNotificationProvider + implements SystemNotificationProvider, InAppNotificationProvider { + public constructor(private context: TunnelState) {} + + public mayDisplay() { + return this.context.state === 'disconnecting' && this.context.details === 'reconnect'; + } + + public getSystemNotification() { + return { + message: messages.pgettext('notifications', 'Reconnecting'), + critical: false, + }; + } + + public getInAppNotification(): InAppNotification { + return { + indicator: 'error', + title: messages.pgettext('in-app-notifications', 'BLOCKING INTERNET'), + }; + } +} diff --git a/gui/src/shared/notifications/unsupportedVersion.ts b/gui/src/shared/notifications/unsupportedVersion.ts new file mode 100644 index 0000000000..ed471bc586 --- /dev/null +++ b/gui/src/shared/notifications/unsupportedVersion.ts @@ -0,0 +1,65 @@ +import { sprintf } from 'sprintf-js'; +import { links } from '../../config.json'; +import { messages } from '../../shared/gettext'; +import { + InAppNotification, + SystemNotification, + InAppNotificationProvider, + SystemNotificationProvider, +} from './notification'; + +interface UnsupportedVersionNotificationContext { + supported: boolean; + consistent: boolean; + nextUpgrade: string | null; +} + +export class UnsupportedVersionNotificationProvider + implements SystemNotificationProvider, InAppNotificationProvider { + public constructor(private context: UnsupportedVersionNotificationContext) {} + + public mayDisplay() { + return this.context.consistent && !this.context.supported && this.context.nextUpgrade !== null; + } + + public getSystemNotification(): SystemNotification { + const message = sprintf( + // TRANSLATORS: The system notification displayed to the user when the running app becomes unsupported. + // TRANSLATORS: Available placeholder: + // TRANSLATORS: %(version) - the newest available version of the app + messages.pgettext( + 'notifications', + 'You are running an unsupported app version. Please upgrade to %(version)s now to ensure your security', + ), + { version: this.context.nextUpgrade }, + ); + + return { + message, + critical: true, + action: { type: 'open-url', url: links.download }, + presentOnce: { value: true, name: this.constructor.name }, + suppressInDevelopment: true, + }; + } + + public getInAppNotification(): InAppNotification { + const subtitle = sprintf( + // TRANSLATORS: The in-app banner displayed to the user when the running app becomes unsupported. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(version)s - the newest available version of the app + messages.pgettext( + 'in-app-notifications', + 'You are running an unsupported app version. Please upgrade to %(version)s now to ensure your security', + ), + { version: this.context.nextUpgrade }, + ); + + return { + indicator: 'error', + title: messages.pgettext('in-app-notifications', 'UNSUPPORTED VERSION'), + subtitle, + action: { type: 'open-url', url: links.download }, + }; + } +} diff --git a/gui/src/shared/notifications/updateAvailable.ts b/gui/src/shared/notifications/updateAvailable.ts new file mode 100644 index 0000000000..4d449bff28 --- /dev/null +++ b/gui/src/shared/notifications/updateAvailable.ts @@ -0,0 +1,37 @@ +import { sprintf } from 'sprintf-js'; +import { links } from '../../config.json'; +import { messages } from '../../shared/gettext'; +import { InAppNotification, InAppNotificationProvider } from './notification'; + +interface UpdateAvailableNotificationContext { + current: string; + nextUpgrade: string | null; +} + +export class UpdateAvailableNotificationProvider implements InAppNotificationProvider { + public constructor(private context: UpdateAvailableNotificationContext) {} + + public mayDisplay() { + return this.context.nextUpgrade !== null && this.context.nextUpgrade !== this.context.current; + } + + public getInAppNotification(): InAppNotification { + const subtitle = sprintf( + // TRANSLATORS: The in-app banner displayed to the user when the app update is available. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(version)s - the newest available version of the app + messages.pgettext( + 'in-app-notifications', + 'Install Mullvad VPN (%(version)s) to stay up to date', + ), + { version: this.context.nextUpgrade }, + ); + + return { + indicator: 'warning', + title: messages.pgettext('in-app-notifications', 'UPDATE AVAILABLE'), + subtitle, + action: { type: 'open-url', url: links.download }, + }; + } +} |
