diff options
37 files changed, 1217 insertions, 1235 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f5f627aa0..e0313dda67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ Line wrap the file at 100 chars. Th ## [Unreleased] ### Added - Obfuscate traffic to the Mullvad API using bridges if it cannot be reached directly. +- Add device management to desktop app. This simplifies knowing which device is which and adds the + option to log out other devices when there are already 5 connected when logging in. #### Windows - Detect mounting and dismounting of volumes, such as VeraCrypt volumes or USB drives, diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot index 403347d5a7..59e3e79613 100644 --- a/gui/locales/messages.pot +++ b/gui/locales/messages.pot @@ -222,6 +222,14 @@ msgctxt "accessibility" msgid "Opens externally" msgstr "" +#. Button action description provided to accessibility tools such as screen +#. readers. +#. Available placeholders: +#. %(deviceName)s - The device name to remove. +msgctxt "accessibility" +msgid "Remove device named %(deviceName)s" +msgstr "" + msgctxt "accessibility" msgid "Remove item" msgstr "" @@ -317,10 +325,6 @@ msgid "Increases anonymity by routing your traffic into one WireGuard server and msgstr "" msgctxt "advanced-settings-view" -msgid "missing key" -msgstr "" - -msgctxt "advanced-settings-view" msgid "OpenVPN" msgstr "" @@ -337,10 +341,6 @@ msgid "The DNS server you want to add is public and will only work with WireGuar msgstr "" msgctxt "advanced-settings-view" -msgid "To enable WireGuard, generate a key under the \"WireGuard key\" setting below." -msgstr "" - -msgctxt "advanced-settings-view" msgid "Tunnel protocol" msgstr "" @@ -489,6 +489,75 @@ msgctxt "connection-info" msgid "Out" msgstr "" +#. Text displayed above button which logs out another device. +#. The text enclosed in "**" will appear bold. +#. Available placeholders: +#. %(deviceName)s - The name of the device to log out. +msgctxt "device-management" +msgid "Are you sure you want to log out of **%(deviceName)s**?" +msgstr "" + +#. Button for continuing login process. +msgctxt "device-management" +msgid "Continue with login" +msgstr "" + +msgctxt "device-management" +msgid "Device is inactive" +msgstr "" + +msgctxt "device-management" +msgid "Device name" +msgstr "" + +msgctxt "device-management" +msgid "Go to login" +msgstr "" + +#. Confirmation button when logging out +msgctxt "device-management" +msgid "Log out anyway" +msgstr "" + +#. Page title informing user that enough devices has been removed to continue +#. login process. +msgctxt "device-management" +msgid "Super!" +msgstr "" + +#. This is is a further explanation of what happens when logging out. +msgctxt "device-management" +msgid "The ports forwarded to this device will be deleted if you log out." +msgstr "" + +#. Further information about consequences of logging out device. +msgctxt "device-management" +msgid "This will delete all forwarded ports. Local settings will be saved." +msgstr "" + +#. Page title informing user that the login failed due to too many registered +#. devices on account. +msgctxt "device-management" +msgid "Too many devices" +msgstr "" + +#. Confirmation button when logging out other device. +msgctxt "device-management" +msgid "Yes, log out device" +msgstr "" + +msgctxt "device-management" +msgid "You can now continue logging in on this device." +msgstr "" + +msgctxt "device-management" +msgid "You have removed this device from your list of active devices. To connect with this device again, log in." +msgstr "" + +msgctxt "device-management" +msgid "You have too many active devices. Please log out of at least one by removing it from the list below. You can find the corresponding nickname under the device’s Account settings." +msgstr "" + #. The message displayed to the user in case of critical error in the GUI #. Available placeholders: #. %(email)s - support email @@ -535,10 +604,6 @@ msgid "Install the latest app version to stay up to date." msgstr "" msgctxt "in-app-notifications" -msgid "Manage keys under Advanced settings." -msgstr "" - -msgctxt "in-app-notifications" msgid "NETWORK TRAFFIC MIGHT BE LEAKING" msgstr "" @@ -562,10 +627,6 @@ msgctxt "in-app-notifications" msgid "UPDATE AVAILABLE" msgstr "" -msgctxt "in-app-notifications" -msgid "VALID WIREGUARD KEY IS MISSING" -msgstr "" - msgctxt "launch-view" msgid "Connecting to Mullvad system service..." msgstr "" @@ -637,6 +698,10 @@ msgid "Please wait" msgstr "" msgctxt "login-view" +msgid "Too many devices" +msgstr "" + +msgctxt "login-view" msgid "Unknown error" msgstr "" @@ -1258,67 +1323,6 @@ msgctxt "tunnel-control" msgid "Switch location" msgstr "" -msgctxt "wireguard-key-view" -msgid "Failed to generate a key" -msgstr "" - -msgctxt "wireguard-key-view" -msgid "Generate key" -msgstr "" - -msgctxt "wireguard-key-view" -msgid "Generating key" -msgstr "" - -msgctxt "wireguard-key-view" -msgid "Key generated" -msgstr "" - -msgctxt "wireguard-key-view" -msgid "Key is invalid" -msgstr "" - -msgctxt "wireguard-key-view" -msgid "Key is valid" -msgstr "" - -msgctxt "wireguard-key-view" -msgid "Key verification failed" -msgstr "" - -msgctxt "wireguard-key-view" -msgid "Manage keys" -msgstr "" - -msgctxt "wireguard-key-view" -msgid "No key set" -msgstr "" - -msgctxt "wireguard-key-view" -msgid "Public key" -msgstr "" - -msgctxt "wireguard-key-view" -msgid "Reconnecting with new WireGuard key..." -msgstr "" - -msgctxt "wireguard-key-view" -msgid "Regenerate key" -msgstr "" - -msgctxt "wireguard-key-view" -msgid "Unable to regenerate key: you already have the maximum number of keys. To generate a new key, you first need to revoke one under “Manage keys.”" -msgstr "" - -msgctxt "wireguard-key-view" -msgid "Verify key" -msgstr "" - -#. Title label in navigation bar -msgctxt "wireguard-keys-nav" -msgid "WireGuard key" -msgstr "" - #. Title label in navigation bar msgctxt "wireguard-settings-nav" msgid "WireGuard settings" @@ -1355,10 +1359,6 @@ msgid "This allows access to WireGuard for devices that only support IPv6." msgstr "" msgctxt "wireguard-settings-view" -msgid "WireGuard key" -msgstr "" - -msgctxt "wireguard-settings-view" msgid "WireGuard settings" msgstr "" @@ -1413,6 +1413,9 @@ msgstr "" msgid "Failed to block all network traffic. Please troubleshoot or report the problem to us." msgstr "" +msgid "Failed to generate a key" +msgstr "" + msgid "Failed to resolve the hostname of custom server" msgstr "" @@ -1422,12 +1425,30 @@ msgstr "" msgid "Failed to start tunnel connection" msgstr "" +msgid "Generate key" +msgstr "" + msgid "If needed we will contact you on %s" msgstr "" msgid "Install Mullvad VPN (%s) to stay up to date" msgstr "" +msgid "Key generated" +msgstr "" + +msgid "Key is invalid" +msgstr "" + +msgid "Key is valid" +msgstr "" + +msgid "Key verification failed" +msgstr "" + +msgid "Manage keys" +msgstr "" + msgid "Mullvad account number" msgstr "" @@ -1437,6 +1458,15 @@ msgstr "" msgid "No relay server matches the current settings" msgstr "" +msgid "Public key" +msgstr "" + +msgid "Reconnecting with new WireGuard key..." +msgstr "" + +msgid "Regenerate key" +msgstr "" + msgid "Secured" msgstr "" @@ -1479,6 +1509,9 @@ msgstr "" msgid "VPN tunnel status" msgstr "" +msgid "Verify key" +msgstr "" + msgid "Virtual adapter error" msgstr "" @@ -1491,6 +1524,9 @@ msgstr "" msgid "WireGuard error" msgstr "" +msgid "WireGuard key" +msgstr "" + msgid "WireGuard public key" msgstr "" diff --git a/gui/src/main/daemon-rpc.ts b/gui/src/main/daemon-rpc.ts index 6638799e2f..2497bceaeb 100644 --- a/gui/src/main/daemon-rpc.ts +++ b/gui/src/main/daemon-rpc.ts @@ -36,8 +36,6 @@ import { TunnelType, IProxyEndpoint, ProxyType, - KeygenEvent, - IWireguardPublicKey, ISettings, ConnectionConfig, DaemonEvent, @@ -48,12 +46,16 @@ import { VoucherResponse, TunnelProtocol, IDnsOptions, + IDeviceConfig, + IDevice, + IDeviceRemoval, + IDeviceEvent, } from '../shared/daemon-rpc-types'; import log from '../shared/logging'; import { ManagementServiceClient } from './management_interface/management_interface_grpc_pb'; import * as grpcTypes from './management_interface/management_interface_pb'; -import { CommunicationError, InvalidAccountError } from './errors'; +import { CommunicationError, InvalidAccountError, TooManyDevicesError } from './errors'; const NETWORK_CALL_TIMEOUT = 10000; const CHANNEL_STATE_TIMEOUT = 1000 * 60 * 60; @@ -257,8 +259,24 @@ export class DaemonRpc { return response.getValue(); } - public async setAccount(accountToken?: AccountToken): Promise<void> { - await this.callString(this.client.setAccount, accountToken); + public async loginAccount(accountToken: AccountToken): Promise<void> { + try { + await this.callString(this.client.loginAccount, accountToken); + } catch (e) { + const error = e as grpc.ServiceError; + switch (error.code) { + case grpc.status.RESOURCE_EXHAUSTED: + throw new TooManyDevicesError(); + case grpc.status.UNAUTHENTICATED: + throw new InvalidAccountError(); + default: + throw new CommunicationError(); + } + } + } + + public async logoutAccount(): Promise<void> { + await this.callEmpty(this.client.logoutAccount); } // TODO: Custom tunnel configurations are not supported by the GUI. @@ -438,19 +456,6 @@ export class DaemonRpc { return response.getValue(); } - public async generateWireguardKey(): Promise<KeygenEvent> { - const response = await this.callEmpty<grpcTypes.KeygenEvent>(this.client.generateWireguardKey); - return convertFromKeygenEvent(response); - } - - public async getWireguardKey(): Promise<IWireguardPublicKey> { - const response = await this.callEmpty<grpcTypes.PublicKey>(this.client.getWireguardKey); - return { - created: response.getCreated()!.toDate().toISOString(), - key: convertFromWireguardKey(response.getKey()), - }; - } - public async setDnsOptions(dns: IDnsOptions): Promise<void> { const dnsOptions = new grpcTypes.DnsOptions(); @@ -473,11 +478,6 @@ export class DaemonRpc { await this.call<grpcTypes.DnsOptions, Empty>(this.client.setDnsOptions, dnsOptions); } - public async verifyWireguardKey(): Promise<boolean> { - const response = await this.callEmpty<BoolValue>(this.client.verifyWireguardKey); - return response.getValue(); - } - public async getVersionInfo(): Promise<IAppVersionInfo> { const response = await this.callEmpty<grpcTypes.AppVersionInfo>(this.client.getVersionInfo); return response.toObject(); @@ -499,6 +499,37 @@ export class DaemonRpc { await this.callEmpty(this.client.checkVolumes); } + public async getDevice(): Promise<IDeviceConfig | undefined> { + try { + const response = await this.callEmpty<grpcTypes.DeviceConfig>(this.client.getDevice); + return convertFromDeviceConfig(response); + } catch (e) { + const error = e as grpc.ServiceError; + if (error.code === grpc.status.NOT_FOUND) { + return undefined; + } else { + throw error; + } + } + } + + public async listDevices(accountToken: AccountToken): Promise<Array<IDevice>> { + const response = await this.callString<grpcTypes.DeviceList>( + this.client.listDevices, + accountToken, + ); + + return response.getDevicesList().map(convertFromDevice); + } + + public async removeDevice(deviceRemoval: IDeviceRemoval): Promise<void> { + const grpcDeviceRemoval = new grpcTypes.DeviceRemoval(); + grpcDeviceRemoval.setAccountToken(deviceRemoval.accountToken); + grpcDeviceRemoval.setDeviceId(deviceRemoval.deviceId); + + await this.call<grpcTypes.DeviceRemoval, Empty>(this.client.removeDevice, grpcDeviceRemoval); + } + private subscriptionId(): number { const current = this.nextSubscriptionId; this.nextSubscriptionId += 1; @@ -1123,36 +1154,26 @@ function convertFromDaemonEvent(data: grpcTypes.DaemonEvent): DaemonEvent { }; } - const keygenEvent = data.getKeyEvent(); - if (keygenEvent !== undefined) { - return { - wireguardKey: convertFromKeygenEvent(keygenEvent), - }; + const deviceConfig = data.getDevice(); + if (deviceConfig !== undefined) { + return { device: convertFromDeviceEvent(deviceConfig) }; } - return { - appVersionInfo: data.getVersionInfo()!.toObject(), - }; -} + const deviceRemoval = data.getRemoveDevice(); + if (deviceRemoval !== undefined) { + return { deviceRemoval: convertFromDeviceRemoval(deviceRemoval) }; + } -function convertFromKeygenEvent(data: grpcTypes.KeygenEvent): KeygenEvent { - switch (data.getEvent()) { - case grpcTypes.KeygenEvent.KeygenEvent.TOO_MANY_KEYS: - return 'too_many_keys'; - case grpcTypes.KeygenEvent.KeygenEvent.NEW_KEY: { - const newKey = data.getNewKey(); - return newKey - ? { - newKey: { - created: newKey.getCreated()!.toDate().toISOString(), - key: convertFromWireguardKey(newKey.getKey()), - }, - } - : 'generation_failure'; - } - case grpcTypes.KeygenEvent.KeygenEvent.GENERATION_FAILURE: - return 'generation_failure'; + const versionInfo = data.getVersionInfo(); + if (versionInfo !== undefined) { + return { appVersionInfo: versionInfo.toObject() }; } + + // Handle unknown daemon events + const keys = Object.entries(data.toObject()) + .filter(([, value]) => value !== undefined) + .map(([key]) => key); + throw new Error(`Unknown daemon event received containing ${keys}`); } function convertFromOpenVpnConstraints( @@ -1350,6 +1371,36 @@ function convertToTransportProtocol(protocol: RelayProtocol): grpcTypes.Transpor } } +function convertFromDeviceEvent(deviceEvent: grpcTypes.DeviceEvent): IDeviceEvent { + return { + deviceConfig: convertFromDeviceConfig(deviceEvent.getDevice()), + remote: deviceEvent.getRemote(), + }; +} + +function convertFromDeviceConfig(deviceConfig?: grpcTypes.DeviceConfig): IDeviceConfig | undefined { + const device = deviceConfig?.getDevice(); + return ( + deviceConfig && { + accountToken: deviceConfig.getAccountToken(), + device: device ? convertFromDevice(device) : undefined, + } + ); +} + +function convertFromDeviceRemoval(deviceRemoval: grpcTypes.RemoveDeviceEvent): Array<IDevice> { + return deviceRemoval.getNewDeviceListList().map(convertFromDevice); +} + +function convertFromDevice(device: grpcTypes.Device): IDevice { + const asObject = device.toObject(); + + return { + ...asObject, + ports: asObject.portsList.map((port) => port.id), + }; +} + function ensureExists<T>(value: T | undefined, errorMessage: string): T { if (value) { return value; diff --git a/gui/src/main/errors.ts b/gui/src/main/errors.ts index 261ee7a164..b7cc82c365 100644 --- a/gui/src/main/errors.ts +++ b/gui/src/main/errors.ts @@ -15,3 +15,9 @@ export class CommunicationError extends Error { super('api.mullvad.net is blocked, please check your firewall'); } } + +export class TooManyDevicesError extends Error { + constructor() { + super('Too many devices'); + } +} diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts index 8cebf9511b..c53456cc65 100644 --- a/gui/src/main/index.ts +++ b/gui/src/main/index.ts @@ -27,15 +27,16 @@ import { DaemonEvent, IAccountData, IAppVersionInfo, + IDeviceConfig, + IDeviceRemoval, IDnsOptions, IRelayList, ISettings, - IWireguardPublicKey, - KeygenEvent, liftConstraint, RelaySettings, RelaySettingsUpdate, TunnelState, + IDeviceEvent, } from '../shared/daemon-rpc-types'; import { messages, relayLocations } from '../shared/gettext'; import { SYSTEM_PREFERRED_LOCALE_KEY } from '../shared/gui-settings-state'; @@ -88,8 +89,6 @@ const windowsSplitTunneling = process.platform === 'win32' && require('./windows const DAEMON_RPC_PATH = process.platform === 'win32' ? 'unix:////./pipe/Mullvad VPN' : 'unix:///var/run/mullvad-vpn'; -const AUTO_CONNECT_FALLBACK_DELAY = 6000; - const GUI_VERSION = app.getVersion().replace('.0', ''); /// Mirrors the beta check regex in the daemon. Matches only well formed beta versions const IS_BETA = /^(\d{4})\.(\d+)-beta(\d+)$/; @@ -108,8 +107,6 @@ enum AppQuitStage { ready, } -type AccountVerification = { status: 'verified' } | { status: 'deferred'; error: Error }; - class ApplicationMain { private notificationController = new NotificationController({ openApp: () => this.windowController?.show(), @@ -145,7 +142,6 @@ class ApplicationMain { private tunnelStateFallbackScheduler = new Scheduler(); private settings: ISettings = { - accountToken: undefined, allowLan: false, autoConnect: false, blockWhenDisconnected: false, @@ -201,6 +197,7 @@ class ApplicationMain { }, }, }; + private deviceConfig?: IDeviceConfig; private guiSettings = new GuiSettings(); private tunnelStateExpectation?: Expectation; @@ -221,8 +218,6 @@ class ApplicationMain { // The UI locale which is set once from onReady handler private locale = 'en'; - private wireguardPublicKey?: IWireguardPublicKey; - private accountExpiryNotificationScheduler = new Scheduler(); private accountDataCache = new AccountDataCache( @@ -240,9 +235,6 @@ class ApplicationMain { }, ); - private autoConnectOnWireguardKeyEvent = false; - private autoConnectFallbackScheduler = new Scheduler(); - private blurNavigationResetScheduler = new Scheduler(); private backgroundThrottleScheduler = new Scheduler(); @@ -648,6 +640,16 @@ class ApplicationMain { return this.handleBootstrapError(error); } + // fetch device + try { + this.setDeviceConfig({ deviceConfig: await this.daemonRpc.getDevice() }); + } catch (e) { + const error = e as Error; + log.error(`Failed to fetch device: ${error.message}`); + + return this.handleBootstrapError(error); + } + // fetch settings try { this.setSettings(await this.daemonRpc.getSettings()); @@ -705,7 +707,7 @@ class ApplicationMain { } // show window when account is not set - if (!this.settings.accountToken) { + if (!this.deviceConfig) { this.windowController?.show(); } }; @@ -722,7 +724,6 @@ class ApplicationMain { this.daemonEventListener = undefined; this.tunnelStateFallback = undefined; - this.autoConnectFallbackScheduler.cancel(); if (wasConnected) { this.connectedToDaemon = false; @@ -775,15 +776,21 @@ class ApplicationMain { this.settings.relaySettings, this.settings.bridgeState, ); - } else if ('wireguardKey' in daemonEvent) { - this.handleWireguardKeygenEvent(daemonEvent.wireguardKey); } else if ('appVersionInfo' in daemonEvent) { this.setLatestVersion(daemonEvent.appVersionInfo); + } else if ('device' in daemonEvent) { + this.setDeviceConfig(daemonEvent.device); + } else if ('deviceRemoval' in daemonEvent) { + if (this.windowController) { + IpcMainEventChannel.account.notifyDevices( + this.windowController.webContents, + daemonEvent.deviceRemoval, + ); + } } }, (error: Error) => { log.error(`Cannot deserialize the daemon event: ${error.message}`); - log.error(error.stack); }, ); @@ -794,7 +801,11 @@ class ApplicationMain { private connectTunnel = async (): Promise<void> => { if ( - connectEnabled(this.connectedToDaemon, this.settings.accountToken, this.tunnelState.state) + connectEnabled( + this.connectedToDaemon, + this.deviceConfig?.accountToken, + this.tunnelState.state, + ) ) { this.setOptimisticTunnelState('connecting'); await this.daemonRpc.connectTunnel(); @@ -803,7 +814,11 @@ class ApplicationMain { private reconnectTunnel = async (): Promise<void> => { if ( - reconnectEnabled(this.connectedToDaemon, this.settings.accountToken, this.tunnelState.state) + reconnectEnabled( + this.connectedToDaemon, + this.deviceConfig?.accountToken, + this.tunnelState.state, + ) ) { this.setOptimisticTunnelState('connecting'); await this.daemonRpc.reconnectTunnel(); @@ -825,37 +840,6 @@ class ApplicationMain { } } - private setWireguardKey(wireguardKey?: IWireguardPublicKey) { - this.wireguardPublicKey = wireguardKey; - if (this.windowController) { - IpcMainEventChannel.wireguardKeys.notifyPublicKey( - this.windowController.webContents, - wireguardKey, - ); - } - - if (wireguardKey) { - this.wireguardKeygenEventAutoConnect(); - } - } - - private handleWireguardKeygenEvent(event: KeygenEvent) { - switch (event) { - case 'too_many_keys': - case 'generation_failure': - this.wireguardPublicKey = undefined; - break; - default: - this.wireguardPublicKey = event.newKey; - } - - if (this.windowController) { - IpcMainEventChannel.wireguardKeys.notifyKeygenEvent(this.windowController.webContents, event); - } - - this.wireguardKeygenEventAutoConnect(); - } - // This function sets a new tunnel state as an assumed next state and saves the current state as // fallback. The fallback is used if the assumed next state isn't reached. private setOptimisticTunnelState(state: 'connecting' | 'disconnecting') { @@ -924,14 +908,6 @@ class ApplicationMain { this.updateTrayIcon(this.tunnelState, newSettings.blockWhenDisconnected); - // make sure to invalidate the account data cache when account tokens change - this.updateAccountDataOnAccountChange(oldSettings.accountToken, newSettings.accountToken); - - if (oldSettings.accountToken !== newSettings.accountToken) { - void this.updateAccountHistory(); - void this.fetchWireguardKey(); - } - if (oldSettings.showBetaReleases !== newSettings.showBetaReleases) { this.setLatestVersion(this.upgradeVersion); } @@ -1135,6 +1111,23 @@ class ApplicationMain { } } + private setDeviceConfig(deviceEvent: IDeviceEvent) { + const oldDeviceConfig = this.deviceConfig; + this.deviceConfig = deviceEvent.deviceConfig; + + // make sure to invalidate the account data cache when account tokens change + this.updateAccountDataOnAccountChange( + oldDeviceConfig?.accountToken, + deviceEvent.deviceConfig?.accountToken, + ); + + void this.updateAccountHistory(); + + if (this.windowController) { + IpcMainEventChannel.account.notifyDevice(this.windowController.webContents, deviceEvent); + } + } + private trayIconType(tunnelState: TunnelState, blockWhenDisconnected: boolean): TrayIconType { switch (tunnelState.state) { case 'connected': @@ -1216,6 +1209,7 @@ class ApplicationMain { accountHistory: this.accountHistory, tunnelState: this.tunnelState, settings: this.settings, + deviceConfig: this.deviceConfig, relayListPair: { relays: this.processRelaysForPresentation(this.relays, this.settings.relaySettings), bridges: this.processBridgesForPresentation(this.relays, this.settings.bridgeState), @@ -1223,7 +1217,6 @@ class ApplicationMain { currentVersion: this.currentVersion, upgradeVersion: this.upgradeVersion, guiSettings: this.guiSettings.state, - wireguardPublicKey: this.wireguardPublicKey, translations: this.translations, windowsSplitTunnelingApplications: this.windowsSplitTunnelingApplications, macOsScrollbarVisibility: this.macOsScrollbarVisibility, @@ -1306,7 +1299,7 @@ class ApplicationMain { IpcMainEventChannel.account.handleLogout(() => this.logout()); IpcMainEventChannel.account.handleGetWwwAuthToken(() => this.daemonRpc.getWwwAuthToken()); IpcMainEventChannel.account.handleSubmitVoucher(async (voucherCode: string) => { - const currentAccountToken = this.settings.accountToken; + const currentAccountToken = this.deviceConfig?.accountToken; const response = await this.daemonRpc.submitVoucher(voucherCode); if (currentAccountToken) { @@ -1317,20 +1310,22 @@ class ApplicationMain { }); IpcMainEventChannel.account.handleUpdateData(() => this.updateAccountData()); + IpcMainEventChannel.account.handleGetDevice(async () => { + const deviceConfig = await this.daemonRpc.getDevice(); + return deviceConfig?.device; + }); + IpcMainEventChannel.account.handleListDevices((accountToken: AccountToken) => { + return this.daemonRpc.listDevices(accountToken); + }); + IpcMainEventChannel.account.handleRemoveDevice((deviceRemoval: IDeviceRemoval) => { + return this.daemonRpc.removeDevice(deviceRemoval); + }); + IpcMainEventChannel.accountHistory.handleClear(async () => { await this.daemonRpc.clearAccountHistory(); void this.updateAccountHistory(); }); - IpcMainEventChannel.wireguardKeys.handleGenerateKey(async () => { - try { - return await this.daemonRpc.generateWireguardKey(); - } catch { - return 'generation_failure'; - } - }); - IpcMainEventChannel.wireguardKeys.handleVerifyKey(() => this.daemonRpc.verifyWireguardKey()); - IpcMainEventChannel.linuxSplitTunneling.handleGetApplications(() => { if (linuxSplitTunneling) { return linuxSplitTunneling.getApplications(this.locale); @@ -1484,28 +1479,11 @@ class ApplicationMain { private async login(accountToken: AccountToken): Promise<void> { try { - const verification = await this.verifyAccount(accountToken); - - if (verification.status === 'deferred') { - log.warn(`Failed to get account data, logging in anyway: ${verification.error.message}`); - } - - this.autoConnectOnWireguardKeyEvent = this.guiSettings.autoConnect; - await this.daemonRpc.setAccount(accountToken); - - // Fallback if daemon doesn't send event. - if (this.autoConnectOnWireguardKeyEvent) { - this.autoConnectFallbackScheduler.schedule( - () => this.wireguardKeygenEventAutoConnect(), - AUTO_CONNECT_FALLBACK_DELAY, - ); - } + await this.daemonRpc.loginAccount(accountToken); } catch (e) { const error = e as Error; log.error(`Failed to login: ${error.message}`); - this.autoConnectOnWireguardKeyEvent = false; - if (error instanceof InvalidAccountError) { throw Error(messages.gettext('Invalid account number')); } else { @@ -1514,21 +1492,10 @@ class ApplicationMain { } } - private wireguardKeygenEventAutoConnect() { - if (this.autoConnectOnWireguardKeyEvent) { - this.autoConnectOnWireguardKeyEvent = false; - this.autoConnectFallbackScheduler.cancel(); - void this.autoConnect(); - } - } - private async autoConnect() { if (process.env.NODE_ENV === 'development') { log.info('Skip autoconnect in development'); - } else if ( - this.settings.accountToken && - (!this.accountData || !hasExpired(this.accountData.expiry)) - ) { + } else if (this.deviceConfig && (!this.accountData || !hasExpired(this.accountData.expiry))) { if (this.guiSettings.autoConnect) { try { log.info('Autoconnect the tunnel'); @@ -1548,9 +1515,8 @@ class ApplicationMain { private async logout(): Promise<void> { try { - await this.daemonRpc.setAccount(); + await this.daemonRpc.logoutAccount(); - this.autoConnectFallbackScheduler.cancel(); this.accountExpiryNotificationScheduler.cancel(); } catch (e) { const error = e as Error; @@ -1560,22 +1526,6 @@ class ApplicationMain { } } - private verifyAccount(accountToken: AccountToken): Promise<AccountVerification> { - return new Promise((resolve, reject) => { - this.accountDataCache.invalidate(); - this.accountDataCache.fetch(accountToken, { - onFinish: () => resolve({ status: 'verified' }), - onError: (error) => { - if (error instanceof InvalidAccountError) { - reject(error); - } else { - resolve({ status: 'deferred', error }); - } - }, - }); - }); - } - private updateAccountDataOnAccountChange(oldAccount?: string, newAccount?: string) { if (oldAccount && !newAccount) { this.accountDataCache.invalidate(); @@ -1589,8 +1539,8 @@ class ApplicationMain { } private updateAccountData() { - if (this.connectedToDaemon && this.settings.accountToken) { - this.accountDataCache.fetch(this.settings.accountToken); + if (this.connectedToDaemon && this.deviceConfig) { + this.accountDataCache.fetch(this.deviceConfig.accountToken); } } @@ -1641,15 +1591,6 @@ class ApplicationMain { } } - private async fetchWireguardKey(): Promise<void> { - try { - this.setWireguardKey(await this.daemonRpc.getWireguardKey()); - } catch (e) { - const error = e as Error; - log.error(`Failed to fetch wireguard key: ${error.message}`); - } - } - private updateDaemonsAutoConnect() { const daemonAutoConnect = this.guiSettings.autoConnect && getOpenAtLogin(); if (daemonAutoConnect !== this.settings.autoConnect) { @@ -1984,7 +1925,7 @@ class ApplicationMain { this.tray?.on('right-click', () => this.trayIconController?.popUpContextMenu( this.connectedToDaemon, - this.settings.accountToken, + this.deviceConfig?.accountToken, this.tunnelState, ), ); @@ -2022,7 +1963,7 @@ class ApplicationMain { private setTrayContextMenu() { this.trayIconController?.setContextMenu( this.connectedToDaemon, - this.settings.accountToken, + this.deviceConfig?.accountToken, this.tunnelState, ); } diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx index 28f73ac9e0..778bf4cdbd 100644 --- a/gui/src/renderer/app.tsx +++ b/gui/src/renderer/app.tsx @@ -11,7 +11,6 @@ import { AppContext } from './context'; import accountActions from './redux/account/actions'; import connectionActions from './redux/connection/actions'; import settingsActions from './redux/settings/actions'; -import { IWgKey } from './redux/settings/reducers'; import configureStore from './redux/store'; import userInterfaceActions from './redux/userinterface/actions'; import versionActions from './redux/version/actions'; @@ -32,16 +31,18 @@ import { BridgeState, IAccountData, IAppVersionInfo, + IDevice, + IDeviceConfig, + IDeviceRemoval, IDnsOptions, ILocation, ISettings, - IWireguardPublicKey, - KeygenEvent, liftConstraint, RelaySettings, RelaySettingsUpdate, TunnelState, VoucherResponse, + IDeviceEvent, } from '../shared/daemon-rpc-types'; import { LogLevel } from '../shared/logging-types'; import IpcOutput from './lib/logging'; @@ -56,6 +57,8 @@ interface IPreferredLocaleDescriptor { code: string; } +type LoginState = 'none' | 'logging in' | 'creating account' | 'too many devices'; + const SUPPORTED_LOCALE_LIST = [ { name: 'Dansk', code: 'da' }, { name: 'Deutsch', code: 'de' }, @@ -95,8 +98,10 @@ export default class AppRenderer { private relayListPair!: IRelayListPair; private tunnelState!: TunnelState; private settings!: ISettings; + private deviceConfig?: IDeviceConfig; private guiSettings!: IGuiSettingsState; - private doingLogin = false; + private loginState: LoginState = 'none'; + private previousLoginState: LoginState = 'none'; private loginScheduler = new Scheduler(); private connectedToDaemon = false; private getLocationPromise?: Promise<ILocation>; @@ -123,6 +128,15 @@ export default class AppRenderer { this.setAccountExpiry(newAccountData?.expiry); }); + IpcRendererEventChannel.account.listenDevice((deviceEvent) => { + const oldDeviceConfig = this.deviceConfig; + this.handleAccountChange(deviceEvent, oldDeviceConfig?.accountToken); + }); + + IpcRendererEventChannel.account.listenDevices((devices) => { + this.reduxActions.account.updateDevices(devices); + }); + IpcRendererEventChannel.accountHistory.listen((newAccountHistory?: AccountToken) => { this.setAccountHistory(newAccountHistory); }); @@ -133,10 +147,7 @@ export default class AppRenderer { }); IpcRendererEventChannel.settings.listen((newSettings: ISettings) => { - const oldSettings = this.settings; - this.setSettings(newSettings); - this.handleAccountChange(oldSettings.accountToken, newSettings.accountToken); this.updateBlockedState(this.tunnelState, newSettings.blockWhenDisconnected); }); @@ -160,14 +171,6 @@ export default class AppRenderer { this.storeAutoStart(autoStart); }); - IpcRendererEventChannel.wireguardKeys.listenPublicKey((publicKey?: IWireguardPublicKey) => { - this.setWireguardPublicKey(publicKey); - }); - - IpcRendererEventChannel.wireguardKeys.listenKeygenEvent((event: KeygenEvent) => { - this.reduxActions.settings.setWireguardKeygenEvent(event); - }); - IpcRendererEventChannel.windowsSplitTunneling.listen((applications: IApplication[]) => { this.reduxActions.settings.setSplitTunnelingApplications(applications); }); @@ -201,7 +204,7 @@ export default class AppRenderer { this.setAccountExpiry(initialState.accountData?.expiry); this.setSettings(initialState.settings); - this.handleAccountChange(undefined, initialState.settings.accountToken); + this.handleAccountChange({ deviceConfig: initialState.deviceConfig }, undefined); this.setAccountHistory(initialState.accountHistory); this.setTunnelState(initialState.tunnelState); this.updateBlockedState(initialState.tunnelState, initialState.settings.blockWhenDisconnected); @@ -211,7 +214,6 @@ export default class AppRenderer { this.setUpgradeVersion(initialState.upgradeVersion); this.setGuiSettings(initialState.guiSettings); this.storeAutoStart(initialState.autoStart); - this.setWireguardPublicKey(initialState.wireguardPublicKey); this.setChangelog(initialState.changelog); if (initialState.macOsScrollbarVisibility !== undefined) { @@ -237,10 +239,7 @@ export default class AppRenderer { void this.updateLocation(); - const navigationBase = this.getNavigationBase( - initialState.isConnected, - initialState.settings.accountToken, - ); + const navigationBase = this.getNavigationBase(); this.history = new History(navigationBase); } @@ -264,24 +263,34 @@ export default class AppRenderer { ); } - public async login(accountToken: AccountToken) { + public login = async (accountToken: AccountToken) => { const actions = this.reduxActions; actions.account.startLogin(accountToken); log.info('Logging in'); - this.doingLogin = true; + this.previousLoginState = this.loginState; + this.loginState = 'logging in'; try { await IpcRendererEventChannel.account.login(accountToken); - actions.account.updateAccountToken(accountToken); - actions.account.loggedIn(); - this.redirectToConnect(); } catch (e) { const error = e as Error; - actions.account.loginFailed(error); + if (error.message === 'Too many devices') { + actions.account.loginTooManyDevices(error); + this.loginState = 'too many devices'; + this.history.reset(RoutePath.tooManyDevices, transitions.push); + } else { + actions.account.loginFailed(error); + } } - } + }; + + public cancelLogin = (): void => { + const reduxAccount = this.reduxActions.account; + reduxAccount.loggedOut(); + this.loginState = 'none'; + }; public async logout() { try { @@ -292,17 +301,22 @@ export default class AppRenderer { } } + public leaveRevokedDevice = async () => { + const reduxAccount = this.reduxActions.account; + reduxAccount.loggedOut(); + this.resetNavigation(); + await this.disconnectTunnel(); + }; + public async createNewAccount() { log.info('Creating account'); const actions = this.reduxActions; actions.account.startCreateAccount(); - this.doingLogin = true; + this.loginState = 'creating account'; try { - const accountToken = await IpcRendererEventChannel.account.create(); - const accountExpiry = new Date().toISOString(); - actions.account.accountCreated(accountToken, accountExpiry); + await IpcRendererEventChannel.account.create(); this.redirectToConnect(); } catch (e) { const error = e as Error; @@ -318,6 +332,20 @@ export default class AppRenderer { IpcRendererEventChannel.account.updateData(); } + public getDevice = (): Promise<IDevice | undefined> => { + return IpcRendererEventChannel.account.getDevice(); + }; + + public fetchDevices = async (accountToken: AccountToken): Promise<Array<IDevice>> => { + const devices = await IpcRendererEventChannel.account.listDevices(accountToken); + this.reduxActions.account.updateDevices(devices); + return devices; + }; + + public removeDevice(deviceRemoval: IDeviceRemoval): Promise<void> { + return IpcRendererEventChannel.account.removeDevice(deviceRemoval); + } + public async connectTunnel(): Promise<void> { return IpcRendererEventChannel.tunnel.connect(); } @@ -425,33 +453,6 @@ export default class AppRenderer { IpcRendererEventChannel.guiSettings.setUnpinnedWindow(unpinnedWindow); } - public async verifyWireguardKey(publicKey: IWgKey) { - const actions = this.reduxActions; - actions.settings.verifyWireguardKey(publicKey); - try { - const valid = await IpcRendererEventChannel.wireguardKeys.verifyKey(); - actions.settings.completeWireguardKeyVerification(valid); - } catch (e) { - const error = e as Error; - log.error(`Failed to verify WireGuard key - ${error.message}`); - actions.settings.completeWireguardKeyVerification(undefined); - } - } - - public async generateWireguardKey() { - const actions = this.reduxActions; - actions.settings.generateWireguardKey(); - const keygenEvent = await IpcRendererEventChannel.wireguardKeys.generateKey(); - actions.settings.setWireguardKeygenEvent(keygenEvent); - } - - public async replaceWireguardKey(oldKey: IWgKey) { - const actions = this.reduxActions; - actions.settings.replaceWireguardKey(oldKey); - const keygenEvent = await IpcRendererEventChannel.wireguardKeys.generateKey(); - actions.settings.setWireguardKeygenEvent(keygenEvent); - } - public getLinuxSplitTunnelingApplications() { return IpcRendererEventChannel.linuxSplitTunneling.getApplications(); } @@ -650,40 +651,56 @@ export default class AppRenderer { private resetNavigation() { if (this.history) { - const pathname = this.history.location.pathname; - const nextPath = this.getNavigationBase(this.connectedToDaemon, this.settings.accountToken); + const pathname = this.history.location.pathname as RoutePath; + const nextPath = this.getNavigationBase() as RoutePath; - // First level contains the possible next locations and the second level contains the possible - // current locations. - const navigationTransitions: { - [from: string]: { [to: string]: ITransitionSpecification }; - } = { - '/': { - '/login': transitions.pop, - '/main': transitions.pop, - '*': transitions.dismiss, - }, - '/login': { - '/': transitions.push, - '/main': transitions.pop, - '*': transitions.none, - }, - '/main': { - '/': transitions.push, - '/login': transitions.push, - '*': transitions.dismiss, - }, - }; + if (pathname !== nextPath) { + // First level contains the possible next locations and the second level contains the + // possible current locations. + const navigationTransitions: Partial< + Record<RoutePath, Partial<Record<RoutePath | '*', ITransitionSpecification>>> + > = { + [RoutePath.launch]: { + [RoutePath.login]: transitions.pop, + [RoutePath.main]: transitions.pop, + '*': transitions.dismiss, + }, + [RoutePath.login]: { + [RoutePath.launch]: transitions.push, + [RoutePath.main]: transitions.pop, + [RoutePath.deviceRevoked]: transitions.pop, + '*': transitions.none, + }, + [RoutePath.main]: { + [RoutePath.launch]: transitions.push, + [RoutePath.login]: transitions.push, + [RoutePath.tooManyDevices]: transitions.push, + '*': transitions.dismiss, + }, + [RoutePath.deviceRevoked]: { + '*': transitions.pop, + }, + }; - const transition = - navigationTransitions[nextPath][pathname] ?? navigationTransitions[nextPath]['*']; - this.history.reset(nextPath, transition); + const transition = + navigationTransitions[nextPath]?.[pathname] ?? navigationTransitions[nextPath]?.['*']; + this.history.reset(nextPath, transition); + } } } - private getNavigationBase(connectedToDaemon: boolean, accountToken?: string): RoutePath { - if (connectedToDaemon) { - return accountToken ? RoutePath.main : RoutePath.login; + private getNavigationBase(): RoutePath { + if (this.connectedToDaemon) { + const loginState = this.reduxStore.getState().account.status; + const deviceRevoked = loginState.type === 'none' && loginState.deviceRevoked; + + if (deviceRevoked) { + return RoutePath.deviceRevoked; + } else if (this.deviceConfig?.accountToken) { + return RoutePath.main; + } else { + return RoutePath.login; + } } else { return RoutePath.launch; } @@ -772,22 +789,49 @@ export default class AppRenderer { } } - private handleAccountChange(oldAccount?: string, newAccount?: string) { + private handleAccountChange(newDeviceEvent: IDeviceEvent, oldAccount?: string) { const reduxAccount = this.reduxActions.account; + this.deviceConfig = newDeviceEvent.deviceConfig; + const newAccount = newDeviceEvent.deviceConfig?.accountToken; + const newDevice = newDeviceEvent.deviceConfig?.device; + if (oldAccount && !newAccount) { this.loginScheduler.cancel(); - reduxAccount.loggedOut(); + if (!this.reduxStore.getState().account.loggingOut && newDeviceEvent.remote) { + reduxAccount.deviceRevoked(); + } else { + reduxAccount.loggedOut(); + } this.resetNavigation(); - } else if (newAccount && oldAccount !== newAccount && !this.doingLogin) { - reduxAccount.updateAccountToken(newAccount); - reduxAccount.loggedIn(); + } else if (newAccount !== undefined && newDevice !== undefined && oldAccount !== newAccount) { + switch (this.loginState) { + case 'none': + case 'logging in': + reduxAccount.loggedIn({ accountToken: newAccount, device: newDevice }); - this.resetNavigation(); + if (this.previousLoginState === 'too many devices') { + this.resetNavigation(); + } else { + this.redirectToConnect(); + } + break; + case 'creating account': + reduxAccount.accountCreated( + { accountToken: newAccount, device: newDevice }, + new Date().toISOString(), + ); + break; + } + + if (this.loginState !== 'logging in' && this.loginState !== 'creating account') { + this.resetNavigation(); + } } - this.doingLogin = false; + this.previousLoginState = this.loginState; + this.loginState = 'none'; } private setLocation(location: Partial<ILocation>) { @@ -839,10 +883,6 @@ export default class AppRenderer { this.reduxActions.settings.updateAutoStart(autoStart); } - private setWireguardPublicKey(publicKey?: IWireguardPublicKey) { - this.reduxActions.settings.setWireguardKey(publicKey); - } - private setChangelog(changelog: IChangelog) { this.reduxActions.userInterface.setChangelog(changelog); } diff --git a/gui/src/renderer/components/Account.tsx b/gui/src/renderer/components/Account.tsx index 833a06d1c2..4a84e3c110 100644 --- a/gui/src/renderer/components/Account.tsx +++ b/gui/src/renderer/components/Account.tsx @@ -9,6 +9,8 @@ import { AccountRowLabel, AccountRows, AccountRowValue, + DeviceRowValue, + StyledSpinnerContainer, StyledBuyCreditButton, StyledContainer, StyledRedeemVoucherButton, @@ -17,24 +19,36 @@ import AccountTokenLabel from './AccountTokenLabel'; import * as AppButton from './AppButton'; import { AriaDescribed, AriaDescription, AriaDescriptionGroup } from './AriaGroup'; import { Layout } from './Layout'; +import { ModalAlert, ModalAlertType, ModalMessage } from './Modal'; import { NavigationBar, NavigationItems, TitleBarItem } from './NavigationBar'; import SettingsHeader, { HeaderTitle } from './SettingsHeader'; -import { AccountToken } from '../../shared/daemon-rpc-types'; +import { AccountToken, IDevice } from '../../shared/daemon-rpc-types'; +import ImageView from './ImageView'; import { BackAction } from './KeyboardNavigation'; interface IProps { + deviceName?: string; accountToken?: AccountToken; accountExpiry?: string; expiryLocale: string; isOffline: boolean; + prepareLogout: () => void; + cancelLogout: () => void; onLogout: () => void; onClose: () => void; onBuyMore: () => Promise<void>; updateAccountData: () => void; + getDevice: () => Promise<IDevice | undefined>; } -export default class Account extends React.Component<IProps> { +interface IState { + logoutDialogState: 'hidden' | 'checking-ports' | 'confirm'; +} + +export default class Account extends React.Component<IProps, IState> { + public state: IState = { logoutDialogState: 'hidden' }; + public componentDidMount() { this.props.updateAccountData(); } @@ -63,6 +77,13 @@ export default class Account extends React.Component<IProps> { <AccountRows> <AccountRow> <AccountRowLabel> + {messages.pgettext('device-management', 'Device name')} + </AccountRowLabel> + <DeviceRowValue>{this.props.deviceName}</DeviceRowValue> + </AccountRow> + + <AccountRow> + <AccountRowLabel> {messages.pgettext('account-view', 'Account number')} </AccountRowLabel> <AccountRowValue @@ -105,16 +126,88 @@ export default class Account extends React.Component<IProps> { <StyledRedeemVoucherButton /> - <AppButton.RedButton onClick={this.props.onLogout}> + <AppButton.RedButton onClick={this.onTryLogout}> {messages.pgettext('account-view', 'Log out')} </AppButton.RedButton> </AccountFooter> </AccountContainer> </StyledContainer> + + {this.renderLogoutDialog()} </Layout> </BackAction> ); } + + private renderLogoutDialog() { + const modalType = + this.state.logoutDialogState === 'checking-ports' ? undefined : ModalAlertType.warning; + + const message = + this.state.logoutDialogState === 'checking-ports' ? ( + <StyledSpinnerContainer> + <ImageView source="icon-spinner" width={60} height={60} /> + </StyledSpinnerContainer> + ) : ( + <ModalMessage> + { + // TRANSLATORS: This is is a further explanation of what happens when logging out. + messages.pgettext( + 'device-management', + 'The ports forwarded to this device will be deleted if you log out.', + ) + } + </ModalMessage> + ); + + const buttons = + this.state.logoutDialogState === 'checking-ports' + ? [] + : [ + <AppButton.RedButton key="logout" onClick={this.props.onLogout}> + { + // TRANSLATORS: Confirmation button when logging out + messages.pgettext('device-management', 'Log out anyway') + } + </AppButton.RedButton>, + <AppButton.BlueButton key="back" onClick={this.cancelLogout}> + {messages.gettext('Back')} + </AppButton.BlueButton>, + ]; + + return ( + <ModalAlert + isOpen={this.state.logoutDialogState !== 'hidden'} + type={modalType} + buttons={buttons}> + {message} + </ModalAlert> + ); + } + + private onTryLogout = async () => { + this.setState({ logoutDialogState: 'checking-ports' }); + this.props.prepareLogout(); + + const device = await this.props.getDevice(); + if (device === undefined) { + this.onHideLogoutConfirmationDialog(); + } else if (device.ports !== undefined && device.ports.length > 0) { + this.setState({ logoutDialogState: 'confirm' }); + } else { + this.props.onLogout(); + this.onHideLogoutConfirmationDialog(); + } + }; + + private cancelLogout = () => { + this.props.cancelLogout(); + this.onHideLogoutConfirmationDialog(); + }; + + private onHideLogoutConfirmationDialog = () => { + this.setState({ logoutDialogState: 'hidden' }); + }; } function FormattedAccountExpiry(props: { expiry?: string; locale: string }) { diff --git a/gui/src/renderer/components/AccountStyles.tsx b/gui/src/renderer/components/AccountStyles.tsx index 0e403231da..549b0cda7d 100644 --- a/gui/src/renderer/components/AccountStyles.tsx +++ b/gui/src/renderer/components/AccountStyles.tsx @@ -44,10 +44,21 @@ export const AccountRowValue = styled(AccountRowText)(normalText, { color: colors.white, }); +export const DeviceRowValue = styled(AccountRowValue)({ + textTransform: 'capitalize', +}); + export const AccountOutOfTime = styled(AccountRowValue)({ color: colors.red, }); +export const StyledSpinnerContainer = styled.div({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '8px 0', +}); + export const AccountFooter = styled.div({ display: 'flex', flexDirection: 'column', diff --git a/gui/src/renderer/components/AdvancedSettings.tsx b/gui/src/renderer/components/AdvancedSettings.tsx index 8ecf6c0052..596a638f51 100644 --- a/gui/src/renderer/components/AdvancedSettings.tsx +++ b/gui/src/renderer/components/AdvancedSettings.tsx @@ -1,12 +1,8 @@ import * as React from 'react'; -import { sprintf } from 'sprintf-js'; import { TunnelProtocol } from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; -import { WgKeyState } from '../redux/settings/reducers'; import { StyledNavigationScrollbars, - StyledNoWireguardKeyError, - StyledNoWireguardKeyErrorContainer, StyledSelectorForFooter, StyledTunnelProtocolContainer, } from './AdvancedSettingsStyles'; @@ -28,7 +24,6 @@ interface IProps { enableIpv6: boolean; blockWhenDisconnected: boolean; tunnelProtocol?: TunnelProtocol; - wireguardKeyState: WgKeyState; setEnableIpv6: (value: boolean) => void; setBlockWhenDisconnected: (value: boolean) => void; setTunnelProtocol: (value: OptionalTunnelProtocol) => void; @@ -49,9 +44,28 @@ export default class AdvancedSettings extends React.Component<IProps, IState> { private blockWhenDisconnectedRef = React.createRef<Switch>(); - public render() { - const hasWireguardKey = this.props.wireguardKeyState.type === 'key-set'; + private tunnelProtocolItems: Array<ISelectorItem<OptionalTunnelProtocol>>; + + public constructor(props: IProps) { + super(props); + + this.tunnelProtocolItems = [ + { + label: messages.gettext('Automatic'), + value: undefined, + }, + { + label: messages.pgettext('advanced-settings-view', 'WireGuard'), + value: 'wireguard', + }, + { + label: messages.pgettext('advanced-settings-view', 'OpenVPN'), + value: 'openvpn', + }, + ]; + } + public render() { return ( <BackAction action={this.props.onClose}> <Layout> @@ -141,22 +155,10 @@ export default class AdvancedSettings extends React.Component<IProps, IState> { <StyledTunnelProtocolContainer> <StyledSelectorForFooter title={messages.pgettext('advanced-settings-view', 'Tunnel protocol')} - values={this.tunnelProtocolItems(hasWireguardKey)} + values={this.tunnelProtocolItems} value={this.props.tunnelProtocol} onSelect={this.onSelectTunnelProtocol} /> - {!hasWireguardKey && ( - <StyledNoWireguardKeyErrorContainer> - <AriaDescription> - <StyledNoWireguardKeyError> - {messages.pgettext( - 'advanced-settings-view', - 'To enable WireGuard, generate a key under the "WireGuard key" setting below.', - )} - </StyledNoWireguardKeyError> - </AriaDescription> - </StyledNoWireguardKeyErrorContainer> - )} </StyledTunnelProtocolContainer> </AriaInputGroup> @@ -191,31 +193,6 @@ export default class AdvancedSettings extends React.Component<IProps, IState> { ); } - private tunnelProtocolItems = ( - hasWireguardKey: boolean, - ): Array<ISelectorItem<OptionalTunnelProtocol>> => { - return [ - { - label: messages.gettext('Automatic'), - value: undefined, - }, - { - label: hasWireguardKey - ? messages.pgettext('advanced-settings-view', 'WireGuard') - : sprintf('%(label)s (%(error)s)', { - label: messages.pgettext('advanced-settings-view', 'WireGuard'), - error: messages.pgettext('advanced-settings-view', 'missing key'), - }), - value: 'wireguard', - disabled: !hasWireguardKey, - }, - { - label: messages.pgettext('advanced-settings-view', 'OpenVPN'), - value: 'openvpn', - }, - ]; - }; - private renderConfirmBlockWhenDisconnectedAlert = () => { return ( <ModalAlert diff --git a/gui/src/renderer/components/AdvancedSettingsStyles.tsx b/gui/src/renderer/components/AdvancedSettingsStyles.tsx index bdc84701c6..f7a87b5311 100644 --- a/gui/src/renderer/components/AdvancedSettingsStyles.tsx +++ b/gui/src/renderer/components/AdvancedSettingsStyles.tsx @@ -1,6 +1,4 @@ import styled from 'styled-components'; -import { colors } from '../../config.json'; -import * as Cell from './cell'; import { NavigationScrollbars } from './NavigationBar'; import Selector from './cell/Selector'; @@ -19,12 +17,3 @@ export const StyledTunnelProtocolContainer = styled(StyledSelectorContainer)({ export const StyledNavigationScrollbars = styled(NavigationScrollbars)({ flex: 1, }); - -export const StyledNoWireguardKeyErrorContainer = styled(Cell.Footer)({ - paddingBottom: 0, -}); - -export const StyledNoWireguardKeyError = styled(Cell.FooterText)({ - fontWeight: 700, - color: colors.red, -}); diff --git a/gui/src/renderer/components/AppRouter.tsx b/gui/src/renderer/components/AppRouter.tsx index f72de697e3..46b322785a 100644 --- a/gui/src/renderer/components/AppRouter.tsx +++ b/gui/src/renderer/components/AppRouter.tsx @@ -16,7 +16,6 @@ import SelectLanguagePage from '../containers/SelectLanguagePage'; import SelectLocationPage from '../containers/SelectLocationPage'; import SettingsPage from '../containers/SettingsPage'; import SupportPage from '../containers/SupportPage'; -import WireguardKeysPage from '../containers/WireguardKeysPage'; import WireguardSettingsPage from '../containers/WireguardSettingsPage'; import { IHistoryProps, ITransitionSpecification, transitions, withHistory } from '../lib/history'; import { @@ -27,6 +26,8 @@ import { } from './ExpiredAccountAddTime'; import { RoutePath } from '../lib/routes'; import FilterByProvider from './FilterByProvider'; +import TooManyDevices from './TooManyDevices'; +import { DeviceRevokedView } from './DeviceRevokedView'; interface IAppRoutesState { currentLocation: IHistoryProps['history']['location']; @@ -77,6 +78,8 @@ class AppRouter extends React.Component<IHistoryProps, IAppRoutesState> { <Switch key={location.key} location={location}> <Route exact path={RoutePath.launch} component={Launch} /> <Route exact path={RoutePath.login} component={LoginPage} /> + <Route exact path={RoutePath.tooManyDevices} component={TooManyDevices} /> + <Route exact path={RoutePath.deviceRevoked} component={DeviceRevokedView} /> <Route exact path={RoutePath.main} component={MainView} /> <Route exact path={RoutePath.redeemVoucher} component={VoucherInput} /> <Route @@ -92,7 +95,6 @@ class AppRouter extends React.Component<IHistoryProps, IAppRoutesState> { <Route exact path={RoutePath.preferences} component={PreferencesPage} /> <Route exact path={RoutePath.advancedSettings} component={AdvancedSettingsPage} /> <Route exact path={RoutePath.wireguardSettings} component={WireguardSettingsPage} /> - <Route exact path={RoutePath.wireguardKeys} component={WireguardKeysPage} /> <Route exact path={RoutePath.openVpnSettings} component={OpenVPNSettingsPage} /> <Route exact path={RoutePath.splitTunneling} component={SplitTunnelingSettings} /> <Route exact path={RoutePath.support} component={SupportPage} /> diff --git a/gui/src/renderer/components/DeviceRevokedView.tsx b/gui/src/renderer/components/DeviceRevokedView.tsx new file mode 100644 index 0000000000..6a51694af1 --- /dev/null +++ b/gui/src/renderer/components/DeviceRevokedView.tsx @@ -0,0 +1,95 @@ +import styled from 'styled-components'; +import { colors } from '../../config.json'; +import { messages } from '../../shared/gettext'; +import { useAppContext } from '../context'; +import { useSelector } from '../redux/store'; +import * as AppButton from './AppButton'; +import CustomScrollbars from './CustomScrollbars'; +import { calculateHeaderBarStyle, DefaultHeaderBar } from './HeaderBar'; +import { Container } from './Layout'; +import ImageView from './ImageView'; +import { Layout } from './Layout'; +import { bigText, smallText } from './common-styles'; + +export const StyledHeader = styled(DefaultHeaderBar)({ + flex: 0, +}); + +export const StyledCustomScrollbars = styled(CustomScrollbars)({ + flex: 1, +}); + +export const StyledContainer = styled(Container)({ + paddingTop: '22px', + minHeight: '100%', + backgroundColor: colors.darkBlue, +}); + +export const StyledBody = styled.div({ + display: 'flex', + flexDirection: 'column', + flex: 1, + padding: '0 22px', +}); + +export const StyledFooter = styled.div({ + display: 'flex', + flexDirection: 'column', + flex: 0, + padding: '18px 22px 22px', +}); + +export const StyledStatusIcon = styled.div({ + alignSelf: 'center', + width: '60px', + height: '60px', + marginBottom: '18px', +}); + +export const StyledTitle = styled.span(bigText, { + lineHeight: '38px', + marginBottom: '8px', + color: colors.white, +}); + +export const StyledMessage = styled.span(smallText, { + marginBottom: '20px', + color: colors.white, +}); + +export function DeviceRevokedView() { + const { leaveRevokedDevice } = useAppContext(); + const tunnelState = useSelector((state) => state.connection.status); + + const Button = tunnelState.state === 'disconnected' ? AppButton.GreenButton : AppButton.RedButton; + + return ( + <Layout> + <StyledHeader barStyle={calculateHeaderBarStyle(tunnelState)} /> + <StyledCustomScrollbars fillContainer> + <StyledContainer> + <StyledBody> + <StyledStatusIcon> + <ImageView source="icon-fail" height={60} width={60} /> + </StyledStatusIcon> + <StyledTitle> + {messages.pgettext('device-management', 'Device is inactive')} + </StyledTitle> + <StyledMessage> + {messages.pgettext( + 'device-management', + 'You have removed this device from your list of active devices. To connect with this device again, log in.', + )} + </StyledMessage> + </StyledBody> + + <StyledFooter> + <Button onClick={leaveRevokedDevice}> + {messages.pgettext('device-management', 'Go to login')} + </Button> + </StyledFooter> + </StyledContainer> + </StyledCustomScrollbars> + </Layout> + ); +} diff --git a/gui/src/renderer/components/ExpiredAccountAddTime.tsx b/gui/src/renderer/components/ExpiredAccountAddTime.tsx index fa98b1df59..47939b8c65 100644 --- a/gui/src/renderer/components/ExpiredAccountAddTime.tsx +++ b/gui/src/renderer/components/ExpiredAccountAddTime.tsx @@ -273,7 +273,7 @@ function HeaderBar() { } function useFinishedCallback() { - const { loggedIn } = useActions(account); + const { accountSetupFinished } = useActions(account); const history = useHistory(); const isNewAccount = useSelector( @@ -283,11 +283,11 @@ function useFinishedCallback() { const callback = useCallback(() => { // Changes login method from "new_account" to "existing_account" if (isNewAccount) { - loggedIn(); + accountSetupFinished(); } history.reset(RoutePath.main, undefined, transitions.push); - }, [isNewAccount, loggedIn, history]); + }, [isNewAccount, accountSetupFinished, history]); return callback; } diff --git a/gui/src/renderer/components/KeyboardNavigation.tsx b/gui/src/renderer/components/KeyboardNavigation.tsx index 4cfba89452..6b3851a49f 100644 --- a/gui/src/renderer/components/KeyboardNavigation.tsx +++ b/gui/src/renderer/components/KeyboardNavigation.tsx @@ -1,5 +1,7 @@ import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { useLocation } from 'react-router'; import { useHistory } from '../lib/history'; +import { disableDismissForRoutes, RoutePath } from '../lib/routes'; interface IKeyboardNavigationProps { children: React.ReactElement; @@ -9,18 +11,22 @@ interface IKeyboardNavigationProps { export default function KeyboardNavigation(props: IKeyboardNavigationProps) { const history = useHistory(); const [backAction, setBackAction] = useState<IBackActionConfiguration>(); + const location = useLocation(); const handleKeyDown = useCallback( (event: KeyboardEvent) => { if (event.key === 'Escape') { - if (event.shiftKey) { - history.dismiss(true); - } else { - backAction?.action(); + const path = location.pathname as RoutePath; + if (!disableDismissForRoutes.includes(path)) { + if (event.shiftKey) { + history.dismiss(true); + } else { + backAction?.action(); + } } } }, - [history.dismiss, backAction], + [history.dismiss, backAction, location.pathname], ); useEffect(() => { diff --git a/gui/src/renderer/components/Login.tsx b/gui/src/renderer/components/Login.tsx index 31afee88b0..ab93e1d6ac 100644 --- a/gui/src/renderer/components/Login.tsx +++ b/gui/src/renderer/components/Login.tsx @@ -151,6 +151,7 @@ export default class Login extends React.Component<IProps, IState> { private formTitle() { switch (this.props.loginState.type) { case 'logging in': + case 'too many devices': return this.props.loginState.method === 'existing_account' ? messages.pgettext('login-view', 'Logging in...') : messages.pgettext('login-view', 'Creating account...'); @@ -173,6 +174,8 @@ export default class Login extends React.Component<IProps, IState> { return this.props.loginState.method === 'existing_account' ? this.props.loginState.error.message || messages.pgettext('login-view', 'Unknown error') : messages.pgettext('login-view', 'Failed to create account'); + case 'too many devices': + return messages.pgettext('login-view', 'Too many devices'); case 'logging in': return this.props.loginState.method === 'existing_account' ? messages.pgettext('login-view', 'Checking account number') @@ -209,7 +212,11 @@ export default class Login extends React.Component<IProps, IState> { } private allowInteraction() { - return this.props.loginState.type !== 'logging in' && this.props.loginState.type !== 'ok'; + return ( + this.props.loginState.type !== 'logging in' && + this.props.loginState.type !== 'ok' && + this.props.loginState.type !== 'too many devices' + ); } private allowCreateAccount() { diff --git a/gui/src/renderer/components/MainView.tsx b/gui/src/renderer/components/MainView.tsx index c7a8851a90..56878fb521 100644 --- a/gui/src/renderer/components/MainView.tsx +++ b/gui/src/renderer/components/MainView.tsx @@ -1,7 +1,6 @@ import { useEffect, useState } from 'react'; -import { useSelector } from 'react-redux'; import { hasExpired } from '../../shared/account-expiry'; -import { IReduxState } from '../redux/store'; +import { useSelector } from '../redux/store'; import ConnectPage from '../containers/ConnectPage'; import ExpiredAccountErrorViewContainer from '../containers/ExpiredAccountErrorViewContainer'; import { useHistory } from '../lib/history'; @@ -9,13 +8,15 @@ import { RoutePath } from '../lib/routes'; export default function MainView() { const history = useHistory(); - const accountExpiry = useSelector((state: IReduxState) => state.account.expiry); - const accountHasExpired = accountExpiry && hasExpired(accountExpiry); + const accountExpiry = useSelector((state) => state.account.expiry); + const accountHasExpired = accountExpiry !== undefined && hasExpired(accountExpiry); const isNewAccount = useSelector( - (state: IReduxState) => - state.account.status.type === 'ok' && state.account.status.method === 'new_account', + (state) => state.account.status.type === 'ok' && state.account.status.method === 'new_account', + ); + + const [showAccountExpired, setShowAccountExpired] = useState<boolean>( + isNewAccount || accountHasExpired, ); - const [showAccountExpired, setShowAccountExpired] = useState(isNewAccount || accountHasExpired); useEffect(() => { if (accountHasExpired) { @@ -25,5 +26,9 @@ export default function MainView() { } }, [showAccountExpired, accountHasExpired]); - return showAccountExpired ? <ExpiredAccountErrorViewContainer /> : <ConnectPage />; + if (showAccountExpired) { + return <ExpiredAccountErrorViewContainer />; + } else { + return <ConnectPage />; + } } diff --git a/gui/src/renderer/components/NotificationArea.tsx b/gui/src/renderer/components/NotificationArea.tsx index 1f79d8b90d..de8b547087 100644 --- a/gui/src/renderer/components/NotificationArea.tsx +++ b/gui/src/renderer/components/NotificationArea.tsx @@ -9,7 +9,6 @@ import { InAppNotificationProvider, InconsistentVersionNotificationProvider, NotificationAction, - NoValidKeyNotificationProvider, ReconnectingNotificationProvider, UnsupportedVersionNotificationProvider, UpdateAvailableNotificationProvider, @@ -38,12 +37,6 @@ export default function NotificationArea(props: IProps) { const blockWhenDisconnected = useSelector( (state: IReduxState) => state.settings.blockWhenDisconnected, ); - const tunnelProtocol = useSelector((state: IReduxState) => - 'normal' in state.settings.relaySettings - ? state.settings.relaySettings.normal.tunnelProtocol - : undefined, - ); - const wireGuardKey = useSelector((state: IReduxState) => state.settings.wireguardKeyState); const hasExcludedApps = useSelector( (state: IReduxState) => state.settings.splitTunneling && state.settings.splitTunnelingApplications.length > 0, @@ -58,7 +51,6 @@ export default function NotificationArea(props: IProps) { hasExcludedApps, }), new ErrorNotificationProvider({ tunnelState, accountExpiry, hasExcludedApps }), - new NoValidKeyNotificationProvider({ tunnelProtocol, wireGuardKey }), new InconsistentVersionNotificationProvider({ consistent: version.consistent }), new UnsupportedVersionNotificationProvider(version), ]; diff --git a/gui/src/renderer/components/TooManyDevices.tsx b/gui/src/renderer/components/TooManyDevices.tsx new file mode 100644 index 0000000000..b743dd2aec --- /dev/null +++ b/gui/src/renderer/components/TooManyDevices.tsx @@ -0,0 +1,317 @@ +import { useCallback, useEffect } from 'react'; +import { sprintf } from 'sprintf-js'; +import styled from 'styled-components'; +import { colors } from '../../config.json'; +import { IDevice } from '../../shared/daemon-rpc-types'; +import { messages } from '../../shared/gettext'; +import { capitalizeEveryWord } from '../../shared/string-helpers'; +import { useAppContext } from '../context'; +import { transitions, useHistory } from '../lib/history'; +import { RoutePath } from '../lib/routes'; +import { useBoolean } from '../lib/utilityHooks'; +import { formatMarkdown } from '../markdown-formatter'; +import { useSelector } from '../redux/store'; +import * as AppButton from './AppButton'; +import * as Cell from './cell'; +import { bigText } from './common-styles'; +import CustomScrollbars from './CustomScrollbars'; +import { Brand, HeaderBarSettingsButton } from './HeaderBar'; +import ImageView from './ImageView'; +import { Header, Layout, SettingsContainer } from './Layout'; +import List from './List'; +import { ModalAlert, ModalAlertType, ModalContainer, ModalMessage } from './Modal'; + +const StyledCustomScrollbars = styled(CustomScrollbars)({ + flex: 1, +}); + +const StyledContainer = styled(SettingsContainer)({ + paddingTop: '14px', + minHeight: '100%', +}); + +const StyledBody = styled.div({ + display: 'flex', + flexDirection: 'column', + flex: 1, + paddingBottom: 'auto', +}); + +const StyledFooter = styled.div({ + display: 'flex', + flexDirection: 'column', + flex: 0, + padding: '18px 22px 22px', +}); + +const StyledStatusIcon = styled.div({ + alignSelf: 'center', + width: '60px', + height: '60px', + marginBottom: '18px', +}); + +const StyledTitle = styled.span(bigText, { + lineHeight: '38px', + margin: '0 22px 8px', + color: colors.white, +}); + +const StyledLabel = styled.span({ + fontFamily: 'Open Sans', + fontSize: '13px', + fontWeight: 600, + lineHeight: '20px', + color: colors.white, + margin: '0 22px 18px', +}); + +const StyledDeviceList = styled(Cell.CellButtonGroup)({ + marginBottom: 0, + flex: '0 0', +}); + +const StyledSpacer = styled.div({ + flex: '1', +}); + +const StyledCellContainer = styled(Cell.Container)({ + marginBottom: '1px', +}); + +const StyledDeviceName = styled(Cell.Label)({ + textTransform: 'capitalize', +}); + +const StyledRemoveDeviceButton = styled.button({ + cursor: 'default', + padding: 0, + marginLeft: 8, + backgroundColor: 'transparent', + border: 'none', +}); + +export default function TooManyDevices() { + const history = useHistory(); + const { fetchDevices, removeDevice, login, cancelLogin } = useAppContext(); + const accountToken = useSelector((state) => state.account.accountToken)!; + const devices = useSelector((state) => state.account.devices); + + const onRemoveDevice = useCallback( + async (deviceId: string) => { + await removeDevice({ accountToken, deviceId }); + }, + [removeDevice, accountToken], + ); + + const continueLogin = useCallback(() => login(accountToken), [login, accountToken]); + const cancel = useCallback(() => { + cancelLogin(); + history.reset(RoutePath.login, transitions.pop); + }, [history.reset, cancelLogin]); + + useEffect(() => void fetchDevices(accountToken), []); + + const iconSource = getIconSource(devices); + const title = getTitle(devices); + const subtitle = getSubtitle(devices); + + return ( + <ModalContainer> + <Layout> + <Header> + <Brand /> + <HeaderBarSettingsButton /> + </Header> + <StyledCustomScrollbars fillContainer> + <StyledContainer> + <StyledBody> + <StyledStatusIcon> + <ImageView key={iconSource} source={iconSource} height={60} width={60} /> + </StyledStatusIcon> + {devices !== undefined && ( + <> + <StyledTitle>{title}</StyledTitle> + <StyledLabel>{subtitle}</StyledLabel> + <DeviceList devices={devices} onRemoveDevice={onRemoveDevice} /> + </> + )} + </StyledBody> + + {devices !== undefined && ( + <StyledFooter> + <AppButton.ButtonGroup> + <AppButton.GreenButton onClick={continueLogin} disabled={devices.length === 5}> + { + // TRANSLATORS: Button for continuing login process. + messages.pgettext('device-management', 'Continue with login') + } + </AppButton.GreenButton> + <AppButton.BlueButton onClick={cancel}> + {messages.gettext('Back')} + </AppButton.BlueButton> + </AppButton.ButtonGroup> + </StyledFooter> + )} + </StyledContainer> + </StyledCustomScrollbars> + </Layout> + </ModalContainer> + ); +} + +interface IDeviceListProps { + devices: Array<IDevice>; + onRemoveDevice: (deviceId: string) => Promise<void>; +} + +function DeviceList(props: IDeviceListProps) { + return ( + <StyledSpacer> + <StyledDeviceList> + <List items={props.devices} getKey={getDeviceKey}> + {(device) => <Device device={device} onRemove={props.onRemoveDevice} />} + </List> + </StyledDeviceList> + </StyledSpacer> + ); +} + +const getDeviceKey = (device: IDevice): string => device.id; + +interface IDeviceProps { + device: IDevice; + onRemove: (deviceId: string) => Promise<void>; +} + +function Device(props: IDeviceProps) { + const [confirmationVisible, showConfirmation, hideConfirmation] = useBoolean(false); + const [deleting, setDeleting] = useBoolean(false); + + const onRemove = useCallback(async () => { + await props.onRemove(props.device.id); + hideConfirmation(); + setDeleting(); + }, [props.onRemove, props.device.id, hideConfirmation, setDeleting]); + + const capitalizedDeviceName = capitalizeEveryWord(props.device.name); + + return ( + <> + <StyledCellContainer> + <StyledDeviceName aria-hidden>{props.device.name}</StyledDeviceName> + <StyledRemoveDeviceButton + onClick={showConfirmation} + aria-label={sprintf( + // TRANSLATORS: Button action description provided to accessibility tools such as screen + // TRANSLATORS: readers. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(deviceName)s - The device name to remove. + messages.pgettext('accessibility', 'Remove device named %(deviceName)s'), + { deviceName: props.device.name }, + )}> + <ImageView + source="icon-close" + tintColor={colors.white40} + tintHoverColor={colors.white60} + /> + </StyledRemoveDeviceButton> + </StyledCellContainer> + <ModalAlert + isOpen={confirmationVisible} + type={ModalAlertType.warning} + iconColor={colors.red} + buttons={[ + <AppButton.RedButton key="remove" onClick={onRemove} disabled={deleting}> + { + // TRANSLATORS: Confirmation button when logging out other device. + messages.pgettext('device-management', 'Yes, log out device') + } + </AppButton.RedButton>, + <AppButton.BlueButton key="back" onClick={hideConfirmation} disabled={deleting}> + {messages.gettext('Back')} + </AppButton.BlueButton>, + ]} + close={hideConfirmation}> + {deleting ? ( + <ImageView source="icon-spinner" /> + ) : ( + <> + <ModalMessage> + {formatMarkdown( + sprintf( + // TRANSLATORS: Text displayed above button which logs out another device. + // TRANSLATORS: The text enclosed in "**" will appear bold. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(deviceName)s - The name of the device to log out. + messages.pgettext( + 'device-management', + 'Are you sure you want to log out of **%(deviceName)s**?', + ), + { deviceName: capitalizedDeviceName }, + ), + )} + </ModalMessage> + {props.device.ports && props.device.ports.length > 0 && ( + <ModalMessage> + { + // TRANSLATORS: Further information about consequences of logging out device. + messages.pgettext( + 'device-management', + 'This will delete all forwarded ports. Local settings will be saved.', + ) + } + </ModalMessage> + )} + </> + )} + </ModalAlert> + </> + ); +} + +function getIconSource(devices?: Array<IDevice>): string { + if (devices) { + if (devices.length === 5) { + return 'icon-fail'; + } else { + return 'icon-success'; + } + } else { + return 'icon-spinner'; + } +} + +function getTitle(devices?: Array<IDevice>): string | undefined { + if (devices) { + if (devices.length === 5) { + // TRANSLATORS: Page title informing user that the login failed due to too many registered + // TRANSLATORS: devices on account. + return messages.pgettext('device-management', 'Too many devices'); + } else { + // TRANSLATORS: Page title informing user that enough devices has been removed to continue + // TRANSLATORS: login process. + return messages.pgettext('device-management', 'Super!'); + } + } else { + return undefined; + } +} + +function getSubtitle(devices?: Array<IDevice>): string | undefined { + if (devices) { + if (devices.length === 5) { + return messages.pgettext( + 'device-management', + 'You have too many active devices. Please log out of at least one by removing it from the list below. You can find the corresponding nickname under the device’s Account settings.', + ); + } else { + return messages.pgettext( + 'device-management', + 'You can now continue logging in on this device.', + ); + } + } else { + return undefined; + } +} diff --git a/gui/src/renderer/components/WireguardKeys.tsx b/gui/src/renderer/components/WireguardKeys.tsx deleted file mode 100644 index 1a4dab38a6..0000000000 --- a/gui/src/renderer/components/WireguardKeys.tsx +++ /dev/null @@ -1,345 +0,0 @@ -import * as React from 'react'; -import { sprintf } from 'sprintf-js'; -import { TunnelState } from '../../shared/daemon-rpc-types'; -import { formatRelativeDate } from '../../shared/date-helper'; -import { messages } from '../../shared/gettext'; -import log from '../../shared/logging'; -import { IWgKey, WgKeyState } from '../redux/settings/reducers'; -import * as AppButton from './AppButton'; -import { AriaDescribed, AriaDescription, AriaDescriptionGroup } from './AriaGroup'; -import ClipboardLabel from './ClipboardLabel'; -import ImageView from './ImageView'; -import { BackAction } from './KeyboardNavigation'; -import { Layout } from './Layout'; -import { NavigationBar, NavigationContainer, NavigationItems, TitleBarItem } from './NavigationBar'; -import SettingsHeader, { HeaderTitle } from './SettingsHeader'; -import { - StyledButtonRow, - StyledContainer, - StyledContent, - StyledLastButtonRow, - StyledMessage, - StyledMessages, - StyledNavigationScrollbars, - StyledRow, - StyledRowLabel, - StyledRowLabelSpacer, - StyledRowValue, -} from './WireguardKeysStyles'; - -export interface IProps { - keyState: WgKeyState; - isOffline: boolean; - tunnelState: TunnelState; - windowFocused: boolean; - - onClose: () => void; - onGenerateKey: () => void; - onReplaceKey: (old: IWgKey) => void; - onVerifyKey: (publicKey: IWgKey) => void; - onVisitWebsiteKey: () => Promise<void>; -} - -export interface IState { - recentlyGeneratedKey: boolean; - userHasInitiatedVerification: boolean; - ageOfKeyString: string; -} - -export default class WireguardKeys extends React.Component<IProps, IState> { - public state = { - recentlyGeneratedKey: false, - userHasInitiatedVerification: false, - ageOfKeyString: WireguardKeys.ageOfKeyString(this.props.keyState), - }; - - private keyAgeUpdateInterval?: number; - - public static getDerivedStateFromProps(props: IProps) { - return { - ageOfKeyString: WireguardKeys.ageOfKeyString(props.keyState), - }; - } - - public componentDidMount() { - this.verifyKey(); - this.keyAgeUpdateInterval = window.setInterval(this.setAgeOfKeyStringState, 60 * 1000); - } - - public componentWillUnmount() { - clearInterval(this.keyAgeUpdateInterval); - } - - public componentDidUpdate(prevProps: IProps) { - const prevKey = - prevProps.keyState.type === 'key-set' ? prevProps.keyState.key.publicKey : undefined; - const key = - this.props.keyState.type === 'key-set' ? this.props.keyState.key.publicKey : undefined; - if (this.props.tunnelState.state === 'connected' && key !== undefined && key != prevKey) { - this.setState({ recentlyGeneratedKey: true }); - } - - if ( - this.state.recentlyGeneratedKey && - prevProps.tunnelState.state !== 'connected' && - this.props.tunnelState.state === 'connected' - ) { - this.setState({ recentlyGeneratedKey: false }); - } - } - - public render() { - return ( - <BackAction action={this.props.onClose}> - <Layout> - <StyledContainer> - <NavigationContainer> - <NavigationBar> - <NavigationItems> - <TitleBarItem> - { - // TRANSLATORS: Title label in navigation bar - messages.pgettext('wireguard-keys-nav', 'WireGuard key') - } - </TitleBarItem> - </NavigationItems> - </NavigationBar> - - <StyledNavigationScrollbars fillContainer> - <StyledContent> - <SettingsHeader> - <HeaderTitle> - {messages.pgettext('wireguard-keys-nav', 'WireGuard key')} - </HeaderTitle> - </SettingsHeader> - - <StyledRow> - <StyledRowLabel> - <span>{messages.pgettext('wireguard-key-view', 'Public key')}</span> - <StyledRowLabelSpacer /> - <span>{this.keyValidityLabel()}</span> - </StyledRowLabel> - - <StyledRowValue>{this.getKeyText()}</StyledRowValue> - </StyledRow> - <StyledRow> - <StyledRowLabel> - {messages.pgettext('wireguard-key-view', 'Key generated')} - </StyledRowLabel> - <StyledRowValue>{this.state.ageOfKeyString}</StyledRowValue> - </StyledRow> - - <StyledMessages>{this.getStatusMessage()}</StyledMessages> - - <StyledButtonRow>{this.getGenerateButton()}</StyledButtonRow> - <StyledButtonRow> - <AppButton.BlueButton - disabled={this.isVerifyButtonDisabled()} - onClick={this.handleVerifyKeyPress}> - <AppButton.Label> - {messages.pgettext('wireguard-key-view', 'Verify key')} - </AppButton.Label> - </AppButton.BlueButton> - </StyledButtonRow> - <StyledLastButtonRow> - <AppButton.BlockingButton - disabled={this.props.isOffline} - onClick={this.props.onVisitWebsiteKey}> - <AriaDescriptionGroup> - <AriaDescribed> - <AppButton.BlueButton> - <AppButton.Label> - {messages.pgettext('wireguard-key-view', 'Manage keys')} - </AppButton.Label> - <AriaDescription> - <AppButton.Icon - source="icon-extLink" - height={16} - width={16} - aria-label={messages.pgettext('accessibility', 'Opens externally')} - /> - </AriaDescription> - </AppButton.BlueButton> - </AriaDescribed> - </AriaDescriptionGroup> - </AppButton.BlockingButton> - </StyledLastButtonRow> - </StyledContent> - </StyledNavigationScrollbars> - </NavigationContainer> - </StyledContainer> - </Layout> - </BackAction> - ); - } - - private isVerifyButtonDisabled(): boolean { - return this.props.keyState.type !== 'key-set'; - } - - private handleVerifyKeyPress = () => { - this.setState({ userHasInitiatedVerification: true }); - this.verifyKey(); - }; - - private verifyKey() { - switch (this.props.keyState.type) { - case 'key-set': { - const key = this.props.keyState.key; - this.props.onVerifyKey(key); - break; - } - default: - log.error(`onVerifyKey called from invalid state - ${this.props.keyState.type}`); - } - } - - /// Action button can either generate or verify a key - private getGenerateButton() { - let buttonText = messages.pgettext('wireguard-key-view', 'Generate key'); - const regenerateText = messages.pgettext('wireguard-key-view', 'Regenerate key'); - - let disabled = false; - let generateKey = this.props.onGenerateKey; - switch (this.props.keyState.type) { - case 'key-set': { - buttonText = regenerateText; - const key = this.props.keyState.key; - generateKey = () => this.props.onReplaceKey(key); - break; - } - case 'being-verified': - disabled = true; - buttonText = regenerateText; - break; - case 'being-replaced': - case 'being-generated': - disabled = true; - buttonText = messages.pgettext('wireguard-key-view', 'Generating key'); - } - - return ( - <AppButton.GreenButton disabled={disabled} onClick={generateKey}> - <AppButton.Label>{buttonText}</AppButton.Label> - </AppButton.GreenButton> - ); - } - - private getKeyText() { - switch (this.props.keyState.type) { - case 'being-verified': - case 'key-set': { - // mimicking the truncating of the key from website - const publicKey = this.props.keyState.key.publicKey; - return ( - <StyledRowValue title={this.props.keyState.key.publicKey}> - <ClipboardLabel - value={publicKey} - displayValue={publicKey.substring(0, 20) + '...'} - obscureValue={false} - /> - </StyledRowValue> - ); - } - case 'being-replaced': - case 'being-generated': - return <ImageView source="icon-spinner" height={19} width={19} />; - default: - return ( - <StyledRowValue>{messages.pgettext('wireguard-key-view', 'No key set')}</StyledRowValue> - ); - } - } - - private keyValidityLabel() { - const keyStateType = this.props.keyState.type; - if (keyStateType === 'being-verified' && this.state.userHasInitiatedVerification) { - return <ImageView source="icon-spinner" height={20} width={20} />; - } else if (this.props.keyState.type === 'key-set') { - const valid = this.props.keyState.key.valid; - const show = this.state.userHasInitiatedVerification || valid === false; - return show && valid !== undefined ? ( - <StyledMessage success={valid}> - {valid - ? messages.pgettext('wireguard-key-view', 'Key is valid') - : messages.pgettext('wireguard-key-view', 'Key is invalid')} - </StyledMessage> - ) : null; - } else { - return null; - } - } - - private static ageOfKeyString(keyState: WgKeyState): string { - switch (keyState.type) { - case 'key-set': - case 'being-verified': { - const createdDate = Math.min(Date.parse(keyState.key.created), Date.now()); - return formatRelativeDate(new Date(), createdDate, true); - } - default: - return '-'; - } - } - - private setAgeOfKeyStringState = () => { - this.setState({ - ageOfKeyString: WireguardKeys.ageOfKeyString(this.props.keyState), - }); - }; - - private getStatusMessage() { - if (this.props.isOffline && this.state.recentlyGeneratedKey) { - return ( - <StyledMessage success={this.state.recentlyGeneratedKey}> - {messages.pgettext('wireguard-key-view', 'Reconnecting with new WireGuard key...')} - </StyledMessage> - ); - } else { - let message = ''; - switch (this.props.keyState.type) { - case 'key-set': { - const key = this.props.keyState.key; - if (key.replacementFailure) { - switch (key.replacementFailure) { - case 'too_many_keys': - message = this.formatKeygenFailure('too-many-keys'); - break; - case 'generation_failure': - message = this.formatKeygenFailure('generation-failure'); - break; - } - } else if (key.verificationFailed) { - message = messages.pgettext('wireguard-key-view', 'Key verification failed'); - } - - break; - } - case 'too-many-keys': - case 'generation-failure': - message = this.formatKeygenFailure(this.props.keyState.type); - break; - } - - return <StyledMessage success={false}>{message}</StyledMessage>; - } - } - - private formatKeygenFailure(failure: 'too-many-keys' | 'generation-failure'): string { - switch (failure) { - case 'too-many-keys': - // TRANSLATORS: "%(manage)" is replaced with the text in the "Manage keys" button. - return sprintf( - messages.pgettext( - 'wireguard-key-view', - 'Unable to regenerate key: you already have the maximum number of keys. To generate a new key, you first need to revoke one under “Manage keys.”', - ), - { manage: messages.pgettext('wireguard-key-view', 'Manage keys') }, - ); - case 'generation-failure': - return messages.pgettext('wireguard-key-view', 'Failed to generate a key'); - default: - return failure; - } - } -} diff --git a/gui/src/renderer/components/WireguardKeysStyles.tsx b/gui/src/renderer/components/WireguardKeysStyles.tsx deleted file mode 100644 index cf443b2fa5..0000000000 --- a/gui/src/renderer/components/WireguardKeysStyles.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import styled from 'styled-components'; -import { colors } from '../../config.json'; -import { normalText, smallText, tinyText } from './common-styles'; -import { Container } from './Layout'; -import { NavigationScrollbars } from './NavigationBar'; - -export const StyledNavigationScrollbars = styled(NavigationScrollbars)({ - flex: 1, -}); - -export const StyledContainer = styled(Container)({ - backgroundColor: colors.darkBlue, -}); - -export const StyledContent = styled.div({ - display: 'flex', - flexDirection: 'column', - flex: 1, -}); - -export const StyledMessages = styled.div({ - padding: '0 22px 20px', - flex: 1, -}); - -export const StyledMessage = styled.span(smallText, (props: { success: boolean }) => ({ - fontWeight: props.success ? 600 : 700, - color: props.success ? colors.green : colors.red, -})); - -export const StyledRow = styled.div({ - display: 'flex', - flexDirection: 'column', - padding: '0 22px', - marginBottom: '20px', -}); - -export const StyledButtonRow = styled(StyledRow)({ - marginBottom: '18px', -}); - -export const StyledLastButtonRow = styled(StyledButtonRow)({ - marginBottom: '22px', -}); - -export const StyledRowLabel = styled.span(tinyText, { - color: colors.white60, - lineHeight: '20px', - marginBottom: '5px', -}); - -export const StyledRowLabelSpacer = styled.div({ - flex: 1, -}); - -export const StyledRowValue = styled.span(normalText, { - fontWeight: 600, - color: colors.white, -}); diff --git a/gui/src/renderer/components/WireguardSettings.tsx b/gui/src/renderer/components/WireguardSettings.tsx index f799cff335..8bec012986 100644 --- a/gui/src/renderer/components/WireguardSettings.tsx +++ b/gui/src/renderer/components/WireguardSettings.tsx @@ -51,7 +51,6 @@ interface IProps { setWireguardMultihop: (value: boolean) => void; setWireguardPort: (port?: number) => void; setWireguardIpVersion: (ipVersion?: IpVersion) => void; - onViewWireguardKeys: () => void; onClose: () => void; } @@ -202,15 +201,6 @@ export default class WireguardSettings extends React.Component<IProps, IState> { </Cell.Footer> </AriaInputGroup> - <Cell.CellButtonGroup> - <Cell.CellButton onClick={this.props.onViewWireguardKeys}> - <Cell.Label> - {messages.pgettext('wireguard-settings-view', 'WireGuard key')} - </Cell.Label> - <Cell.Icon height={12} width={7} source="icon-chevron" /> - </Cell.CellButton> - </Cell.CellButtonGroup> - <AriaInputGroup> <Cell.Container> <AriaLabel> diff --git a/gui/src/renderer/containers/AccountPage.tsx b/gui/src/renderer/containers/AccountPage.tsx index 641374c504..2309c614fb 100644 --- a/gui/src/renderer/containers/AccountPage.tsx +++ b/gui/src/renderer/containers/AccountPage.tsx @@ -1,19 +1,26 @@ import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; import { links } from '../../config.json'; import Account from '../components/Account'; import withAppContext, { IAppContext } from '../context'; import { IHistoryProps, withHistory } from '../lib/history'; import { IReduxState, ReduxDispatch } from '../redux/store'; +import accountActions from '../redux/account/actions'; const mapStateToProps = (state: IReduxState) => ({ + deviceName: state.account.deviceName, accountToken: state.account.accountToken, accountExpiry: state.account.expiry, expiryLocale: state.userInterface.locale, isOffline: state.connection.isBlocked, }); -const mapDispatchToProps = (_dispatch: ReduxDispatch, props: IHistoryProps & IAppContext) => { +const mapDispatchToProps = (dispatch: ReduxDispatch, props: IHistoryProps & IAppContext) => { + const account = bindActionCreators(accountActions, dispatch); + return { + prepareLogout: () => account.prepareLogout(), + cancelLogout: () => account.cancelLogout(), onLogout: () => { void props.app.logout(); }, @@ -22,6 +29,7 @@ const mapDispatchToProps = (_dispatch: ReduxDispatch, props: IHistoryProps & IAp }, onBuyMore: () => props.app.openLinkWithAuth(links.purchase), updateAccountData: () => props.app.updateAccountData(), + getDevice: () => props.app.getDevice(), }; }; diff --git a/gui/src/renderer/containers/AdvancedSettingsPage.tsx b/gui/src/renderer/containers/AdvancedSettingsPage.tsx index 51ba310aad..0bc00ee204 100644 --- a/gui/src/renderer/containers/AdvancedSettingsPage.tsx +++ b/gui/src/renderer/containers/AdvancedSettingsPage.tsx @@ -16,7 +16,6 @@ const mapStateToProps = (state: IReduxState) => { return { enableIpv6: state.settings.enableIpv6, blockWhenDisconnected: state.settings.blockWhenDisconnected, - wireguardKeyState: state.settings.wireguardKeyState, tunnelProtocol, }; }; diff --git a/gui/src/renderer/containers/WireguardKeysPage.tsx b/gui/src/renderer/containers/WireguardKeysPage.tsx deleted file mode 100644 index b0c25e06a9..0000000000 --- a/gui/src/renderer/containers/WireguardKeysPage.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { connect } from 'react-redux'; -import { links } from '../../config.json'; -import WireguardKeys from '../components/WireguardKeys'; -import withAppContext, { IAppContext } from '../context'; -import { IHistoryProps, withHistory } from '../lib/history'; -import { IWgKey } from '../redux/settings/reducers'; -import { IReduxState, ReduxDispatch } from '../redux/store'; - -const mapStateToProps = (state: IReduxState) => ({ - keyState: state.settings.wireguardKeyState, - isOffline: state.connection.isBlocked, - tunnelState: state.connection.status, - windowFocused: state.userInterface.windowFocused, -}); -const mapDispatchToProps = (_dispatch: ReduxDispatch, props: IHistoryProps & IAppContext) => { - return { - onClose: () => props.history.pop(), - onGenerateKey: () => props.app.generateWireguardKey(), - onReplaceKey: (oldKey: IWgKey) => props.app.replaceWireguardKey(oldKey), - onVerifyKey: (publicKey: IWgKey) => props.app.verifyWireguardKey(publicKey), - onVisitWebsiteKey: () => props.app.openLinkWithAuth(links.manageKeys), - }; -}; - -export default withAppContext( - withHistory(connect(mapStateToProps, mapDispatchToProps)(WireguardKeys)), -); diff --git a/gui/src/renderer/containers/WireguardSettingsPage.tsx b/gui/src/renderer/containers/WireguardSettingsPage.tsx index 4b0af37510..d9ac31756d 100644 --- a/gui/src/renderer/containers/WireguardSettingsPage.tsx +++ b/gui/src/renderer/containers/WireguardSettingsPage.tsx @@ -6,7 +6,6 @@ import WireguardSettings from '../components/WireguardSettings'; import withAppContext, { IAppContext } from '../context'; import { createWireguardRelayUpdater } from '../lib/constraint-updater'; import { IHistoryProps, withHistory } from '../lib/history'; -import { RoutePath } from '../lib/routes'; import { RelaySettingsRedux } from '../redux/settings/reducers'; import { IReduxState, ReduxDispatch } from '../redux/store'; @@ -108,8 +107,6 @@ const mapDispatchToProps = (_dispatch: ReduxDispatch, props: IHistoryProps & IAp log.error('Failed to update mtu value', error.message); } }, - - onViewWireguardKeys: () => props.history.push(RoutePath.wireguardKeys), }; }; diff --git a/gui/src/renderer/lib/routes.ts b/gui/src/renderer/lib/routes.ts index 8a63cd6de0..994a9f6124 100644 --- a/gui/src/renderer/lib/routes.ts +++ b/gui/src/renderer/lib/routes.ts @@ -5,6 +5,8 @@ export type GeneratedRoutePath = { routePath: string }; export enum RoutePath { launch = '/', login = '/login', + tooManyDevices = '/login/too-many-devices', + deviceRevoked = '/login/device-revoked', main = '/main', redeemVoucher = '/main/voucher/redeem', voucherSuccess = '/main/voucher/success/:newExpiry/:secondsAdded', @@ -16,7 +18,6 @@ export enum RoutePath { preferences = '/settings/preferences', advancedSettings = '/settings/advanced', wireguardSettings = '/settings/advanced/wireguard', - wireguardKeys = '/settings/advanced/wireguard/keys', openVpnSettings = '/settings/advanced/openvpn', splitTunneling = '/settings/advanced/split-tunneling', support = '/settings/support', @@ -24,6 +25,18 @@ export enum RoutePath { filterByProvider = '/select-location/filter-by-provider', } +export const disableDismissForRoutes = [ + RoutePath.launch, + RoutePath.login, + RoutePath.tooManyDevices, + RoutePath.deviceRevoked, + RoutePath.main, + RoutePath.redeemVoucher, + RoutePath.voucherSuccess, + RoutePath.timeAdded, + RoutePath.setupFinished, +]; + export function generateRoutePath( routePath: RoutePath, parameters: Parameters<typeof generatePath>[1], diff --git a/gui/src/renderer/redux/account/actions.ts b/gui/src/renderer/redux/account/actions.ts index b8fbe94d39..aa0a949533 100644 --- a/gui/src/renderer/redux/account/actions.ts +++ b/gui/src/renderer/redux/account/actions.ts @@ -1,4 +1,4 @@ -import { AccountToken } from '../../../shared/daemon-rpc-types'; +import { AccountToken, IDeviceConfig, IDevice } from '../../../shared/daemon-rpc-types'; interface IStartLoginAction { type: 'START_LOGIN'; @@ -7,6 +7,8 @@ interface IStartLoginAction { interface ILoggedInAction { type: 'LOGGED_IN'; + accountToken: AccountToken; + deviceName?: string; } interface ILoginFailedAction { @@ -14,6 +16,19 @@ interface ILoginFailedAction { error: Error; } +interface ILoginTooManyDevicesAction { + type: 'TOO_MANY_DEVICES'; + error: Error; +} + +interface IPrepareLogoutAction { + type: 'PREPARE_LOG_OUT'; +} + +interface ICancelLogoutAction { + type: 'CANCEL_LOGOUT'; +} + interface ILoggedOutAction { type: 'LOGGED_OUT'; } @@ -22,6 +37,10 @@ interface IResetLoginErrorAction { type: 'RESET_LOGIN_ERROR'; } +interface IDeviceRevokedAction { + type: 'DEVICE_REVOKED'; +} + interface IStartCreateAccount { type: 'START_CREATE_ACCOUNT'; } @@ -33,13 +52,18 @@ interface ICreateAccountFailed { interface IAccountCreated { type: 'ACCOUNT_CREATED'; - token: AccountToken; + accountToken: AccountToken; + deviceName?: string; expiry: string; } +interface IAccountSetupFinished { + type: 'ACCOUNT_SETUP_FINISHED'; +} + interface IUpdateAccountTokenAction { type: 'UPDATE_ACCOUNT_TOKEN'; - token: AccountToken; + accountToken: AccountToken; } interface IUpdateAccountHistoryAction { @@ -52,18 +76,29 @@ interface IUpdateAccountExpiryAction { expiry?: string; } +interface IUpdateDevicesAction { + type: 'UPDATE_DEVICES'; + devices: Array<IDevice>; +} + export type AccountAction = | IStartLoginAction | ILoggedInAction | ILoginFailedAction + | ILoginTooManyDevicesAction + | IPrepareLogoutAction + | ICancelLogoutAction | ILoggedOutAction | IResetLoginErrorAction + | IDeviceRevokedAction | IStartCreateAccount | ICreateAccountFailed | IAccountCreated + | IAccountSetupFinished | IUpdateAccountTokenAction | IUpdateAccountHistoryAction - | IUpdateAccountExpiryAction; + | IUpdateAccountExpiryAction + | IUpdateDevicesAction; function startLogin(accountToken: AccountToken): IStartLoginAction { return { @@ -72,9 +107,11 @@ function startLogin(accountToken: AccountToken): IStartLoginAction { }; } -function loggedIn(): ILoggedInAction { +function loggedIn(deviceConfig: IDeviceConfig): ILoggedInAction { return { type: 'LOGGED_IN', + accountToken: deviceConfig.accountToken, + deviceName: deviceConfig.device?.name, }; } @@ -85,6 +122,25 @@ function loginFailed(error: Error): ILoginFailedAction { }; } +function loginTooManyDevices(error: Error): ILoginTooManyDevicesAction { + return { + type: 'TOO_MANY_DEVICES', + error, + }; +} + +function prepareLogout(): IPrepareLogoutAction { + return { + type: 'PREPARE_LOG_OUT', + }; +} + +function cancelLogout(): ICancelLogoutAction { + return { + type: 'CANCEL_LOGOUT', + }; +} + function loggedOut(): ILoggedOutAction { return { type: 'LOGGED_OUT', @@ -97,6 +153,12 @@ function resetLoginError(): IResetLoginErrorAction { }; } +function deviceRevoked(): IDeviceRevokedAction { + return { + type: 'DEVICE_REVOKED', + }; +} + function startCreateAccount(): IStartCreateAccount { return { type: 'START_CREATE_ACCOUNT', @@ -110,18 +172,23 @@ function createAccountFailed(error: Error): ICreateAccountFailed { }; } -function accountCreated(token: AccountToken, expiry: string): IAccountCreated { +function accountCreated(deviceConfig: IDeviceConfig, expiry: string): IAccountCreated { return { type: 'ACCOUNT_CREATED', - token, + accountToken: deviceConfig.accountToken, + deviceName: deviceConfig.device?.name, expiry, }; } -function updateAccountToken(token: AccountToken): IUpdateAccountTokenAction { +function accountSetupFinished(): IAccountSetupFinished { + return { type: 'ACCOUNT_SETUP_FINISHED' }; +} + +function updateAccountToken(accountToken: AccountToken): IUpdateAccountTokenAction { return { type: 'UPDATE_ACCOUNT_TOKEN', - token, + accountToken, }; } @@ -139,16 +206,29 @@ function updateAccountExpiry(expiry?: string): IUpdateAccountExpiryAction { }; } +function updateDevices(devices: Array<IDevice>): IUpdateDevicesAction { + return { + type: 'UPDATE_DEVICES', + devices: devices.sort((a, b) => a.name.localeCompare(b.name)), + }; +} + export default { startLogin, loggedIn, loginFailed, + loginTooManyDevices, + prepareLogout, + cancelLogout, loggedOut, resetLoginError, + deviceRevoked, startCreateAccount, createAccountFailed, accountCreated, + accountSetupFinished, updateAccountToken, updateAccountHistory, updateAccountExpiry, + updateDevices, }; diff --git a/gui/src/renderer/redux/account/reducers.ts b/gui/src/renderer/redux/account/reducers.ts index 53bc55db1b..00f2ef7bb5 100644 --- a/gui/src/renderer/redux/account/reducers.ts +++ b/gui/src/renderer/redux/account/reducers.ts @@ -1,23 +1,29 @@ -import { AccountToken } from '../../../shared/daemon-rpc-types'; +import { AccountToken, IDevice } from '../../../shared/daemon-rpc-types'; import { ReduxAction } from '../store'; type LoginMethod = 'existing_account' | 'new_account'; export type LoginState = - | { type: 'none' } + | { type: 'none'; deviceRevoked: boolean } | { type: 'logging in' | 'ok'; method: LoginMethod } - | { type: 'failed'; method: LoginMethod; error: Error }; + | { type: 'failed' | 'too many devices'; method: LoginMethod; error: Error }; export interface IAccountReduxState { accountToken?: AccountToken; + deviceName?: string; + devices: Array<IDevice>; accountHistory?: AccountToken; expiry?: string; // ISO8601 status: LoginState; + loggingOut: boolean; } const initialState: IAccountReduxState = { accountToken: undefined, + deviceName: undefined, + devices: [], accountHistory: undefined, expiry: undefined, - status: { type: 'none' }, + status: { type: 'none', deviceRevoked: false }, + loggingOut: false, }; export default function ( @@ -35,6 +41,8 @@ export default function ( return { ...state, status: { type: 'ok', method: 'existing_account' }, + accountToken: action.accountToken, + deviceName: action.deviceName, }; case 'LOGIN_FAILED': return { @@ -42,17 +50,38 @@ export default function ( status: { type: 'failed', method: 'existing_account', error: action.error }, accountToken: undefined, }; + case 'TOO_MANY_DEVICES': + return { + ...state, + status: { type: 'too many devices', method: 'existing_account', error: action.error }, + }; + case 'PREPARE_LOG_OUT': + return { + ...state, + loggingOut: true, + }; + case 'CANCEL_LOGOUT': + return { + ...state, + loggingOut: false, + }; case 'LOGGED_OUT': return { ...state, - status: { type: 'none' }, + status: { type: 'none', deviceRevoked: false }, accountToken: undefined, expiry: undefined, + loggingOut: false, }; case 'RESET_LOGIN_ERROR': return { ...state, - status: { type: 'none' }, + status: { type: 'none', deviceRevoked: false }, + }; + case 'DEVICE_REVOKED': + return { + ...state, + status: { type: 'none', deviceRevoked: true }, }; case 'START_CREATE_ACCOUNT': return { @@ -68,13 +97,19 @@ export default function ( return { ...state, status: { type: 'ok', method: 'new_account' }, - accountToken: action.token, + accountToken: action.accountToken, + deviceName: action.deviceName, expiry: action.expiry, }; + case 'ACCOUNT_SETUP_FINISHED': + return { + ...state, + status: { type: 'ok', method: 'existing_account' }, + }; case 'UPDATE_ACCOUNT_TOKEN': return { ...state, - accountToken: action.token, + accountToken: action.accountToken, }; case 'UPDATE_ACCOUNT_HISTORY': return { @@ -86,6 +121,11 @@ export default function ( ...state, expiry: action.expiry, }; + case 'UPDATE_DEVICES': + return { + ...state, + devices: action.devices, + }; } return state; diff --git a/gui/src/renderer/redux/settings/actions.ts b/gui/src/renderer/redux/settings/actions.ts index 1b1e48265c..32cd157157 100644 --- a/gui/src/renderer/redux/settings/actions.ts +++ b/gui/src/renderer/redux/settings/actions.ts @@ -1,12 +1,7 @@ -import { - BridgeState, - IDnsOptions, - IWireguardPublicKey, - KeygenEvent, -} from '../../../shared/daemon-rpc-types'; +import { BridgeState, IDnsOptions } from '../../../shared/daemon-rpc-types'; import { IGuiSettingsState } from '../../../shared/gui-settings-state'; import { IApplication } from '../../../shared/application-types'; -import { BridgeSettingsRedux, IRelayLocationRedux, IWgKey, RelaySettingsRedux } from './reducers'; +import { BridgeSettingsRedux, IRelayLocationRedux, RelaySettingsRedux } from './reducers'; export interface IUpdateGuiSettingsAction { type: 'UPDATE_GUI_SETTINGS'; @@ -73,36 +68,6 @@ export interface IUpdateAutoStartAction { autoStart: boolean; } -// Used to set wireguard key when accounts are changed. -export interface IWireguardSetKey { - type: 'SET_WIREGUARD_KEY'; - key?: IWgKey; -} - -export interface IWireguardGenerateKey { - type: 'GENERATE_WIREGUARD_KEY'; -} - -export interface IWireguardReplaceKey { - type: 'REPLACE_WIREGUARD_KEY'; - oldKey: IWgKey; -} - -export interface IWireguardVerifyKey { - type: 'VERIFY_WIREGUARD_KEY'; - key: IWgKey; -} - -export interface IWireguardKeygenEvent { - type: 'WIREGUARD_KEYGEN_EVENT'; - event: KeygenEvent; -} - -export interface IWireguardKeyVerifiedAction { - type: 'WIREGUARD_KEY_VERIFICATION_COMPLETE'; - verified?: boolean; -} - export interface IUpdateDnsOptionsAction { type: 'UPDATE_DNS_OPTIONS'; dns: IDnsOptions; @@ -132,12 +97,6 @@ export type SettingsAction = | IUpdateOpenVpnMssfixAction | IUpdateWireguardMtuAction | IUpdateAutoStartAction - | IWireguardSetKey - | IWireguardVerifyKey - | IWireguardGenerateKey - | IWireguardReplaceKey - | IWireguardKeygenEvent - | IWireguardKeyVerifiedAction | IUpdateDnsOptionsAction | IUpdateSplitTunnelingStateAction | ISetSplitTunnelingApplicationsAction; @@ -237,54 +196,6 @@ function updateAutoStart(autoStart: boolean): IUpdateAutoStartAction { }; } -function setWireguardKey(publicKey?: IWireguardPublicKey): IWireguardSetKey { - const key = publicKey - ? { - publicKey: publicKey.key, - created: publicKey.created, - valid: undefined, - } - : undefined; - return { - type: 'SET_WIREGUARD_KEY', - key, - }; -} - -function setWireguardKeygenEvent(event: KeygenEvent): IWireguardKeygenEvent { - return { - type: 'WIREGUARD_KEYGEN_EVENT', - event, - }; -} - -function generateWireguardKey(): IWireguardGenerateKey { - return { - type: 'GENERATE_WIREGUARD_KEY', - }; -} - -function replaceWireguardKey(oldKey: IWgKey): IWireguardReplaceKey { - return { - type: 'REPLACE_WIREGUARD_KEY', - oldKey, - }; -} - -function verifyWireguardKey(key: IWgKey): IWireguardVerifyKey { - return { - type: 'VERIFY_WIREGUARD_KEY', - key, - }; -} - -function completeWireguardKeyVerification(verified?: boolean): IWireguardKeyVerifiedAction { - return { - type: 'WIREGUARD_KEY_VERIFICATION_COMPLETE', - verified, - }; -} - function updateDnsOptions(dns: IDnsOptions): IUpdateDnsOptionsAction { return { type: 'UPDATE_DNS_OPTIONS', @@ -322,12 +233,6 @@ export default { updateOpenVpnMssfix, updateWireguardMtu, updateAutoStart, - setWireguardKey, - setWireguardKeygenEvent, - generateWireguardKey, - replaceWireguardKey, - verifyWireguardKey, - completeWireguardKeyVerification, updateDnsOptions, updateSplitTunnelingState, setSplitTunnelingApplications, diff --git a/gui/src/renderer/redux/settings/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts index 449ff2b563..9b55160a5e 100644 --- a/gui/src/renderer/redux/settings/reducers.ts +++ b/gui/src/renderer/redux/settings/reducers.ts @@ -1,7 +1,6 @@ import { IApplication } from '../../../shared/application-types'; import { BridgeState, - KeygenEvent, LiftedConstraint, ProxySettings, RelayLocation, @@ -11,7 +10,6 @@ import { IpVersion, } from '../../../shared/daemon-rpc-types'; import { IGuiSettingsState } from '../../../shared/gui-settings-state'; -import log from '../../../shared/logging'; import { ReduxAction } from '../store'; export type RelaySettingsRedux = @@ -73,54 +71,6 @@ export interface IRelayLocationRedux { cities: IRelayLocationCityRedux[]; } -export interface IWgKey { - publicKey: string; - created: string; - valid?: boolean; - replacementFailure?: KeygenEvent; - verificationFailed?: boolean; -} - -interface IWgKeySet { - type: 'key-set'; - key: IWgKey; -} - -interface IWgKeyNotSet { - type: 'key-not-set'; -} - -interface IWgTooManyKeys { - type: 'too-many-keys'; -} - -interface IWgKeyGenerationFailure { - type: 'generation-failure'; -} - -interface IWgKeyBeingGenerated { - type: 'being-generated'; -} - -interface IWgKeyBeingReplaced { - type: 'being-replaced'; - oldKey: IWgKey; -} - -interface IWgKeyBeingVerified { - type: 'being-verified'; - key: IWgKey; -} - -export type WgKeyState = - | IWgKeySet - | IWgKeyNotSet - | IWgKeyGenerationFailure - | IWgTooManyKeys - | IWgKeyBeingVerified - | IWgKeyBeingReplaced - | IWgKeyBeingGenerated; - export interface ISettingsReduxState { autoStart: boolean; guiSettings: IGuiSettingsState; @@ -140,7 +90,6 @@ export interface ISettingsReduxState { mtu?: number; }; dns: IDnsOptions; - wireguardKeyState: WgKeyState; splitTunneling: boolean; splitTunnelingApplications: IApplication[]; } @@ -183,9 +132,6 @@ const initialState: ISettingsReduxState = { showBetaReleases: false, openVpn: {}, wireguard: {}, - wireguardKeyState: { - type: 'key-not-set', - }, dns: { state: 'default', defaultOptions: { @@ -290,42 +236,6 @@ export default function ( bridgeState: action.bridgeState, }; - case 'SET_WIREGUARD_KEY': - return { - ...state, - wireguardKeyState: setWireguardKey(action.key), - }; - case 'WIREGUARD_KEYGEN_EVENT': - return { - ...state, - wireguardKeyState: setWireguardKeygenEvent(state, action.event), - }; - case 'WIREGUARD_KEY_VERIFICATION_COMPLETE': - return { - ...state, - wireguardKeyState: applyKeyVerification(state.wireguardKeyState, action.verified), - }; - case 'VERIFY_WIREGUARD_KEY': - return { - ...state, - wireguardKeyState: { type: 'being-verified', key: resetWireguardKeyErrors(action.key) }, - }; - - case 'GENERATE_WIREGUARD_KEY': - return { - ...state, - wireguardKeyState: { type: 'being-generated' }, - }; - - case 'REPLACE_WIREGUARD_KEY': - return { - ...state, - wireguardKeyState: { - type: 'being-replaced', - oldKey: resetWireguardKeyErrors(action.oldKey), - }, - }; - case 'UPDATE_DNS_OPTIONS': return { ...state, @@ -348,76 +258,3 @@ export default function ( return state; } } - -function setWireguardKey(key?: IWgKey): WgKeyState { - if (key) { - return { - type: 'key-set', - key, - }; - } else { - return { - type: 'key-not-set', - }; - } -} - -function resetWireguardKeyErrors(key: IWgKey): IWgKey { - return { - publicKey: key.publicKey, - created: key.created, - }; -} - -function setWireguardKeygenEvent(state: ISettingsReduxState, keygenEvent: KeygenEvent): WgKeyState { - const oldKeyState = state.wireguardKeyState; - if (oldKeyState.type === 'being-replaced') { - switch (keygenEvent) { - case 'too_many_keys': - case 'generation_failure': - return { - type: 'key-set', - key: { - ...oldKeyState.oldKey, - replacementFailure: keygenEvent, - }, - }; - default: - break; - } - } - switch (keygenEvent) { - case 'too_many_keys': - return { type: 'too-many-keys' }; - case 'generation_failure': - return { type: 'generation-failure' }; - default: - return { - type: 'key-set', - key: { - publicKey: keygenEvent.newKey.key, - created: keygenEvent.newKey.created, - valid: undefined, - }, - }; - } -} - -function applyKeyVerification(state: WgKeyState, verified?: boolean): WgKeyState { - const verificationFailed = verified === undefined ? true : undefined; - switch (state.type) { - case 'being-verified': - return { - type: 'key-set', - key: { - ...state.key, - valid: verified, - verificationFailed, - }, - }; - // drop the verification event if the key wasn't being verified. - default: - log.error("Received key verification event when key wasn't being verified"); - return state; - } -} diff --git a/gui/src/shared/daemon-rpc-types.ts b/gui/src/shared/daemon-rpc-types.ts index eec1e1f2a4..b08f375e96 100644 --- a/gui/src/shared/daemon-rpc-types.ts +++ b/gui/src/shared/daemon-rpc-types.ts @@ -104,8 +104,9 @@ export type DaemonEvent = | { tunnelState: TunnelState } | { settings: ISettings } | { relayList: IRelayList } - | { wireguardKey: KeygenEvent } - | { appVersionInfo: IAppVersionInfo }; + | { appVersionInfo: IAppVersionInfo } + | { device: IDeviceEvent } + | { deviceRemoval: Array<IDevice> }; export interface ITunnelStateRelayInfo { endpoint: ITunnelEndpoint; @@ -321,8 +322,28 @@ export interface IAppVersionInfo { suggestedIsBeta?: boolean; } +export interface IDeviceEvent { + deviceConfig?: IDeviceConfig; + remote?: boolean; +} + +export interface IDeviceConfig { + accountToken: AccountToken; + device?: IDevice; +} + +export interface IDevice { + id: string; + name: string; + ports?: Array<string>; +} + +export interface IDeviceRemoval { + accountToken: string; + deviceId: string; +} + export interface ISettings { - accountToken?: AccountToken; allowLan: boolean; autoConnect: boolean; blockWhenDisconnected: boolean; @@ -334,18 +355,6 @@ export interface ISettings { splitTunnel: SplitTunnelSettings; } -export type KeygenEvent = INewWireguardKey | KeygenFailure; -export type KeygenFailure = 'too_many_keys' | 'generation_failure'; - -export interface INewWireguardKey { - newKey: IWireguardPublicKey; -} - -export interface IWireguardPublicKey { - key: string; - created: string; -} - export type BridgeState = 'auto' | 'on' | 'off'; export type SplitTunnelSettings = { diff --git a/gui/src/shared/ipc-schema.ts b/gui/src/shared/ipc-schema.ts index 0ed0985bf1..29d00bb1c4 100644 --- a/gui/src/shared/ipc-schema.ts +++ b/gui/src/shared/ipc-schema.ts @@ -6,15 +6,17 @@ import { BridgeState, IAccountData, IAppVersionInfo, + IDevice, + IDeviceConfig, + IDeviceRemoval, IDnsOptions, ILocation, IRelayList, ISettings, - IWireguardPublicKey, - KeygenEvent, RelaySettingsUpdate, TunnelState, VoucherResponse, + IDeviceEvent, } from './daemon-rpc-types'; import { IGuiSettingsState } from './gui-settings-state'; import { LogLevel } from './logging-types'; @@ -52,11 +54,11 @@ export interface IAppStateSnapshot { accountHistory?: AccountToken; tunnelState: TunnelState; settings: ISettings; + deviceConfig?: IDeviceConfig; relayListPair: IRelayListPair; currentVersion: ICurrentAppVersionInfo; upgradeVersion: IAppVersionInfo; guiSettings: IGuiSettingsState; - wireguardPublicKey?: IWireguardPublicKey; translations: ITranslations; windowsSplitTunnelingApplications?: IApplication[]; macOsScrollbarVisibility?: MacOsScrollbarVisibility; @@ -166,12 +168,17 @@ export const ipcSchema = { }, account: { '': notifyRenderer<IAccountData | undefined>(), + device: notifyRenderer<IDeviceEvent>(), + devices: notifyRenderer<Array<IDevice>>(), create: invoke<void, string>(), login: invoke<AccountToken, void>(), logout: invoke<void, void>(), getWwwAuthToken: invoke<void, string>(), submitVoucher: invoke<string, VoucherResponse>(), updateData: send<void>(), + getDevice: invoke<void, IDevice | undefined>(), + listDevices: invoke<AccountToken, Array<IDevice>>(), + removeDevice: invoke<IDeviceRemoval, void>(), }, accountHistory: { '': notifyRenderer<AccountToken | undefined>(), @@ -181,12 +188,6 @@ export const ipcSchema = { '': notifyRenderer<boolean>(), set: invoke<boolean, void>(), }, - wireguardKeys: { - publicKey: notifyRenderer<IWireguardPublicKey | undefined>(), - keygenEvent: notifyRenderer<KeygenEvent>(), - generateKey: invoke<void, KeygenEvent>(), - verifyKey: invoke<void, boolean>(), - }, problemReport: { collectLogs: invoke<string | undefined, string>(), sendReport: invoke<{ email: string; message: string; savedReportId: string }, void>(), diff --git a/gui/src/shared/localization-contexts.ts b/gui/src/shared/localization-contexts.ts index b402278571..e323567094 100644 --- a/gui/src/shared/localization-contexts.ts +++ b/gui/src/shared/localization-contexts.ts @@ -2,6 +2,7 @@ export type LocalizationContexts = | 'changelog' | 'accessibility' | 'login-view' + | 'device-management' | 'auth-failure' | 'launch-view' | 'error-boundary-view' @@ -29,8 +30,6 @@ export type LocalizationContexts = | 'wireguard-settings-nav' | 'openvpn-settings-view' | 'openvpn-settings-nav' - | 'wireguard-key-view' - | 'wireguard-keys-nav' | 'split-tunneling-view' | 'split-tunneling-nav' | 'support-view' diff --git a/gui/src/shared/notifications/no-valid-key.ts b/gui/src/shared/notifications/no-valid-key.ts deleted file mode 100644 index 6de28ab8df..0000000000 --- a/gui/src/shared/notifications/no-valid-key.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { WgKeyState } from '../../renderer/redux/settings/reducers'; -import { messages } from '../../shared/gettext'; -import { LiftedConstraint, TunnelProtocol } from '../daemon-rpc-types'; -import { InAppNotification, InAppNotificationProvider } from './notification'; - -interface NoValidKeyNotificationContext { - tunnelProtocol?: LiftedConstraint<TunnelProtocol>; - wireGuardKey: WgKeyState; -} - -export class NoValidKeyNotificationProvider implements InAppNotificationProvider { - public constructor(private context: NoValidKeyNotificationContext) {} - - public mayDisplay() { - const usingWireGuard = - this.context.tunnelProtocol === 'wireguard' || - (this.context.tunnelProtocol === 'any' && - (process.platform ?? window.env.platform) !== 'win32'); - const keyInvalid = - this.context.wireGuardKey.type === 'key-not-set' || - this.context.wireGuardKey.type === 'too-many-keys' || - this.context.wireGuardKey.type === 'generation-failure' || - (this.context.wireGuardKey.type === 'key-set' && - this.context.wireGuardKey.key.valid === false) || - (this.context.wireGuardKey.type === 'key-set' && - this.context.wireGuardKey.key.replacementFailure === 'too_many_keys'); - - return usingWireGuard && keyInvalid; - } - - public getInAppNotification(): InAppNotification { - return { - indicator: 'warning', - title: messages.pgettext('in-app-notifications', 'VALID WIREGUARD KEY IS MISSING'), - subtitle: messages.pgettext('in-app-notifications', 'Manage keys under Advanced settings.'), - }; - } -} diff --git a/gui/src/shared/notifications/notification.ts b/gui/src/shared/notifications/notification.ts index 570bf2f12b..2152da0e79 100644 --- a/gui/src/shared/notifications/notification.ts +++ b/gui/src/shared/notifications/notification.ts @@ -41,7 +41,6 @@ export * from './connected'; export * from './connecting'; export * from './disconnected'; export * from './error'; -export * from './no-valid-key'; export * from './inconsistent-version'; export * from './reconnecting'; export * from './unsupported-version'; diff --git a/gui/src/shared/string-helpers.ts b/gui/src/shared/string-helpers.ts index 30a2ac9d58..c69ebddfcf 100644 --- a/gui/src/shared/string-helpers.ts +++ b/gui/src/shared/string-helpers.ts @@ -1,3 +1,7 @@ export function capitalize(inputString: string): string { return inputString.charAt(0).toUpperCase() + inputString.slice(1); } + +export function capitalizeEveryWord(inputString: string): string { + return inputString.split(' ').map(capitalize).join(' '); +} diff --git a/gui/test/history.spec.ts b/gui/test/history.spec.ts index 2c92e0219e..be0bb6a6a0 100644 --- a/gui/test/history.spec.ts +++ b/gui/test/history.spec.ts @@ -7,7 +7,7 @@ const BASE_PATH = RoutePath.launch; const FIRST_PATH = RoutePath.main; const SECOND_PATH = RoutePath.settings; const THIRD_PATH = RoutePath.advancedSettings; -const FOURTH_PATH = RoutePath.wireguardKeys; +const FOURTH_PATH = RoutePath.preferences; const FIFTH_PATH = RoutePath.splitTunneling; describe('History', () => { |
