diff options
| author | Tobias Järvelöv <tobias.jarvelov@mullvad.net> | 2025-10-10 13:40:42 +0200 |
|---|---|---|
| committer | Tobias Järvelöv <tobias.jarvelov@mullvad.net> | 2025-10-10 13:40:42 +0200 |
| commit | acb57dbe1d47464f786a23b65e30e8e09ba55e2e (patch) | |
| tree | 50de9f5ec4d856905c509b9dc54ebe76c58a46bb | |
| parent | 4c1f46f96a7456fc5933c03118179cd1e4e2675d (diff) | |
| parent | 64fb99c783c80a6e379aa2727bd7e2d7a7feadcf (diff) | |
| download | mullvadvpn-acb57dbe1d47464f786a23b65e30e8e09ba55e2e.tar.xz mullvadvpn-acb57dbe1d47464f786a23b65e30e8e09ba55e2e.zip | |
Merge remote-tracking branch 'origin/device-management-desktop'
121 files changed, 1653 insertions, 647 deletions
diff --git a/desktop/package-lock.json b/desktop/package-lock.json index ed17f05847..3737ec5558 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -5926,6 +5926,33 @@ "node": ">= 6" } }, + "node_modules/framer-motion": { + "version": "12.23.22", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.22.tgz", + "integrity": "sha512-ZgGvdxXCw55ZYvhoZChTlG6pUuehecgvEAJz0BHoC5pQKW1EC5xf1Mul1ej5+ai+pVY0pylyFfdl45qnM1/GsA==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.21", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -8176,6 +8203,47 @@ "node": ">=10" } }, + "node_modules/motion": { + "version": "12.23.22", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.23.22.tgz", + "integrity": "sha512-iSq6X9vLHbeYwmHvhK//+U74ROaPnZmBuy60XZzqNl0QtZkWfoZyMDHYnpKuWFv0sNMqHgED8aCXk94LCoQPGg==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.23.22", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.23.21", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.21.tgz", + "integrity": "sha512-5xDXx/AbhrfgsQmSE7YESMn4Dpo6x5/DTZ4Iyy4xqDvVHWvFVoV+V2Ri2S/ksx+D40wrZ7gPYiMWshkdoqNgNQ==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -10440,8 +10508,7 @@ "node_modules/tslib": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", - "dev": true + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" }, "node_modules/type-check": { "version": "0.4.0", @@ -11318,6 +11385,7 @@ "gl-matrix": "^3.4.3", "google-protobuf": "^3.21.0", "management-interface": "0.0.0", + "motion": "^12.23.22", "node-gettext": "^3.0.0", "nseventforwarder": "0.0.0", "react": "^19.1.1", @@ -15864,6 +15932,16 @@ "mime-types": "^2.1.12" } }, + "framer-motion": { + "version": "12.23.22", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.22.tgz", + "integrity": "sha512-ZgGvdxXCw55ZYvhoZChTlG6pUuehecgvEAJz0BHoC5pQKW1EC5xf1Mul1ej5+ai+pVY0pylyFfdl45qnM1/GsA==", + "requires": { + "motion-dom": "^12.23.21", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + } + }, "fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -17461,6 +17539,28 @@ } } }, + "motion": { + "version": "12.23.22", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.23.22.tgz", + "integrity": "sha512-iSq6X9vLHbeYwmHvhK//+U74ROaPnZmBuy60XZzqNl0QtZkWfoZyMDHYnpKuWFv0sNMqHgED8aCXk94LCoQPGg==", + "requires": { + "framer-motion": "^12.23.22", + "tslib": "^2.4.0" + } + }, + "motion-dom": { + "version": "12.23.21", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.21.tgz", + "integrity": "sha512-5xDXx/AbhrfgsQmSE7YESMn4Dpo6x5/DTZ4Iyy4xqDvVHWvFVoV+V2Ri2S/ksx+D40wrZ7gPYiMWshkdoqNgNQ==", + "requires": { + "motion-utils": "^12.23.6" + } + }, + "motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==" + }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -17511,6 +17611,7 @@ "google-protobuf": "^3.21.0", "management-interface": "0.0.0", "mocha": "^10.8.2", + "motion": "^12.23.22", "node-gettext": "^3.0.0", "nseventforwarder": "0.0.0", "playwright": "^1.55.0", @@ -19397,8 +19498,7 @@ "tslib": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", - "dev": true + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" }, "type-check": { "version": "0.4.0", diff --git a/desktop/packages/mullvad-vpn/locales/da/messages.po b/desktop/packages/mullvad-vpn/locales/da/messages.po index 0d4bcda751..7189f5bf6a 100644 --- a/desktop/packages/mullvad-vpn/locales/da/messages.po +++ b/desktop/packages/mullvad-vpn/locales/da/messages.po @@ -1532,7 +1532,6 @@ msgstr "Kunne ikke oprette konto" #. Error message shown above login input when trying to login but the app fails #. to fetch the list of registered devices. -msgctxt "login-view" msgid "Failed to fetch list of devices" msgstr "Kunne ikke hente listen over enheder" diff --git a/desktop/packages/mullvad-vpn/locales/de/messages.po b/desktop/packages/mullvad-vpn/locales/de/messages.po index b6316ef7f8..2049c014a7 100644 --- a/desktop/packages/mullvad-vpn/locales/de/messages.po +++ b/desktop/packages/mullvad-vpn/locales/de/messages.po @@ -1532,7 +1532,6 @@ msgstr "Konto konnte nicht erstellt werden" #. Error message shown above login input when trying to login but the app fails #. to fetch the list of registered devices. -msgctxt "login-view" msgid "Failed to fetch list of devices" msgstr "Fehler beim Abrufen der Geräteliste" diff --git a/desktop/packages/mullvad-vpn/locales/es/messages.po b/desktop/packages/mullvad-vpn/locales/es/messages.po index 3eab082ca7..c4fe8ef24f 100644 --- a/desktop/packages/mullvad-vpn/locales/es/messages.po +++ b/desktop/packages/mullvad-vpn/locales/es/messages.po @@ -1532,7 +1532,6 @@ msgstr "No se puede crear la cuenta" #. Error message shown above login input when trying to login but the app fails #. to fetch the list of registered devices. -msgctxt "login-view" msgid "Failed to fetch list of devices" msgstr "No se pudo obtener la lista de dispositivos" diff --git a/desktop/packages/mullvad-vpn/locales/fi/messages.po b/desktop/packages/mullvad-vpn/locales/fi/messages.po index de01340c11..17f1135205 100644 --- a/desktop/packages/mullvad-vpn/locales/fi/messages.po +++ b/desktop/packages/mullvad-vpn/locales/fi/messages.po @@ -1532,7 +1532,6 @@ msgstr "Tilin luonti epäonnistui" #. Error message shown above login input when trying to login but the app fails #. to fetch the list of registered devices. -msgctxt "login-view" msgid "Failed to fetch list of devices" msgstr "Laiteluettelon nouto epäonnistui" diff --git a/desktop/packages/mullvad-vpn/locales/fr/messages.po b/desktop/packages/mullvad-vpn/locales/fr/messages.po index 7268a02261..6e99623a9d 100644 --- a/desktop/packages/mullvad-vpn/locales/fr/messages.po +++ b/desktop/packages/mullvad-vpn/locales/fr/messages.po @@ -1532,7 +1532,6 @@ msgstr "Échec de la création du compte" #. Error message shown above login input when trying to login but the app fails #. to fetch the list of registered devices. -msgctxt "login-view" msgid "Failed to fetch list of devices" msgstr "Impossible de récupérer la liste des appareils" diff --git a/desktop/packages/mullvad-vpn/locales/it/messages.po b/desktop/packages/mullvad-vpn/locales/it/messages.po index 69e56447ad..f8688b9fe0 100644 --- a/desktop/packages/mullvad-vpn/locales/it/messages.po +++ b/desktop/packages/mullvad-vpn/locales/it/messages.po @@ -1532,7 +1532,6 @@ msgstr "Impossibile creare l'account" #. Error message shown above login input when trying to login but the app fails #. to fetch the list of registered devices. -msgctxt "login-view" msgid "Failed to fetch list of devices" msgstr "Impossibile recuperare l'elenco dei dispositivi" diff --git a/desktop/packages/mullvad-vpn/locales/ja/messages.po b/desktop/packages/mullvad-vpn/locales/ja/messages.po index a977b42c9d..8fef1a4e00 100644 --- a/desktop/packages/mullvad-vpn/locales/ja/messages.po +++ b/desktop/packages/mullvad-vpn/locales/ja/messages.po @@ -1526,7 +1526,6 @@ msgstr "アカウントを作成できませんでした" #. Error message shown above login input when trying to login but the app fails #. to fetch the list of registered devices. -msgctxt "login-view" msgid "Failed to fetch list of devices" msgstr "デバイスのリストを取得できませんでした" diff --git a/desktop/packages/mullvad-vpn/locales/ko/messages.po b/desktop/packages/mullvad-vpn/locales/ko/messages.po index 5273479dbf..210141bb2c 100644 --- a/desktop/packages/mullvad-vpn/locales/ko/messages.po +++ b/desktop/packages/mullvad-vpn/locales/ko/messages.po @@ -1526,7 +1526,6 @@ msgstr "계정을 만들지 못함" #. Error message shown above login input when trying to login but the app fails #. to fetch the list of registered devices. -msgctxt "login-view" msgid "Failed to fetch list of devices" msgstr "장치 목록을 가져오지 못함" diff --git a/desktop/packages/mullvad-vpn/locales/messages.pot b/desktop/packages/mullvad-vpn/locales/messages.pot index 122c7ea117..84ddc3e126 100644 --- a/desktop/packages/mullvad-vpn/locales/messages.pot +++ b/desktop/packages/mullvad-vpn/locales/messages.pot @@ -159,6 +159,11 @@ msgstr "" msgid "Enable direct only" msgstr "" +#. Error message shown trying to login but the app fails +#. to fetch the list of registered devices. +msgid "Failed to fetch list of devices" +msgstr "" + msgid "FAILED TO SECURE CONNECTION" msgstr "" @@ -448,6 +453,11 @@ msgctxt "account-view" msgid "Log out" msgstr "" +#. Link text in the account view to navigate to the manage devices view. +msgctxt "account-view" +msgid "Manage devices" +msgstr "" + msgctxt "account-view" msgid "OUT OF TIME" msgstr "" @@ -1006,14 +1016,6 @@ msgctxt "custom-bridge" msgid "Edit custom bridge" msgstr "" -#. Text displayed above button which logs out another device. -#. The text enclosed in "<b></b>" 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 <b>%(deviceName)s</b> out?" -msgstr "" - #. Button for continuing login process. msgctxt "device-management" msgid "Continue with login" @@ -1026,6 +1028,11 @@ msgctxt "device-management" msgid "Created: %(createdDate)s" msgstr "" +#. Label indicating that this device is the current device. +msgctxt "device-management" +msgid "Current device" +msgstr "" + msgctxt "device-management" msgid "Device is inactive" msgstr "" @@ -1059,10 +1066,29 @@ msgctxt "device-management" msgid "If you log out, the device and the device name is removed. When you log back in again, the device will get a new name." msgstr "" +#. Title label in navigation bar for the manage devices view. +#. Title text in the manage devices view +msgctxt "device-management" +msgid "Manage devices" +msgstr "" + msgctxt "device-management" msgid "Please log out of at least one by removing it from the list below. You can find the corresponding device name under the device’s Account settings." msgstr "" +#. Button label for confirming removing a device. +msgctxt "device-management" +msgid "Remove" +msgstr "" + +#. Text displayed above button which logs out another device. +#. The text enclosed in "<b></b>" will appear bold. +#. Available placeholders: +#. %(deviceName)s - The name of the device to log out. +msgctxt "device-management" +msgid "Remove <em>%(deviceName)s?</em>" +msgstr "" + #. Page title informing user that enough devices has been removed to continue #. login process. msgctxt "device-management" @@ -1070,6 +1096,10 @@ msgid "Super!" msgstr "" msgctxt "device-management" +msgid "The device will be removed from the list and logged out." +msgstr "" + +msgctxt "device-management" msgid "This is the name assigned to the device. Each device logged in on a Mullvad account gets a unique name that helps you identify it when you manage your devices in the app or on the website." msgstr "" @@ -1083,9 +1113,15 @@ msgctxt "device-management" msgid "Too many devices" msgstr "" -#. Button label for confirming logout of another device. +#. Button text to retry fetching devices. msgctxt "device-management" -msgid "Yes, log out device" +msgid "Try again" +msgstr "" + +#. Subtitle text in the manage devices view, explaining +#. devices and what they can do in the manage devices view. +msgctxt "device-management" +msgid "View and manage all your logged in devices. You can have up to 5 devices on one account at a time. Each device gets a name when logged in to help you tell them apart easily." msgstr "" msgctxt "device-management" @@ -1361,6 +1397,13 @@ msgctxt "in-app-notifications" msgid "The selected %(wireGuard)s port is not supported, please change it under " msgstr "" +#. Notification text when a new device has been created. +#. Available placeholders: +#. - %(deviceName)s: Name of created device. +msgctxt "in-app-notifications" +msgid "This device is now named <em>%(deviceName)s</em>. See more under \"Manage devices\" in Account." +msgstr "" + #. The in-app banner displayed to the user when the app beta update is #. available. #. Available placeholders: @@ -1397,10 +1440,6 @@ msgctxt "in-app-notifications" msgid "VERIFICATION FAILED" msgstr "" -msgctxt "in-app-notifications" -msgid "Welcome, this device is now called <b>%(deviceName)s</b>. For more details see the info button in Account." -msgstr "" - #. Status text app is trying to connect to the system service. msgctxt "launch-view" msgid "Connecting to Mullvad system service..." @@ -1528,12 +1567,6 @@ msgctxt "login-view" msgid "Failed to create account" msgstr "" -#. Error message shown above login input when trying to login but the app fails -#. to fetch the list of registered devices. -msgctxt "login-view" -msgid "Failed to fetch list of devices" -msgstr "" - msgctxt "login-view" msgid "Finishing upgrade." msgstr "" @@ -2954,6 +2987,9 @@ msgstr "" msgid "Android 16 has a known issue. Please restart your device and try again. To learn more," msgstr "" +msgid "Are you sure you want to log <b>%s</b> out?" +msgstr "" + msgid "At least one method needs to be enabled" msgstr "" @@ -3026,9 +3062,6 @@ msgstr "" msgid "Critical error (your attention is required)" msgstr "" -msgid "Current device" -msgstr "" - msgid "Current: %s" msgstr "" @@ -3194,9 +3227,6 @@ msgstr "" msgid "Makes sure the device is always on the VPN tunnel." msgstr "" -msgid "Manage devices" -msgstr "" - msgid "More actions" msgstr "" @@ -3359,9 +3389,6 @@ msgstr "" msgid "The app is blocking internet, please disconnect first" msgstr "" -msgid "The device will be removed from the list and logged out." -msgstr "" - msgid "The local DNS server will not work unless you enable \"Local Network Sharing\" under VPN settings." msgstr "" @@ -3461,9 +3488,6 @@ msgstr "" msgid "Verifying voucher…" msgstr "" -msgid "View and manage all your logged in devices. You can have up to 5 devices on one account at a time. Each device gets a name when logged in to help you tell them apart easily." -msgstr "" - msgid "We are still verifying your purchase, this might take some time. Your time will be added if the verification is successful." msgstr "" @@ -3485,6 +3509,9 @@ msgstr "" msgid "WireGuard port" msgstr "" +msgid "Yes, log out device" +msgstr "" + msgid "\"%s\" was created" msgstr "" diff --git a/desktop/packages/mullvad-vpn/locales/my/messages.po b/desktop/packages/mullvad-vpn/locales/my/messages.po index e927fe1800..036bebe63f 100644 --- a/desktop/packages/mullvad-vpn/locales/my/messages.po +++ b/desktop/packages/mullvad-vpn/locales/my/messages.po @@ -1526,7 +1526,6 @@ msgstr "အကောင့် ဖန်တီးရန် မအောင်မ #. Error message shown above login input when trying to login but the app fails #. to fetch the list of registered devices. -msgctxt "login-view" msgid "Failed to fetch list of devices" msgstr "စက်စာရင်းကို ယူရန် မအောင်မြင်ခဲ့ပါ" diff --git a/desktop/packages/mullvad-vpn/locales/nb/messages.po b/desktop/packages/mullvad-vpn/locales/nb/messages.po index 7e501a1b1d..18ef96e9f8 100644 --- a/desktop/packages/mullvad-vpn/locales/nb/messages.po +++ b/desktop/packages/mullvad-vpn/locales/nb/messages.po @@ -1532,7 +1532,6 @@ msgstr "Kunne ikke opprette konto" #. Error message shown above login input when trying to login but the app fails #. to fetch the list of registered devices. -msgctxt "login-view" msgid "Failed to fetch list of devices" msgstr "Kunne ikke hente liste over enheter" diff --git a/desktop/packages/mullvad-vpn/locales/nl/messages.po b/desktop/packages/mullvad-vpn/locales/nl/messages.po index e728f3a2e2..22fd5c579e 100644 --- a/desktop/packages/mullvad-vpn/locales/nl/messages.po +++ b/desktop/packages/mullvad-vpn/locales/nl/messages.po @@ -1532,7 +1532,6 @@ msgstr "Account aanmaken mislukt" #. Error message shown above login input when trying to login but the app fails #. to fetch the list of registered devices. -msgctxt "login-view" msgid "Failed to fetch list of devices" msgstr "Ophalen van lijst van apparaten mislukt" diff --git a/desktop/packages/mullvad-vpn/locales/pl/messages.po b/desktop/packages/mullvad-vpn/locales/pl/messages.po index 39aa401af3..e2f6145ff9 100644 --- a/desktop/packages/mullvad-vpn/locales/pl/messages.po +++ b/desktop/packages/mullvad-vpn/locales/pl/messages.po @@ -1544,7 +1544,6 @@ msgstr "Nie można utworzyć konta" #. Error message shown above login input when trying to login but the app fails #. to fetch the list of registered devices. -msgctxt "login-view" msgid "Failed to fetch list of devices" msgstr "Nie udało się pobrać listy urządzeń" diff --git a/desktop/packages/mullvad-vpn/locales/pt/messages.po b/desktop/packages/mullvad-vpn/locales/pt/messages.po index bfac5ddd8e..3c7c6e806e 100644 --- a/desktop/packages/mullvad-vpn/locales/pt/messages.po +++ b/desktop/packages/mullvad-vpn/locales/pt/messages.po @@ -1532,7 +1532,6 @@ msgstr "Não foi possível criar a conta" #. Error message shown above login input when trying to login but the app fails #. to fetch the list of registered devices. -msgctxt "login-view" msgid "Failed to fetch list of devices" msgstr "Erro ao obter a lista de dispositivos" diff --git a/desktop/packages/mullvad-vpn/locales/ru/messages.po b/desktop/packages/mullvad-vpn/locales/ru/messages.po index 845a4ad2bf..0a159b62f1 100644 --- a/desktop/packages/mullvad-vpn/locales/ru/messages.po +++ b/desktop/packages/mullvad-vpn/locales/ru/messages.po @@ -1544,7 +1544,6 @@ msgstr "Не удалось создать учетную запись" #. Error message shown above login input when trying to login but the app fails #. to fetch the list of registered devices. -msgctxt "login-view" msgid "Failed to fetch list of devices" msgstr "Не удалось получить список устройств" diff --git a/desktop/packages/mullvad-vpn/locales/sv/messages.po b/desktop/packages/mullvad-vpn/locales/sv/messages.po index 430007b642..648714e759 100644 --- a/desktop/packages/mullvad-vpn/locales/sv/messages.po +++ b/desktop/packages/mullvad-vpn/locales/sv/messages.po @@ -1532,7 +1532,6 @@ msgstr "Det gick inte att skapa konto" #. Error message shown above login input when trying to login but the app fails #. to fetch the list of registered devices. -msgctxt "login-view" msgid "Failed to fetch list of devices" msgstr "Det gick inte att hämta lista med enheter" diff --git a/desktop/packages/mullvad-vpn/locales/th/messages.po b/desktop/packages/mullvad-vpn/locales/th/messages.po index 7afd1fb692..2af3cafc0e 100644 --- a/desktop/packages/mullvad-vpn/locales/th/messages.po +++ b/desktop/packages/mullvad-vpn/locales/th/messages.po @@ -1526,7 +1526,6 @@ msgstr "ไม่สามารถสร้างบัญชีได้" #. Error message shown above login input when trying to login but the app fails #. to fetch the list of registered devices. -msgctxt "login-view" msgid "Failed to fetch list of devices" msgstr "ไม่สามารถดึงรายการอุปกรณ์มาได้" diff --git a/desktop/packages/mullvad-vpn/locales/tr/messages.po b/desktop/packages/mullvad-vpn/locales/tr/messages.po index b4b3ed7c16..0a529d06fd 100644 --- a/desktop/packages/mullvad-vpn/locales/tr/messages.po +++ b/desktop/packages/mullvad-vpn/locales/tr/messages.po @@ -1532,7 +1532,6 @@ msgstr "Hesap oluşturulamadı" #. Error message shown above login input when trying to login but the app fails #. to fetch the list of registered devices. -msgctxt "login-view" msgid "Failed to fetch list of devices" msgstr "Cihaz listesi alınamadı" diff --git a/desktop/packages/mullvad-vpn/locales/zh-CN/messages.po b/desktop/packages/mullvad-vpn/locales/zh-CN/messages.po index 0b27484b49..39f0998d1b 100644 --- a/desktop/packages/mullvad-vpn/locales/zh-CN/messages.po +++ b/desktop/packages/mullvad-vpn/locales/zh-CN/messages.po @@ -1526,7 +1526,6 @@ msgstr "无法创建帐户" #. Error message shown above login input when trying to login but the app fails #. to fetch the list of registered devices. -msgctxt "login-view" msgid "Failed to fetch list of devices" msgstr "无法获取设备列表" diff --git a/desktop/packages/mullvad-vpn/locales/zh-TW/messages.po b/desktop/packages/mullvad-vpn/locales/zh-TW/messages.po index 0949123946..94b5a1d7d1 100644 --- a/desktop/packages/mullvad-vpn/locales/zh-TW/messages.po +++ b/desktop/packages/mullvad-vpn/locales/zh-TW/messages.po @@ -1526,7 +1526,6 @@ msgstr "無法建立帳戶" #. Error message shown above login input when trying to login but the app fails #. to fetch the list of registered devices. -msgctxt "login-view" msgid "Failed to fetch list of devices" msgstr "無法取得裝置清單" diff --git a/desktop/packages/mullvad-vpn/package.json b/desktop/packages/mullvad-vpn/package.json index 6b49ba6b14..f3e81bc953 100644 --- a/desktop/packages/mullvad-vpn/package.json +++ b/desktop/packages/mullvad-vpn/package.json @@ -18,6 +18,7 @@ "gl-matrix": "^3.4.3", "google-protobuf": "^3.21.0", "management-interface": "0.0.0", + "motion": "^12.23.22", "node-gettext": "^3.0.0", "nseventforwarder": "0.0.0", "react": "^19.1.1", diff --git a/desktop/packages/mullvad-vpn/scripts/verify-translations-format.ts b/desktop/packages/mullvad-vpn/scripts/verify-translations-format.ts index ad3c027ac9..4b21b46426 100644 --- a/desktop/packages/mullvad-vpn/scripts/verify-translations-format.ts +++ b/desktop/packages/mullvad-vpn/scripts/verify-translations-format.ts @@ -4,7 +4,7 @@ import path from 'path'; const LOCALES_DIR = path.join('locales'); -const ALLOWED_TAGS = ['b', 'br']; +const ALLOWED_TAGS = ['b', 'br', 'em']; const ALLOWED_VOID_TAGS = ['br']; // Make sure to report these strings to crowdin. View this as a temporary escape diff --git a/desktop/packages/mullvad-vpn/src/renderer/app.tsx b/desktop/packages/mullvad-vpn/src/renderer/app.tsx index 120d2d8ce5..74b4fd1c6c 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/app.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/app.tsx @@ -1,3 +1,4 @@ +import { MotionConfig } from 'motion/react'; import { StrictMode } from 'react'; import { Provider } from 'react-redux'; import { Router } from 'react-router'; @@ -349,7 +350,9 @@ export default class AppRenderer { <ErrorBoundary> <ModalContainer> <KeyboardNavigation> - <AppRouter /> + <MotionConfig reducedMotion="user"> + <AppRouter /> + </MotionConfig> </KeyboardNavigation> {window.env.platform === 'darwin' && <MacOsScrollbarDetection />} </ModalContainer> diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/Account.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/Account.tsx deleted file mode 100644 index b7c1f2354a..0000000000 --- a/desktop/packages/mullvad-vpn/src/renderer/components/Account.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { useCallback, useEffect } from 'react'; - -import { formatDate, hasExpired } from '../../shared/account-expiry'; -import { urls } from '../../shared/constants'; -import { messages } from '../../shared/gettext'; -import { useAppContext } from '../context'; -import { Button, Flex } from '../lib/components'; -import { FlexColumn } from '../lib/components/flex-column'; -import { useHistory } from '../lib/history'; -import { useExclusiveTask } from '../lib/hooks/use-exclusive-task'; -import { useEffectEvent } from '../lib/utility-hooks'; -import { useSelector } from '../redux/store'; -import { AppNavigationHeader } from './'; -import AccountNumberLabel from './AccountNumberLabel'; -import { - AccountContainer, - AccountOutOfTime, - AccountRow, - AccountRowLabel, - AccountRows, - AccountRowValue, - DeviceRowValue, -} from './AccountStyles'; -import DeviceInfoButton from './DeviceInfoButton'; -import { BackAction } from './KeyboardNavigation'; -import { Footer, Layout, SettingsContainer } from './Layout'; -import { RedeemVoucherButton } from './RedeemVoucher'; -import SettingsHeader, { HeaderTitle } from './SettingsHeader'; - -export default function Account() { - const history = useHistory(); - const isOffline = useSelector((state) => state.connection.isBlocked); - const { updateAccountData, openUrlWithAuth, logout } = useAppContext(); - - const [buyMore] = useExclusiveTask(async () => { - await openUrlWithAuth(urls.purchase); - }); - - const onMount = useEffectEvent(() => updateAccountData()); - // These lint rules are disabled for now because the react plugin for eslint does - // not understand that useEffectEvent should not be added to the dependency array. - // Enable these rules again when eslint can lint useEffectEvent properly. - // eslint-disable-next-line react-compiler/react-compiler - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => onMount(), []); - - // Hack needed because if we just call `logout` directly in `onClick` - // then it is run with the wrong `this`. - const doLogout = useCallback(async () => { - await logout(); - }, [logout]); - - return ( - <BackAction action={history.pop}> - <Layout> - <SettingsContainer> - <AppNavigationHeader - title={ - // TRANSLATORS: Title label in navigation bar - messages.pgettext('account-view', 'Account') - } - /> - - <AccountContainer> - <SettingsHeader> - <HeaderTitle>{messages.pgettext('account-view', 'Account')}</HeaderTitle> - </SettingsHeader> - - <AccountRows> - <AccountRow> - <AccountRowLabel> - {messages.pgettext('device-management', 'Device name')} - </AccountRowLabel> - <DeviceNameRow /> - </AccountRow> - - <AccountRow> - <AccountRowLabel> - {messages.pgettext('account-view', 'Account number')} - </AccountRowLabel> - <AccountNumberRow /> - </AccountRow> - - <AccountRow> - <AccountRowLabel>{messages.pgettext('account-view', 'Paid until')}</AccountRowLabel> - <AccountExpiryRow /> - </AccountRow> - </AccountRows> - - <Footer> - <FlexColumn $gap="medium"> - <Button - variant="success" - disabled={isOffline} - onClick={buyMore} - aria-description={messages.pgettext('accessibility', 'Opens externally')}> - <Button.Text>{messages.gettext('Buy more credit')}</Button.Text> - <Button.Icon icon="external" /> - </Button> - - <RedeemVoucherButton /> - - <Button variant="destructive" onClick={doLogout}> - <Button.Text> - { - // TRANSLATORS: Button label for logging out. - messages.pgettext('account-view', 'Log out') - } - </Button.Text> - </Button> - </FlexColumn> - </Footer> - </AccountContainer> - </SettingsContainer> - </Layout> - </BackAction> - ); -} - -function DeviceNameRow() { - const deviceName = useSelector((state) => state.account.deviceName); - return ( - <Flex $gap="small" $alignItems="center"> - <DeviceRowValue>{deviceName}</DeviceRowValue> - <DeviceInfoButton /> - </Flex> - ); -} - -function AccountNumberRow() { - const accountNumber = useSelector((state) => state.account.accountNumber); - return <AccountRowValue as={AccountNumberLabel} accountNumber={accountNumber || ''} />; -} - -function AccountExpiryRow() { - const accountExpiry = useSelector((state) => state.account.expiry); - const expiryLocale = useSelector((state) => state.userInterface.locale); - return <FormattedAccountExpiry expiry={accountExpiry} locale={expiryLocale} />; -} - -function FormattedAccountExpiry(props: { expiry?: string; locale: string }) { - if (props.expiry) { - if (hasExpired(props.expiry)) { - return ( - <AccountOutOfTime>{messages.pgettext('account-view', 'OUT OF TIME')}</AccountOutOfTime> - ); - } else { - return <AccountRowValue>{formatDate(props.expiry, props.locale)}</AccountRowValue>; - } - } else { - return ( - <AccountRowValue> - {messages.pgettext('account-view', 'Currently unavailable')} - </AccountRowValue> - ); - } -} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/AccountStyles.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/AccountStyles.tsx deleted file mode 100644 index aacef7f662..0000000000 --- a/desktop/packages/mullvad-vpn/src/renderer/components/AccountStyles.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import styled from 'styled-components'; - -import { colors } from '../lib/foundations'; -import { measurements, normalText, tinyText } from './common-styles'; - -export const AccountContainer = styled.div({ - display: 'flex', - flexDirection: 'column', - flex: 1, -}); - -export const AccountRows = styled.div({ - display: 'flex', - flexDirection: 'column', - flex: 1, -}); - -export const AccountRow = styled.div({ - padding: `0 ${measurements.horizontalViewMargin}`, - marginBottom: measurements.rowVerticalMargin, -}); - -const AccountRowText = styled.span({ - display: 'block', - fontFamily: 'Open Sans', -}); - -export const AccountRowLabel = styled(AccountRowText)(tinyText, { - lineHeight: '20px', - marginBottom: '5px', - color: colors.whiteAlpha60, -}); - -export const AccountRowValue = styled(AccountRowText)(normalText, { - fontWeight: 600, - color: colors.white, -}); - -export const DeviceRowValue = styled(AccountRowValue)({ - textTransform: 'capitalize', -}); - -export const AccountOutOfTime = styled(AccountRowValue)({ - color: colors.red, -}); - -export const StyledDeviceNameRow = styled.div({ - display: 'flex', - flexDirection: 'row', -}); diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx index 5f0bf016d2..561ab55877 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx @@ -4,7 +4,6 @@ import { Route, Switch } from 'react-router'; import { RoutePath } from '../../shared/routes'; import SelectLocation from '../components/select-location/SelectLocationContainer'; import { useViewTransitions } from '../lib/transition-hooks'; -import Account from './Account'; import ApiAccessMethods from './ApiAccessMethods'; import Debug from './Debug'; import { DeviceRevokedView } from './DeviceRevokedView'; @@ -25,9 +24,9 @@ import SettingsImport from './SettingsImport'; import SettingsTextImport from './SettingsTextImport'; import StateTriggeredNavigation from './StateTriggeredNavigation'; import Support from './Support'; -import TooManyDevices from './TooManyDevices'; import UserInterfaceSettings from './UserInterfaceSettings'; import { + Account, AppInfoView, AppUpgradeView, ChangelogView, @@ -35,11 +34,13 @@ import { LaunchView, LoginView, MainView, + ManageDevicesView, MultihopSettingsView, OpenVpnSettingsView, SettingsView, ShadowsocksSettingsView, SplitTunnelingView, + TooManyDevicesView, UdpOverTcpSettingsView, VpnSettingsView, WireguardSettingsView, @@ -60,7 +61,7 @@ export default function AppRouter() { <Switch key={currentLocation.key} location={currentLocation}> <Route exact path={RoutePath.launch} component={LaunchView} /> <Route exact path={RoutePath.login} component={LoginView} /> - <Route exact path={RoutePath.tooManyDevices} component={TooManyDevices} /> + <Route exact path={RoutePath.tooManyDevices} component={TooManyDevicesView} /> <Route exact path={RoutePath.deviceRevoked} component={DeviceRevokedView} /> <Route exact path={RoutePath.main} component={MainView} /> <Route exact path={RoutePath.expired} component={ExpiredAccountErrorView} /> @@ -93,6 +94,7 @@ export default function AppRouter() { <Route exact path={RoutePath.appInfo} component={AppInfoView} /> <Route exact path={RoutePath.changelog} component={ChangelogView} /> <Route exact path={RoutePath.appUpgrade} component={AppUpgradeView} /> + <Route exact path={RoutePath.manageDevices} component={ManageDevicesView} /> </Switch> </Focus> </> diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/ExpiredAccountErrorView.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/ExpiredAccountErrorView.tsx index 61db44db59..1e7005c554 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/ExpiredAccountErrorView.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/ExpiredAccountErrorView.tsx @@ -5,13 +5,13 @@ import { urls } from '../../shared/constants'; import { messages } from '../../shared/gettext'; import log from '../../shared/logging'; import { RoutePath } from '../../shared/routes'; -import { capitalizeEveryWord } from '../../shared/string-helpers'; import { useAppContext } from '../context'; import { Button, Flex } from '../lib/components'; import { FlexColumn } from '../lib/components/flex-column'; import { useHistory } from '../lib/history'; import { useExclusiveTask } from '../lib/hooks/use-exclusive-task'; import { IconBadge } from '../lib/icon-badge'; +import { formatDeviceName } from '../lib/utils'; import { useSelector } from '../redux/store'; import { AppMainHeader } from './app-main-header'; import * as Cell from './cell'; @@ -138,7 +138,7 @@ function WelcomeView() { // TRANSLATORS: %(deviceName)s - The name of the current device messages.pgettext('device-management', 'Device name: %(deviceName)s'), { - deviceName: capitalizeEveryWord(account.deviceName ?? ''), + deviceName: formatDeviceName(account.deviceName ?? ''), }, )} </StyledDeviceLabel> diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/Modal.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/Modal.tsx index 85b73fa82a..3e3ba50e94 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/Modal.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/Modal.tsx @@ -159,7 +159,7 @@ const ModalAlertButtonContainer = styled.div({ marginRight: '16px', }); -interface IModalAlertProps { +interface IModalAlertBaseProps { type?: ModalAlertType; iconColor?: string; title?: string; @@ -170,6 +170,10 @@ interface IModalAlertProps { close?: () => void; } +export interface IModalAlertProps extends IModalAlertBaseProps { + isOpen: boolean; +} + interface OpenState { isClosing: boolean; wasOpen: boolean; @@ -218,7 +222,7 @@ interface IModalAlertState { visible: boolean; } -interface IModalAlertImplProps extends IModalAlertProps, IModalContext { +interface IModalAlertImplProps extends IModalAlertBaseProps, IModalContext { closing: boolean; onTransitionEnd: () => void; } diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/TooManyDevices.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/TooManyDevices.tsx deleted file mode 100644 index baa57853f4..0000000000 --- a/desktop/packages/mullvad-vpn/src/renderer/components/TooManyDevices.tsx +++ /dev/null @@ -1,346 +0,0 @@ -import { useCallback } from 'react'; -import { sprintf } from 'sprintf-js'; -import styled from 'styled-components'; - -import { IDevice } from '../../shared/daemon-rpc-types'; -import { messages } from '../../shared/gettext'; -import log from '../../shared/logging'; -import { RoutePath } from '../../shared/routes'; -import { capitalizeEveryWord } from '../../shared/string-helpers'; -import { useAppContext } from '../context'; -import { Button, Flex, IconButton, Spinner } from '../lib/components'; -import { FlexColumn } from '../lib/components/flex-column'; -import { colors } from '../lib/foundations'; -import { TransitionType, useHistory } from '../lib/history'; -import { formatHtml } from '../lib/html-formatter'; -import { IconBadge, IconBadgeProps } from '../lib/icon-badge'; -import { useBoolean } from '../lib/utility-hooks'; -import { useSelector } from '../redux/store'; -import { AppMainHeader } from './app-main-header'; -import * as Cell from './cell'; -import { bigText, measurements, normalText, tinyText } from './common-styles'; -import CustomScrollbars from './CustomScrollbars'; -import { Footer, 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)({ - minHeight: '100%', -}); - -const StyledBody = styled.div({ - display: 'flex', - flexDirection: 'column', - flex: 1, - paddingBottom: 'auto', -}); - -const StyledTitle = styled.span(bigText, { - lineHeight: '38px', - margin: `0 ${measurements.horizontalViewMargin} 8px`, - color: colors.white, -}); - -const StyledLabel = styled.span({ - fontFamily: 'Open Sans', - fontSize: '12px', - fontWeight: 600, - lineHeight: '20px', - color: colors.white, - margin: `0 ${measurements.horizontalViewMargin} 18px`, -}); - -const StyledSpacer = styled.div({ - flex: '1', -}); - -const StyledDeviceInfo = styled(Cell.Label)({ - display: 'flex', - flexDirection: 'column', - marginTop: '9px', - marginBottom: '9px', -}); - -const StyledDeviceName = styled.span(normalText, { - fontWeight: 'normal', - lineHeight: '20px', - textTransform: 'capitalize', -}); - -const StyledDeviceDate = styled.span(tinyText, { - fontSize: '10px', - lineHeight: '10px', - color: colors.whiteAlpha60, -}); - -export default function TooManyDevices() { - const { reset } = useHistory(); - const { removeDevice, login, cancelLogin } = useAppContext(); - const accountNumber = useSelector((state) => state.account.accountNumber)!; - const devices = useSelector((state) => state.account.devices); - const loginState = useSelector((state) => state.account.status); - - const onRemoveDevice = useCallback( - async (deviceId: string) => { - await removeDevice({ accountNumber, deviceId }); - }, - [removeDevice, accountNumber], - ); - - const continueLogin = useCallback(() => { - void login(accountNumber); - reset(RoutePath.login, { transition: TransitionType.pop }); - }, [reset, login, accountNumber]); - const cancel = useCallback(() => { - cancelLogin(); - reset(RoutePath.login, { transition: TransitionType.pop }); - }, [reset, cancelLogin]); - - const imageSource = getIconSource(devices); - const title = getTitle(devices); - const subtitle = getSubtitle(devices); - - const continueButtonDisabled = devices.length === 5 || loginState.type !== 'too many devices'; - - return ( - <ModalContainer> - <Layout> - <AppMainHeader> - <AppMainHeader.SettingsButton /> - </AppMainHeader> - <StyledCustomScrollbars fillContainer> - <StyledContainer> - <StyledBody> - <Flex $justifyContent="center" $margin={{ top: 'large', bottom: 'medium' }}> - <IconBadge key={imageSource} state={imageSource} /> - </Flex> - {devices !== undefined && ( - <> - <StyledTitle data-testid="title">{title}</StyledTitle> - <StyledLabel>{subtitle}</StyledLabel> - <DeviceList devices={devices} onRemoveDevice={onRemoveDevice} /> - </> - )} - </StyledBody> - - {devices !== undefined && ( - <Footer> - <FlexColumn $gap="medium"> - <Button - variant="success" - onClick={continueLogin} - disabled={continueButtonDisabled}> - <Button.Text> - { - // TRANSLATORS: Button for continuing login process. - messages.pgettext('device-management', 'Continue with login') - } - </Button.Text> - </Button> - <Button onClick={cancel}> - <Button.Text>{messages.gettext('Back')}</Button.Text> - </Button> - </FlexColumn> - </Footer> - )} - </StyledContainer> - </StyledCustomScrollbars> - </Layout> - </ModalContainer> - ); -} - -interface IDeviceListProps { - devices: Array<IDevice>; - onRemoveDevice: (deviceId: string) => Promise<void>; -} - -function DeviceList(props: IDeviceListProps) { - return ( - <StyledSpacer> - <List items={props.devices} getKey={getDeviceKey}> - {(device) => <Device device={device} onRemove={props.onRemoveDevice} />} - </List> - </StyledSpacer> - ); -} - -const getDeviceKey = (device: IDevice): string => device.id; - -interface IDeviceProps { - device: IDevice; - onRemove: (deviceId: string) => Promise<void>; -} - -function Device(props: IDeviceProps) { - const { onRemove: propsOnRemove } = props; - - const { fetchDevices } = useAppContext(); - const accountNumber = useSelector((state) => state.account.accountNumber)!; - const [confirmationVisible, showConfirmation, hideConfirmation] = useBoolean(false); - const [deleting, setDeleting, unsetDeleting] = useBoolean(false); - const [error, setError, resetError] = useBoolean(false); - - const handleError = useCallback( - async (error: Error) => { - log.error(`Failed to remove device: ${error.message}`); - - let devices: Array<IDevice> | undefined = undefined; - try { - devices = await fetchDevices(accountNumber); - } catch { - /* no-op */ - } - - if (devices === undefined || devices.find((device) => device.id === props.device.id)) { - hideConfirmation(); - unsetDeleting(); - setError(); - } - }, - [fetchDevices, accountNumber, props.device.id, hideConfirmation, unsetDeleting, setError], - ); - - const onRemove = useCallback(async () => { - setDeleting(); - hideConfirmation(); - try { - await propsOnRemove(props.device.id); - } catch (e) { - await handleError(e as Error); - } - }, [propsOnRemove, props.device.id, hideConfirmation, setDeleting, handleError]); - - const capitalizedDeviceName = capitalizeEveryWord(props.device.name); - const createdDate = props.device.created.toISOString().split('T')[0]; - - return ( - <> - <Cell.Container> - <StyledDeviceInfo> - <StyledDeviceName aria-hidden>{props.device.name}</StyledDeviceName> - <StyledDeviceDate> - {sprintf( - // TRANSLATORS: Label informing the user when a device was created. - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(createdDate)s - The creation date of the device. - messages.pgettext('device-management', 'Created: %(createdDate)s'), - { - createdDate, - }, - )} - </StyledDeviceDate> - </StyledDeviceInfo> - {deleting ? ( - <Spinner /> - ) : ( - <IconButton - variant="secondary" - 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 }, - )}> - <IconButton.Icon icon="cross-circle" /> - </IconButton> - )} - </Cell.Container> - <ModalAlert - isOpen={confirmationVisible} - type={ModalAlertType.warning} - iconColor={colors.red} - buttons={[ - <Button variant="destructive" key="remove" onClick={onRemove} disabled={deleting}> - <Button.Text> - { - // TRANSLATORS: Button label for confirming logout of another device. - messages.pgettext('device-management', 'Yes, log out device') - } - </Button.Text> - </Button>, - <Button key="back" onClick={hideConfirmation} disabled={deleting}> - <Button.Text>{messages.gettext('Back')}</Button.Text> - </Button>, - ]} - close={hideConfirmation}> - <ModalMessage> - {formatHtml( - sprintf( - // TRANSLATORS: Text displayed above button which logs out another device. - // TRANSLATORS: The text enclosed in "<b></b>" 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 <b>%(deviceName)s</b> out?', - ), - { deviceName: capitalizedDeviceName }, - ), - )} - </ModalMessage> - </ModalAlert> - <ModalAlert - isOpen={error} - type={ModalAlertType.warning} - iconColor={colors.red} - buttons={[ - <Button key="close" onClick={resetError}> - <Button.Text>{messages.gettext('Close')}</Button.Text> - </Button>, - ]} - close={resetError} - message={messages.pgettext('device-management', 'Failed to remove device')} - /> - </> - ); -} - -function getIconSource(devices: Array<IDevice>): IconBadgeProps['state'] { - if (devices.length === 5) { - return 'negative'; - } else { - return 'positive'; - } -} - -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', - 'Please log out of at least one by removing it from the list below. You can find the corresponding device name 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/desktop/packages/mullvad-vpn/src/renderer/components/app-main-header/components/AppMainHeaderDeviceInfo.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/app-main-header/components/AppMainHeaderDeviceInfo.tsx index f1a52371c9..c7dfa3c5ff 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/app-main-header/components/AppMainHeaderDeviceInfo.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/app-main-header/components/AppMainHeaderDeviceInfo.tsx @@ -3,8 +3,8 @@ import styled from 'styled-components'; import { closeToExpiry, formatRemainingTime, hasExpired } from '../../../../shared/account-expiry'; import { messages } from '../../../../shared/gettext'; -import { capitalizeEveryWord } from '../../../../shared/string-helpers'; import { Flex, FootnoteMini } from '../../../lib/components'; +import { formatDeviceName } from '../../../lib/utils'; import { useSelector } from '../../../redux/store'; const StyledTimeLeftLabel = styled(FootnoteMini)({ @@ -41,7 +41,7 @@ export const AppMainHeaderDeviceInfo = () => { // TRANSLATORS: %(deviceName)s - The name of the current device messages.pgettext('device-management', 'Device name: %(deviceName)s'), { - deviceName: capitalizeEveryWord(deviceName ?? ''), + deviceName: formatDeviceName(deviceName ?? ''), }, )} </StyledDeviceLabel> diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/DeviceListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/DeviceListItem.tsx new file mode 100644 index 0000000000..ef85339eca --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/DeviceListItem.tsx @@ -0,0 +1,98 @@ +import { sprintf } from 'sprintf-js'; +import styled, { css } from 'styled-components'; + +import { IDevice } from '../../../shared/daemon-rpc-types'; +import { messages } from '../../../shared/gettext'; +import { Text } from '../../lib/components'; +import { FlexColumn } from '../../lib/components/flex-column'; +import { ListItem, ListItemProps } from '../../lib/components/list-item'; +import { spacings } from '../../lib/foundations'; +import { useBoolean } from '../../lib/utility-hooks'; +import { formatDeviceName } from '../../lib/utils'; +import { DeviceListItemProvider, useDeviceListItemContext } from './'; +import { ConfirmDialog, ErrorDialog, RemoveButton } from './components'; +import { useFormattedDate, useIsCurrentDevice } from './hooks'; + +export type SettingsToggleListItemProps = { + device: IDevice; +} & Omit<ListItemProps, 'children'>; + +const StyledListItem = styled(ListItem)<{ $isCurrentDevice: boolean }>( + ({ $isCurrentDevice }) => css` + ${() => { + if ($isCurrentDevice) { + return css` + margin-bottom: ${spacings.medium}; + `; + } + return null; + }} + `, +); + +function DeviceListItemInner({ ...props }: Omit<SettingsToggleListItemProps, 'device'>) { + const { device, deleting, confirmDialogVisible, error } = useDeviceListItemContext(); + const createdDate = useFormattedDate(device.created); + const isCurrentDevice = useIsCurrentDevice(); + + return ( + <> + <StyledListItem disabled={deleting} $isCurrentDevice={isCurrentDevice} {...props}> + <ListItem.Item> + <ListItem.Content> + <FlexColumn> + <ListItem.Label>{formatDeviceName(device.name)}</ListItem.Label> + <ListItem.Text variant="footnoteMini"> + {sprintf( + // TRANSLATORS: Label informing the user when a device was created. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(createdDate)s - The creation date of the device. + messages.pgettext('device-management', 'Created: %(createdDate)s'), + { + createdDate, + }, + )} + </ListItem.Text> + </FlexColumn> + <ListItem.Group> + {isCurrentDevice ? ( + <Text variant="labelTiny" color="whiteAlpha60"> + { + // TRANSLATORS: Label indicating that this device is the current device. + messages.pgettext('device-management', 'Current device') + } + </Text> + ) : ( + <RemoveButton /> + )} + </ListItem.Group> + </ListItem.Content> + </ListItem.Item> + </StyledListItem> + <ConfirmDialog isOpen={confirmDialogVisible} /> + <ErrorDialog isOpen={error} /> + </> + ); +} + +export function DeviceListItem({ device, ...props }: SettingsToggleListItemProps) { + const [confirmDialogVisible, showConfirmDialog, hideConfirmDialog] = useBoolean(false); + const [error, setError, resetError] = useBoolean(false); + const [deleting, setDeleting, resetDeleting] = useBoolean(false); + + return ( + <DeviceListItemProvider + device={device} + deleting={deleting} + setDeleting={setDeleting} + resetDeleting={resetDeleting} + confirmDialogVisible={confirmDialogVisible} + showConfirmDialog={showConfirmDialog} + hideConfirmDialog={hideConfirmDialog} + error={error} + resetError={resetError} + setError={setError}> + <DeviceListItemInner {...props} /> + </DeviceListItemProvider> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/DeviceListItemContext.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/DeviceListItemContext.tsx new file mode 100644 index 0000000000..e12d70e1d5 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/DeviceListItemContext.tsx @@ -0,0 +1,35 @@ +import { createContext, useContext } from 'react'; + +import { IDevice } from '../../../shared/daemon-rpc-types'; + +type DeviceListItemContextType = { + device: IDevice; + deleting: boolean; + setDeleting: () => void; + resetDeleting: () => void; + confirmDialogVisible: boolean; + showConfirmDialog: () => void; + hideConfirmDialog: () => void; + error: boolean; + setError: () => void; + resetError: () => void; +}; + +const DeviceListItemContext = createContext<DeviceListItemContextType | undefined>(undefined); + +type DeviceListItemContextProviderProps = React.PropsWithChildren<DeviceListItemContextType>; + +export const DeviceListItemProvider = ({ + children, + ...props +}: DeviceListItemContextProviderProps) => { + return <DeviceListItemContext.Provider value={props}>{children}</DeviceListItemContext.Provider>; +}; + +export const useDeviceListItemContext = (): DeviceListItemContextType => { + const context = useContext(DeviceListItemContext); + if (!context) { + throw new Error('useDeviceListItemContext must be used within a DeviceListItemProvider'); + } + return context; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/confirm-dialog/ConfirmDialog.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/confirm-dialog/ConfirmDialog.tsx new file mode 100644 index 0000000000..3750286726 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/confirm-dialog/ConfirmDialog.tsx @@ -0,0 +1,55 @@ +import { sprintf } from 'sprintf-js'; + +import { messages } from '../../../../../shared/gettext'; +import { Button, Text } from '../../../../lib/components'; +import { formatHtml } from '../../../../lib/html-formatter'; +import { formatDeviceName } from '../../../../lib/utils'; +import { IModalAlertProps, ModalAlert, ModalAlertType, ModalMessage } from '../../../Modal'; +import { useDeviceListItemContext } from '../../DeviceListItemContext'; +import { useHandleRemoveDevice } from './hooks'; + +export type ConfirmDialogProps = IModalAlertProps; + +export function ConfirmDialog({ isOpen }: ConfirmDialogProps) { + const { device, hideConfirmDialog, deleting } = useDeviceListItemContext(); + const handleRemoveDevice = useHandleRemoveDevice(); + + return ( + <ModalAlert + isOpen={isOpen} + type={ModalAlertType.caution} + buttons={[ + <Button key="remove" onClick={handleRemoveDevice} disabled={deleting}> + <Button.Text> + { + // TRANSLATORS: Button label for confirming removing a device. + messages.pgettext('device-management', 'Remove') + } + </Button.Text> + </Button>, + <Button key="back" onClick={hideConfirmDialog} disabled={deleting}> + <Button.Text>{messages.gettext('Back')}</Button.Text> + </Button>, + ]} + close={hideConfirmDialog}> + <ModalMessage> + {formatHtml( + sprintf( + // TRANSLATORS: Text displayed above button which logs out another device. + // TRANSLATORS: The text enclosed in "<b></b>" will appear bold. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(deviceName)s - The name of the device to log out. + messages.pgettext('device-management', 'Remove <em>%(deviceName)s?</em>'), + { deviceName: formatDeviceName(device.name) }, + ), + )} + </ModalMessage> + <Text variant="labelTinySemiBold" color="whiteAlpha60"> + {messages.pgettext( + 'device-management', + 'The device will be removed from the list and logged out.', + )} + </Text> + </ModalAlert> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/confirm-dialog/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/confirm-dialog/hooks/index.ts new file mode 100644 index 0000000000..8852da78ba --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/confirm-dialog/hooks/index.ts @@ -0,0 +1,3 @@ +export * from './use-handle-remove-device'; +export * from './use-handle-remove-device-error'; +export * from './use-remove-device'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/confirm-dialog/hooks/use-handle-remove-device-error.ts b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/confirm-dialog/hooks/use-handle-remove-device-error.ts new file mode 100644 index 0000000000..ed9fab49ff --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/confirm-dialog/hooks/use-handle-remove-device-error.ts @@ -0,0 +1,38 @@ +import React from 'react'; + +import { IDevice } from '../../../../../../shared/daemon-rpc-types'; +import log from '../../../../../../shared/logging'; +import { useAppContext } from '../../../../../context'; +import { useSelector } from '../../../../../redux/store'; +import { useDeviceListItemContext } from '../../../DeviceListItemContext'; + +export const useHandleRemoveDeviceError = () => { + const { fetchDevices } = useAppContext(); + const { + hideConfirmDialog, + resetDeleting, + setError, + device: { id: deviceId }, + } = useDeviceListItemContext(); + const accountNumber = useSelector((state) => state.account.accountNumber)!; + + const handleError = React.useCallback( + async (error: Error) => { + log.error(`Failed to remove device: ${error.message}`); + + let devices: Array<IDevice> | undefined = undefined; + try { + devices = await fetchDevices(accountNumber); + } finally { + if (devices === undefined || devices.some((device) => device.id === deviceId)) { + hideConfirmDialog(); + resetDeleting(); + setError(); + } + } + }, + [fetchDevices, accountNumber, deviceId, hideConfirmDialog, resetDeleting, setError], + ); + + return handleError; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/confirm-dialog/hooks/use-handle-remove-device.ts b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/confirm-dialog/hooks/use-handle-remove-device.ts new file mode 100644 index 0000000000..1148dadb6d --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/confirm-dialog/hooks/use-handle-remove-device.ts @@ -0,0 +1,22 @@ +import React from 'react'; + +import { useDeviceListItemContext } from '../../../DeviceListItemContext'; +import { useRemoveDevice } from '../hooks'; +import { useHandleRemoveDeviceError } from './use-handle-remove-device-error'; + +export const useHandleRemoveDevice = () => { + const { device, setDeleting, hideConfirmDialog } = useDeviceListItemContext(); + const removeDevice = useRemoveDevice(); + const handleError = useHandleRemoveDeviceError(); + + const handleRemoveDevice = React.useCallback(async () => { + setDeleting(); + hideConfirmDialog(); + try { + await removeDevice(device.id); + } catch (e) { + await handleError(e as Error); + } + }, [setDeleting, hideConfirmDialog, removeDevice, device.id, handleError]); + return handleRemoveDevice; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/confirm-dialog/hooks/use-remove-device.ts b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/confirm-dialog/hooks/use-remove-device.ts new file mode 100644 index 0000000000..d55827f0a4 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/confirm-dialog/hooks/use-remove-device.ts @@ -0,0 +1,17 @@ +import React from 'react'; + +import { useAppContext } from '../../../../../context'; +import { useSelector } from '../../../../../redux/store'; + +export const useRemoveDevice = () => { + const { removeDevice: contextRemoveDevice } = useAppContext(); + const accountNumber = useSelector((state) => state.account.accountNumber)!; + const removeDevice = React.useCallback( + async (deviceId: string) => { + await contextRemoveDevice({ accountNumber, deviceId }); + }, + [contextRemoveDevice, accountNumber], + ); + + return removeDevice; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/confirm-dialog/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/confirm-dialog/index.ts new file mode 100644 index 0000000000..7c30ee33ad --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/confirm-dialog/index.ts @@ -0,0 +1 @@ +export * from './ConfirmDialog'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/error-dialog/ErrorDialog.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/error-dialog/ErrorDialog.tsx new file mode 100644 index 0000000000..dd652f3fee --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/error-dialog/ErrorDialog.tsx @@ -0,0 +1,23 @@ +import { messages } from '../../../../../shared/gettext'; +import { Button } from '../../../../lib/components'; +import { IModalAlertProps, ModalAlert, ModalAlertType } from '../../../Modal'; +import { useDeviceListItemContext } from '../../DeviceListItemContext'; + +export type ErrorDialogProps = IModalAlertProps; + +export function ErrorDialog({ isOpen }: ErrorDialogProps) { + const { resetError } = useDeviceListItemContext(); + return ( + <ModalAlert + isOpen={isOpen} + type={ModalAlertType.failure} + buttons={[ + <Button key="close" onClick={resetError}> + <Button.Text>{messages.gettext('Close')}</Button.Text> + </Button>, + ]} + close={resetError} + message={messages.pgettext('device-management', 'Failed to remove device')} + /> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/error-dialog/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/error-dialog/index.ts new file mode 100644 index 0000000000..227a076c82 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/error-dialog/index.ts @@ -0,0 +1 @@ +export * from './ErrorDialog'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/index.ts new file mode 100644 index 0000000000..75769df7f5 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/index.ts @@ -0,0 +1,3 @@ +export * from './confirm-dialog'; +export * from './remove-button'; +export * from './error-dialog'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/remove-button/RemoveButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/remove-button/RemoveButton.tsx new file mode 100644 index 0000000000..2e551c6a2d --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/remove-button/RemoveButton.tsx @@ -0,0 +1,26 @@ +import { sprintf } from 'sprintf-js'; + +import { messages } from '../../../../../shared/gettext'; +import { IconButton } from '../../../../lib/components'; +import { useDeviceListItemContext } from '../..'; + +export function RemoveButton() { + const { device, deleting, showConfirmDialog } = useDeviceListItemContext(); + + return ( + <IconButton + variant="secondary" + onClick={showConfirmDialog} + disabled={deleting} + 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: device.name }, + )}> + <IconButton.Icon icon="cross-circle" /> + </IconButton> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/remove-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/remove-button/index.ts new file mode 100644 index 0000000000..38a9e24aac --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/remove-button/index.ts @@ -0,0 +1 @@ +export * from './RemoveButton'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/hooks/index.ts new file mode 100644 index 0000000000..d8626923b4 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './use-is-current-device'; +export * from './use-formatted-date'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/hooks/use-formatted-date.ts b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/hooks/use-formatted-date.ts new file mode 100644 index 0000000000..5ddc670aad --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/hooks/use-formatted-date.ts @@ -0,0 +1,6 @@ +export function useFormattedDate(date: Date) { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/hooks/use-is-current-device.ts b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/hooks/use-is-current-device.ts new file mode 100644 index 0000000000..53f0201cfc --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/hooks/use-is-current-device.ts @@ -0,0 +1,8 @@ +import { useSelector } from '../../../redux/store'; +import { useDeviceListItemContext } from '../DeviceListItemContext'; + +export const useIsCurrentDevice = () => { + const currentDevice = useSelector((state) => state.account.deviceName); + const { device } = useDeviceListItemContext(); + return device.name === currentDevice; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/index.ts new file mode 100644 index 0000000000..86d9427e20 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/index.ts @@ -0,0 +1,2 @@ +export * from './DeviceListItem'; +export * from './DeviceListItemContext'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/device-list/DeviceList.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/device-list/DeviceList.tsx new file mode 100644 index 0000000000..fab2f5f648 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/device-list/DeviceList.tsx @@ -0,0 +1,19 @@ +import { IDevice } from '../../../shared/daemon-rpc-types'; +import { AnimatedList } from '../../lib/components/animated-list'; +import { DeviceListItem } from '../device-list-item'; + +export type DeviceListProps = { + devices: IDevice[]; +}; + +export function DeviceList({ devices }: DeviceListProps) { + return ( + <AnimatedList> + {devices.map((device) => ( + <AnimatedList.Item key={device.id}> + <DeviceListItem device={device} /> + </AnimatedList.Item> + ))} + </AnimatedList> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/device-list/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/device-list/index.ts new file mode 100644 index 0000000000..6f96cca162 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/device-list/index.ts @@ -0,0 +1 @@ +export * from './DeviceList'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/account/Account.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/Account.tsx new file mode 100644 index 0000000000..b62961c9e6 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/Account.tsx @@ -0,0 +1,107 @@ +import { useCallback, useEffect } from 'react'; +import styled from 'styled-components'; + +import { urls } from '../../../../shared/constants'; +import { messages } from '../../../../shared/gettext'; +import { useAppContext } from '../../../context'; +import { Button, Text } from '../../../lib/components'; +import { FlexColumn } from '../../../lib/components/flex-column'; +import { View } from '../../../lib/components/view'; +import { useHistory } from '../../../lib/history'; +import { useExclusiveTask } from '../../../lib/hooks/use-exclusive-task'; +import { useEffectEvent } from '../../../lib/utility-hooks'; +import { useSelector } from '../../../redux/store'; +import { AppNavigationHeader } from '../..'; +import { BackAction } from '../../KeyboardNavigation'; +import { SettingsContainer } from '../../Layout'; +import { RedeemVoucherButton } from '../../RedeemVoucher'; +import { HeaderTitle } from '../../SettingsHeader'; +import { AccountExpiryRow, AccountNumberRow, DeviceNameRow, LabelledRow } from './components'; + +const StyledViewContainer = styled(View.Container)` + height: 100%; + justify-content: space-between; +`; + +export function Account() { + const history = useHistory(); + const isOffline = useSelector((state) => state.connection.isBlocked); + const { updateAccountData, openUrlWithAuth, logout } = useAppContext(); + + const [buyMore] = useExclusiveTask(async () => { + await openUrlWithAuth(urls.purchase); + }); + + const onMount = useEffectEvent(() => updateAccountData()); + // These lint rules are disabled for now because the react plugin for eslint does + // not understand that useEffectEvent should not be added to the dependency array. + // Enable these rules again when eslint can lint useEffectEvent properly. + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => onMount(), []); + + // Hack needed because if we just call `logout` directly in `onClick` + // then it is run with the wrong `this`. + const doLogout = useCallback(async () => { + await logout(); + }, [logout]); + + return ( + <BackAction action={history.pop}> + <View> + <SettingsContainer> + <AppNavigationHeader + title={ + // TRANSLATORS: Title label in navigation bar + messages.pgettext('account-view', 'Account') + } + /> + + <StyledViewContainer> + <FlexColumn $gap="medium"> + <Text variant="titleBig"> + <HeaderTitle>{messages.pgettext('account-view', 'Account')}</HeaderTitle> + </Text> + + <FlexColumn $gap="large"> + <LabelledRow label={messages.pgettext('device-management', 'Device name')}> + <DeviceNameRow /> + </LabelledRow> + + <LabelledRow label={messages.pgettext('account-view', 'Account number')}> + <AccountNumberRow /> + </LabelledRow> + + <LabelledRow $gap="tiny" label={messages.pgettext('account-view', 'Paid until')}> + <AccountExpiryRow /> + </LabelledRow> + </FlexColumn> + </FlexColumn> + + <FlexColumn $gap="medium" $padding={{ bottom: 'large' }}> + <Button + variant="success" + disabled={isOffline} + onClick={buyMore} + aria-description={messages.pgettext('accessibility', 'Opens externally')}> + <Button.Text>{messages.gettext('Buy more credit')}</Button.Text> + <Button.Icon icon="external" /> + </Button> + + <RedeemVoucherButton /> + + <Button variant="destructive" onClick={doLogout}> + <Button.Text> + { + // TRANSLATORS: Button label for logging out. + messages.pgettext('account-view', 'Log out') + } + </Button.Text> + </Button> + </FlexColumn> + </StyledViewContainer> + </SettingsContainer> + </View> + </BackAction> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/account-expiry-row/AccountExpiryRow.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/account-expiry-row/AccountExpiryRow.tsx new file mode 100644 index 0000000000..eb4fb141d0 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/account-expiry-row/AccountExpiryRow.tsx @@ -0,0 +1,8 @@ +import { useSelector } from '../../../../../redux/store'; +import { FormattedAccountExpiry } from '../formatted-account-expiry/FormattedAccountExpiry'; + +export function AccountExpiryRow() { + const accountExpiry = useSelector((state) => state.account.expiry); + const expiryLocale = useSelector((state) => state.userInterface.locale); + return <FormattedAccountExpiry expiry={accountExpiry} locale={expiryLocale} />; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/account-expiry-row/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/account-expiry-row/index.ts new file mode 100644 index 0000000000..94c9d6b4c6 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/account-expiry-row/index.ts @@ -0,0 +1 @@ +export * from './AccountExpiryRow'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/account-number-row/AccountNumberRow.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/account-number-row/AccountNumberRow.tsx new file mode 100644 index 0000000000..a19b45d8df --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/account-number-row/AccountNumberRow.tsx @@ -0,0 +1,10 @@ +import { Text } from '../../../../../lib/components'; +import { useSelector } from '../../../../../redux/store'; +import AccountNumberLabel from '../../../../AccountNumberLabel'; + +export function AccountNumberRow() { + const accountNumber = useSelector((state) => state.account.accountNumber); + return ( + <Text variant="bodySmallSemibold" as={AccountNumberLabel} accountNumber={accountNumber || ''} /> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/account-number-row/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/account-number-row/index.ts new file mode 100644 index 0000000000..4e6ee7e5e5 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/account-number-row/index.ts @@ -0,0 +1 @@ +export * from './AccountNumberRow'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/device-name-row/DeviceNameRow.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/device-name-row/DeviceNameRow.tsx new file mode 100644 index 0000000000..56f0f9e9f9 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/device-name-row/DeviceNameRow.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import styled from 'styled-components'; + +import { messages } from '../../../../../../shared/gettext'; +import { RoutePath } from '../../../../../../shared/routes'; +import { Flex, Text } from '../../../../../lib/components'; +import { Link } from '../../../../../lib/components/link'; +import { TransitionType, useHistory } from '../../../../../lib/history'; +import { useSelector } from '../../../../../redux/store'; + +const StyledText = styled(Text)` + text-transform: capitalize; +`; + +export function DeviceNameRow() { + const history = useHistory(); + const deviceName = useSelector((state) => state.account.deviceName); + + const navigateToManageDevices = React.useCallback(() => { + history.push(RoutePath.manageDevices, { transition: TransitionType.push }); + }, [history]); + + return ( + <Flex $justifyContent="space-between"> + <StyledText variant="bodySmallSemibold">{deviceName}</StyledText> + <Link as="button" onClick={navigateToManageDevices}> + <Link.Text> + { + // TRANSLATORS: Link text in the account view to navigate to the manage devices view. + messages.pgettext('account-view', 'Manage devices') + } + </Link.Text> + </Link> + </Flex> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/device-name-row/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/device-name-row/index.ts new file mode 100644 index 0000000000..86f3b9427f --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/device-name-row/index.ts @@ -0,0 +1 @@ +export * from './DeviceNameRow'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/formatted-account-expiry/FormattedAccountExpiry.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/formatted-account-expiry/FormattedAccountExpiry.tsx new file mode 100644 index 0000000000..cac89eb581 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/formatted-account-expiry/FormattedAccountExpiry.tsx @@ -0,0 +1,23 @@ +import { formatDate, hasExpired } from '../../../../../../shared/account-expiry'; +import { messages } from '../../../../../../shared/gettext'; +import { Text } from '../../../../../lib/components'; + +export function FormattedAccountExpiry(props: { expiry?: string; locale: string }) { + if (props.expiry) { + if (hasExpired(props.expiry)) { + return ( + <Text variant="bodySmallSemibold" color="red"> + {messages.pgettext('account-view', 'OUT OF TIME')} + </Text> + ); + } else { + return <Text variant="bodySmallSemibold">{formatDate(props.expiry, props.locale)}</Text>; + } + } else { + return ( + <Text variant="bodySmallSemibold"> + {messages.pgettext('account-view', 'Currently unavailable')} + </Text> + ); + } +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/formatted-account-expiry/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/formatted-account-expiry/index.ts new file mode 100644 index 0000000000..7dbe455a16 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/formatted-account-expiry/index.ts @@ -0,0 +1 @@ +export * from './FormattedAccountExpiry'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/index.ts new file mode 100644 index 0000000000..bf346ef735 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/index.ts @@ -0,0 +1,5 @@ +export * from './account-expiry-row'; +export * from './account-number-row'; +export * from './device-name-row'; +export * from './formatted-account-expiry'; +export * from './labelled-row'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/labelled-row/LabelledRow.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/labelled-row/LabelledRow.tsx new file mode 100644 index 0000000000..0e60bf8872 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/labelled-row/LabelledRow.tsx @@ -0,0 +1,17 @@ +import { Text } from '../../../../../lib/components'; +import { FlexColumn, FlexColumnProps } from '../../../../../lib/components/flex-column'; + +type LabelledRowProps = FlexColumnProps & { + label?: string; +}; + +export function LabelledRow({ label, children, ...props }: LabelledRowProps) { + return ( + <FlexColumn $gap="tiny" {...props}> + <Text variant="labelTiny" color="whiteAlpha60"> + {label} + </Text> + {children} + </FlexColumn> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/labelled-row/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/labelled-row/index.ts new file mode 100644 index 0000000000..c430a7aa77 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/labelled-row/index.ts @@ -0,0 +1 @@ +export * from './LabelledRow'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/account/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/index.ts new file mode 100644 index 0000000000..4c71833ffe --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/account/index.ts @@ -0,0 +1 @@ +export * from './Account'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/index.ts index 7e721b496a..dbd592ca0f 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/views/index.ts +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/index.ts @@ -1,8 +1,10 @@ +export * from './account'; export * from './app-info'; export * from './app-upgrade'; export * from './daita-settings'; export * from './launch'; export * from './main'; +export * from './manage-devices'; export * from './multihop-settings'; export * from './login'; export * from './open-vpn-settings'; @@ -10,6 +12,7 @@ export * from './changelog'; export * from './settings'; export * from './shadowsocks-settings'; export * from './split-tunneling'; +export * from './too-many-devices'; export * from './udp-over-tcp-settings'; export * from './vpn-settings'; export * from './wireguard-settings'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/login/LoginView.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/login/LoginView.tsx index 8e96d87d4d..7e6822dd06 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/views/login/LoginView.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/login/LoginView.tsx @@ -254,9 +254,9 @@ class Login extends React.Component<IProps, IState> { // TRANSLATORS: with too many registered devices. return messages.pgettext('login-view', 'Too many devices'); case 'list-devices': - // TRANSLATORS: Error message shown above login input when trying to login but the app fails + // TRANSLATORS: Error message shown trying to login but the app fails // TRANSLATORS: to fetch the list of registered devices. - return messages.pgettext('login-view', 'Failed to fetch list of devices'); + return messages.gettext('Failed to fetch list of devices'); case 'communication': return 'api.mullvad.net is blocked, please check your firewall'; default: diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/ManageDevicesContext.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/ManageDevicesContext.tsx new file mode 100644 index 0000000000..5d86418ce8 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/ManageDevicesContext.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +type ManageDevicesContextProps = { + isFetching: boolean; + isLoading: boolean; + refetchDevices: () => Promise<void>; +}; + +const ManageDevicesContext = React.createContext<ManageDevicesContextProps | undefined>(undefined); + +export const useManageDevicesContext = (): ManageDevicesContextProps => { + const context = React.useContext(ManageDevicesContext); + if (!context) { + throw new Error('useManageDevicesContext must be used within a ManageDevicesProvider'); + } + return context; +}; + +interface ManageDevicesProviderProps { + isFetching: boolean; + isLoading: boolean; + refetchDevices: () => Promise<void>; + children: React.ReactNode; +} + +export function ManageDevicesProvider({ + isFetching, + isLoading, + refetchDevices, + children, +}: ManageDevicesProviderProps) { + return ( + <ManageDevicesContext.Provider value={{ isFetching, isLoading, refetchDevices }}> + {children} + </ManageDevicesContext.Provider> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/ManageDevicesView.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/ManageDevicesView.tsx new file mode 100644 index 0000000000..d94337176e --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/ManageDevicesView.tsx @@ -0,0 +1,61 @@ +import { messages } from '../../../../shared/gettext'; +import { Text } from '../../../lib/components'; +import { FlexColumn } from '../../../lib/components/flex-column'; +import { View } from '../../../lib/components/view'; +import { useHistory } from '../../../lib/history'; +import { AppNavigationHeader } from '../../app-navigation-header'; +import { DeviceList } from '../../device-list'; +import { BackAction } from '../../KeyboardNavigation'; +import { NavigationContainer } from '../../NavigationContainer'; +import { NavigationScrollbars } from '../../NavigationScrollbars'; +import { DevicesState } from './components'; +import { useQueryDevices } from './hooks'; +import { ManageDevicesProvider } from './ManageDevicesContext'; + +export function ManageDevicesView() { + const { pop } = useHistory(); + const { devices, isFetching, isLoading, refetch } = useQueryDevices(); + const showDeviceList = devices && devices.length > 0; + + return ( + <ManageDevicesProvider isLoading={isLoading} isFetching={isFetching} refetchDevices={refetch}> + <BackAction action={pop}> + <View backgroundColor="darkBlue"> + <NavigationContainer> + <AppNavigationHeader + title={ + // TRANSLATORS: Title label in navigation bar for the manage devices view. + messages.pgettext('device-management', 'Manage devices') + } + /> + <NavigationScrollbars> + <FlexColumn $gap="medium"> + <View.Container> + <FlexColumn $gap="small"> + <Text variant="titleBig"> + { + // TRANSLATORS: Title text in the manage devices view + messages.pgettext('device-management', 'Manage devices') + } + </Text> + <Text variant="labelTiny" color="whiteAlpha60"> + { + // TRANSLATORS: Subtitle text in the manage devices view, explaining + // TRANSLATORS: devices and what they can do in the manage devices view. + messages.pgettext( + 'device-management', + 'View and manage all your logged in devices. You can have up to 5 devices on one account at a time. Each device gets a name when logged in to help you tell them apart easily.', + ) + } + </Text> + </FlexColumn> + </View.Container> + {showDeviceList ? <DeviceList devices={devices} /> : <DevicesState />} + </FlexColumn> + </NavigationScrollbars> + </NavigationContainer> + </View> + </BackAction> + </ManageDevicesProvider> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/components/devices-empty-state/DevicesEmptyState.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/components/devices-empty-state/DevicesEmptyState.tsx new file mode 100644 index 0000000000..16acd8cbf3 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/components/devices-empty-state/DevicesEmptyState.tsx @@ -0,0 +1,23 @@ +import { messages } from '../../../../../../shared/gettext'; +import { EmptyState } from '../../../../../lib/components/empty-state'; +import { useManageDevicesContext } from '../../ManageDevicesContext'; + +export function DevicesEmptyState() { + const { isFetching, refetchDevices } = useManageDevicesContext(); + return ( + <EmptyState variant={isFetching ? 'loading' : 'error'} $alignSelf="stretch"> + <EmptyState.StatusIcon /> + <EmptyState.TextContainer> + <EmptyState.Title>{messages.gettext('Failed to fetch list of devices')}</EmptyState.Title> + </EmptyState.TextContainer> + <EmptyState.Button onClick={refetchDevices}> + <EmptyState.Button.Text> + { + // TRANSLATORS: Button text to retry fetching devices. + messages.pgettext('device-management', 'Try again') + } + </EmptyState.Button.Text> + </EmptyState.Button> + </EmptyState> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/components/devices-empty-state/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/components/devices-empty-state/index.ts new file mode 100644 index 0000000000..e5790f7b1c --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/components/devices-empty-state/index.ts @@ -0,0 +1 @@ +export * from './DevicesEmptyState'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/components/devices-state/DevicesState.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/components/devices-state/DevicesState.tsx new file mode 100644 index 0000000000..e2ba998c90 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/components/devices-state/DevicesState.tsx @@ -0,0 +1,17 @@ +import { Spinner } from '../../../../../lib/components'; +import { View } from '../../../../../lib/components/view'; +import { useManageDevicesContext } from '../../ManageDevicesContext'; +import { DevicesEmptyState } from '../devices-empty-state'; + +export function DevicesState() { + const { isLoading } = useManageDevicesContext(); + return ( + <View.Container + $flexDirection="column" + $gap="tiny" + $alignItems="center" + $padding={{ bottom: 'tiny' }}> + {isLoading ? <Spinner size="big" /> : <DevicesEmptyState />} + </View.Container> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/components/devices-state/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/components/devices-state/index.ts new file mode 100644 index 0000000000..ff7d5029a3 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/components/devices-state/index.ts @@ -0,0 +1 @@ +export * from './DevicesState'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/components/index.ts new file mode 100644 index 0000000000..91f6a5d42d --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/components/index.ts @@ -0,0 +1,2 @@ +export * from './devices-empty-state'; +export * from './devices-state'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/hooks/index.ts new file mode 100644 index 0000000000..d82fc0d63b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './use-query-devices'; +export * from './use-sorted-devices'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/hooks/use-fetch-devices.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/hooks/use-fetch-devices.ts new file mode 100644 index 0000000000..b420a1568d --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/hooks/use-fetch-devices.ts @@ -0,0 +1,13 @@ +import React from 'react'; + +import { useAppContext } from '../../../../context'; +import { useSelector } from '../../../../redux/store'; + +export const useFetchDevices = () => { + const { fetchDevices: contextFetchDevices } = useAppContext(); + const accountNumber = useSelector((state) => state.account.accountNumber)!; + + return React.useCallback(() => { + return contextFetchDevices(accountNumber); + }, [accountNumber, contextFetchDevices]); +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/hooks/use-query-devices.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/hooks/use-query-devices.ts new file mode 100644 index 0000000000..562b14e954 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/hooks/use-query-devices.ts @@ -0,0 +1,15 @@ +import { IDevice } from '../../../../../shared/daemon-rpc-types'; +import { useQuery } from '../../../../lib/hooks'; +import { useFetchDevices } from './use-fetch-devices'; +import { useSortedDevices } from './use-sorted-devices'; + +export const useQueryDevices = () => { + const fetchDevices = useFetchDevices(); + const devices = useSortedDevices(); + const { isLoading, isFetching, refetch } = useQuery<IDevice[]>({ + queryFn: fetchDevices, + queryKey: ['fetch-devices'], + }); + + return { devices, isLoading, isFetching, refetch }; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/hooks/use-sorted-devices.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/hooks/use-sorted-devices.ts new file mode 100644 index 0000000000..e364188191 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/hooks/use-sorted-devices.ts @@ -0,0 +1,14 @@ +import React from 'react'; + +import { useSelector } from '../../../../redux/store'; + +export const useSortedDevices = () => { + const devices = useSelector((state) => state.account.devices); + const currentDeviceName = useSelector((state) => state.account.deviceName); + return React.useMemo(() => { + const currentDevice = devices.find((device) => device.name === currentDeviceName); + if (!currentDevice) return devices; + + return [currentDevice, ...devices.filter((device) => device.name !== currentDeviceName)]; + }, [currentDeviceName, devices]); +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/index.ts new file mode 100644 index 0000000000..ce2a24ae81 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/index.ts @@ -0,0 +1 @@ +export * from './ManageDevicesView'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/too-many-devices/TooManyDevicesView.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/too-many-devices/TooManyDevicesView.tsx new file mode 100644 index 0000000000..8ca04797e1 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/too-many-devices/TooManyDevicesView.tsx @@ -0,0 +1,130 @@ +import { useCallback } from 'react'; +import styled from 'styled-components'; + +import { IDevice } from '../../../../shared/daemon-rpc-types'; +import { messages } from '../../../../shared/gettext'; +import { RoutePath } from '../../../../shared/routes'; +import { useAppContext } from '../../../context'; +import { Button, Flex, Text } from '../../../lib/components'; +import { FlexColumn } from '../../../lib/components/flex-column'; +import { View } from '../../../lib/components/view'; +import { TransitionType, useHistory } from '../../../lib/history'; +import { IconBadge, IconBadgeProps } from '../../../lib/icon-badge'; +import { useSelector } from '../../../redux/store'; +import { AppMainHeader } from '../../app-main-header'; +import CustomScrollbars from '../../CustomScrollbars'; +import { DeviceList } from '../../device-list'; + +const StyledCustomScrollbars = styled(CustomScrollbars)({ + flex: 1, +}); + +export function TooManyDevicesView() { + const { reset } = useHistory(); + const { login, cancelLogin } = useAppContext(); + const accountNumber = useSelector((state) => state.account.accountNumber)!; + const devices = useSelector((state) => state.account.devices); + const loginState = useSelector((state) => state.account.status); + + const continueLogin = useCallback(() => { + void login(accountNumber); + reset(RoutePath.login, { transition: TransitionType.pop }); + }, [reset, login, accountNumber]); + + const cancel = useCallback(() => { + cancelLogin(); + reset(RoutePath.login, { transition: TransitionType.pop }); + }, [reset, cancelLogin]); + + const imageSource = getIconSource(devices); + const title = getTitle(devices); + const subtitle = getSubtitle(devices); + + const continueButtonDisabled = devices.length === 5 || loginState.type !== 'too many devices'; + + return ( + <View backgroundColor="darkBlue"> + <AppMainHeader> + <AppMainHeader.SettingsButton /> + </AppMainHeader> + <StyledCustomScrollbars fillContainer> + <FlexColumn $gap="large"> + <View.Container> + <Flex $justifyContent="center" $margin={{ top: 'large' }}> + <IconBadge key={imageSource} state={imageSource} /> + </Flex> + </View.Container> + {devices !== undefined && ( + <> + <View.Container $gap="small"> + <Text variant="titleLarge" data-testid="title"> + {title} + </Text> + <Text variant="labelTiny">{subtitle}</Text> + </View.Container> + <DeviceList devices={devices} /> + </> + )} + + {devices !== undefined && ( + <View.Container $gap="medium" $padding={{ bottom: 'large' }}> + <Button variant="success" onClick={continueLogin} disabled={continueButtonDisabled}> + <Button.Text> + { + // TRANSLATORS: Button for continuing login process. + messages.pgettext('device-management', 'Continue with login') + } + </Button.Text> + </Button> + <Button onClick={cancel}> + <Button.Text>{messages.gettext('Back')}</Button.Text> + </Button> + </View.Container> + )} + </FlexColumn> + </StyledCustomScrollbars> + </View> + ); +} + +function getIconSource(devices: Array<IDevice>): IconBadgeProps['state'] { + if (devices.length === 5) { + return 'negative'; + } else { + return 'positive'; + } +} + +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', + 'Please log out of at least one by removing it from the list below. You can find the corresponding device name 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/desktop/packages/mullvad-vpn/src/renderer/components/views/too-many-devices/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/too-many-devices/index.ts new file mode 100644 index 0000000000..172082f618 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/too-many-devices/index.ts @@ -0,0 +1 @@ +export * from './TooManyDevicesView'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animated-list/AnimatedList.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animated-list/AnimatedList.tsx new file mode 100644 index 0000000000..445378d93b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animated-list/AnimatedList.tsx @@ -0,0 +1,25 @@ +import { AnimatePresence } from 'motion/react'; +import React from 'react'; +import styled from 'styled-components'; + +import { AnimatedListItem } from './components'; + +export type AnimatedListProps = React.ComponentPropsWithRef<'ul'>; + +const StyledUl = styled.ul` + width: 100%; +`; + +function AnimatedList({ children, ...props }: AnimatedListProps) { + return ( + <StyledUl {...props}> + <AnimatePresence initial={false}>{children}</AnimatePresence> + </StyledUl> + ); +} + +const AnimatedListNamespace = Object.assign(AnimatedList, { + Item: AnimatedListItem, +}); + +export { AnimatedListNamespace as AnimatedList }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animated-list/components/animated-list-item/AnimatedListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animated-list/components/animated-list-item/AnimatedListItem.tsx new file mode 100644 index 0000000000..2836d5b558 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animated-list/components/animated-list-item/AnimatedListItem.tsx @@ -0,0 +1,28 @@ +import { motion } from 'motion/react'; +import styled from 'styled-components'; + +export type AnimatedListItemProps = React.ComponentPropsWithRef<'li'>; + +const StyledLi = styled(motion.li)` + overflow: hidden; +`; + +const itemVariants = { + hidden: { height: 0 }, + show: { height: 'auto' }, + exit: { height: 0 }, +}; + +export function AnimatedListItem({ children }: AnimatedListItemProps) { + return ( + <StyledLi + layout + variants={itemVariants} + initial="hidden" + animate="show" + exit="exit" + transition={{ duration: 0.1, ease: 'easeOut' }}> + {children} + </StyledLi> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animated-list/components/animated-list-item/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animated-list/components/animated-list-item/index.ts new file mode 100644 index 0000000000..cdb2575bb2 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animated-list/components/animated-list-item/index.ts @@ -0,0 +1 @@ +export * from './AnimatedListItem'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animated-list/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animated-list/components/index.ts new file mode 100644 index 0000000000..b520a7745f --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animated-list/components/index.ts @@ -0,0 +1 @@ +export * from './animated-list-item'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animated-list/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animated-list/index.ts new file mode 100644 index 0000000000..b1b031e8a7 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animated-list/index.ts @@ -0,0 +1 @@ +export * from './AnimatedList'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/button/Button.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/button/Button.tsx index 52a7192ee7..26b8dcae92 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/button/Button.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/button/Button.tsx @@ -33,21 +33,11 @@ const styles = { disabled: colors.red40, }, }, - flex: { - fill: '1 1 0', - fit: '0 0 auto', - }, - widths: { - fill: undefined, - fit: 'fit-content', - }, }; export const StyledButton = styled.button<TransientProps<Pick<ButtonProps, 'variant' | 'width'>>>` ${({ $width = 'fill', $variant = 'primary' }) => { const variant = styles.variants[$variant]; - const size = styles.flex[$width]; - const width = styles.widths[$width]; return css` --background: ${variant.background}; @@ -55,8 +45,6 @@ export const StyledButton = styled.button<TransientProps<Pick<ButtonProps, 'vari --pressed: ${variant.pressed}; --disabled: ${variant.disabled}; --radius: ${styles.radius}; - --size: ${size}; - --width: ${width}; --transition-duration: 0.15s; display: flex; @@ -67,10 +55,22 @@ export const StyledButton = styled.button<TransientProps<Pick<ButtonProps, 'vari min-height: 32px; min-width: 60px; - width: var(--width); border-radius: var(--radius); background: var(--background); + ${() => { + if ($width === 'fill') { + return css` + width: 100%; + `; + } else if ($width === 'fit') { + return css` + width: fit-content; + `; + } + return null; + }} + @media (prefers-reduced-motion: no-preference) { transition: background-color var(--transition-duration) ease; } diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/EmptyState.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/EmptyState.tsx new file mode 100644 index 0000000000..7d94385ebe --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/EmptyState.tsx @@ -0,0 +1,35 @@ +import { Flex, FlexProps } from '../flex'; +import { + EmptyStateButton, + EmptyStateStatusIcon, + EmptyStateSubtitle, + EmptyStateTextContainer, + EmptyStateTitle, +} from './components'; +import { EmptyStateProvider } from './EmptyStateContext'; + +type EmptyStateVariant = 'success' | 'error' | 'loading'; + +export type EmptyStateProps = FlexProps & { + variant?: EmptyStateVariant; +}; + +function EmptyState({ variant = 'error', children, ...props }: EmptyStateProps) { + return ( + <EmptyStateProvider variant={variant}> + <Flex $flexDirection="column" $gap="medium" $alignItems="center" {...props}> + {children} + </Flex> + </EmptyStateProvider> + ); +} + +const EmptyStateNamespace = Object.assign(EmptyState, { + StatusIcon: EmptyStateStatusIcon, + Subtitle: EmptyStateSubtitle, + Title: EmptyStateTitle, + Button: EmptyStateButton, + TextContainer: EmptyStateTextContainer, +}); + +export { EmptyStateNamespace as EmptyState }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/EmptyStateContext.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/EmptyStateContext.tsx new file mode 100644 index 0000000000..ea89b73515 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/EmptyStateContext.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import { EmptyStateProps } from './EmptyState'; + +type EmptyStateContextProps = { + variant: EmptyStateProps['variant']; +}; + +const EmptyStateContext = React.createContext<EmptyStateContextProps | undefined>(undefined); + +export const useEmptyStateContext = (): EmptyStateContextProps => { + const context = React.useContext(EmptyStateContext); + if (!context) { + throw new Error('useEmptyStateContext must be used within a EmptyStateProvider'); + } + return context; +}; + +interface EmptyStateProviderProps { + variant: EmptyStateProps['variant']; + children: React.ReactNode; +} + +export function EmptyStateProvider({ variant, children }: EmptyStateProviderProps) { + return <EmptyStateContext.Provider value={{ variant }}>{children}</EmptyStateContext.Provider>; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-button/EmptyStateButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-button/EmptyStateButton.tsx new file mode 100644 index 0000000000..c2de7604bd --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-button/EmptyStateButton.tsx @@ -0,0 +1,20 @@ +import { Button, ButtonProps } from '../../../button'; +import { useEmptyStateContext } from '../../EmptyStateContext'; + +export type EmpptyStateButtonProps = ButtonProps; + +function EmptyStateButton({ children, ...props }: EmpptyStateButtonProps) { + const { variant } = useEmptyStateContext(); + const disabled = variant === 'loading'; + return ( + <Button disabled={disabled} {...props}> + {children} + </Button> + ); +} + +const EmptyStateButtonNamespace = Object.assign(EmptyStateButton, { + Text: Button.Text, +}); + +export { EmptyStateButtonNamespace as EmptyStateButton }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-button/index.ts new file mode 100644 index 0000000000..675721619a --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-button/index.ts @@ -0,0 +1 @@ +export * from './EmptyStateButton'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-status-icon/EmptyStateStatusIcon.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-status-icon/EmptyStateStatusIcon.tsx new file mode 100644 index 0000000000..8ef16a2ab5 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-status-icon/EmptyStateStatusIcon.tsx @@ -0,0 +1,14 @@ +import { IconBadge } from '../../../../icon-badge'; +import { Spinner } from '../../../spinner'; +import { useEmptyStateContext } from '../../EmptyStateContext'; + +export function EmptyStateStatusIcon() { + const { variant } = useEmptyStateContext(); + return ( + <> + {variant === 'success' && <IconBadge state={'positive'} />} + {variant === 'error' && <IconBadge state={'negative'} />} + {variant === 'loading' && <Spinner size="big" />} + </> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-status-icon/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-status-icon/index.ts new file mode 100644 index 0000000000..0bd3c665bb --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-status-icon/index.ts @@ -0,0 +1 @@ +export * from './EmptyStateStatusIcon'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-subtitle/EmptyStateSubtitle.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-subtitle/EmptyStateSubtitle.tsx new file mode 100644 index 0000000000..61a4c02c41 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-subtitle/EmptyStateSubtitle.tsx @@ -0,0 +1,10 @@ +import { Text, TextProps } from '../../../text'; + +export type EmptyStateSubtitleProps = TextProps; +export function EmptyStateSubtitle({ children, ...props }: EmptyStateSubtitleProps) { + return ( + <Text variant="labelTiny" color="whiteAlpha60" textAlign="center" {...props}> + {children} + </Text> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-subtitle/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-subtitle/index.ts new file mode 100644 index 0000000000..f210c0dc29 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-subtitle/index.ts @@ -0,0 +1 @@ +export * from './EmptyStateSubtitle'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-text-container/EmptyStateTextContainer.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-text-container/EmptyStateTextContainer.tsx new file mode 100644 index 0000000000..0f964527a8 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-text-container/EmptyStateTextContainer.tsx @@ -0,0 +1,11 @@ +import { Flex, FlexProps } from '../../../flex'; + +export type EmptyStateTextContainerProps = FlexProps; + +export function EmptyStateTextContainer({ children, ...props }: EmptyStateTextContainerProps) { + return ( + <Flex $flexDirection="column" $alignItems="center" $gap="tiny" {...props}> + {children} + </Flex> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-text-container/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-text-container/index.ts new file mode 100644 index 0000000000..6db950b516 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-text-container/index.ts @@ -0,0 +1 @@ +export * from './EmptyStateTextContainer'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-title/EmptyStateTitle.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-title/EmptyStateTitle.tsx new file mode 100644 index 0000000000..0de48a2f39 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-title/EmptyStateTitle.tsx @@ -0,0 +1,11 @@ +import { Text, TextProps } from '../../../text'; + +export type EmptyStateTitleProps = TextProps; + +export function EmptyStateTitle({ children, ...props }: EmptyStateTitleProps) { + return ( + <Text variant="titleMedium" textAlign="center" {...props}> + {children} + </Text> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-title/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-title/index.ts new file mode 100644 index 0000000000..ce04a342db --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-title/index.ts @@ -0,0 +1 @@ +export * from './EmptyStateTitle'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/index.ts new file mode 100644 index 0000000000..060505932e --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/index.ts @@ -0,0 +1,5 @@ +export * from './empty-state-button'; +export * from './empty-state-status-icon'; +export * from './empty-state-subtitle'; +export * from './empty-state-text-container'; +export * from './empty-state-title'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/index.ts new file mode 100644 index 0000000000..c54a68a9de --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/index.ts @@ -0,0 +1 @@ +export * from './EmptyState'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/flex-column/FlexColumn.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/flex-column/FlexColumn.tsx index decbfe25d8..fe57e98f26 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/flex-column/FlexColumn.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/flex-column/FlexColumn.tsx @@ -1,5 +1,5 @@ import { Flex, FlexProps } from '../flex'; -type FlexColumnProps = Omit<FlexProps, '$flexDirection'>; +export type FlexColumnProps = Omit<FlexProps, '$flexDirection'>; export const FlexColumn = (props: FlexColumnProps) => <Flex $flexDirection="column" {...props} />; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/flex/Flex.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/flex/Flex.tsx index 57e05ca11d..1f7b560458 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/flex/Flex.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/flex/Flex.tsx @@ -14,6 +14,7 @@ export interface FlexProps extends LayoutProps { $flexShrink?: React.CSSProperties['flexShrink']; $flexBasis?: React.CSSProperties['flexBasis']; $flexWrap?: React.CSSProperties['flexWrap']; + $alignSelf?: React.CSSProperties['alignSelf']; children?: React.ReactNode; } @@ -27,6 +28,7 @@ export const Flex = styled(Layout)<FlexProps>(({ $flexShrink, $flexBasis, $flexWrap, + $alignSelf, }) => { const $gap = gapProp ? spacings[gapProp] : undefined; return { @@ -40,5 +42,6 @@ export const Flex = styled(Layout)<FlexProps>(({ flexShrink: $flexShrink, flexBasis: $flexBasis, flexWrap: $flexWrap, + alignSelf: $alignSelf, }; }); diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text/Text.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text/Text.tsx index cc1a53fcf4..ddb23a3aea 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text/Text.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text/Text.tsx @@ -16,7 +16,7 @@ const StyledText = styled.span<TransientProps<TextBaseProps>>( ({ $variant = 'bodySmall', $color = 'white', $textAlign }) => { const { fontFamily, fontSize, fontWeight, lineHeight } = typography[$variant]; const color = colors[$color]; - return ` + return css` --color: ${color}; color: var(--color); diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/index.ts index e75fe95d06..c262425b53 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/index.ts +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/index.ts @@ -1,2 +1,3 @@ export * from './use-exclusive-task'; export * from './use-roving-focus'; +export * from './use-query'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-query.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-query.ts new file mode 100644 index 0000000000..999ba16c3c --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-query.ts @@ -0,0 +1,61 @@ +import React from 'react'; + +export type UseQueryProps<T> = { + enabled?: boolean; + queryFn: () => Promise<T>; + queryKey: string[]; +}; + +export const useQuery = <T>({ queryFn, queryKey, enabled = true }: UseQueryProps<T>) => { + const [data, setData] = React.useState<T | undefined>(undefined); + const [error, setError] = React.useState<Error | undefined>(undefined); + const [isError, setIsError] = React.useState<boolean>(false); + const [isFetching, setIsFetching] = React.useState<boolean>(false); + + const hasLoadedRef = React.useRef(false); + const mountedRef = React.useRef(false); + const runIdRef = React.useRef(0); + + const cacheKey = queryKey.join(); + + const run = React.useCallback(async () => { + const runId = ++runIdRef.current; + + const isActive = () => mountedRef.current && runId === runIdRef.current; + + setIsFetching(true); + setIsError(false); + setError(undefined); + + try { + const result = await queryFn(); + if (isActive()) { + setData(result); + } + } catch (err) { + if (isActive()) { + setIsError(true); + setError(err as Error); + } + } finally { + if (isActive()) { + setIsFetching(false); + } + if (!hasLoadedRef.current) { + hasLoadedRef.current = true; + } + } + }, [hasLoadedRef, queryFn]); + + const isLoading = isFetching && !hasLoadedRef.current; + + React.useEffect(() => { + mountedRef.current = true; + if (enabled) void run(); + return () => { + mountedRef.current = false; + }; + }, [enabled, cacheKey, run]); + + return { data, error, isError, isLoading, isFetching, refetch: run }; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/html-formatter.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/html-formatter.tsx index 452b7fb1e8..0766a59bd5 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/html-formatter.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/html-formatter.tsx @@ -1,18 +1,63 @@ -import React from 'react'; +import React, { JSX } from 'react'; import styled from 'styled-components'; -const boldSyntax = /(<b>.*?<\/b>)/g; +import { colors } from './foundations'; + const Bold = styled.span({ fontWeight: 700 }); +const Emphasis = styled.em({ color: colors.white, fontWeight: 600 }); + +// When a new tag is added here, it must also be added to allowed tags in +// our verify translations script. +const testMap: Partial< + Record< + keyof JSX.IntrinsicElements, + { + test: RegExp; + replace: RegExp; + } + > +> = { + b: { + test: /(<b>.*?<\/b>)/g, + replace: /<b>|<\/b>/g, + }, + em: { + test: /(<em>.*?<\/em>)/g, + replace: /<em>|<\/em>/g, + }, +} as const; + +const componentMap: Partial< + Record<keyof JSX.IntrinsicElements, React.ComponentType<{ children: React.ReactNode }>> +> = { + b: Bold, + em: Emphasis, +} as const; export function formatHtml(inputString: string): React.ReactElement { - const formattedString = inputString.split(boldSyntax).map((value, index) => { - if (boldSyntax.test(value)) { - const valueWithoutTags = value.replaceAll(/<b>|<\/b>/g, ''); - return <Bold key={index}>{valueWithoutTags}</Bold>; - } else { - return <React.Fragment key={index}>{value}</React.Fragment>; + const formattedString: JSX.Element[] = []; + + Object.entries(testMap).forEach(([key, { test, replace }]) => { + const parts = inputString.split(test).filter((part) => part !== ''); + if (parts.length <= 1) { + return; } + + parts.map((value, index) => { + if (test.test(value)) { + const Component = componentMap[key as keyof typeof componentMap]!; + const valueWithoutTags = value.replaceAll(replace, ''); + + formattedString.push(<Component key={index}>{valueWithoutTags}</Component>); + } else { + formattedString.push(<React.Fragment key={index}>{value}</React.Fragment>); + } + }); }); + if (formattedString.length === 0) { + formattedString.push(<>{inputString}</>); + } + return <>{formattedString}</>; } diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/new-device.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/new-device.ts index f5bd085d89..3d4f2b0318 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/new-device.ts +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/new-device.ts @@ -2,7 +2,7 @@ import { sprintf } from 'sprintf-js'; import { messages } from '../../../shared/gettext'; import { InAppNotification, InAppNotificationProvider } from '../../../shared/notifications'; -import { capitalizeEveryWord } from '../../../shared/string-helpers'; +import { formatDeviceName } from '../utils'; interface NewDeviceNotificationContext { shouldDisplay: boolean; @@ -20,11 +20,14 @@ export class NewDeviceNotificationProvider implements InAppNotificationProvider indicator: 'success', title: messages.pgettext('in-app-notifications', 'NEW DEVICE CREATED'), subtitle: sprintf( + // TRANSLATORS: Notification text when a new device has been created. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: - %(deviceName)s: Name of created device. messages.pgettext( 'in-app-notifications', - 'Welcome, this device is now called <b>%(deviceName)s</b>. For more details see the info button in Account.', + 'This device is now named <em>%(deviceName)s</em>. See more under "Manage devices" in Account.', ), - { deviceName: capitalizeEveryWord(this.context.deviceName) }, + { deviceName: formatDeviceName(this.context.deviceName) }, ), action: { type: 'close', close: this.context.close }, }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/utils/format-device-name.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/utils/format-device-name.ts new file mode 100644 index 0000000000..88f18d4750 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/utils/format-device-name.ts @@ -0,0 +1,5 @@ +import { capitalizeEveryWord } from '../../../shared/string-helpers'; + +export function formatDeviceName(deviceName: string) { + return capitalizeEveryWord(deviceName); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/utils/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/utils/index.ts new file mode 100644 index 0000000000..3a910e173a --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/utils/index.ts @@ -0,0 +1 @@ +export * from './format-device-name'; diff --git a/desktop/packages/mullvad-vpn/src/shared/routes.ts b/desktop/packages/mullvad-vpn/src/shared/routes.ts index 1868b2639a..816d5c3cf4 100644 --- a/desktop/packages/mullvad-vpn/src/shared/routes.ts +++ b/desktop/packages/mullvad-vpn/src/shared/routes.ts @@ -34,4 +34,5 @@ export enum RoutePath { appInfo = '/settings/app-info', changelog = '/settings/changelog', appUpgrade = '/settings/app-upgrade', + manageDevices = '/settings/manage-devices', } diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/manage-devices/helpers.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/manage-devices/helpers.ts new file mode 100644 index 0000000000..f806732113 --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/manage-devices/helpers.ts @@ -0,0 +1,29 @@ +import { IDevice } from '../../../../src/shared/daemon-rpc-types'; +import { MockedTestUtils } from '../mocked-utils'; + +export const createHelpers = (utils: MockedTestUtils) => { + const setCurrentDevice = async (currentDevice: IDevice) => { + await utils.ipc.account.device.notify({ + type: 'logged in', + deviceState: { + type: 'logged in', + accountAndDevice: { + accountNumber: '0000-0000-0000-0000', + device: { + id: currentDevice.id, + name: currentDevice.name, + created: currentDevice.created, + }, + }, + }, + }); + }; + + const setDevices = async (devices: IDevice[]) => { + await utils.ipc.account.devices.notify(devices); + }; + + return { setCurrentDevice, setDevices }; +}; + +export type MAnageDevicesHelpers = ReturnType<typeof createHelpers>; diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/manage-devices/manage-devices.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/manage-devices/manage-devices.spec.ts new file mode 100644 index 0000000000..04cc376451 --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/manage-devices/manage-devices.spec.ts @@ -0,0 +1,75 @@ +import { expect, test } from '@playwright/test'; +import { Page } from 'playwright'; + +import { IDevice } from '../../../../src/shared/daemon-rpc-types'; +import { RoutesObjectModel } from '../../route-object-models'; +import { MockedTestUtils, startMockedApp } from '../mocked-utils'; +import { createHelpers, MAnageDevicesHelpers as ManageDevicesHelpers } from './helpers'; + +let page: Page; +let util: MockedTestUtils; +let routes: RoutesObjectModel; +let helpers: ManageDevicesHelpers; + +export const mockDevices: IDevice[] = [ + { id: '1', name: 'Sneaky dog', created: new Date('2024-12-05') }, + { id: '2', name: 'Wise cat', created: new Date('2025-01-14') }, + { id: '3', name: 'Cool panda', created: new Date('2025-03-22') }, + { id: '4', name: 'Strong fish', created: new Date('2025-06-01') }, + { id: '5', name: 'Magic elk', created: new Date('2025-09-10') }, +]; + +let devices = mockDevices; + +test.describe('Manage devices view', () => { + const currentDevice = mockDevices[0]; + + test.beforeAll(async () => { + ({ page, util } = await startMockedApp()); + routes = new RoutesObjectModel(page, util); + helpers = createHelpers(util); + + await routes.main.waitForRoute(); + + await util.ipc.account.listDevices.handle(devices); + await helpers.setCurrentDevice(currentDevice); + + await routes.main.gotoAccount(); + await routes.account.gotoManageDevices(); + }); + + test.beforeEach(() => { + devices = mockDevices; + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('Should display all account devices', async () => { + const deviceListItems = routes.manageDevices.selectors.deviceListItems(); + for (const device of devices) { + await expect(deviceListItems.filter({ hasText: device.name })).toBeVisible(); + } + }); + + test('Should not be able to delete current device', async () => { + const deviceListItems = routes.manageDevices.selectors.removeDeviceButton(currentDevice.name); + await expect(deviceListItems).toHaveCount(0); + }); + + test('Should be able to delete non-current devices', async () => { + const nonCurrentDevices = devices.filter((device) => device.id !== currentDevice.id); + for (const device of nonCurrentDevices) { + const deviceItem = routes.manageDevices.selectors.deviceListItem(device.name); + const removeButton = routes.manageDevices.selectors.removeDeviceButton(device.name); + await removeButton.click(); + + const confirmButton = routes.manageDevices.selectors.confirmRemoveDeviceButton(); + await Promise.all([util.ipc.account.removeDevice.expect(), confirmButton.click()]); + devices = devices.filter((d) => d.name !== device.name); + await helpers.setDevices(devices); + await expect(deviceItem).toHaveCount(0); + } + }); +}); diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/account/account-route-object-model.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/account/account-route-object-model.ts new file mode 100644 index 0000000000..0a3b91be3b --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/account/account-route-object-model.ts @@ -0,0 +1,22 @@ +import { Page } from 'playwright'; + +import { RoutePath } from '../../../../src/shared/routes'; +import { TestUtils } from '../../utils'; +import { createSelectors } from './selectors'; + +export class AccountRouteObjectModel { + readonly page: Page; + readonly utils: TestUtils; + readonly selectors: ReturnType<typeof createSelectors>; + + constructor(page: Page, util: TestUtils) { + this.page = page; + this.utils = util; + this.selectors = createSelectors(page); + } + + async gotoManageDevices() { + await this.selectors.manageDevicesButton().click(); + await this.utils.expectRoute(RoutePath.manageDevices); + } +} diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/account/index.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/account/index.ts new file mode 100644 index 0000000000..87606940ca --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/account/index.ts @@ -0,0 +1,2 @@ +export * from './account-route-object-model'; +export * from './selectors'; diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/account/selectors.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/account/selectors.ts new file mode 100644 index 0000000000..60aafd2d6a --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/account/selectors.ts @@ -0,0 +1,5 @@ +import { Page } from 'playwright'; + +export const createSelectors = (page: Page) => ({ + manageDevicesButton: () => page.getByRole('button', { name: 'Manage devices' }), +}); diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/main/main-route-object-model.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/main/main-route-object-model.ts index 2add02f601..e58895cf91 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/main/main-route-object-model.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/main/main-route-object-model.ts @@ -29,6 +29,11 @@ export class MainRouteObjectModel { await this.utils.expectRoute(RoutePath.selectLocation); } + async gotoAccount() { + await this.selectors.accountButton().click(); + await this.utils.expectRoute(RoutePath.account); + } + async expandConnectionPanel() { await this.selectors.connectionPanelChevronButton().click(); } diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/main/selectors.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/main/selectors.ts index 95f300c460..dd78ae4ae2 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/main/selectors.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/main/selectors.ts @@ -2,6 +2,7 @@ import { Page } from 'playwright'; export const createSelectors = (page: Page) => ({ settingsButton: () => page.locator('button[aria-label="Settings"]'), + accountButton: () => page.locator('button[aria-label="Account settings"]'), selectLocationButton: () => page.getByLabel('Select location'), connectionPanelChevronButton: () => page.getByTestId('connection-panel-chevron'), inIpLabel: () => page.getByTestId('in-ip'), diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/manage-devices/index.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/manage-devices/index.ts new file mode 100644 index 0000000000..2caff95ea1 --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/manage-devices/index.ts @@ -0,0 +1,2 @@ +export * from './manage-devices-route-object-model'; +export * from './selectors'; diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/manage-devices/manage-devices-route-object-model.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/manage-devices/manage-devices-route-object-model.ts new file mode 100644 index 0000000000..ab1a55c0d2 --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/manage-devices/manage-devices-route-object-model.ts @@ -0,0 +1,16 @@ +import { Page } from 'playwright'; + +import { TestUtils } from '../../utils'; +import { createSelectors } from './selectors'; + +export class ManageDevicesRouteObjectModel { + readonly page: Page; + readonly utils: TestUtils; + readonly selectors: ReturnType<typeof createSelectors>; + + constructor(page: Page, util: TestUtils) { + this.page = page; + this.utils = util; + this.selectors = createSelectors(page); + } +} diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/manage-devices/selectors.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/manage-devices/selectors.ts new file mode 100644 index 0000000000..5e6250ee5c --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/manage-devices/selectors.ts @@ -0,0 +1,8 @@ +import { Page } from 'playwright'; + +export const createSelectors = (page: Page) => ({ + deviceListItems: () => page.getByRole('list'), + deviceListItem: (name: string) => page.getByRole('listitem').filter({ hasText: name }), + removeDeviceButton: (name: string) => page.getByLabel(`Remove device named ${name}`), + confirmRemoveDeviceButton: () => page.getByRole('button', { name: 'Remove', exact: true }), +}); diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/routes-object-model.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/routes-object-model.ts index 77ec121306..1e31a36441 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/routes-object-model.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/routes-object-model.ts @@ -1,6 +1,7 @@ import { Page } from 'playwright'; import { TestUtils } from '../utils'; +import { AccountRouteObjectModel } from './account'; import { DaitaSettingsRouteObjectModel } from './daita-settings'; import { DeviceRevokedRouteObjectModel } from './device-revoked'; import { ExpiredRouteObjectModel } from './expired'; @@ -8,6 +9,7 @@ import { FilterRouteObjectModel } from './filter'; import { LaunchRouteObjectModel } from './launch'; import { LoginRouteObjectModel } from './login'; import { MainRouteObjectModel } from './main'; +import { ManageDevicesRouteObjectModel } from './manage-devices'; import { MultihopSettingsRouteObjectModel } from './multihop-settings'; import { RedeemVoucherRouteObjectModel } from './redeem-voucher'; import { SelectLanguageRouteObjectModel } from './select-language'; @@ -47,6 +49,8 @@ export class RoutesObjectModel { readonly daitaSettings: DaitaSettingsRouteObjectModel; readonly splitTunnelingSettings: SplitTunnelingSettingsRouteObjectModel; readonly shadowsocksSettings: ShadowsocksSettingsRouteObjectModel; + readonly account: AccountRouteObjectModel; + readonly manageDevices: ManageDevicesRouteObjectModel; constructor(page: Page, utils: TestUtils) { this.selectLanguage = new SelectLanguageRouteObjectModel(page, utils); @@ -71,5 +75,7 @@ export class RoutesObjectModel { this.daitaSettings = new DaitaSettingsRouteObjectModel(page, utils); this.splitTunnelingSettings = new SplitTunnelingSettingsRouteObjectModel(page, utils); this.shadowsocksSettings = new ShadowsocksSettingsRouteObjectModel(page, utils); + this.account = new AccountRouteObjectModel(page, utils); + this.manageDevices = new ManageDevicesRouteObjectModel(page, utils); } } diff --git a/desktop/packages/mullvad-vpn/test/unit/html-formatter.spec.ts b/desktop/packages/mullvad-vpn/test/unit/html-formatter.spec.ts new file mode 100644 index 0000000000..7cdce41994 --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/unit/html-formatter.spec.ts @@ -0,0 +1,62 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import React from 'react'; + +import { formatHtml } from '../../src/renderer/lib/html-formatter'; + +type WithChildren = React.ReactElement<{ children?: React.ReactNode }>; + +describe('Format html', () => { + const expectChildrenToMatch = (element: React.ReactElement, expectedParts: string[]) => { + const kids = React.Children.toArray((element as WithChildren).props.children); + + expect(kids).to.have.lengthOf(expectedParts.length); + kids.forEach((kid, index) => { + expect((kid as WithChildren).props.children).to.equal(expectedParts[index]); + }); + }; + + it('should format middle bold tag', () => { + expectChildrenToMatch(formatHtml('Some <b>bold</b> text'), ['Some ', 'bold', ' text']); + }); + it('should format starting bold tag', () => { + expectChildrenToMatch(formatHtml('<b>Some</b> bold text'), ['Some', ' bold text']); + }); + it('should format ending bold tag', () => { + expectChildrenToMatch(formatHtml('Some bold <b>text</b>'), ['Some bold ', 'text']); + }); + it('should format multiple bold tags', () => { + expectChildrenToMatch(formatHtml('Some <b>bold</b> and <b>more bold</b> text'), [ + 'Some ', + 'bold', + ' and ', + 'more bold', + ' text', + ]); + }); + it('should format middle emphasis tag', () => { + expectChildrenToMatch(formatHtml('Some <em>emphasized</em> text'), [ + 'Some ', + 'emphasized', + ' text', + ]); + }); + it('should format starting emphasis tag', () => { + expectChildrenToMatch(formatHtml('<em>Some</em> emphasized text'), [ + 'Some', + ' emphasized text', + ]); + }); + it('should format ending emphasis tag', () => { + expectChildrenToMatch(formatHtml('Some emphasized <em>text</em>'), [ + 'Some emphasized ', + 'text', + ]); + }); + it('should format multiple emphasis tags', () => { + expectChildrenToMatch( + formatHtml('Some <em>emphasized</em> and <em>more emphasized</em> text'), + ['Some ', 'emphasized', ' and ', 'more emphasized', ' text'], + ); + }); +}); |
