summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorTobias Järvelöv <tobias.jarvelov@mullvad.net>2025-10-10 13:40:42 +0200
committerTobias Järvelöv <tobias.jarvelov@mullvad.net>2025-10-10 13:40:42 +0200
commitacb57dbe1d47464f786a23b65e30e8e09ba55e2e (patch)
tree50de9f5ec4d856905c509b9dc54ebe76c58a46bb
parent4c1f46f96a7456fc5933c03118179cd1e4e2675d (diff)
parent64fb99c783c80a6e379aa2727bd7e2d7a7feadcf (diff)
downloadmullvadvpn-acb57dbe1d47464f786a23b65e30e8e09ba55e2e.tar.xz
mullvadvpn-acb57dbe1d47464f786a23b65e30e8e09ba55e2e.zip
Merge remote-tracking branch 'origin/device-management-desktop'
-rw-r--r--desktop/package-lock.json108
-rw-r--r--desktop/packages/mullvad-vpn/locales/da/messages.po1
-rw-r--r--desktop/packages/mullvad-vpn/locales/de/messages.po1
-rw-r--r--desktop/packages/mullvad-vpn/locales/es/messages.po1
-rw-r--r--desktop/packages/mullvad-vpn/locales/fi/messages.po1
-rw-r--r--desktop/packages/mullvad-vpn/locales/fr/messages.po1
-rw-r--r--desktop/packages/mullvad-vpn/locales/it/messages.po1
-rw-r--r--desktop/packages/mullvad-vpn/locales/ja/messages.po1
-rw-r--r--desktop/packages/mullvad-vpn/locales/ko/messages.po1
-rw-r--r--desktop/packages/mullvad-vpn/locales/messages.pot91
-rw-r--r--desktop/packages/mullvad-vpn/locales/my/messages.po1
-rw-r--r--desktop/packages/mullvad-vpn/locales/nb/messages.po1
-rw-r--r--desktop/packages/mullvad-vpn/locales/nl/messages.po1
-rw-r--r--desktop/packages/mullvad-vpn/locales/pl/messages.po1
-rw-r--r--desktop/packages/mullvad-vpn/locales/pt/messages.po1
-rw-r--r--desktop/packages/mullvad-vpn/locales/ru/messages.po1
-rw-r--r--desktop/packages/mullvad-vpn/locales/sv/messages.po1
-rw-r--r--desktop/packages/mullvad-vpn/locales/th/messages.po1
-rw-r--r--desktop/packages/mullvad-vpn/locales/tr/messages.po1
-rw-r--r--desktop/packages/mullvad-vpn/locales/zh-CN/messages.po1
-rw-r--r--desktop/packages/mullvad-vpn/locales/zh-TW/messages.po1
-rw-r--r--desktop/packages/mullvad-vpn/package.json1
-rw-r--r--desktop/packages/mullvad-vpn/scripts/verify-translations-format.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/app.tsx5
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/Account.tsx157
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/AccountStyles.tsx50
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx8
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/ExpiredAccountErrorView.tsx4
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/Modal.tsx8
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/TooManyDevices.tsx346
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/app-main-header/components/AppMainHeaderDeviceInfo.tsx4
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/DeviceListItem.tsx98
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/DeviceListItemContext.tsx35
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/confirm-dialog/ConfirmDialog.tsx55
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/confirm-dialog/hooks/index.ts3
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/confirm-dialog/hooks/use-handle-remove-device-error.ts38
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/confirm-dialog/hooks/use-handle-remove-device.ts22
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/confirm-dialog/hooks/use-remove-device.ts17
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/confirm-dialog/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/error-dialog/ErrorDialog.tsx23
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/error-dialog/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/index.ts3
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/remove-button/RemoveButton.tsx26
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/components/remove-button/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/hooks/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/hooks/use-formatted-date.ts6
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/hooks/use-is-current-device.ts8
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/device-list-item/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/device-list/DeviceList.tsx19
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/device-list/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/account/Account.tsx107
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/account-expiry-row/AccountExpiryRow.tsx8
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/account-expiry-row/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/account-number-row/AccountNumberRow.tsx10
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/account-number-row/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/device-name-row/DeviceNameRow.tsx36
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/device-name-row/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/formatted-account-expiry/FormattedAccountExpiry.tsx23
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/formatted-account-expiry/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/index.ts5
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/labelled-row/LabelledRow.tsx17
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/account/components/labelled-row/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/account/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/index.ts3
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/login/LoginView.tsx4
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/ManageDevicesContext.tsx37
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/ManageDevicesView.tsx61
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/components/devices-empty-state/DevicesEmptyState.tsx23
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/components/devices-empty-state/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/components/devices-state/DevicesState.tsx17
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/components/devices-state/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/components/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/hooks/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/hooks/use-fetch-devices.ts13
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/hooks/use-query-devices.ts15
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/hooks/use-sorted-devices.ts14
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/manage-devices/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/too-many-devices/TooManyDevicesView.tsx130
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/too-many-devices/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/animated-list/AnimatedList.tsx25
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/animated-list/components/animated-list-item/AnimatedListItem.tsx28
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/animated-list/components/animated-list-item/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/animated-list/components/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/animated-list/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/button/Button.tsx26
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/EmptyState.tsx35
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/EmptyStateContext.tsx26
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-button/EmptyStateButton.tsx20
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-button/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-status-icon/EmptyStateStatusIcon.tsx14
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-status-icon/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-subtitle/EmptyStateSubtitle.tsx10
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-subtitle/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-text-container/EmptyStateTextContainer.tsx11
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-text-container/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-title/EmptyStateTitle.tsx11
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/empty-state-title/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/components/index.ts5
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/empty-state/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/flex-column/FlexColumn.tsx2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/flex/Flex.tsx3
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/text/Text.tsx2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/hooks/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-query.ts61
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/html-formatter.tsx61
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/notifications/new-device.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/utils/format-device-name.ts5
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/utils/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/shared/routes.ts1
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/mocked/manage-devices/helpers.ts29
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/mocked/manage-devices/manage-devices.spec.ts75
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/account/account-route-object-model.ts22
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/account/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/account/selectors.ts5
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/main/main-route-object-model.ts5
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/main/selectors.ts1
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/manage-devices/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/manage-devices/manage-devices-route-object-model.ts16
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/manage-devices/selectors.ts8
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/routes-object-model.ts6
-rw-r--r--desktop/packages/mullvad-vpn/test/unit/html-formatter.spec.ts62
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'],
+ );
+ });
+});