瀏覽代碼

Merge pull request #350 from meienberger/release/1.2.0

Release: 1.2.0
Nicolas Meienberger 2 年之前
父節點
當前提交
69386778a7
共有 100 個文件被更改,包括 2958 次插入967 次删除
  1. 2 1
      .eslintrc.js
  2. 10 0
      __mocks__/redis.ts
  3. 1 0
      docker-compose.dev.yml
  4. 1 0
      docker-compose.rc.yml
  5. 1 0
      docker-compose.test.yml
  6. 1 0
      docker-compose.yml
  7. 11 4
      package.json
  8. 330 29
      pnpm-lock.yaml
  9. 9 6
      prisma/schema.prisma
  10. 6 0
      scripts/install.sh
  11. 9 5
      src/client/components/AppStatus/AppStatus.tsx
  12. 7 3
      src/client/components/AppTile/AppTile.tsx
  13. 3 5
      src/client/components/Layout/Layout.tsx
  14. 0 70
      src/client/components/hoc/ToastProvider/ToastProvider.test.tsx
  15. 0 24
      src/client/components/hoc/ToastProvider/ToastProvider.tsx
  16. 0 1
      src/client/components/hoc/ToastProvider/index.ts
  17. 1 2
      src/client/components/ui/Dialog/Dialog.module.scss
  18. 68 0
      src/client/components/ui/Dialog/Dialog.tsx
  19. 1 0
      src/client/components/ui/Dialog/index.ts
  20. 11 12
      src/client/components/ui/Header/Header.test.tsx
  21. 8 4
      src/client/components/ui/Header/Header.tsx
  22. 6 1
      src/client/components/ui/Input/Input.tsx
  23. 0 141
      src/client/components/ui/Modal/Modal.test.tsx
  24. 0 65
      src/client/components/ui/Modal/Modal.tsx
  25. 0 13
      src/client/components/ui/Modal/ModalBody.tsx
  26. 0 11
      src/client/components/ui/Modal/ModalFooter.tsx
  27. 0 11
      src/client/components/ui/Modal/ModalHeader.tsx
  28. 0 4
      src/client/components/ui/Modal/index.ts
  29. 16 0
      src/client/components/ui/OtpInput/OtpInput.module.scss
  30. 262 0
      src/client/components/ui/OtpInput/OtpInput.test.tsx
  31. 157 0
      src/client/components/ui/OtpInput/OtpInput.tsx
  32. 1 0
      src/client/components/ui/OtpInput/index.ts
  33. 10 0
      src/client/components/ui/Switch/Switch.module.scss
  34. 9 11
      src/client/components/ui/Switch/Switch.test.tsx
  35. 19 16
      src/client/components/ui/Switch/Switch.tsx
  36. 1 1
      src/client/mocks/fixtures/app.fixtures.ts
  37. 1 1
      src/client/mocks/getTrpcMock.ts
  38. 2 1
      src/client/mocks/handlers.ts
  39. 3 0
      src/client/mocks/index.ts
  40. 6 6
      src/client/modules/AppStore/components/CategorySelector/CategorySelector.tsx
  41. 40 19
      src/client/modules/Apps/components/AppActions/AppActions.test.tsx
  42. 3 1
      src/client/modules/Apps/components/AppActions/AppActions.tsx
  43. 9 5
      src/client/modules/Apps/components/InstallForm/InstallForm.tsx
  44. 11 9
      src/client/modules/Apps/components/InstallModal/InstallModal.tsx
  45. 16 14
      src/client/modules/Apps/components/StopModal.tsx
  46. 18 16
      src/client/modules/Apps/components/UninstallModal.tsx
  47. 9 9
      src/client/modules/Apps/components/UpdateModal/UpdateModal.test.tsx
  48. 19 17
      src/client/modules/Apps/components/UpdateModal/UpdateModal.tsx
  49. 11 9
      src/client/modules/Apps/components/UpdateSettingsModal.tsx
  50. 58 188
      src/client/modules/Apps/containers/AppDetailsContainer/AppDetailsContainer.test.tsx
  51. 13 14
      src/client/modules/Apps/containers/AppDetailsContainer/AppDetailsContainer.tsx
  52. 2 2
      src/client/modules/Auth/components/LoginForm/LoginForm.tsx
  53. 32 0
      src/client/modules/Auth/components/TotpForm/TotpForm.tsx
  54. 1 0
      src/client/modules/Auth/components/TotpForm/index.ts
  55. 112 12
      src/client/modules/Auth/containers/LoginContainer/LoginContainer.test.tsx
  56. 25 5
      src/client/modules/Auth/containers/LoginContainer/LoginContainer.tsx
  57. 2 6
      src/client/modules/Auth/containers/RegisterContainer/RegisterContainer.test.tsx
  58. 2 3
      src/client/modules/Auth/containers/RegisterContainer/RegisterContainer.tsx
  59. 6 13
      src/client/modules/Auth/containers/ResetPasswordContainer/ResetPasswordContainer.test.tsx
  60. 4 5
      src/client/modules/Auth/containers/ResetPasswordContainer/ResetPasswordContainer.tsx
  61. 72 0
      src/client/modules/Settings/components/ChangePasswordForm/ChangePasswordForm.test.tsx
  62. 63 0
      src/client/modules/Settings/components/ChangePasswordForm/ChangePasswordForm.tsx
  63. 1 0
      src/client/modules/Settings/components/ChangePasswordForm/index.ts
  64. 285 0
      src/client/modules/Settings/components/OtpForm/OptForm.test.tsx
  65. 153 0
      src/client/modules/Settings/components/OtpForm/OtpForm.tsx
  66. 1 0
      src/client/modules/Settings/components/OtpForm/index.ts
  67. 16 14
      src/client/modules/Settings/components/RestartModal/RestartModal.tsx
  68. 1 1
      src/client/modules/Settings/components/SettingsForm/SettingsForm.tsx
  69. 16 14
      src/client/modules/Settings/components/UpdateModal/UpdateModal.tsx
  70. 21 24
      src/client/modules/Settings/containers/GeneralActions/GeneralActions.test.tsx
  71. 23 9
      src/client/modules/Settings/containers/GeneralActions/GeneralActions.tsx
  72. 9 0
      src/client/modules/Settings/containers/SecurityContainer/SecurityContainer.test.tsx
  73. 27 0
      src/client/modules/Settings/containers/SecurityContainer/SecurityContainer.tsx
  74. 1 0
      src/client/modules/Settings/containers/SecurityContainer/index.ts
  75. 3 9
      src/client/modules/Settings/containers/SettingsContainer/SettingsContainer.test.tsx
  76. 3 4
      src/client/modules/Settings/containers/SettingsContainer/SettingsContainer.tsx
  77. 5 0
      src/client/modules/Settings/pages/SettingsPage/SettingsPage.tsx
  78. 0 46
      src/client/state/toastStore.ts
  79. 5 0
      src/client/utils/trpc.ts
  80. 6 0
      src/client/utils/typescript.ts
  81. 12 6
      src/pages/_app.tsx
  82. 5 0
      src/pages/_document.tsx
  83. 5 0
      src/server/common/get-server-auth-session.ts
  84. 8 4
      src/server/context.ts
  85. 0 3
      src/server/core/EventDispatcher/EventDispatcher.test.ts
  86. 3 1
      src/server/core/EventDispatcher/EventDispatcher.ts
  87. 14 0
      src/server/core/TipiCache/TipiCache.ts
  88. 0 2
      src/server/core/TipiConfig/TipiConfig.ts
  89. 1 1
      src/server/index.ts
  90. 23 0
      src/server/migrations/00006-add-totp-user-fields.sql
  91. 212 0
      src/server/routers/auth/auth.router.test.ts
  92. 10 1
      src/server/routers/auth/auth.router.ts
  93. 3 4
      src/server/services/apps/apps.helpers.ts
  94. 345 7
      src/server/services/auth/auth.service.test.ts
  95. 189 7
      src/server/services/auth/auth.service.ts
  96. 4 2
      src/server/services/system/system.service.test.ts
  97. 8 4
      src/server/services/system/system.service.ts
  98. 4 8
      src/server/tests/user.factory.ts
  99. 3 1
      src/server/trpc.ts
  100. 32 0
      src/server/utils/__tests__/encryption.test.ts

+ 2 - 1
.eslintrc.js

@@ -1,5 +1,5 @@
 module.exports = {
-  plugins: ['@typescript-eslint', 'import', 'react', 'jest', 'jsdoc', 'import'],
+  plugins: ['@typescript-eslint', 'import', 'react', 'jest', 'jsdoc', 'jsx-a11y'],
   extends: [
     'plugin:@typescript-eslint/recommended',
     'next/core-web-vitals',
@@ -11,6 +11,7 @@ module.exports = {
     'prettier',
     'plugin:react/recommended',
     'plugin:jsdoc/recommended',
+    'plugin:jsx-a11y/recommended',
   ],
   parser: '@typescript-eslint/parser',
   parserOptions: {

+ 10 - 0
__mocks__/redis.ts

@@ -12,5 +12,15 @@ export const createClient = jest.fn(() => {
     quit: jest.fn(),
     del: (key: string) => values.delete(key),
     ttl: (key: string) => expirations.get(key),
+    keys: (key: string) => {
+      const keys = [];
+      // eslint-disable-next-line no-restricted-syntax
+      for (const [k] of values) {
+        if (k.startsWith(key)) {
+          keys.push(k);
+        }
+      }
+      return keys;
+    },
   };
 });

+ 1 - 0
docker-compose.dev.yml

@@ -56,6 +56,7 @@ services:
       DOMAIN: ${DOMAIN}
       ARCHITECTURE: ${ARCHITECTURE}
       REDIS_HOST: ${REDIS_HOST}
+      DEMO_MODE: ${DEMO_MODE}
     networks:
       - tipi_main_network
     ports:

+ 1 - 0
docker-compose.rc.yml

@@ -68,6 +68,7 @@ services:
       DOMAIN: ${DOMAIN}
       ARCHITECTURE: ${ARCHITECTURE}
       REDIS_HOST: ${REDIS_HOST}
+      DEMO_MODE: ${DEMO_MODE}
     volumes:
       - ${PWD}/.env:/runtipi/.env
       - ${PWD}/state:/runtipi/state

+ 1 - 0
docker-compose.test.yml

@@ -70,6 +70,7 @@ services:
       DOMAIN: ${DOMAIN}
       ARCHITECTURE: ${ARCHITECTURE}
       REDIS_HOST: ${REDIS_HOST}
+      DEMO_MODE: ${DEMO_MODE}
     volumes:
       - ${PWD}/.env:/runtipi/.env
       - ${PWD}/state:/runtipi/state

+ 1 - 0
docker-compose.yml

@@ -68,6 +68,7 @@ services:
       DOMAIN: ${DOMAIN}
       ARCHITECTURE: ${ARCHITECTURE}
       REDIS_HOST: ${REDIS_HOST}
+      DEMO_MODE: ${DEMO_MODE}
     volumes:
       - ${PWD}/.env:/runtipi/.env
       - ${PWD}/state:/runtipi/state

+ 11 - 4
package.json

@@ -1,6 +1,6 @@
 {
   "name": "runtipi",
-  "version": "1.1.2",
+  "version": "1.2.0",
   "description": "A homeserver for everyone",
   "scripts": {
     "copy:migrations": "mkdir -p dist/migrations && cp -r ./src/server/migrations dist",
@@ -30,7 +30,12 @@
   },
   "dependencies": {
     "@hookform/resolvers": "^2.9.10",
-    "@prisma/client": "^4.11.0",
+    "@otplib/core": "^12.0.1",
+    "@otplib/plugin-crypto": "^12.0.1",
+    "@otplib/plugin-thirty-two": "^12.0.1",
+    "@prisma/client": "^4.12.0",
+    "@radix-ui/react-dialog": "^1.0.3",
+    "@radix-ui/react-switch": "^1.0.2",
     "@radix-ui/react-tabs": "^1.0.3",
     "@runtipi/postgres-migrations": "^5.3.0",
     "@tabler/core": "1.0.0-beta17",
@@ -51,12 +56,14 @@
     "node-cron": "^3.0.1",
     "node-fetch-commonjs": "^3.2.4",
     "pg": "^8.10.0",
+    "qrcode.react": "^3.1.0",
     "react": "18.2.0",
     "react-dom": "18.2.0",
     "react-hook-form": "^7.43.7",
+    "react-hot-toast": "^2.4.0",
     "react-markdown": "^8.0.3",
     "react-select": "^5.6.1",
-    "react-tooltip": "^4.4.3",
+    "react-tooltip": "^5.10.5",
     "redis": "^4.6.5",
     "remark-breaks": "^3.0.2",
     "remark-gfm": "^3.0.1",
@@ -115,7 +122,7 @@
     "next-router-mock": "^0.9.2",
     "nodemon": "^2.0.21",
     "prettier": "^2.8.4",
-    "prisma": "^4.11.0",
+    "prisma": "^4.12.0",
     "ts-jest": "^29.0.3",
     "ts-node": "^10.9.1",
     "typescript": "5.0.2",

+ 330 - 29
pnpm-lock.yaml

@@ -4,9 +4,24 @@ dependencies:
   '@hookform/resolvers':
     specifier: ^2.9.10
     version: 2.9.11(react-hook-form@7.43.7)
+  '@otplib/core':
+    specifier: ^12.0.1
+    version: 12.0.1
+  '@otplib/plugin-crypto':
+    specifier: ^12.0.1
+    version: 12.0.1
+  '@otplib/plugin-thirty-two':
+    specifier: ^12.0.1
+    version: 12.0.1
   '@prisma/client':
-    specifier: ^4.11.0
-    version: 4.11.0(prisma@4.11.0)
+    specifier: ^4.12.0
+    version: 4.12.0(prisma@4.12.0)
+  '@radix-ui/react-dialog':
+    specifier: ^1.0.3
+    version: 1.0.3(@types/react@18.0.28)(react-dom@18.2.0)(react@18.2.0)
+  '@radix-ui/react-switch':
+    specifier: ^1.0.2
+    version: 1.0.2(react-dom@18.2.0)(react@18.2.0)
   '@radix-ui/react-tabs':
     specifier: ^1.0.3
     version: 1.0.3(react-dom@18.2.0)(react@18.2.0)
@@ -67,6 +82,9 @@ dependencies:
   pg:
     specifier: ^8.10.0
     version: 8.10.0
+  qrcode.react:
+    specifier: ^3.1.0
+    version: 3.1.0(react@18.2.0)
   react:
     specifier: 18.2.0
     version: 18.2.0
@@ -76,6 +94,9 @@ dependencies:
   react-hook-form:
     specifier: ^7.43.7
     version: 7.43.7(react@18.2.0)
+  react-hot-toast:
+    specifier: ^2.4.0
+    version: 2.4.0(csstype@3.1.1)(react-dom@18.2.0)(react@18.2.0)
   react-markdown:
     specifier: ^8.0.3
     version: 8.0.5(@types/react@18.0.28)(react@18.2.0)
@@ -83,8 +104,8 @@ dependencies:
     specifier: ^5.6.1
     version: 5.7.0(@types/react@18.0.28)(react-dom@18.2.0)(react@18.2.0)
   react-tooltip:
-    specifier: ^4.4.3
-    version: 4.5.1(react-dom@18.2.0)(react@18.2.0)
+    specifier: ^5.10.5
+    version: 5.10.5(react-dom@18.2.0)(react@18.2.0)
   redis:
     specifier: ^4.6.5
     version: 4.6.5
@@ -256,8 +277,8 @@ devDependencies:
     specifier: ^2.8.4
     version: 2.8.4
   prisma:
-    specifier: ^4.11.0
-    version: 4.11.0
+    specifier: ^4.12.0
+    version: 4.12.0
   ts-jest:
     specifier: ^29.0.3
     version: 29.0.5(@babel/core@7.21.3)(esbuild@0.16.17)(jest@29.5.0)(typescript@5.0.2)
@@ -1476,6 +1497,23 @@ packages:
     resolution: {integrity: sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q==}
     dev: true
 
+  /@otplib/core@12.0.1:
+    resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==}
+    dev: false
+
+  /@otplib/plugin-crypto@12.0.1:
+    resolution: {integrity: sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==}
+    dependencies:
+      '@otplib/core': 12.0.1
+    dev: false
+
+  /@otplib/plugin-thirty-two@12.0.1:
+    resolution: {integrity: sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==}
+    dependencies:
+      '@otplib/core': 12.0.1
+      thirty-two: 1.0.2
+    dev: false
+
   /@phc/format@1.0.0:
     resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
     engines: {node: '>=10'}
@@ -1497,8 +1535,8 @@ packages:
     resolution: {integrity: sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==}
     dev: false
 
-  /@prisma/client@4.11.0(prisma@4.11.0):
-    resolution: {integrity: sha512-0INHYkQIqgAjrt7NzhYpeDQi8x3Nvylc2uDngKyFDDj1tTRQ4uV1HnVmd1sQEraeVAN63SOK0dgCKQHlvjL0KA==}
+  /@prisma/client@4.12.0(prisma@4.12.0):
+    resolution: {integrity: sha512-j9/ighfWwux97J2dS15nqhl60tYoH8V0IuSsgZDb6bCFcQD3fXbXmxjYC8GHhIgOk3lB7Pq+8CwElz2MiDpsSg==}
     engines: {node: '>=14.17'}
     requiresBuild: true
     peerDependencies:
@@ -1507,16 +1545,16 @@ packages:
       prisma:
         optional: true
     dependencies:
-      '@prisma/engines-version': 4.11.0-57.8fde8fef4033376662cad983758335009d522acb
-      prisma: 4.11.0
+      '@prisma/engines-version': 4.12.0-67.659ef412370fa3b41cd7bf6e94587c1dfb7f67e7
+      prisma: 4.12.0
     dev: false
 
-  /@prisma/engines-version@4.11.0-57.8fde8fef4033376662cad983758335009d522acb:
-    resolution: {integrity: sha512-3Vd8Qq06d5xD8Ch5WauWcUUrsVPdMC6Ge8ILji8RFfyhUpqon6qSyGM0apvr1O8n8qH8cKkEFqRPsYjuz5r83g==}
+  /@prisma/engines-version@4.12.0-67.659ef412370fa3b41cd7bf6e94587c1dfb7f67e7:
+    resolution: {integrity: sha512-JIHNj5jlXb9mcaJwakM0vpgRYJIAurxTUqM0iX0tfEQA5XLZ9ONkIckkhuAKdAzocZ+80GYg7QSsfpjg7OxbOA==}
     dev: false
 
-  /@prisma/engines@4.11.0:
-    resolution: {integrity: sha512-0AEBi2HXGV02cf6ASsBPhfsVIbVSDC9nbQed4iiY5eHttW9ZtMxHThuKZE1pnESbr8HRdgmFSa/Kn4OSNYuibg==}
+  /@prisma/engines@4.12.0:
+    resolution: {integrity: sha512-0alKtnxhNB5hYU+ymESBlGI4b9XrGGSdv7Ud+8TE/fBNOEhIud0XQsAR+TrvUZgS4na5czubiMsODw0TUrgkIA==}
     requiresBuild: true
 
   /@radix-ui/primitive@1.0.0:
@@ -1558,6 +1596,33 @@ packages:
       react: 18.2.0
     dev: false
 
+  /@radix-ui/react-dialog@1.0.3(@types/react@18.0.28)(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-owNhq36kNPqC2/a+zJRioPg6HHnTn5B/sh/NjTY8r4W9g1L5VJlrzZIVcBr7R9Mg8iLjVmh6MGgMlfoVf/WO/A==}
+    peerDependencies:
+      react: ^16.8 || ^17.0 || ^18.0
+      react-dom: ^16.8 || ^17.0 || ^18.0
+    dependencies:
+      '@babel/runtime': 7.20.13
+      '@radix-ui/primitive': 1.0.0
+      '@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
+      '@radix-ui/react-context': 1.0.0(react@18.2.0)
+      '@radix-ui/react-dismissable-layer': 1.0.3(react-dom@18.2.0)(react@18.2.0)
+      '@radix-ui/react-focus-guards': 1.0.0(react@18.2.0)
+      '@radix-ui/react-focus-scope': 1.0.2(react-dom@18.2.0)(react@18.2.0)
+      '@radix-ui/react-id': 1.0.0(react@18.2.0)
+      '@radix-ui/react-portal': 1.0.2(react-dom@18.2.0)(react@18.2.0)
+      '@radix-ui/react-presence': 1.0.0(react-dom@18.2.0)(react@18.2.0)
+      '@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0)(react@18.2.0)
+      '@radix-ui/react-slot': 1.0.1(react@18.2.0)
+      '@radix-ui/react-use-controllable-state': 1.0.0(react@18.2.0)
+      aria-hidden: 1.2.3
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+      react-remove-scroll: 2.5.5(@types/react@18.0.28)(react@18.2.0)
+    transitivePeerDependencies:
+      - '@types/react'
+    dev: false
+
   /@radix-ui/react-direction@1.0.0(react@18.2.0):
     resolution: {integrity: sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==}
     peerDependencies:
@@ -1567,6 +1632,45 @@ packages:
       react: 18.2.0
     dev: false
 
+  /@radix-ui/react-dismissable-layer@1.0.3(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-nXZOvFjOuHS1ovumntGV7NNoLaEp9JEvTht3MBjP44NSW5hUKj/8OnfN3+8WmB+CEhN44XaGhpHoSsUIEl5P7Q==}
+    peerDependencies:
+      react: ^16.8 || ^17.0 || ^18.0
+      react-dom: ^16.8 || ^17.0 || ^18.0
+    dependencies:
+      '@babel/runtime': 7.20.13
+      '@radix-ui/primitive': 1.0.0
+      '@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
+      '@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0)(react@18.2.0)
+      '@radix-ui/react-use-callback-ref': 1.0.0(react@18.2.0)
+      '@radix-ui/react-use-escape-keydown': 1.0.2(react@18.2.0)
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+    dev: false
+
+  /@radix-ui/react-focus-guards@1.0.0(react@18.2.0):
+    resolution: {integrity: sha512-UagjDk4ijOAnGu4WMUPj9ahi7/zJJqNZ9ZAiGPp7waUWJO0O1aWXi/udPphI0IUjvrhBsZJGSN66dR2dsueLWQ==}
+    peerDependencies:
+      react: ^16.8 || ^17.0 || ^18.0
+    dependencies:
+      '@babel/runtime': 7.20.13
+      react: 18.2.0
+    dev: false
+
+  /@radix-ui/react-focus-scope@1.0.2(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-spwXlNTfeIprt+kaEWE/qYuYT3ZAqJiAGjN/JgdvgVDTu8yc+HuX+WOWXrKliKnLnwck0F6JDkqIERncnih+4A==}
+    peerDependencies:
+      react: ^16.8 || ^17.0 || ^18.0
+      react-dom: ^16.8 || ^17.0 || ^18.0
+    dependencies:
+      '@babel/runtime': 7.20.13
+      '@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
+      '@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0)(react@18.2.0)
+      '@radix-ui/react-use-callback-ref': 1.0.0(react@18.2.0)
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+    dev: false
+
   /@radix-ui/react-id@1.0.0(react@18.2.0):
     resolution: {integrity: sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==}
     peerDependencies:
@@ -1577,6 +1681,18 @@ packages:
       react: 18.2.0
     dev: false
 
+  /@radix-ui/react-portal@1.0.2(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-swu32idoCW7KA2VEiUZGBSu9nB6qwGdV6k6HYhUoOo3M1FFpD+VgLzUqtt3mwL1ssz7r2x8MggpLSQach2Xy/Q==}
+    peerDependencies:
+      react: ^16.8 || ^17.0 || ^18.0
+      react-dom: ^16.8 || ^17.0 || ^18.0
+    dependencies:
+      '@babel/runtime': 7.20.13
+      '@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0)(react@18.2.0)
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+    dev: false
+
   /@radix-ui/react-presence@1.0.0(react-dom@18.2.0)(react@18.2.0):
     resolution: {integrity: sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==}
     peerDependencies:
@@ -1632,6 +1748,24 @@ packages:
       react: 18.2.0
     dev: false
 
+  /@radix-ui/react-switch@1.0.2(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-BcG/LKehxt36NXG0wPnoCitIfSMtU9Xo7BmythYA1PAMLtsMvW7kALfBzmduQoHTWcKr0AVcFyh0gChBUp9TiQ==}
+    peerDependencies:
+      react: ^16.8 || ^17.0 || ^18.0
+      react-dom: ^16.8 || ^17.0 || ^18.0
+    dependencies:
+      '@babel/runtime': 7.20.13
+      '@radix-ui/primitive': 1.0.0
+      '@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
+      '@radix-ui/react-context': 1.0.0(react@18.2.0)
+      '@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0)(react@18.2.0)
+      '@radix-ui/react-use-controllable-state': 1.0.0(react@18.2.0)
+      '@radix-ui/react-use-previous': 1.0.0(react@18.2.0)
+      '@radix-ui/react-use-size': 1.0.0(react@18.2.0)
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+    dev: false
+
   /@radix-ui/react-tabs@1.0.3(react-dom@18.2.0)(react@18.2.0):
     resolution: {integrity: sha512-4CkF/Rx1GcrusI/JZ1Rvyx4okGUs6wEenWA0RG/N+CwkRhTy7t54y7BLsWUXrAz/GRbBfHQg/Odfs/RoW0CiRA==}
     peerDependencies:
@@ -1670,6 +1804,16 @@ packages:
       react: 18.2.0
     dev: false
 
+  /@radix-ui/react-use-escape-keydown@1.0.2(react@18.2.0):
+    resolution: {integrity: sha512-DXGim3x74WgUv+iMNCF+cAo8xUHHeqvjx8zs7trKf+FkQKPQXLk2sX7Gx1ysH7Q76xCpZuxIJE7HLPxRE+Q+GA==}
+    peerDependencies:
+      react: ^16.8 || ^17.0 || ^18.0
+    dependencies:
+      '@babel/runtime': 7.20.13
+      '@radix-ui/react-use-callback-ref': 1.0.0(react@18.2.0)
+      react: 18.2.0
+    dev: false
+
   /@radix-ui/react-use-layout-effect@1.0.0(react@18.2.0):
     resolution: {integrity: sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==}
     peerDependencies:
@@ -1679,6 +1823,25 @@ packages:
       react: 18.2.0
     dev: false
 
+  /@radix-ui/react-use-previous@1.0.0(react@18.2.0):
+    resolution: {integrity: sha512-RG2K8z/K7InnOKpq6YLDmT49HGjNmrK+fr82UCVKT2sW0GYfVnYp4wZWBooT/EYfQ5faA9uIjvsuMMhH61rheg==}
+    peerDependencies:
+      react: ^16.8 || ^17.0 || ^18.0
+    dependencies:
+      '@babel/runtime': 7.20.13
+      react: 18.2.0
+    dev: false
+
+  /@radix-ui/react-use-size@1.0.0(react@18.2.0):
+    resolution: {integrity: sha512-imZ3aYcoYCKhhgNpkNDh/aTiU05qw9hX+HHI1QDBTyIlcFjgeFlKKySNGMwTp7nYFLQg/j0VA2FmCY4WPDDHMg==}
+    peerDependencies:
+      react: ^16.8 || ^17.0 || ^18.0
+    dependencies:
+      '@babel/runtime': 7.20.13
+      '@radix-ui/react-use-layout-effect': 1.0.0(react@18.2.0)
+      react: 18.2.0
+    dev: false
+
   /@redis/bloom@1.2.0(@redis/client@1.5.6):
     resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==}
     peerDependencies:
@@ -2616,6 +2779,13 @@ packages:
     resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
     dev: true
 
+  /aria-hidden@1.2.3:
+    resolution: {integrity: sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==}
+    engines: {node: '>=10'}
+    dependencies:
+      tslib: 2.5.0
+    dev: false
+
   /aria-query@5.1.3:
     resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==}
     dependencies:
@@ -2996,6 +3166,10 @@ packages:
     resolution: {integrity: sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==}
     dev: true
 
+  /classnames@2.3.2:
+    resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==}
+    dev: false
+
   /cli-cursor@3.1.0:
     resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
     engines: {node: '>=8'}
@@ -3370,6 +3544,10 @@ packages:
     engines: {node: '>=8'}
     dev: true
 
+  /detect-node-es@1.1.0:
+    resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
+    dev: false
+
   /diff-sequences@29.4.3:
     resolution: {integrity: sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==}
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -4401,6 +4579,11 @@ packages:
       has: 1.0.3
       has-symbols: 1.0.3
 
+  /get-nonce@1.0.1:
+    resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
+    engines: {node: '>=6'}
+    dev: false
+
   /get-package-type@0.1.0:
     resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==}
     engines: {node: '>=8.0.0'}
@@ -4510,6 +4693,14 @@ packages:
     resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==}
     dev: true
 
+  /goober@2.1.12(csstype@3.1.1):
+    resolution: {integrity: sha512-yXHAvO08FU1JgTXX6Zn6sYCUFfB/OJSX8HHjDSgerZHZmFKAb08cykp5LBw5QnmyMcZyPRMqkdyHUSSzge788Q==}
+    peerDependencies:
+      csstype: ^3.0.10
+    dependencies:
+      csstype: 3.1.1
+    dev: false
+
   /gopd@1.0.1:
     resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==}
     dependencies:
@@ -4735,6 +4926,12 @@ packages:
       side-channel: 1.0.4
     dev: true
 
+  /invariant@2.2.4:
+    resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
+    dependencies:
+      loose-envify: 1.4.0
+    dev: false
+
   /ipaddr.js@1.9.1:
     resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
     engines: {node: '>= 0.10'}
@@ -6905,13 +7102,13 @@ packages:
       react-is: 18.2.0
     dev: true
 
-  /prisma@4.11.0:
-    resolution: {integrity: sha512-4zZmBXssPUEiX+GeL0MUq/Yyie4ltiKmGu7jCJFnYMamNrrulTBc+D+QwAQSJ01tyzeGHlD13kOnqPwRipnlNw==}
+  /prisma@4.12.0:
+    resolution: {integrity: sha512-xqVper4mbwl32BWzLpdznHAYvYDWQQWK2tBfXjdUD397XaveRyAP7SkBZ6kFlIg8kKayF4hvuaVtYwXd9BodAg==}
     engines: {node: '>=14.17'}
     hasBin: true
     requiresBuild: true
     dependencies:
-      '@prisma/engines': 4.11.0
+      '@prisma/engines': 4.12.0
 
   /prompts@2.4.2:
     resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
@@ -6964,6 +7161,14 @@ packages:
     resolution: {integrity: sha512-t+x1zEHDjBwkDGY5v5ApnZ/utcd4XYDiJsaQQoptTXgUXX95sDg1elCdJghzicm7n2mbCBJ3uYWr6M22SO19rg==}
     dev: true
 
+  /qrcode.react@3.1.0(react@18.2.0):
+    resolution: {integrity: sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q==}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0
+    dependencies:
+      react: 18.2.0
+    dev: false
+
   /qs@6.11.0:
     resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==}
     engines: {node: '>=0.6'}
@@ -7022,6 +7227,20 @@ packages:
       react: 18.2.0
     dev: false
 
+  /react-hot-toast@2.4.0(csstype@3.1.1)(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-qnnVbXropKuwUpriVVosgo8QrB+IaPJCpL8oBI6Ov84uvHZ5QQcTp2qg6ku2wNfgJl6rlQXJIQU5q+5lmPOutA==}
+    engines: {node: '>=10'}
+    peerDependencies:
+      react: '>=16'
+      react-dom: '>=16'
+    dependencies:
+      goober: 2.1.12(csstype@3.1.1)
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+    transitivePeerDependencies:
+      - csstype
+    dev: false
+
   /react-is@16.13.1:
     resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
 
@@ -7059,6 +7278,41 @@ packages:
       - supports-color
     dev: false
 
+  /react-remove-scroll-bar@2.3.4(@types/react@18.0.28)(react@18.2.0):
+    resolution: {integrity: sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==}
+    engines: {node: '>=10'}
+    peerDependencies:
+      '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+    dependencies:
+      '@types/react': 18.0.28
+      react: 18.2.0
+      react-style-singleton: 2.2.1(@types/react@18.0.28)(react@18.2.0)
+      tslib: 2.5.0
+    dev: false
+
+  /react-remove-scroll@2.5.5(@types/react@18.0.28)(react@18.2.0):
+    resolution: {integrity: sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==}
+    engines: {node: '>=10'}
+    peerDependencies:
+      '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+    dependencies:
+      '@types/react': 18.0.28
+      react: 18.2.0
+      react-remove-scroll-bar: 2.3.4(@types/react@18.0.28)(react@18.2.0)
+      react-style-singleton: 2.2.1(@types/react@18.0.28)(react@18.2.0)
+      tslib: 2.5.0
+      use-callback-ref: 1.3.0(@types/react@18.0.28)(react@18.2.0)
+      use-sidecar: 1.1.2(@types/react@18.0.28)(react@18.2.0)
+    dev: false
+
   /react-select@5.7.0(@types/react@18.0.28)(react-dom@18.2.0)(react@18.2.0):
     resolution: {integrity: sha512-lJGiMxCa3cqnUr2Jjtg9YHsaytiZqeNOKeibv6WF5zbK/fPegZ1hg3y/9P1RZVLhqBTs0PfqQLKuAACednYGhQ==}
     peerDependencies:
@@ -7088,17 +7342,33 @@ packages:
       react: 18.2.0
     dev: false
 
-  /react-tooltip@4.5.1(react-dom@18.2.0)(react@18.2.0):
-    resolution: {integrity: sha512-Zo+CSFUGXar1uV+bgXFFDe7VeS2iByeIp5rTgTcc2HqtuOS5D76QapejNNfx320MCY91TlhTQat36KGFTqgcvw==}
-    engines: {npm: '>=6.13'}
+  /react-style-singleton@2.2.1(@types/react@18.0.28)(react@18.2.0):
+    resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==}
+    engines: {node: '>=10'}
+    peerDependencies:
+      '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+    dependencies:
+      '@types/react': 18.0.28
+      get-nonce: 1.0.1
+      invariant: 2.2.4
+      react: 18.2.0
+      tslib: 2.5.0
+    dev: false
+
+  /react-tooltip@5.10.5(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-3bi4UtoPSdaQh0R17B3vMPhNFiATpAbXIV8AqlHqrrIdqo33OJyxuPHtgborw3KXVQ5a6iyyAmCY8ztjUB4CrA==}
     peerDependencies:
-      react: '>=16.0.0'
-      react-dom: '>=16.0.0'
+      react: '>=16.14.0'
+      react-dom: '>=16.14.0'
     dependencies:
-      prop-types: 15.8.1
+      '@floating-ui/dom': 1.2.1
+      classnames: 2.3.2
       react: 18.2.0
       react-dom: 18.2.0(react@18.2.0)
-      uuid: 7.0.3
     dev: false
 
   /react-transition-group@4.4.5(react-dom@18.2.0)(react@18.2.0):
@@ -7779,6 +8049,11 @@ packages:
     resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
     dev: true
 
+  /thirty-two@1.0.2:
+    resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==}
+    engines: {node: '>=0.2.6'}
+    dev: false
+
   /through@2.3.8:
     resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
     dev: true
@@ -8107,6 +8382,21 @@ packages:
       requires-port: 1.0.0
     dev: true
 
+  /use-callback-ref@1.3.0(@types/react@18.0.28)(react@18.2.0):
+    resolution: {integrity: sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==}
+    engines: {node: '>=10'}
+    peerDependencies:
+      '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+    dependencies:
+      '@types/react': 18.0.28
+      react: 18.2.0
+      tslib: 2.5.0
+    dev: false
+
   /use-isomorphic-layout-effect@1.1.2(@types/react@18.0.28)(react@18.2.0):
     resolution: {integrity: sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==}
     peerDependencies:
@@ -8120,6 +8410,22 @@ packages:
       react: 18.2.0
     dev: false
 
+  /use-sidecar@1.1.2(@types/react@18.0.28)(react@18.2.0):
+    resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==}
+    engines: {node: '>=10'}
+    peerDependencies:
+      '@types/react': ^16.9.0 || ^17.0.0 || ^18.0.0
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+    dependencies:
+      '@types/react': 18.0.28
+      detect-node-es: 1.1.0
+      react: 18.2.0
+      tslib: 2.5.0
+    dev: false
+
   /use-sync-external-store@1.2.0(react@18.2.0):
     resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==}
     peerDependencies:
@@ -8146,11 +8452,6 @@ packages:
     engines: {node: '>= 0.4.0'}
     dev: false
 
-  /uuid@7.0.3:
-    resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==}
-    hasBin: true
-    dev: false
-
   /uuid@8.3.2:
     resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
     hasBin: true

+ 9 - 6
prisma/schema.prisma

@@ -42,12 +42,15 @@ model Update {
 }
 
 model User {
-  id        Int      @id(map: "PK_cace4a159ff9f2512dd42373760") @default(autoincrement())
-  username  String   @unique(map: "UQ_78a916df40e02a9deb1c4b75edb") @db.VarChar
-  password  String   @db.VarChar
-  createdAt DateTime @default(now()) @db.Timestamp(6)
-  updatedAt DateTime @default(now()) @db.Timestamp(6)
-  operator  Boolean  @default(false)
+  id           Int      @id(map: "PK_cace4a159ff9f2512dd42373760") @default(autoincrement())
+  username     String   @unique(map: "UQ_78a916df40e02a9deb1c4b75edb") @db.VarChar
+  password     String   @db.VarChar
+  createdAt    DateTime @default(now()) @db.Timestamp(6)
+  updatedAt    DateTime @default(now()) @db.Timestamp(6)
+  operator     Boolean  @default(false)
+  totp_secret  String?
+  totp_enabled Boolean  @default(false)
+  salt         String?
 
   @@map("user")
 }

+ 6 - 0
scripts/install.sh

@@ -89,8 +89,14 @@ mkdir -p media/usenet/watch
 mkdir -p media/usenet/completed
 mkdir -p media/usenet/incomplete
 
+mkdir -p media/downloads
+mkdir -p media/downloads/watch
+mkdir -p media/downloads/completed
+mkdir -p media/downloads/incomplete
+
 mkdir -p media/data
 mkdir -p media/data/books
+mkdir -p media/data/comics
 mkdir -p media/data/movies
 mkdir -p media/data/music
 mkdir -p media/data/tv

+ 9 - 5
src/client/components/AppStatus/AppStatus.tsx

@@ -1,10 +1,11 @@
 import clsx from 'clsx';
 import React from 'react';
+import { Tooltip } from 'react-tooltip';
 import * as AppTypes from '../../core/types';
 import styles from './AppStatus.module.scss';
 
 export const AppStatus: React.FC<{ status: AppTypes.AppStatus; lite?: boolean }> = ({ status, lite }) => {
-  const formattedStatus = `${status[0]}${status.substring(1, status.length).toLowerCase()}`;
+  const formattedStatus = `${status[0]?.toUpperCase()}${status.substring(1, status.length).toLowerCase()}`;
 
   const classes = clsx('status-dot status-gray', {
     'status-dot-animated status-green': status === 'running',
@@ -12,9 +13,12 @@ export const AppStatus: React.FC<{ status: AppTypes.AppStatus; lite?: boolean }>
   });
 
   return (
-    <div data-place="top" data-tip={lite && formattedStatus} className="d-flex align-items-center">
-      <span className={classes} />
-      {!lite && <span className={clsx(styles.text, 'ms-2 text-muted')}>{formattedStatus}</span>}
-    </div>
+    <>
+      {lite && <Tooltip id={formattedStatus} anchorSelect=".appStatus" place="top" />}
+      <div data-tooltip-content={formattedStatus} data-tooltip-id={formattedStatus} className="appStatus d-flex align-items-center">
+        <span className={classes} />
+        {!lite && <span className={clsx(styles.text, 'ms-2 text-muted')}>{formattedStatus}</span>}
+      </div>
+    </>
   );
 };

+ 7 - 3
src/client/components/AppTile/AppTile.tsx

@@ -1,6 +1,7 @@
 import Link from 'next/link';
 import React from 'react';
 import { IconDownload } from '@tabler/icons-react';
+import { Tooltip } from 'react-tooltip';
 import { AppStatus } from '../AppStatus';
 import { AppLogo } from '../AppLogo/AppLogo';
 import { limitText } from '../../modules/AppStore/helpers/table.helpers';
@@ -30,9 +31,12 @@ export const AppTile: React.FC<{ app: AppTileInfo; status: AppStatusEnum; update
           </div>
         </div>
         {updateAvailable && (
-          <div data-tip="Update available" className="ribbon bg-green ribbon-top">
-            <IconDownload size={20} />
-          </div>
+          <>
+            <Tooltip anchorSelect=".updateAvailable">Update available</Tooltip>
+            <div className="updateAvailable ribbon bg-green ribbon-top">
+              <IconDownload size={20} />
+            </div>
+          </>
         )}
       </Link>
     </div>

+ 3 - 5
src/client/components/Layout/Layout.tsx

@@ -2,7 +2,6 @@ import Head from 'next/head';
 import Link from 'next/link';
 import React, { useEffect } from 'react';
 import clsx from 'clsx';
-import ReactTooltip from 'react-tooltip';
 import semver from 'semver';
 import { useRouter } from 'next/router';
 import { Header } from '../ui/Header';
@@ -20,7 +19,7 @@ interface IProps {
 
 export const Layout: React.FC<IProps> = ({ children, breadcrumbs, title, actions }) => {
   const router = useRouter();
-  const refreshToken = trpc.auth.refreshToken.useMutation({
+  const { mutate } = trpc.auth.refreshToken.useMutation({
     onSuccess: (data) => {
       if (data?.token) localStorage.setItem('token', data.token);
     },
@@ -31,8 +30,8 @@ export const Layout: React.FC<IProps> = ({ children, breadcrumbs, title, actions
   });
 
   useEffect(() => {
-    refreshToken.mutate();
-  }, []);
+    mutate();
+  }, [mutate]);
 
   const { version } = useSystemStore();
   const defaultVersion = '0.0.0';
@@ -61,7 +60,6 @@ export const Layout: React.FC<IProps> = ({ children, breadcrumbs, title, actions
       <Head>
         <title>{`${title} - Tipi`}</title>
       </Head>
-      <ReactTooltip offset={{ right: 1 }} effect="solid" place="bottom" />
       <Header isUpdateAvailable={!isLatest} />
       <div className="page-wrapper">
         <div className="page-header d-print-none">

+ 0 - 70
src/client/components/hoc/ToastProvider/ToastProvider.test.tsx

@@ -1,70 +0,0 @@
-import React from 'react';
-import { act, render, renderHook, screen, waitFor } from '../../../../../tests/test-utils';
-import { useToastStore } from '../../../state/toastStore';
-import { ToastProvider } from './ToastProvider';
-
-describe('Test: ToastProvider', () => {
-  it("should render it's children", async () => {
-    render(
-      <ToastProvider>
-        <div>children</div>
-      </ToastProvider>,
-    );
-
-    await waitFor(() => {
-      expect(screen.getByText('children')).toBeInTheDocument();
-    });
-  });
-
-  it('should render Toasts', async () => {
-    render(
-      <ToastProvider>
-        <div>children</div>
-      </ToastProvider>,
-    );
-    const { result } = renderHook(() => useToastStore());
-
-    act(() => {
-      result.current.addToast({
-        status: 'success',
-        title: 'title',
-        description: 'description',
-        id: 'id',
-      });
-    });
-
-    await waitFor(() => {
-      expect(screen.getByText('title')).toBeInTheDocument();
-    });
-  });
-
-  it('should remove Toasts when the close button is clicked', async () => {
-    render(
-      <ToastProvider>
-        <div>children</div>
-      </ToastProvider>,
-    );
-    const { result } = renderHook(() => useToastStore());
-
-    act(() => {
-      result.current.addToast({
-        status: 'success',
-        title: 'title',
-        description: 'description',
-        id: 'id',
-      });
-    });
-
-    await waitFor(() => {
-      expect(screen.getByText('title')).toBeInTheDocument();
-    });
-
-    act(() => {
-      screen.getByTestId('toast-close-button').click();
-    });
-
-    await waitFor(() => {
-      expect(screen.queryByText('title')).not.toBeInTheDocument();
-    });
-  });
-});

+ 0 - 24
src/client/components/hoc/ToastProvider/ToastProvider.tsx

@@ -1,24 +0,0 @@
-import React from 'react';
-import { IToast, useToastStore } from '../../../state/toastStore';
-import { Toast } from '../../ui/Toast';
-
-export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
-  const { toasts, removeToast } = useToastStore();
-
-  const renderToast = (toast: IToast) => {
-    const { status, title, description, id } = toast;
-
-    return <Toast status={status} title={title} message={description} id={id} onClose={() => removeToast(id)} />;
-  };
-
-  return (
-    <>
-      {toasts.map((toast) => (
-        <div key={toast.id} className="position-fixed bottom-0 end-0 p-3" style={{ zIndex: 11 }}>
-          {renderToast(toast)}
-        </div>
-      ))}
-      {children}
-    </>
-  );
-};

+ 0 - 1
src/client/components/hoc/ToastProvider/index.ts

@@ -1 +0,0 @@
-export { ToastProvider } from './ToastProvider';

+ 1 - 2
src/client/components/ui/Modal/Modal.module.scss → src/client/components/ui/Dialog/Dialog.module.scss

@@ -15,12 +15,11 @@
     background-color: rgba(0, 0, 0, 0);
   }
   to {
-    background-color: rgba(0, 0, 0, 0.8);
+    background-color: rgba(0, 0, 0, 0.5);
   }
 }
 
 .dimmedBackground {
-  background-color: rgba(0, 0, 0, 0.8);
   animation-name: dimmedBackground;
   animation-duration: 0.2s;
   animation-iteration-count: 1;

+ 68 - 0
src/client/components/ui/Dialog/Dialog.tsx

@@ -0,0 +1,68 @@
+'use client';
+
+import * as React from 'react';
+import * as DialogPrimitive from '@radix-ui/react-dialog';
+import clsx from 'clsx';
+import styles from './Dialog.module.scss';
+
+type Sizes = 'sm' | 'md' | 'lg' | 'xl';
+type ModalType = 'default' | 'primary' | 'success' | 'info' | 'warning' | 'danger';
+
+type ModalProps = {
+  size?: Sizes;
+  type?: ModalType;
+};
+
+const Dialog = DialogPrimitive.Root;
+const DialogTrigger = DialogPrimitive.Trigger;
+
+const DialogPortal = ({ className, children, ...props }: DialogPrimitive.DialogPortalProps & ModalProps) => (
+  <DialogPrimitive.Portal className={clsx(className)} {...props}>
+    <div className={clsx('modal modal-sm d-block', styles.dimmedBackground)}>
+      <div className={clsx(`modal-dialog modal-dialog-centered modal-${props.size || 'lg'}`, styles.zoomIn)}>
+        <div className="shadow modal-content">
+          <DialogPrimitive.Close className="btn-close mt-1">
+            <button data-testid="modal-close-button" type="button" className="btn-close" aria-label="Close" />
+          </DialogPrimitive.Close>
+          <div data-testid="modal-status" className={clsx('modal-status', { [`bg-${props.type}`]: Boolean(props.type), 'd-none': !props.type })} />
+          {children}
+        </div>
+      </div>
+    </div>
+  </DialogPrimitive.Portal>
+);
+DialogPortal.displayName = DialogPrimitive.Portal.displayName;
+
+const DialogOverlay = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Overlay>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>>(({ className, children, ...props }, ref) => (
+  <DialogPrimitive.Overlay className={clsx('', className)} {...props} ref={ref} />
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Content>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & ModalProps>(
+  ({ className, children, ...props }, ref) => (
+    <DialogPortal type={props.type} size={props.size}>
+      <DialogPrimitive.Content ref={ref} className={clsx('modal-content mt-1', className)} {...props}>
+        {children}
+      </DialogPrimitive.Content>
+    </DialogPortal>
+  ),
+);
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div data-testid="modal-header" className={clsx('modal-header', className)} {...props} />;
+DialogHeader.displayName = 'DialogHeader';
+
+const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={clsx('modal-footer', className)} {...props} />;
+DialogFooter.displayName = 'DialogFooter';
+
+const DialogTitle = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Title>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>>(({ className, ...props }, ref) => (
+  <DialogPrimitive.Title ref={ref} className={clsx('modal-title', className)} {...props} />
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Description>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>>(({ className, ...props }, ref) => (
+  <DialogPrimitive.Description ref={ref} className={clsx('modal-body', className)} {...props} />
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription };

+ 1 - 0
src/client/components/ui/Dialog/index.ts

@@ -0,0 +1 @@
+export { Dialog, DialogTitle, DialogFooter, DialogHeader, DialogContent, DialogTrigger, DialogDescription } from './Dialog';

+ 11 - 12
src/client/components/ui/Header/Header.test.tsx

@@ -5,8 +5,7 @@ import { Header } from './Header';
 
 describe('Header', () => {
   it('renders without crashing', () => {
-    const { container } = render(<Header />);
-    expect(container).toBeInTheDocument();
+    render(<Header />);
   });
 
   it('renders the brand logo', () => {
@@ -16,22 +15,22 @@ describe('Header', () => {
   });
 
   it('renders the dark mode toggle', () => {
-    const { container } = render(<Header />);
-    const darkModeToggle = container.querySelector('[data-tip="Dark mode"]');
+    render(<Header />);
+    const darkModeToggle = screen.getByTestId('dark-mode-toggle');
     expect(darkModeToggle).toContainElement(screen.getByTestId('icon-moon'));
   });
 
   it('renders the light mode toggle', () => {
-    const { container } = render(<Header />);
-    const lightModeToggle = container.querySelector('[data-tip="Light mode"]');
+    render(<Header />);
+    const lightModeToggle = screen.getByTestId('light-mode-toggle');
     expect(lightModeToggle).toContainElement(screen.getByTestId('icon-sun'));
   });
 
   it('Should toggle the dark mode on click of the dark mode toggle', () => {
     const { result } = renderHook(() => useUIStore());
 
-    const { container } = render(<Header />);
-    const darkModeToggle = container.querySelector('[data-tip="Dark mode"]');
+    render(<Header />);
+    const darkModeToggle = screen.getByTestId('dark-mode-toggle');
     fireEvent.click(darkModeToggle as Element);
 
     expect(result.current.darkMode).toBe(true);
@@ -40,8 +39,8 @@ describe('Header', () => {
   it('Should toggle the dark mode on click of the light mode toggle', () => {
     const { result } = renderHook(() => useUIStore());
 
-    const { container } = render(<Header />);
-    const lightModeToggle = container.querySelector('[data-tip="Light mode"]');
+    render(<Header />);
+    const lightModeToggle = screen.getByTestId('light-mode-toggle');
     fireEvent.click(lightModeToggle as Element);
 
     expect(result.current.darkMode).toBe(false);
@@ -49,8 +48,8 @@ describe('Header', () => {
 
   it('Should remove the token from local storage on logout', async () => {
     localStorage.setItem('token', 'token');
-    const { container } = render(<Header />);
-    const logoutButton = container.querySelector('[data-tip="Log out"]');
+    render(<Header />);
+    const logoutButton = screen.getByTestId('logout-button');
     fireEvent.click(logoutButton as Element);
 
     await waitFor(() => {

+ 8 - 4
src/client/components/ui/Header/Header.tsx

@@ -4,6 +4,7 @@ import Image from 'next/image';
 import clsx from 'clsx';
 import Link from 'next/link';
 import { useRouter } from 'next/router';
+import { Tooltip } from 'react-tooltip';
 import { getUrl } from '../../../core/helpers/url-helpers';
 import { useUIStore } from '../../../state/uiStore';
 import { NavBar } from '../NavBar';
@@ -62,14 +63,17 @@ export const Header: React.FC<IProps> = ({ isUpdateAvailable }) => {
               </a>
             </div>
           </div>
-          <div className="d-flex">
-            <div onClick={() => setDarkMode(true)} role="button" aria-hidden="true" className="nav-link px-0 hide-theme-dark cursor-pointer" data-tip="Dark mode">
+          <div style={{ zIndex: 1 }} className="d-flex">
+            <Tooltip anchorSelect=".darkMode">Dark mode</Tooltip>
+            <div onClick={() => setDarkMode(true)} role="button" aria-hidden="true" className="darkMode nav-link px-0 hide-theme-dark cursor-pointer" data-testid="dark-mode-toggle">
               <IconMoon data-testid="icon-moon" size={20} />
             </div>
-            <div onClick={() => setDarkMode(false)} aria-hidden="true" className="nav-link px-0 hide-theme-light cursor-pointer" data-tip="Light mode">
+            <Tooltip anchorSelect=".lightMode">Light mode</Tooltip>
+            <div onClick={() => setDarkMode(false)} aria-hidden="true" className="lightMode nav-link px-0 hide-theme-light cursor-pointer" data-testid="light-mode-toggle">
               <IconSun data-testid="icon-sun" size={20} />
             </div>
-            <div onClick={() => logout.mutate()} tabIndex={0} onKeyPress={() => logout.mutate()} role="button" className="nav-link px-0 cursor-pointer" data-tip="Log out">
+            <Tooltip anchorSelect=".logOut">Log out</Tooltip>
+            <div onClick={() => logout.mutate()} tabIndex={0} onKeyPress={() => logout.mutate()} role="button" className="logOut nav-link px-0 cursor-pointer" data-testid="logout-button">
               <IconLogout size={20} />
             </div>
           </div>

+ 6 - 1
src/client/components/ui/Input/Input.tsx

@@ -13,16 +13,20 @@ interface IProps {
   onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
   disabled?: boolean;
   value?: string;
+  readOnly?: boolean;
 }
 
-export const Input = React.forwardRef<HTMLInputElement, IProps>(({ onChange, onBlur, name, label, placeholder, error, type = 'text', className, value, isInvalid, disabled }, ref) => (
+export const Input = React.forwardRef<HTMLInputElement, IProps>(({ onChange, onBlur, name, label, placeholder, error, type = 'text', className, value, isInvalid, disabled, readOnly }, ref) => (
   <div className={clsx(className)}>
     {label && (
       <label htmlFor={name} className="form-label">
         {label}
       </label>
     )}
+    {/* eslint-disable-next-line jsx-a11y/no-redundant-roles */}
     <input
+      aria-label={name}
+      role="textbox"
       disabled={disabled}
       name={name}
       id={name}
@@ -33,6 +37,7 @@ export const Input = React.forwardRef<HTMLInputElement, IProps>(({ onChange, onB
       ref={ref}
       className={clsx('form-control', { 'is-invalid is-invalid-lite': error || isInvalid })}
       placeholder={placeholder}
+      readOnly={readOnly}
     />
     {error && <div className="invalid-feedback">{error}</div>}
   </div>

+ 0 - 141
src/client/components/ui/Modal/Modal.test.tsx

@@ -1,141 +0,0 @@
-import React from 'react';
-import '@testing-library/jest-dom/extend-expect';
-import { fireEvent, render } from '../../../../../tests/test-utils';
-import { Modal } from './Modal';
-import { ModalBody } from './ModalBody';
-import { ModalFooter } from './ModalFooter';
-import { ModalHeader } from './ModalHeader';
-
-describe('Modal component', () => {
-  it('should render without errors', () => {
-    const { container } = render(
-      <Modal onClose={() => {}}>
-        <p>Test modal content</p>
-      </Modal>,
-    );
-    expect(container).toBeTruthy();
-  });
-
-  it('should not be visible by default', () => {
-    const { queryByTestId } = render(
-      <Modal onClose={() => {}}>
-        <p>Test modal content</p>
-      </Modal>,
-    );
-    // display should be none
-    expect(queryByTestId('modal')).toHaveStyle('display: none');
-  });
-
-  it('should be visible when `isOpen` prop is true', () => {
-    const { getByTestId } = render(
-      <Modal onClose={() => {}} isOpen>
-        <p>Test modal content</p>
-      </Modal>,
-    );
-    // display should be block
-    expect(getByTestId('modal')).toHaveStyle('display: block');
-  });
-
-  it('should not be visible when `isOpen` prop is false', () => {
-    const { queryByTestId } = render(
-      <Modal onClose={() => {}}>
-        <p>Test modal content</p>
-      </Modal>,
-    );
-    expect(queryByTestId('modal')).toHaveStyle('display: none');
-  });
-
-  it('should call the `onClose` prop when the close button is clicked', () => {
-    const onClose = jest.fn();
-    const { getByLabelText } = render(
-      <Modal onClose={onClose} isOpen>
-        <p>Test modal content</p>
-      </Modal>,
-    );
-    fireEvent.click(getByLabelText('Close'));
-    expect(onClose).toHaveBeenCalled();
-  });
-
-  it('should call the `onClose` callback when user clicks outside of the modal', () => {
-    const onClose = jest.fn();
-    const { container } = render(
-      <Modal onClose={onClose} isOpen>
-        <p>Test modal content</p>
-      </Modal>,
-    );
-    fireEvent.click(container);
-    expect(onClose).toHaveBeenCalled();
-  });
-
-  it('should have the correct `size` class when the `size` prop is passed', () => {
-    const { getByTestId } = render(
-      <Modal onClose={() => {}} isOpen size="sm">
-        <p>Test modal content</p>
-      </Modal>,
-    );
-    expect(getByTestId('modal')).toHaveClass('modal-sm');
-  });
-
-  it('should have the correct `type` class when the `type` prop is passed', () => {
-    const { getByTestId } = render(
-      <Modal onClose={() => {}} isOpen type="primary">
-        <p>Test modal content</p>
-      </Modal>,
-    );
-    expect(getByTestId('modal-status')).toHaveClass('bg-primary');
-    expect(getByTestId('modal-status')).not.toHaveClass('d-none');
-  });
-
-  it('should render the modal content as a child of the modal', () => {
-    const { getByTestId, getByText } = render(
-      <Modal onClose={() => {}} isOpen>
-        <p>Test modal content</p>
-      </Modal>,
-    );
-    expect(getByTestId('modal')).toContainElement(getByText('Test modal content'));
-  });
-
-  it('should call the `onClose` callback when the escape key is pressed', () => {
-    const onClose = jest.fn();
-    render(
-      <Modal onClose={onClose} isOpen>
-        <p>Test modal content</p>
-      </Modal>,
-    );
-    fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' });
-    expect(onClose).toHaveBeenCalled();
-  });
-
-  it('should correctly render with ModalBody', () => {
-    const { getByTestId } = render(
-      <Modal onClose={() => {}} isOpen>
-        <ModalBody>
-          <p>Test modal content</p>
-        </ModalBody>
-      </Modal>,
-    );
-    expect(getByTestId('modal-body')).toBeInTheDocument();
-  });
-
-  it('should correctly render with ModalFooter', () => {
-    const { getByTestId } = render(
-      <Modal onClose={() => {}} isOpen>
-        <ModalFooter>
-          <p>Test modal content</p>
-        </ModalFooter>
-      </Modal>,
-    );
-    expect(getByTestId('modal-footer')).toBeInTheDocument();
-  });
-
-  it('should correctly render with ModalHeader', () => {
-    const { getByTestId } = render(
-      <Modal onClose={() => {}} isOpen>
-        <ModalHeader>
-          <p>Test modal content</p>
-        </ModalHeader>
-      </Modal>,
-    );
-    expect(getByTestId('modal-header')).toBeInTheDocument();
-  });
-});

+ 0 - 65
src/client/components/ui/Modal/Modal.tsx

@@ -1,65 +0,0 @@
-import clsx from 'clsx';
-import React, { useCallback, useEffect, useState } from 'react';
-import styles from './Modal.module.scss';
-
-interface IProps {
-  children: React.ReactNode;
-  isOpen?: boolean;
-  onClose: () => void;
-  size?: 'sm' | 'md' | 'lg' | 'xl';
-  type?: 'default' | 'primary' | 'success' | 'info' | 'warning' | 'danger';
-}
-
-export const Modal: React.FC<IProps> = ({ children, isOpen, onClose, size = 'lg', type }) => {
-  const style = { display: 'none' };
-
-  if (isOpen) {
-    style.display = 'block';
-  }
-
-  const [modal, setModal] = useState<HTMLDivElement | null>(null);
-
-  // On click outside
-  const handleClickOutside = useCallback(
-    (event: MouseEvent) => {
-      if (modal && !modal.contains(event.target as Node)) {
-        onClose();
-      }
-    },
-    [modal, onClose],
-  );
-
-  // On click outside
-  useEffect(() => {
-    document.addEventListener('click', handleClickOutside, true);
-    return () => document.removeEventListener('click', handleClickOutside, true);
-  }, [handleClickOutside]);
-
-  // Close on escape
-  const handleEscape = useCallback(
-    (event: KeyboardEvent) => {
-      if (event.key === 'Escape') {
-        onClose();
-      }
-    },
-    [onClose],
-  );
-
-  // Close on escape
-  useEffect(() => {
-    document.addEventListener('keydown', handleEscape, true);
-    return () => document.removeEventListener('keydown', handleEscape, true);
-  }, [handleEscape]);
-
-  return (
-    <div data-testid="modal" className={clsx('modal modal-sm', styles.dimmedBackground)} tabIndex={-1} style={style} role="dialog">
-      <div ref={setModal} className={clsx(`modal-dialog modal-dialog-centered modal-${size}`, styles.zoomIn)} role="document">
-        <div className="shadow modal-content">
-          <button data-testid="modal-close-button" type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close" onClick={onClose} />
-          <div data-testid="modal-status" className={clsx('modal-status', { [`bg-${type}`]: Boolean(type), 'd-none': !type })} />
-          {children}
-        </div>
-      </div>
-    </div>
-  );
-};

+ 0 - 13
src/client/components/ui/Modal/ModalBody.tsx

@@ -1,13 +0,0 @@
-import clsx from 'clsx';
-import React from 'react';
-
-interface IProps {
-  children: React.ReactNode;
-  className?: string;
-}
-
-export const ModalBody: React.FC<IProps> = ({ children, className }) => (
-  <div data-testid="modal-body" className={clsx('modal-body', className)}>
-    {children}
-  </div>
-);

+ 0 - 11
src/client/components/ui/Modal/ModalFooter.tsx

@@ -1,11 +0,0 @@
-import React from 'react';
-
-interface IProps {
-  children: React.ReactNode;
-}
-
-export const ModalFooter: React.FC<IProps> = ({ children }) => (
-  <div data-testid="modal-footer" className="modal-footer">
-    {children}
-  </div>
-);

+ 0 - 11
src/client/components/ui/Modal/ModalHeader.tsx

@@ -1,11 +0,0 @@
-import React from 'react';
-
-interface IProps {
-  children: React.ReactNode;
-}
-
-export const ModalHeader: React.FC<IProps> = ({ children }) => (
-  <div data-testid="modal-header" className="modal-header">
-    {children}
-  </div>
-);

+ 0 - 4
src/client/components/ui/Modal/index.ts

@@ -1,4 +0,0 @@
-export { Modal } from './Modal';
-export { ModalBody } from './ModalBody';
-export { ModalFooter } from './ModalFooter';
-export { ModalHeader } from './ModalHeader';

+ 16 - 0
src/client/components/ui/OtpInput/OtpInput.module.scss

@@ -0,0 +1,16 @@
+.otpGroup {
+  display: flex;
+  width: 100%;
+  max-width: 360px;
+  column-gap: 10px;
+}
+
+.otpInput {
+  width: 100%;
+  border: 1px solid #ccc;
+  border-radius: 5px;
+  text-align: center;
+  font-size: 32px;
+  font-weight: bold;
+  line-height: 1;
+}

+ 262 - 0
src/client/components/ui/OtpInput/OtpInput.test.tsx

@@ -0,0 +1,262 @@
+import React from 'react';
+import { faker } from '@faker-js/faker';
+import { OtpInput } from './OtpInput';
+import { fireEvent, render, screen } from '../../../../../tests/test-utils';
+
+describe('<OtpInput />', () => {
+  it('should accept value & valueLength props', () => {
+    // arrange
+    const value = faker.datatype.number({ min: 0, max: 999999 }).toString();
+    const valueArray = value.split('');
+    const valueLength = value.length;
+    render(<OtpInput value={value} valueLength={valueLength} onChange={() => {}} />);
+
+    const inputEls = screen.queryAllByRole('textbox');
+
+    // assert
+    expect(inputEls).toHaveLength(valueLength);
+    inputEls.forEach((inputEl, idx) => {
+      expect(inputEl).toHaveValue(valueArray[idx]);
+    });
+  });
+
+  it('should allow typing of digits', () => {
+    // arrange
+    const valueLength = faker.datatype.number({ min: 2, max: 6 }); // random number from 2-6 (minimum 2 so it can focus on the next input)
+    const onChange = jest.fn();
+    render(<OtpInput valueLength={valueLength} onChange={onChange} value="" />);
+
+    const inputEls = screen.queryAllByRole('textbox');
+
+    // assert
+    expect(inputEls).toHaveLength(valueLength);
+    inputEls.forEach((inputEl, idx) => {
+      const digit = faker.datatype.number({ min: 0, max: 9 }).toString(); // random number from 0-9, typing of digits is 1 by 1
+
+      // trigger a change event
+      fireEvent.change(inputEl, {
+        target: { value: digit }, // pass it as the target.value in the event data
+      });
+
+      // custom matcher to check that "onChange" function was called with the same digit
+      expect(onChange).toBeCalledTimes(1);
+      expect(onChange).toBeCalledWith(digit);
+
+      const inputFocused = inputEls[idx + 1] || inputEl;
+      expect(inputFocused).toHaveFocus();
+      onChange.mockReset(); // resets the call times for the next iteration of the loop
+    });
+  });
+
+  it('should NOT allow typing of non-digits', () => {
+    // arrange
+    const valueLength = faker.datatype.number({ min: 2, max: 6 });
+    const onChange = jest.fn();
+    render(<OtpInput valueLength={valueLength} onChange={onChange} value="" />);
+
+    const inputEls = screen.queryAllByRole('textbox');
+
+    // assert
+    expect(inputEls).toHaveLength(valueLength);
+
+    inputEls.forEach((inputEl) => {
+      const nonDigit = faker.random.alpha(1);
+
+      fireEvent.change(inputEl, {
+        target: { value: nonDigit },
+      });
+
+      expect(onChange).not.toBeCalled();
+
+      onChange.mockReset();
+    });
+  });
+
+  it('should allow deleting of digits (focus on previous element)', () => {
+    const value = faker.datatype.number({ min: 10, max: 999999 }).toString(); // minimum 2-digit so it can focus on the previous input
+    const valueLength = value.length;
+    const lastIdx = valueLength - 1;
+    const onChange = jest.fn();
+
+    render(<OtpInput value={value} valueLength={valueLength} onChange={onChange} />);
+
+    const inputEls = screen.queryAllByRole('textbox');
+
+    expect(inputEls).toHaveLength(valueLength);
+
+    for (let idx = lastIdx; idx > -1; idx -= 1) {
+      // loop backwards to simulate the focus on the previous input
+      const inputEl = inputEls[idx] as HTMLInputElement;
+      const target = { value: '' };
+
+      // trigger both change and keydown event
+      fireEvent.change(inputEl, { target });
+      fireEvent.keyDown(inputEl, {
+        target,
+        key: 'Backspace',
+      });
+
+      const valueArray = value.split('');
+
+      valueArray[idx] = ' '; // the deleted digit is expected to be replaced with a space in the string
+
+      const expectedValue = valueArray.join('');
+
+      expect(onChange).toBeCalledTimes(1);
+      expect(onChange).toBeCalledWith(expectedValue);
+
+      // custom matcher to check that the focus is on the previous input
+      // OR
+      // focus is on the current input if previous input doesn't exist
+      const inputFocused = inputEls[idx - 1] || inputEl;
+
+      expect(inputFocused).toHaveFocus();
+
+      onChange.mockReset();
+    }
+  });
+
+  it('should allow deleting of digits (do NOT focus on previous element)', () => {
+    const value = faker.datatype.number({ min: 10, max: 999999 }).toString();
+    const valueArray = value.split('');
+    const valueLength = value.length;
+    const lastIdx = valueLength - 1;
+    const onChange = jest.fn();
+
+    render(<OtpInput value={value} valueLength={valueLength} onChange={onChange} />);
+
+    const inputEls = screen.queryAllByRole('textbox');
+
+    expect(inputEls).toHaveLength(valueLength);
+
+    for (let idx = lastIdx; idx > 0; idx -= 1) {
+      // idx > 0, because there's no previous input in index 0
+      const inputEl = inputEls[idx] as HTMLInputElement;
+
+      fireEvent.keyDown(inputEl, {
+        key: 'Backspace',
+        target: { value: valueArray[idx] },
+      });
+
+      const prevInputEl = inputEls[idx - 1];
+
+      expect(prevInputEl).not.toHaveFocus();
+
+      onChange.mockReset();
+    }
+  });
+
+  it('should NOT allow deleting of digits in the middle', () => {
+    const value = faker.datatype.number({ min: 100000, max: 999999 }).toString();
+    const valueLength = value.length;
+    const onChange = jest.fn();
+
+    render(<OtpInput value={value} valueLength={valueLength} onChange={onChange} />);
+
+    const inputEls = screen.queryAllByRole('textbox');
+    const thirdInputEl = inputEls[2] as HTMLInputElement;
+    const target = { value: '' };
+
+    fireEvent.change(thirdInputEl, { target: { value: '' } });
+    fireEvent.keyDown(thirdInputEl, {
+      target,
+      key: 'Backspace',
+    });
+
+    expect(onChange).not.toBeCalled();
+  });
+
+  it('should allow pasting of digits (same length as valueLength)', () => {
+    const value = faker.datatype.number({ min: 10, max: 999999 }).toString(); // minimum 2-digit so it is considered as a paste event
+    const valueLength = value.length;
+    const onChange = jest.fn();
+
+    render(<OtpInput valueLength={valueLength} onChange={onChange} value="" />);
+
+    const inputEls = screen.queryAllByRole('textbox');
+
+    // get a random input element from the input elements to paste the digits on
+    const randomIdx = faker.datatype.number({ min: 0, max: valueLength - 1 });
+    const randomInputEl = inputEls[randomIdx] as HTMLInputElement;
+
+    fireEvent.change(randomInputEl, { target: { value } });
+
+    expect(onChange).toBeCalledTimes(1);
+    expect(onChange).toBeCalledWith(value);
+
+    expect(randomInputEl).not.toHaveFocus();
+  });
+
+  it('should NOT allow pasting of digits (less than valueLength)', () => {
+    const value = faker.datatype.number({ min: 10, max: 99999 }).toString(); // random 2-5 digit code (less than "valueLength")
+    const valueLength = faker.datatype.number({ min: 6, max: 10 }); // random number from 6-10
+    const onChange = jest.fn();
+
+    render(<OtpInput valueLength={valueLength} onChange={onChange} value="" />);
+
+    const inputEls = screen.queryAllByRole('textbox');
+    const randomIdx = faker.datatype.number({ min: 0, max: valueLength - 1 });
+    const randomInputEl = inputEls[randomIdx] as HTMLInputElement;
+
+    fireEvent.change(randomInputEl, { target: { value } });
+
+    expect(onChange).not.toBeCalled();
+  });
+
+  it('should focus to next element on right/down key', () => {
+    render(<OtpInput valueLength={3} onChange={jest.fn} value="1234" />);
+
+    const inputEls = screen.queryAllByRole('textbox');
+    const firstInputEl = inputEls[0] as HTMLInputElement;
+
+    fireEvent.keyDown(firstInputEl, {
+      key: 'ArrowRight',
+    });
+
+    expect(inputEls[1]).toHaveFocus();
+
+    const secondInputEl = inputEls[1] as HTMLInputElement;
+
+    fireEvent.keyDown(secondInputEl, {
+      key: 'ArrowDown',
+    });
+
+    expect(inputEls[2]).toHaveFocus();
+  });
+
+  it('should focus to next element on left/up key', () => {
+    render(<OtpInput valueLength={3} onChange={jest.fn} value="1234" />);
+
+    const inputEls = screen.queryAllByRole('textbox');
+    const lastInputEl = inputEls[2] as HTMLInputElement;
+
+    fireEvent.keyDown(lastInputEl, {
+      key: 'ArrowLeft',
+    });
+
+    expect(inputEls[1]).toHaveFocus();
+
+    const secondInputEl = inputEls[1] as HTMLInputElement;
+
+    fireEvent.keyDown(secondInputEl, {
+      key: 'ArrowUp',
+    });
+
+    expect(inputEls[0]).toHaveFocus();
+  });
+
+  it('should only focus to input if previous input has value', () => {
+    const valueLength = 6;
+
+    render(<OtpInput valueLength={valueLength} onChange={jest.fn} value="" />);
+
+    const inputEls = screen.queryAllByRole('textbox');
+    const lastInputEl = inputEls[valueLength - 1] as HTMLInputElement;
+
+    lastInputEl.focus();
+
+    const firstInputEl = inputEls[0];
+
+    expect(firstInputEl).toHaveFocus();
+  });
+});

+ 157 - 0
src/client/components/ui/OtpInput/OtpInput.tsx

@@ -0,0 +1,157 @@
+import React, { useMemo } from 'react';
+import clsx from 'clsx';
+import classes from './OtpInput.module.scss';
+
+type Props = {
+  value: string;
+  valueLength: number;
+  onChange: (value: string) => void;
+  className?: string;
+};
+
+const RE_DIGIT = /^\d+$/;
+
+export const OtpInput = ({ value, valueLength, onChange, className }: Props) => {
+  const valueItems = useMemo(() => {
+    const valueArray = value.split('');
+    const items: string[] = [];
+
+    for (let i = 0; i < valueLength; i += 1) {
+      const char = valueArray[i];
+
+      if (char && RE_DIGIT.test(char)) {
+        items.push(char);
+      } else {
+        items.push('');
+      }
+    }
+
+    return items;
+  }, [value, valueLength]);
+
+  const focusToNextInput = (target: HTMLElement) => {
+    const nextElementSibling = target.nextElementSibling as HTMLInputElement | null;
+
+    if (nextElementSibling) {
+      nextElementSibling.focus();
+    }
+  };
+  const focusToPrevInput = (target: HTMLElement) => {
+    const previousElementSibling = target.previousElementSibling as HTMLInputElement | null;
+
+    if (previousElementSibling) {
+      previousElementSibling.focus();
+    }
+  };
+
+  const inputOnChange = (e: React.ChangeEvent<HTMLInputElement>, idx: number) => {
+    const { target } = e;
+    let targetValue = target.value.trim();
+    const isTargetValueDigit = RE_DIGIT.test(targetValue);
+
+    if (!isTargetValueDigit && targetValue !== '') {
+      return;
+    }
+
+    const nextInputEl = target.nextElementSibling as HTMLInputElement | null;
+
+    // only delete digit if next input element has no value
+    if (!isTargetValueDigit && nextInputEl && nextInputEl.value !== '') {
+      return;
+    }
+
+    targetValue = isTargetValueDigit ? targetValue : ' ';
+
+    const targetValueLength = targetValue.length;
+
+    if (targetValueLength === 1) {
+      const newValue = value.substring(0, idx) + targetValue + value.substring(idx + 1);
+
+      onChange(newValue);
+
+      if (!isTargetValueDigit) {
+        return;
+      }
+
+      focusToNextInput(target);
+
+      const nextElementSibling = target.nextElementSibling as HTMLInputElement | null;
+
+      if (nextElementSibling) {
+        nextElementSibling.focus();
+      }
+    } else if (targetValueLength === valueLength) {
+      onChange(targetValue);
+
+      target.blur();
+    }
+  };
+
+  const inputOnFocus = (e: React.FocusEvent<HTMLInputElement>) => {
+    const { target } = e;
+
+    const prevInputEl = target.previousElementSibling as HTMLInputElement | null;
+
+    if (prevInputEl && prevInputEl.value === '') {
+      return prevInputEl.focus();
+    }
+
+    target.setSelectionRange(0, target.value.length);
+
+    return null;
+  };
+
+  const inputOnKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
+    const { key } = e;
+    const target = e.target as HTMLInputElement;
+
+    if (key === 'ArrowRight' || key === 'ArrowDown') {
+      e.preventDefault();
+      return focusToNextInput(target);
+    }
+
+    if (key === 'ArrowLeft' || key === 'ArrowUp') {
+      e.preventDefault();
+      return focusToPrevInput(target);
+    }
+
+    const targetValue = target.value;
+    target.setSelectionRange(0, targetValue.length);
+
+    if (e.key !== 'Backspace' || target.value !== '') {
+      return null;
+    }
+
+    focusToPrevInput(target);
+
+    const previousElementSibling = target.previousElementSibling as HTMLInputElement | null;
+
+    if (previousElementSibling) {
+      previousElementSibling.focus();
+    }
+
+    return null;
+  };
+
+  return (
+    <div className={classes.otpGroup}>
+      {valueItems.map((digit, idx) => (
+        <input
+          aria-label={`digit-${idx}`}
+          onChange={(e) => inputOnChange(e, idx)}
+          onKeyDown={inputOnKeyDown}
+          onFocus={inputOnFocus}
+          // eslint-disable-next-line react/no-array-index-key
+          key={idx}
+          type="text"
+          inputMode="numeric"
+          autoComplete="one-time-code"
+          pattern="\d{1}"
+          maxLength={valueLength}
+          className={clsx('form-control', classes.otpInput, className)}
+          value={digit}
+        />
+      ))}
+    </div>
+  );
+};

+ 1 - 0
src/client/components/ui/OtpInput/index.ts

@@ -0,0 +1 @@
+export { OtpInput } from './OtpInput';

+ 10 - 0
src/client/components/ui/Switch/Switch.module.scss

@@ -0,0 +1,10 @@
+.root[data-state='checked'] {
+  background-color: var(--tblr-primary);
+  background-position: right center;
+  --tblr-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23ffffff'/%3e%3c/svg%3e");
+
+  &:focus,
+  &:hover {
+    --tblr-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23ffffff'/%3e%3c/svg%3e");
+  }
+}

+ 9 - 11
src/client/components/ui/Switch/Switch.test.tsx

@@ -1,9 +1,7 @@
 import React from 'react';
 
-import '@testing-library/jest-dom/extend-expect';
-
 import { Switch } from './Switch';
-import { fireEvent, render } from '../../../../../tests/test-utils';
+import { fireEvent, render, screen } from '../../../../../tests/test-utils';
 
 describe('Switch', () => {
   it('renders the label', () => {
@@ -22,16 +20,16 @@ describe('Switch', () => {
   });
 
   it('renders the checked state', () => {
-    const { container } = render(<Switch checked onChange={jest.fn} />);
-    const checkbox = container.querySelector('input[type="checkbox"]');
+    render(<Switch checked onChange={jest.fn} />);
+    const checkbox = screen.getByRole('switch');
 
     expect(checkbox).toBeChecked();
   });
 
   it('triggers onChange event when clicked', () => {
     const onChange = jest.fn();
-    const { container } = render(<Switch onChange={onChange} />);
-    const checkbox = container.querySelector('input[type="checkbox"]') as Element;
+    render(<Switch onCheckedChange={onChange} />);
+    const checkbox = screen.getByRole('switch');
 
     fireEvent.click(checkbox);
 
@@ -40,8 +38,8 @@ describe('Switch', () => {
 
   it('triggers onBlur event when blurred', () => {
     const onBlur = jest.fn();
-    const { container } = render(<Switch onBlur={onBlur} />);
-    const checkbox = container.querySelector('input[type="checkbox"]') as Element;
+    render(<Switch onBlur={onBlur} />);
+    const checkbox = screen.getByRole('switch');
 
     fireEvent.blur(checkbox);
 
@@ -49,8 +47,8 @@ describe('Switch', () => {
   });
 
   it('should change the checked state when clicked', () => {
-    const { container } = render(<Switch onChange={jest.fn} />);
-    const checkbox = container.querySelector('input[type="checkbox"]') as Element;
+    render(<Switch onChange={jest.fn} />);
+    const checkbox = screen.getByRole('switch');
 
     fireEvent.click(checkbox);
 

+ 19 - 16
src/client/components/ui/Switch/Switch.tsx

@@ -1,19 +1,22 @@
-import React from 'react';
+'use client';
 
-interface IProps {
-  label?: string;
-  className?: string;
-  checked?: boolean;
-  onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
-  name?: string;
-  onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
-}
+import * as React from 'react';
+import * as SwitchPrimitives from '@radix-ui/react-switch';
+import clsx from 'clsx';
+import classes from './Switch.module.scss';
 
-export const Switch = React.forwardRef<HTMLInputElement, IProps>(({ onChange, onBlur, name, label, checked, className }, ref) => (
-  <div className={className}>
-    <label htmlFor={name} aria-labelledby={name} className="form-check form-switch">
-      <input id={name} name={name} ref={ref} onChange={onChange} onBlur={onBlur} className="form-check-input" type="checkbox" checked={checked} />
-      <span className="form-check-label">{label}</span>
-    </label>
-  </div>
+type RootProps = typeof SwitchPrimitives.Root;
+
+const Switch = React.forwardRef<React.ElementRef<RootProps>, React.ComponentPropsWithoutRef<RootProps> & { label?: string }>(({ className, ...props }, ref) => (
+  <label htmlFor={props.name} aria-labelledby={props.name} className={clsx('form-check form-switch form-check-sigle', className)}>
+    <SwitchPrimitives.Root name={props.name} className={clsx('form-check-input', classes.root)} {...props} ref={ref}>
+      <SwitchPrimitives.Thumb />
+    </SwitchPrimitives.Root>
+    <span id={props.name} className="form-check-label text-muted">
+      {props.label}
+    </span>
+  </label>
 ));
+Switch.displayName = SwitchPrimitives.Root.displayName;
+
+export { Switch };

+ 1 - 1
src/client/mocks/fixtures/app.fixtures.ts

@@ -5,7 +5,7 @@ import { App, AppCategory, AppInfo, AppWithInfo } from '../../core/types';
 const randomCategory = (): AppCategory[] => {
   const categories = Object.values(APP_CATEGORIES);
   const randomIndex = faker.datatype.number({ min: 0, max: categories.length - 1 });
-  return [categories[randomIndex]!];
+  return [categories[randomIndex] as AppCategory];
 };
 
 export const createApp = (overrides?: Partial<AppInfo>): AppInfo => {

+ 1 - 1
src/client/mocks/getTrpcMock.ts

@@ -25,7 +25,7 @@ export type RpcErrorResponse = {
   };
 };
 
-const jsonRpcSuccessResponse = (data: unknown): RpcSuccessResponse<any> => {
+const jsonRpcSuccessResponse = (data: unknown): RpcSuccessResponse<unknown> => {
   const response = SuperJSON.serialize(data);
 
   return {

+ 2 - 1
src/client/mocks/handlers.ts

@@ -7,7 +7,7 @@ export const handlers = [
   getTRPCMock({
     path: ['system', 'getVersion'],
     type: 'query',
-    response: { current: '1.0.0', latest: '1.0.0' },
+    response: { current: '1.0.0', latest: '1.0.0', body: 'hello' },
   }),
   getTRPCMock({
     path: ['system', 'update'],
@@ -61,6 +61,7 @@ export const handlers = [
     path: ['auth', 'me'],
     type: 'query',
     response: {
+      totp_enabled: false,
       id: faker.datatype.number(),
       username: faker.internet.userName(),
     },

+ 3 - 0
src/client/mocks/index.ts

@@ -1,3 +1,6 @@
+/**
+ * This file is the entry point for the mock service worker.
+ */
 async function initMocks() {
   if (typeof window === 'undefined') {
     const { server } = await import('./server');

+ 6 - 6
src/client/modules/AppStore/components/CategorySelector/CategorySelector.tsx

@@ -36,28 +36,28 @@ const CategorySelector: React.FC<IProps> = ({ onSelect, className, initialValue
       className={className}
       value={value}
       styles={{
-        menu: (provided: any) => ({
+        menu: (provided: object) => ({
           ...provided,
           backgroundColor: bgColor,
           color,
         }),
-        control: (provided: any) => ({
+        control: (provided: object) => ({
           ...provided,
           backgroundColor: bgColor,
           color,
           borderColor,
         }),
-        option: (provided: any, state: any) => ({
+        option: (provided: object, state: { isFocused: boolean }) => ({
           ...provided,
           backgroundColor: state.isFocused ? '#243049' : bgColor,
           color: state.isFocused ? '#fff' : color,
         }),
-        singleValue: (provided: any) => ({
+        singleValue: (provided: object) => ({
           ...provided,
           color,
           fontSize: '0.8rem',
         }),
-        placeholder: (provided: any) => ({
+        placeholder: (provided: object) => ({
           ...provided,
           color: '#a5a9b1',
           fontSize: '0.8rem',
@@ -66,7 +66,7 @@ const CategorySelector: React.FC<IProps> = ({ onSelect, className, initialValue
       onChange={handleChange}
       defaultValue={[]}
       name="categories"
-      options={options as any}
+      options={options}
       placeholder="Category"
     />
   );

+ 40 - 19
src/client/modules/Apps/components/AppActions/AppActions.test.tsx

@@ -13,70 +13,91 @@ describe('Test: AppActions', () => {
     exposable: [],
   } as unknown as AppInfo;
 
-  it('should render the correct buttons when app status is stopped', () => {
-    // Arrange
+  it('should call the callbacks when buttons are clicked', () => {
+    // arrange
     const onStart = jest.fn();
     const onRemove = jest.fn();
     // @ts-expect-error
-    const { getByText } = render(<AppActions status="stopped" info={app} onStart={onStart} onUninstall={onRemove} />);
+    render(<AppActions status="stopped" info={app} onStart={onStart} onUninstall={onRemove} />);
 
-    // Act
-    fireEvent.click(getByText('Start'));
-    fireEvent.click(getByText('Remove'));
+    // act
+    const startButton = screen.getByRole('button', { name: 'Start' });
+    fireEvent.click(startButton);
+    const removeButton = screen.getByText('Remove');
+    fireEvent.click(removeButton);
 
-    // Assert
-    expect(getByText('Start')).toBeInTheDocument();
-    expect(getByText('Remove')).toBeInTheDocument();
+    // assert
     expect(onStart).toHaveBeenCalled();
     expect(onRemove).toHaveBeenCalled();
   });
 
   it('should render the correct buttons when app status is running', () => {
+    // arrange
     // @ts-expect-error
-    const { getByText } = render(<AppActions status="running" info={app} />);
-    expect(getByText('Stop')).toBeInTheDocument();
-    expect(getByText('Open')).toBeInTheDocument();
-    expect(getByText('Settings')).toBeInTheDocument();
+    render(<AppActions status="running" info={app} />);
+
+    // assert
+    expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'Open' })).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument();
   });
 
   it('should render the correct buttons when app status is starting', () => {
+    // arrange
     // @ts-expect-error
     render(<AppActions status="starting" info={app} />);
-    expect(screen.getByText('Cancel')).toBeInTheDocument();
+
+    // assert
+    expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
     expect(screen.getByTestId('action-button-loading')).toBeInTheDocument();
   });
 
   it('should render the correct buttons when app status is stopping', () => {
+    // arrange
     // @ts-expect-error
     render(<AppActions status="stopping" info={app} />);
-    expect(screen.getByText('Cancel')).toBeInTheDocument();
+
+    // assert
+    expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
     expect(screen.getByTestId('action-button-loading')).toBeInTheDocument();
   });
 
   it('should render the correct buttons when app status is removing', () => {
+    // arrange
     // @ts-expect-error
     render(<AppActions status="uninstalling" info={app} />);
-    expect(screen.getByText('Cancel')).toBeInTheDocument();
+
+    // assert
+    expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
     expect(screen.getByTestId('action-button-loading')).toBeInTheDocument();
   });
 
   it('should render the correct buttons when app status is installing', () => {
+    // arrange
     // @ts-ignore
     render(<AppActions status="installing" info={app} />);
-    expect(screen.getByText('Cancel')).toBeInTheDocument();
+
+    // assert
+    expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
     expect(screen.getByTestId('action-button-loading')).toBeInTheDocument();
   });
 
   it('should render the correct buttons when app status is updating', () => {
+    // arrange
     // @ts-expect-error
     render(<AppActions status="updating" info={app} />);
-    expect(screen.getByText('Cancel')).toBeInTheDocument();
+
+    // assert
+    expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
     expect(screen.getByTestId('action-button-loading')).toBeInTheDocument();
   });
 
   it('should render the correct buttons when app status is missing', () => {
+    // arrange
     // @ts-expect-error
     render(<AppActions status="missing" info={app} />);
-    expect(screen.getByText('Install')).toBeInTheDocument();
+
+    // assert
+    expect(screen.getByRole('button', { name: 'Install' })).toBeInTheDocument();
   });
 });

+ 3 - 1
src/client/modules/Apps/components/AppActions/AppActions.tsx

@@ -32,8 +32,10 @@ interface BtnProps {
 const ActionButton: React.FC<BtnProps> = (props) => {
   const { IconComponent, onClick, title, loading, color, width = 140 } = props;
 
+  const testId = loading ? 'action-button-loading' : undefined;
+
   return (
-    <Button loading={loading} data-testid={`action-button-${title?.toLowerCase()}`} onClick={onClick} width={width} className={clsx('me-2 px-4 mt-2', [`btn-${color}`])}>
+    <Button loading={loading} data-testid={testId} onClick={onClick} width={width} className={clsx('me-2 px-4 mt-2', [`btn-${color}`])}>
       {title}
       {IconComponent && <IconComponent className="ms-1" size={14} />}
     </Button>

+ 9 - 5
src/client/modules/Apps/components/InstallForm/InstallForm.tsx

@@ -1,5 +1,5 @@
 import React, { useEffect } from 'react';
-import { useForm } from 'react-hook-form';
+import { Controller, useForm } from 'react-hook-form';
 
 import { Button } from '../../../../components/ui/Button';
 import { Switch } from '../../../../components/ui/Switch';
@@ -32,6 +32,7 @@ export const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValu
     setValue,
     watch,
     setError,
+    control,
   } = useForm<FormValues>({});
   const watchExposed = watch('exposed', false);
 
@@ -57,7 +58,12 @@ export const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValu
 
   const renderExposeForm = () => (
     <>
-      <Switch className="mb-3" {...register('exposed')} label="Expose app" />
+      <Controller
+        control={control}
+        name="exposed"
+        defaultValue={false}
+        render={({ field: { onChange, value, ref, ...props } }) => <Switch className="mb-3" ref={ref} checked={value} onCheckedChange={onChange} {...props} label="Expose app" />}
+      />
       {watchExposed && (
         <div className="mb-3">
           <Input {...register('domain')} label="Domain name" error={errors.domain?.message} disabled={loading} placeholder="Domain name" />
@@ -83,10 +89,8 @@ export const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValu
     }
   };
 
-  const name = initalValues ? 'update' : 'install';
-
   return (
-    <form data-testid={`${name}-form`} className="flex flex-col" onSubmit={handleSubmit(validate)}>
+    <form className="flex flex-col" onSubmit={handleSubmit(validate)}>
       {formFields.filter(typeFilter).map(renderField)}
       {exposable && renderExposeForm()}
       <Button loading={loading} type="submit" className="btn-success">

+ 11 - 9
src/client/modules/Apps/components/InstallModal/InstallModal.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
+import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/components/ui/Dialog';
 import { InstallForm } from '../InstallForm';
-import { Modal, ModalBody, ModalHeader } from '../../../../components/ui/Modal';
 import { AppInfo } from '../../../../core/types';
 import { FormValues } from '../InstallForm/InstallForm';
 
@@ -12,12 +12,14 @@ interface IProps {
 }
 
 export const InstallModal: React.FC<IProps> = ({ info, isOpen, onClose, onSubmit }) => (
-  <Modal onClose={onClose} isOpen={isOpen}>
-    <ModalHeader>
-      <h5 className="modal-title">Install {info.name}</h5>
-    </ModalHeader>
-    <ModalBody>
-      <InstallForm onSubmit={onSubmit} formFields={info.form_fields} exposable={info.exposable} />
-    </ModalBody>
-  </Modal>
+  <Dialog open={isOpen} onOpenChange={onClose}>
+    <DialogContent>
+      <DialogHeader>
+        <h5 className="modal-title">Install {info.name}</h5>
+      </DialogHeader>
+      <DialogDescription>
+        <InstallForm onSubmit={onSubmit} formFields={info.form_fields} exposable={info.exposable} />
+      </DialogDescription>
+    </DialogContent>
+  </Dialog>
 );

+ 16 - 14
src/client/modules/Apps/components/StopModal.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
 import { Button } from '../../../components/ui/Button';
-import { Modal, ModalBody, ModalFooter, ModalHeader } from '../../../components/ui/Modal';
 import { AppInfo } from '../../../core/types';
 
 interface IProps {
@@ -11,17 +11,19 @@ interface IProps {
 }
 
 export const StopModal: React.FC<IProps> = ({ info, isOpen, onClose, onConfirm }) => (
-  <Modal size="sm" onClose={onClose} isOpen={isOpen}>
-    <ModalHeader>
-      <h5 className="modal-title">Stop {info.name} ?</h5>
-    </ModalHeader>
-    <ModalBody>
-      <div className="text-muted">All data will be retained</div>
-    </ModalBody>
-    <ModalFooter>
-      <Button data-testid="modal-stop-button" onClick={onConfirm} className="btn-danger">
-        Stop
-      </Button>
-    </ModalFooter>
-  </Modal>
+  <Dialog open={isOpen} onOpenChange={onClose}>
+    <DialogContent size="sm">
+      <DialogHeader>
+        <h5 className="modal-title">Stop {info.name} ?</h5>
+      </DialogHeader>
+      <DialogDescription>
+        <div className="text-muted">All data will be retained</div>
+      </DialogDescription>
+      <DialogFooter>
+        <Button onClick={onConfirm} className="btn-danger">
+          Stop
+        </Button>
+      </DialogFooter>
+    </DialogContent>
+  </Dialog>
 );

+ 18 - 16
src/client/modules/Apps/components/UninstallModal.tsx

@@ -1,7 +1,7 @@
 import { IconAlertTriangle } from '@tabler/icons-react';
 import React from 'react';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
 import { Button } from '../../../components/ui/Button';
-import { Modal, ModalBody, ModalFooter, ModalHeader } from '../../../components/ui/Modal';
 import { AppInfo } from '../../../core/types';
 
 interface IProps {
@@ -12,19 +12,21 @@ interface IProps {
 }
 
 export const UninstallModal: React.FC<IProps> = ({ info, isOpen, onClose, onConfirm }) => (
-  <Modal size="sm" type="danger" onClose={onClose} isOpen={isOpen}>
-    <ModalHeader>
-      <h5 className="modal-title">Uninstall {info.name} ?</h5>
-    </ModalHeader>
-    <ModalBody className="text-center py-4">
-      <IconAlertTriangle className="icon mb-2 text-danger icon-lg" />
-      <h3>Are you sure?</h3>
-      <div className="text-muted">All data for this app will be lost.</div>
-    </ModalBody>
-    <ModalFooter>
-      <Button onClick={onConfirm} className="btn-danger">
-        Uninstall
-      </Button>
-    </ModalFooter>
-  </Modal>
+  <Dialog open={isOpen} onOpenChange={onClose}>
+    <DialogContent type="danger" size="sm">
+      <DialogHeader>
+        <h5 className="modal-title">Uninstall {info.name} ?</h5>
+      </DialogHeader>
+      <DialogDescription className="text-center py-4">
+        <IconAlertTriangle className="icon mb-2 text-danger icon-lg" />
+        <h3>Are you sure?</h3>
+        <div className="text-muted">All data for this app will be lost.</div>
+      </DialogDescription>
+      <DialogFooter>
+        <Button onClick={onConfirm} className="btn-danger">
+          Uninstall
+        </Button>
+      </DialogFooter>
+    </DialogContent>
+  </Dialog>
 );

+ 9 - 9
src/client/modules/Apps/components/UpdateModal/UpdateModal.test.tsx

@@ -7,40 +7,40 @@ describe('UpdateModal', () => {
   const newVersion = '1.2.3';
 
   it('renders with the correct title and version number', () => {
-    // Arrange
+    // arrange
     render(<UpdateModal info={app} newVersion={newVersion} isOpen onClose={jest.fn()} onConfirm={jest.fn()} />);
 
-    // Assert
+    // assert
     expect(screen.getByText(`Update ${app.name} ?`)).toBeInTheDocument();
     expect(screen.getByText(`${newVersion}`)).toBeInTheDocument();
   });
 
   it('should not render when isOpen is false', () => {
-    // Arrange
+    // arrange
     render(<UpdateModal info={app} newVersion={newVersion} isOpen={false} onClose={jest.fn()} onConfirm={jest.fn()} />);
     const modal = screen.queryByTestId('modal');
 
-    // Assert (modal should have style display: none)
-    expect(modal).toHaveStyle('display: none');
+    // assert
+    expect(modal).not.toBeInTheDocument();
   });
 
   it('calls onClose when the close button is clicked', () => {
-    // Arrange
+    // arrange
     const onClose = jest.fn();
     render(<UpdateModal info={app} newVersion={newVersion} isOpen onClose={onClose} onConfirm={jest.fn()} />);
 
-    // Act
+    // act
     const closeButton = screen.getByTestId('modal-close-button');
     fireEvent.click(closeButton);
     expect(onClose).toHaveBeenCalled();
   });
 
   it('calls onConfirm when the update button is clicked', () => {
-    // Arrange
+    // arrange
     const onConfirm = jest.fn();
     render(<UpdateModal info={app} newVersion={newVersion} isOpen onClose={jest.fn()} onConfirm={onConfirm} />);
 
-    // Act
+    // act
     const updateButton = screen.getByText('Update');
     fireEvent.click(updateButton);
     expect(onConfirm).toHaveBeenCalled();

+ 19 - 17
src/client/modules/Apps/components/UpdateModal/UpdateModal.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
 import { Button } from '../../../../components/ui/Button';
-import { Modal, ModalBody, ModalFooter, ModalHeader } from '../../../../components/ui/Modal';
 import { AppInfo } from '../../../../core/types';
 
 interface IProps {
@@ -12,20 +12,22 @@ interface IProps {
 }
 
 export const UpdateModal: React.FC<IProps> = ({ info, newVersion, isOpen, onClose, onConfirm }) => (
-  <Modal size="sm" onClose={onClose} isOpen={isOpen}>
-    <ModalHeader>
-      <h5 className="modal-title">Update {info.name} ?</h5>
-    </ModalHeader>
-    <ModalBody>
-      <div className="text-muted">
-        Update app to latest verion : <b>{newVersion}</b> ?<br />
-        This will reset your custom configuration (e.g. changes in docker-compose.yml)
-      </div>
-    </ModalBody>
-    <ModalFooter>
-      <Button data-testid="modal-update-button" onClick={onConfirm} className="btn-success">
-        Update
-      </Button>
-    </ModalFooter>
-  </Modal>
+  <Dialog open={isOpen} onOpenChange={onClose}>
+    <DialogContent size="sm">
+      <DialogHeader>
+        <h5 className="modal-title">Update {info.name} ?</h5>
+      </DialogHeader>
+      <DialogDescription>
+        <div className="text-muted">
+          Update app to latest verion : <b>{newVersion}</b> ?<br />
+          This will reset your custom configuration (e.g. changes in docker-compose.yml)
+        </div>
+      </DialogDescription>
+      <DialogFooter>
+        <Button onClick={onConfirm} className="btn-success">
+          Update
+        </Button>
+      </DialogFooter>
+    </DialogContent>
+  </Dialog>
 );

+ 11 - 9
src/client/modules/Apps/components/UpdateSettingsModal.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
+import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/components/ui/Dialog';
 import { InstallForm } from './InstallForm';
-import { Modal, ModalBody, ModalHeader } from '../../../components/ui/Modal';
 import { AppInfo } from '../../../core/types';
 import { FormValues } from './InstallForm/InstallForm';
 
@@ -15,12 +15,14 @@ interface IProps {
 }
 
 export const UpdateSettingsModal: React.FC<IProps> = ({ info, config, isOpen, onClose, onSubmit, exposed, domain }) => (
-  <Modal onClose={onClose} isOpen={isOpen}>
-    <ModalHeader>
-      <h5 className="modal-title">Update {info.name} config</h5>
-    </ModalHeader>
-    <ModalBody>
-      <InstallForm onSubmit={onSubmit} formFields={info.form_fields} exposable={info.exposable} initalValues={{ ...config, exposed, domain }} />
-    </ModalBody>
-  </Modal>
+  <Dialog open={isOpen} onOpenChange={onClose}>
+    <DialogContent>
+      <DialogHeader>
+        <h5 className="modal-title">Update {info.name} config</h5>
+      </DialogHeader>
+      <DialogDescription>
+        <InstallForm onSubmit={onSubmit} formFields={info.form_fields} exposable={info.exposable} initalValues={{ ...config, exposed, domain }} />
+      </DialogDescription>
+    </DialogContent>
+  </Dialog>
 );

+ 58 - 188
src/client/modules/Apps/containers/AppDetailsContainer/AppDetailsContainer.test.tsx

@@ -1,9 +1,8 @@
 import React from 'react';
-import { fireEvent, render, renderHook, screen, waitFor } from '../../../../../../tests/test-utils';
+import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
 import { createAppEntity } from '../../../../mocks/fixtures/app.fixtures';
 import { getTRPCMock, getTRPCMockError } from '../../../../mocks/getTrpcMock';
 import { server } from '../../../../mocks/server';
-import { useToastStore } from '../../../../state/toastStore';
 import { AppDetailsContainer } from './AppDetailsContainer';
 
 describe('Test: AppDetailsContainer', () => {
@@ -23,7 +22,7 @@ describe('Test: AppDetailsContainer', () => {
       render(<AppDetailsContainer app={app} />);
 
       // Assert
-      expect(screen.getByTestId('action-button-update')).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument();
     });
 
     it('should display install button when app is not installed', async () => {
@@ -33,7 +32,7 @@ describe('Test: AppDetailsContainer', () => {
       render(<AppDetailsContainer app={app} />);
 
       // Assert
-      expect(screen.getByTestId('action-button-install')).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: 'Install' })).toBeInTheDocument();
     });
 
     it('should display uninstall and start button when app is stopped', async () => {
@@ -43,8 +42,8 @@ describe('Test: AppDetailsContainer', () => {
       render(<AppDetailsContainer app={app} />);
 
       // Assert
-      expect(screen.getByTestId('action-button-remove')).toBeInTheDocument();
-      expect(screen.getByTestId('action-button-start')).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument();
     });
 
     it('should display stop, open and settings buttons when app is running', async () => {
@@ -53,9 +52,9 @@ describe('Test: AppDetailsContainer', () => {
       render(<AppDetailsContainer app={app} />);
 
       // Assert
-      expect(screen.getByTestId('action-button-stop')).toBeInTheDocument();
-      expect(screen.getByTestId('action-button-open')).toBeInTheDocument();
-      expect(screen.getByTestId('action-button-settings')).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: 'Open' })).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument();
     });
 
     it('should not display update button when update is not available', async () => {
@@ -64,7 +63,7 @@ describe('Test: AppDetailsContainer', () => {
       render(<AppDetailsContainer app={app} />);
 
       // Assert
-      expect(screen.queryByTestId('action-button-update')).not.toBeInTheDocument();
+      expect(screen.queryByRole('button', { name: 'Update' })).not.toBeInTheDocument();
     });
 
     it('should not display open button when app has no_gui set to true', async () => {
@@ -73,7 +72,7 @@ describe('Test: AppDetailsContainer', () => {
       render(<AppDetailsContainer app={app} />);
 
       // Assert
-      expect(screen.queryByTestId('action-button-open')).not.toBeInTheDocument();
+      expect(screen.queryByRole('button', { name: 'Open' })).not.toBeInTheDocument();
     });
   });
 
@@ -85,7 +84,7 @@ describe('Test: AppDetailsContainer', () => {
       render(<AppDetailsContainer app={app} />);
 
       // Act
-      const openButton = screen.getByTestId('action-button-open');
+      const openButton = screen.getByRole('button', { name: 'Open' });
       openButton.click();
 
       // Assert
@@ -99,7 +98,7 @@ describe('Test: AppDetailsContainer', () => {
       render(<AppDetailsContainer app={app} />);
 
       // Act
-      const openButton = screen.getByTestId('action-button-open');
+      const openButton = screen.getByRole('button', { name: 'Open' });
       openButton.click();
 
       // Assert
@@ -112,23 +111,21 @@ describe('Test: AppDetailsContainer', () => {
       // Arrange
       const app = createAppEntity({ overrides: { status: 'missing' } });
       server.use(getTRPCMock({ path: ['app', 'installApp'], type: 'mutation', response: app }));
-      const { result } = renderHook(() => useToastStore());
       render(<AppDetailsContainer app={app} />);
+      const openModalButton = screen.getByRole('button', { name: 'Install' });
+      fireEvent.click(openModalButton);
 
       // Act
-      const installForm = screen.getByTestId('install-form');
-      fireEvent.submit(installForm);
+      const installButton = screen.getByRole('button', { name: 'Install' });
+      fireEvent.click(installButton);
 
       await waitFor(() => {
-        expect(result.current.toasts).toHaveLength(1);
-        expect(result.current.toasts[0].status).toEqual('success');
-        expect(result.current.toasts[0].title).toEqual('App installed successfully');
+        expect(screen.getByText('App installed successfully')).toBeInTheDocument();
       });
     });
 
     it('should display a toast error when install mutation fails', async () => {
       // Arrange
-      const { result } = renderHook(() => useToastStore());
       server.use(
         getTRPCMockError({
           path: ['app', 'installApp'],
@@ -139,33 +136,15 @@ describe('Test: AppDetailsContainer', () => {
 
       const app = createAppEntity({ overrides: { status: 'missing' } });
       render(<AppDetailsContainer app={app} />);
+      const openModalButton = screen.getByRole('button', { name: 'Install' });
+      fireEvent.click(openModalButton);
 
       // Act
-      const installForm = screen.getByTestId('install-form');
-      fireEvent.submit(installForm);
+      const installButton = screen.getByRole('button', { name: 'Install' });
+      fireEvent.click(installButton);
 
       await waitFor(() => {
-        expect(result.current.toasts).toHaveLength(1);
-        expect(result.current.toasts[0].description).toEqual('my big error');
-        expect(result.current.toasts[0].status).toEqual('error');
-      });
-    });
-
-    // Skipping because trpc.useContext is not working in tests
-    it.skip('should put the app in installing state when install mutation is called', async () => {
-      // Arrange
-      const { result } = renderHook(() => useToastStore());
-      const app = createAppEntity({ overrides: { status: 'missing' } });
-      server.use(getTRPCMock({ path: ['app', 'installApp'], type: 'mutation', response: app, delay: 100 }));
-      render(<AppDetailsContainer app={app} />);
-
-      // Act
-      const installForm = screen.getByTestId('install-form');
-      fireEvent.submit(installForm);
-
-      await waitFor(() => {
-        expect(screen.getByText('installing')).toBeInTheDocument();
-        expect(result.current.toasts).toHaveLength(1);
+        expect(screen.getByText('Failed to install app: my big error')).toBeInTheDocument();
       });
     });
   });
@@ -175,60 +154,34 @@ describe('Test: AppDetailsContainer', () => {
       // Arrange
       const app = createAppEntity({ overrides: { version: 2, latestVersion: 3 } });
       server.use(getTRPCMock({ path: ['app', 'updateApp'], type: 'mutation', response: app }));
-      const { result } = renderHook(() => useToastStore());
       render(<AppDetailsContainer app={app} />);
+      const openModalButton = screen.getByRole('button', { name: 'Update' });
+      fireEvent.click(openModalButton);
 
       // Act
-      const updateButton = screen.getByTestId('action-button-update');
-      updateButton.click();
-      const modalUpdateButton = screen.getByTestId('modal-update-button');
+      const modalUpdateButton = screen.getByRole('button', { name: 'Update' });
       modalUpdateButton.click();
 
       await waitFor(() => {
-        expect(result.current.toasts).toHaveLength(1);
-        expect(result.current.toasts[0].status).toEqual('success');
-        expect(result.current.toasts[0].title).toEqual('App updated successfully');
+        expect(screen.getByText('App updated successfully')).toBeInTheDocument();
       });
     });
 
     it('should display a toast error when update mutation fails', async () => {
       // Arrange
-      const { result } = renderHook(() => useToastStore());
       server.use(getTRPCMockError({ path: ['app', 'updateApp'], type: 'mutation', message: 'my big error' }));
       const app = createAppEntity({ overrides: { version: 2, latestVersion: 3 }, overridesInfo: { tipi_version: 3 } });
       render(<AppDetailsContainer app={app} />);
+      const openModalButton = screen.getByRole('button', { name: 'Update' });
+      fireEvent.click(openModalButton);
 
       // Act
-      const updateButton = screen.getByTestId('action-button-update');
-      updateButton.click();
-      const modalUpdateButton = screen.getByTestId('modal-update-button');
+      const modalUpdateButton = screen.getByRole('button', { name: 'Update' });
       modalUpdateButton.click();
 
       // Assert
       await waitFor(() => {
-        expect(result.current.toasts).toHaveLength(1);
-        expect(result.current.toasts[0].description).toEqual('my big error');
-        expect(result.current.toasts[0].status).toEqual('error');
-      });
-    });
-
-    // Skipping because trpc.useContext is not working in tests
-    it.skip('should put the app in updating state when update mutation is called', async () => {
-      // Arrange
-      const { result } = renderHook(() => useToastStore());
-      const app = createAppEntity({ overrides: { version: 2 }, overridesInfo: { tipi_version: 3 } });
-      server.use(getTRPCMock({ path: ['app', 'updateApp'], type: 'mutation', response: app, delay: 100 }));
-      render(<AppDetailsContainer app={app} />);
-
-      // Act
-      const updateButton = screen.getByTestId('action-button-update');
-      updateButton.click();
-      const modalUpdateButton = screen.getByTestId('modal-update-button');
-      modalUpdateButton.click();
-
-      await waitFor(() => {
-        expect(screen.getByText('updating')).toBeInTheDocument();
-        expect(result.current.toasts).toHaveLength(1);
+        expect(screen.getByText('Failed to update app: my big error')).toBeInTheDocument();
       });
     });
   });
@@ -238,62 +191,35 @@ describe('Test: AppDetailsContainer', () => {
       // Arrange
       const app = createAppEntity({ status: 'stopped' });
       server.use(getTRPCMock({ path: ['app', 'uninstallApp'], type: 'mutation', response: { id: app.id, config: {}, status: 'missing' } }));
-      const { result } = renderHook(() => useToastStore());
       render(<AppDetailsContainer app={app} />);
+      const openModalButton = screen.getByRole('button', { name: 'Remove' });
+      fireEvent.click(openModalButton);
 
       // Act
-      const uninstallButton = screen.getByTestId('action-button-remove');
-      uninstallButton.click();
-      const modalUninstallButton = screen.getByText('Uninstall');
+      const modalUninstallButton = screen.getByRole('button', { name: 'Uninstall' });
       modalUninstallButton.click();
 
       // Assert
       await waitFor(() => {
-        expect(result.current.toasts).toHaveLength(1);
-        expect(result.current.toasts[0].status).toEqual('success');
-        expect(result.current.toasts[0].title).toEqual('App uninstalled successfully');
+        expect(screen.getByText('App uninstalled successfully')).toBeInTheDocument();
       });
     });
 
     it('should display a toast error when uninstall mutation fails', async () => {
       // Arrange
-      const { result } = renderHook(() => useToastStore());
       server.use(getTRPCMockError({ path: ['app', 'uninstallApp'], type: 'mutation', message: 'my big error' }));
       const app = createAppEntity({ status: 'stopped' });
       render(<AppDetailsContainer app={app} />);
+      const openModalButton = screen.getByRole('button', { name: 'Remove' });
+      fireEvent.click(openModalButton);
 
       // Act
-      const uninstallButton = screen.getByTestId('action-button-remove');
-      uninstallButton.click();
-      const modalUninstallButton = screen.getByText('Uninstall');
+      const modalUninstallButton = screen.getByRole('button', { name: 'Uninstall' });
       modalUninstallButton.click();
 
       // Assert
       await waitFor(() => {
-        expect(result.current.toasts).toHaveLength(1);
-        expect(result.current.toasts[0].description).toEqual('my big error');
-        expect(result.current.toasts[0].status).toEqual('error');
-      });
-    });
-
-    // Skipping because trpc.useContext is not working in tests
-    it.skip('should put the app in uninstalling state when uninstall mutation is called', async () => {
-      // Arrange
-      const { result } = renderHook(() => useToastStore());
-      const app = createAppEntity({ status: 'stopped' });
-      server.use(getTRPCMock({ path: ['app', 'uninstallApp'], type: 'mutation', response: { id: app.id, config: {}, status: 'missing' }, delay: 100 }));
-      render(<AppDetailsContainer app={app} />);
-
-      // Act
-      const uninstallButton = screen.getByTestId('action-button-remove');
-      uninstallButton.click();
-      const modalUninstallButton = screen.getByText('Uninstall');
-      modalUninstallButton.click();
-
-      await waitFor(() => {
-        expect(screen.getByText('uninstalling')).toBeInTheDocument();
-        expect(screen.queryByText('installing')).not.toBeInTheDocument();
-        expect(result.current.toasts).toHaveLength(1);
+        expect(screen.getByText('Failed to uninstall app: my big error')).toBeInTheDocument();
       });
     });
   });
@@ -303,55 +229,31 @@ describe('Test: AppDetailsContainer', () => {
       // Arrange
       const app = createAppEntity({ status: 'stopped' });
       server.use(getTRPCMock({ path: ['app', 'startApp'], type: 'mutation', response: app }));
-      const { result } = renderHook(() => useToastStore());
       render(<AppDetailsContainer app={app} />);
 
       // Act
-      const startButton = screen.getByTestId('action-button-start');
+      const startButton = screen.getByRole('button', { name: 'Start' });
       startButton.click();
 
       // Assert
       await waitFor(() => {
-        expect(result.current.toasts).toHaveLength(1);
-        expect(result.current.toasts[0].status).toEqual('success');
-        expect(result.current.toasts[0].title).toEqual('App started successfully');
+        expect(screen.getByText('App started successfully')).toBeInTheDocument();
       });
     });
 
     it('should display a toast error when start mutation fails', async () => {
       // Arrange
-      const { result } = renderHook(() => useToastStore());
       server.use(getTRPCMockError({ path: ['app', 'startApp'], type: 'mutation', message: 'my big error' }));
       const app = createAppEntity({ status: 'stopped' });
       render(<AppDetailsContainer app={app} />);
 
       // Act
-      const startButton = screen.getByTestId('action-button-start');
+      const startButton = screen.getByRole('button', { name: 'Start' });
       startButton.click();
 
       // Assert
       await waitFor(() => {
-        expect(result.current.toasts).toHaveLength(1);
-        expect(result.current.toasts[0].description).toEqual('my big error');
-        expect(result.current.toasts[0].status).toEqual('error');
-      });
-    });
-
-    // Skipping because trpc.useContext is not working in tests
-    it.skip('should put the app in starting state when start mutation is called', async () => {
-      // Arrange
-      const { result } = renderHook(() => useToastStore());
-      const app = createAppEntity({ status: 'stopped' });
-      server.use(getTRPCMock({ path: ['app', 'startApp'], type: 'mutation', response: app, delay: 100 }));
-      render(<AppDetailsContainer app={app} />);
-
-      // Act
-      const startButton = screen.getByTestId('action-button-start');
-      startButton.click();
-
-      await waitFor(() => {
-        expect(screen.getByText('starting')).toBeInTheDocument();
-        expect(result.current.toasts).toHaveLength(1);
+        expect(screen.getByText('Failed to start app: my big error')).toBeInTheDocument();
       });
     });
   });
@@ -361,61 +263,35 @@ describe('Test: AppDetailsContainer', () => {
       // Arrange
       const app = createAppEntity({ status: 'running' });
       server.use(getTRPCMock({ path: ['app', 'stopApp'], type: 'mutation', response: app }));
-      const { result } = renderHook(() => useToastStore());
       render(<AppDetailsContainer app={app} />);
+      const openModalButton = screen.getByRole('button', { name: 'Stop' });
+      fireEvent.click(openModalButton);
 
       // Act
-      const stopButton = screen.getByTestId('action-button-stop');
-      stopButton.click();
-      const modalStopButton = screen.getByTestId('modal-stop-button');
+      const modalStopButton = screen.getByRole('button', { name: 'Stop' });
       modalStopButton.click();
 
       // Assert
       await waitFor(() => {
-        expect(result.current.toasts).toHaveLength(1);
-        expect(result.current.toasts[0].status).toEqual('success');
-        expect(result.current.toasts[0].title).toEqual('App stopped successfully');
+        expect(screen.getByText('App stopped successfully')).toBeInTheDocument();
       });
     });
 
     it('should display a toast error when stop mutation fails', async () => {
       // Arrange
-      const { result } = renderHook(() => useToastStore());
       server.use(getTRPCMockError({ path: ['app', 'stopApp'], type: 'mutation', message: 'my big error' }));
       const app = createAppEntity({ status: 'running' });
       render(<AppDetailsContainer app={app} />);
+      const openModalButton = screen.getByRole('button', { name: 'Stop' });
+      fireEvent.click(openModalButton);
 
       // Act
-      const stopButton = screen.getByTestId('action-button-stop');
-      stopButton.click();
-      const modalStopButton = screen.getByTestId('modal-stop-button');
+      const modalStopButton = screen.getByRole('button', { name: 'Stop' });
       modalStopButton.click();
 
       // Assert
       await waitFor(() => {
-        expect(result.current.toasts).toHaveLength(1);
-        expect(result.current.toasts[0].description).toEqual('my big error');
-        expect(result.current.toasts[0].status).toEqual('error');
-      });
-    });
-
-    // Skipping because trpc.useContext is not working in tests
-    it.skip('should put the app in stopping state when stop mutation is called', async () => {
-      // Arrange
-      const { result } = renderHook(() => useToastStore());
-      const app = createAppEntity({ status: 'running' });
-      server.use(getTRPCMock({ path: ['app', 'stopApp'], type: 'mutation', response: app }));
-      render(<AppDetailsContainer app={app} />);
-
-      // Act
-      const stopButton = screen.getByTestId('action-button-stop');
-      stopButton.click();
-      const modalStopButton = screen.getByTestId('modal-stop-button');
-      modalStopButton.click();
-
-      await waitFor(() => {
-        expect(screen.getByText('stopping')).toBeInTheDocument();
-        expect(result.current.toasts).toHaveLength(1);
+        expect(screen.getByText('Failed to stop app: my big error')).toBeInTheDocument();
       });
     });
   });
@@ -425,41 +301,35 @@ describe('Test: AppDetailsContainer', () => {
       // Arrange
       const app = createAppEntity({ status: 'running', overridesInfo: { exposable: true } });
       server.use(getTRPCMock({ path: ['app', 'updateAppConfig'], type: 'mutation', response: app }));
-      const { result } = renderHook(() => useToastStore());
       render(<AppDetailsContainer app={app} />);
+      const openModalButton = screen.getByRole('button', { name: 'Settings' });
+      fireEvent.click(openModalButton);
 
       // Act
-      const configButton = screen.getByTestId('action-button-settings');
+      const configButton = screen.getByRole('button', { name: 'Update' });
       configButton.click();
-      const modalConfigButton = screen.getAllByText('Update');
-      modalConfigButton[1]?.click();
 
       // Assert
       await waitFor(() => {
-        expect(result.current.toasts).toHaveLength(1);
-        expect(result.current.toasts[0].status).toEqual('success');
-        expect(result.current.toasts[0].title).toEqual('App config updated successfully. Restart the app to apply the changes');
+        expect(screen.getByText('App config updated successfully. Restart the app to apply the changes')).toBeInTheDocument();
       });
     });
 
     it('should display a toast error when update config mutation fails', async () => {
       // Arrange
-      const { result } = renderHook(() => useToastStore());
       server.use(getTRPCMockError({ path: ['app', 'updateAppConfig'], type: 'mutation', message: 'my big error' }));
       const app = createAppEntity({ status: 'running', overridesInfo: { exposable: true } });
       render(<AppDetailsContainer app={app} />);
+      const openModalButton = screen.getByRole('button', { name: 'Settings' });
+      fireEvent.click(openModalButton);
 
       // Act
-      const configButton = screen.getByTestId('action-button-settings');
+      const configButton = screen.getByRole('button', { name: 'Update' });
       configButton.click();
-      const modalConfigButton = screen.getAllByText('Update');
-      modalConfigButton[1]?.click();
 
       // Assert
       await waitFor(() => {
-        expect(result.current.toasts).toHaveLength(1);
-        expect(result.current.toasts[0].description).toEqual('my big error');
-        expect(result.current.toasts[0].status).toEqual('error');
+        expect(screen.getByText('Failed to update app config: my big error')).toBeInTheDocument();
       });
     });
   });

+ 13 - 14
src/client/modules/Apps/containers/AppDetailsContainer/AppDetailsContainer.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
+import { toast } from 'react-hot-toast';
 import { useDisclosure } from '../../../../hooks/useDisclosure';
-import { useToastStore } from '../../../../state/toastStore';
 import { AppLogo } from '../../../../components/AppLogo/AppLogo';
 import { AppStatus } from '../../../../components/AppStatus';
 import { AppActions } from '../../components/AppActions';
@@ -20,7 +20,6 @@ interface IProps {
 }
 
 export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
-  const { addToast } = useToastStore();
   const installDisclosure = useDisclosure();
   const uninstallDisclosure = useDisclosure();
   const stopDisclosure = useDisclosure();
@@ -41,11 +40,11 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
     },
     onSuccess: () => {
       invalidate();
-      addToast({ title: 'App installed successfully', status: 'success' });
+      toast.success('App installed successfully');
     },
     onError: (e) => {
       invalidate();
-      addToast({ title: 'Install error', description: e.message, status: 'error' });
+      toast.error(`Failed to install app: ${e.message}`);
     },
   });
 
@@ -56,9 +55,9 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
     },
     onSuccess: () => {
       invalidate();
-      addToast({ title: 'App uninstalled successfully', status: 'success' });
+      toast.success('App uninstalled successfully');
     },
-    onError: (e) => addToast({ title: 'Uninstall error', description: e.message, status: 'error' }),
+    onError: (e) => toast.error(`Failed to uninstall app: ${e.message}`),
   });
 
   const stop = trpc.app.stopApp.useMutation({
@@ -68,9 +67,9 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
     },
     onSuccess: () => {
       invalidate();
-      addToast({ title: 'App stopped successfully', status: 'success' });
+      toast.success('App stopped successfully');
     },
-    onError: (e) => addToast({ title: 'Stop error', description: e.message, status: 'error' }),
+    onError: (e) => toast.error(`Failed to stop app: ${e.message}`),
   });
 
   const update = trpc.app.updateApp.useMutation({
@@ -80,9 +79,9 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
     },
     onSuccess: () => {
       invalidate();
-      addToast({ title: 'App updated successfully', status: 'success' });
+      toast.success('App updated successfully');
     },
-    onError: (e) => addToast({ title: 'Update error', description: e.message, status: 'error' }),
+    onError: (e) => toast.error(`Failed to update app: ${e.message}`),
   });
 
   const start = trpc.app.startApp.useMutation({
@@ -91,18 +90,18 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
     },
     onSuccess: () => {
       invalidate();
-      addToast({ title: 'App started successfully', status: 'success' });
+      toast.success('App started successfully');
     },
-    onError: (e) => addToast({ title: 'Start error', description: e.message, status: 'error' }),
+    onError: (e) => toast.error(`Failed to start app: ${e.message}`),
   });
 
   const updateConfig = trpc.app.updateAppConfig.useMutation({
     onMutate: () => updateSettingsDisclosure.close(),
     onSuccess: () => {
       invalidate();
-      addToast({ title: 'App config updated successfully. Restart the app to apply the changes', status: 'success' });
+      toast.success('App config updated successfully. Restart the app to apply the changes');
     },
-    onError: (e) => addToast({ title: 'Update error', description: e.message, status: 'error' }),
+    onError: (e) => toast.error(`Failed to update app config: ${e.message}`),
   });
 
   const updateAvailable = Number(app.version || 0) < Number(app?.latestVersion || 0);

+ 2 - 2
src/client/modules/Auth/components/LoginForm/LoginForm.tsx

@@ -36,11 +36,11 @@ export const LoginForm: React.FC<IProps> = ({ loading, onSubmit }) => {
   return (
     <form className="flex flex-col" onSubmit={handleSubmit(onSubmit)}>
       <h2 className="h2 text-center mb-3">Login to your account</h2>
-      <Input {...register('email')} label="Email address" error={errors.email?.message} disabled={loading} type="email" className="mb-3" placeholder="you@example.com" />
+      <Input {...register('email')} name="email" label="Email address" error={errors.email?.message} disabled={loading} type="email" className="mb-3" placeholder="you@example.com" />
       <span className="form-label-description">
         <Link href="/reset-password">Forgot password?</Link>
       </span>
-      <Input {...register('password')} label="Password" error={errors.password?.message} disabled={loading} type="password" className="mb-3" placeholder="Your password" />
+      <Input {...register('password')} name="password" label="Password" error={errors.password?.message} disabled={loading} type="password" className="mb-3" placeholder="Your password" />
       <Button disabled={isDisabled} loading={loading} type="submit" className="btn btn-primary w-100">
         Login
       </Button>

+ 32 - 0
src/client/modules/Auth/components/TotpForm/TotpForm.tsx

@@ -0,0 +1,32 @@
+import { Button } from '@/components/ui/Button';
+import { OtpInput } from '@/components/ui/OtpInput';
+import React from 'react';
+
+type Props = {
+  onSubmit: (totpCode: string) => void;
+  loading?: boolean;
+};
+
+export const TotpForm = (props: Props) => {
+  const { onSubmit, loading } = props;
+  const [totpCode, setTotpCode] = React.useState('');
+
+  return (
+    <form
+      onSubmit={(e) => {
+        setTotpCode('');
+        e.preventDefault();
+        onSubmit(totpCode);
+      }}
+    >
+      <div className="flex items-center justify-center">
+        <h3 className="">Two-factor authentication</h3>
+        <p className="text-sm text-gray-500">Enter the code from your authenticator app</p>
+        <OtpInput valueLength={6} value={totpCode} onChange={(o) => setTotpCode(o)} />
+        <Button disabled={totpCode.trim().length < 6} loading={loading} type="submit" className="mt-3">
+          Confirm
+        </Button>
+      </div>
+    </form>
+  );
+};

+ 1 - 0
src/client/modules/Auth/components/TotpForm/index.ts

@@ -0,0 +1 @@
+export { TotpForm } from './TotpForm';

+ 112 - 12
src/client/modules/Auth/containers/LoginContainer/LoginContainer.test.tsx

@@ -1,9 +1,8 @@
 import { faker } from '@faker-js/faker';
 import React from 'react';
-import { fireEvent, render, renderHook, screen, waitFor } from '../../../../../../tests/test-utils';
+import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
 import { getTRPCMock, getTRPCMockError } from '../../../../mocks/getTrpcMock';
 import { server } from '../../../../mocks/server';
-import { useToastStore } from '../../../../state/toastStore';
 import { LoginContainer } from './LoginContainer';
 
 describe('Test: LoginContainer', () => {
@@ -28,8 +27,8 @@ describe('Test: LoginContainer', () => {
     // Arrange
     render(<LoginContainer />);
     const loginButton = screen.getByRole('button', { name: 'Login' });
-    const emailInput = screen.getByLabelText('Email address');
-    const passwordInput = screen.getByLabelText('Password');
+    const emailInput = screen.getByRole('textbox', { name: 'email' });
+    const passwordInput = screen.getByRole('textbox', { name: 'password' });
 
     // Act
     fireEvent.change(emailInput, { target: { value: faker.internet.email() } });
@@ -49,8 +48,8 @@ describe('Test: LoginContainer', () => {
 
     // Act
     const loginButton = screen.getByRole('button', { name: 'Login' });
-    const emailInput = screen.getByLabelText('Email address');
-    const passwordInput = screen.getByLabelText('Password');
+    const emailInput = screen.getByRole('textbox', { name: 'email' });
+    const passwordInput = screen.getByRole('textbox', { name: 'password' });
 
     fireEvent.change(emailInput, { target: { value: email } });
     fireEvent.change(passwordInput, { target: { value: password } });
@@ -64,14 +63,13 @@ describe('Test: LoginContainer', () => {
 
   it('should show error message if login fails', async () => {
     // Arrange
-    const { result } = renderHook(() => useToastStore());
     server.use(getTRPCMockError({ path: ['auth', 'login'], type: 'mutation', status: 500, message: 'my big error' }));
     render(<LoginContainer />);
 
     // Act
     const loginButton = screen.getByRole('button', { name: 'Login' });
-    const emailInput = screen.getByLabelText('Email address');
-    const passwordInput = screen.getByLabelText('Password');
+    const emailInput = screen.getByRole('textbox', { name: 'email' });
+    const passwordInput = screen.getByRole('textbox', { name: 'password' });
 
     fireEvent.change(emailInput, { target: { value: 'test@test.com' } });
     fireEvent.change(passwordInput, { target: { value: 'test' } });
@@ -79,11 +77,113 @@ describe('Test: LoginContainer', () => {
 
     // Assert
     await waitFor(() => {
-      expect(result.current.toasts).toHaveLength(1);
-      expect(result.current.toasts[0].description).toEqual('my big error');
-      expect(result.current.toasts[0].status).toEqual('error');
+      expect(screen.getByText(/my big error/)).toBeInTheDocument();
     });
     const token = localStorage.getItem('token');
     expect(token).toBeNull();
   });
+
+  it('should show totp form if totpSessionId is returned', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const password = faker.internet.password();
+    const totpSessionId = faker.datatype.uuid();
+    server.use(
+      getTRPCMock({
+        path: ['auth', 'login'],
+        type: 'mutation',
+        response: { totpSessionId },
+      }),
+    );
+    render(<LoginContainer />);
+
+    // act
+    const loginButton = screen.getByRole('button', { name: 'Login' });
+    const emailInput = screen.getByRole('textbox', { name: 'email' });
+    const passwordInput = screen.getByRole('textbox', { name: 'password' });
+
+    fireEvent.change(emailInput, { target: { value: email } });
+    fireEvent.change(passwordInput, { target: { value: password } });
+    fireEvent.click(loginButton);
+
+    // assert
+    await waitFor(() => {
+      expect(screen.getByText('Two-factor authentication')).toBeInTheDocument();
+    });
+  });
+
+  it('should show error message if totp code is invalid', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const password = faker.internet.password();
+    const totpSessionId = faker.datatype.uuid();
+    server.use(getTRPCMock({ path: ['auth', 'login'], type: 'mutation', response: { totpSessionId } }));
+    server.use(getTRPCMockError({ path: ['auth', 'verifyTotp'], type: 'mutation', status: 500, message: 'Invalid totp code' }));
+    render(<LoginContainer />);
+
+    // act
+    const loginButton = screen.getByRole('button', { name: 'Login' });
+    const emailInput = screen.getByRole('textbox', { name: 'email' });
+    const passwordInput = screen.getByRole('textbox', { name: 'password' });
+
+    fireEvent.change(emailInput, { target: { value: email } });
+    fireEvent.change(passwordInput, { target: { value: password } });
+    fireEvent.click(loginButton);
+
+    await waitFor(() => {
+      expect(screen.getByText('Two-factor authentication')).toBeInTheDocument();
+    });
+
+    const totpInputs = screen.getAllByRole('textbox', { name: /digit/ });
+
+    totpInputs.forEach((input, index) => {
+      fireEvent.change(input, { target: { value: index } });
+    });
+
+    const totpSubmitButton = screen.getByRole('button', { name: 'Confirm' });
+    fireEvent.click(totpSubmitButton);
+
+    // assert
+    await waitFor(() => {
+      expect(screen.getByText(/Invalid totp code/)).toBeInTheDocument();
+    });
+  });
+
+  it('should add token in localStorage if totp code is valid', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const password = faker.internet.password();
+    const totpSessionId = faker.datatype.uuid();
+    const token = faker.datatype.uuid();
+    server.use(getTRPCMock({ path: ['auth', 'login'], type: 'mutation', response: { totpSessionId } }));
+    server.use(getTRPCMock({ path: ['auth', 'verifyTotp'], type: 'mutation', response: { token } }));
+    render(<LoginContainer />);
+
+    // act
+    const loginButton = screen.getByRole('button', { name: 'Login' });
+    const emailInput = screen.getByRole('textbox', { name: 'email' });
+    const passwordInput = screen.getByRole('textbox', { name: 'password' });
+
+    fireEvent.change(emailInput, { target: { value: email } });
+    fireEvent.change(passwordInput, { target: { value: password } });
+    fireEvent.click(loginButton);
+
+    await waitFor(() => {
+      expect(screen.getByText('Two-factor authentication')).toBeInTheDocument();
+    });
+
+    const totpInputs = screen.getAllByRole('textbox', { name: /digit/ });
+
+    totpInputs.forEach((input, index) => {
+      fireEvent.change(input, { target: { value: index } });
+    });
+
+    const totpSubmitButton = screen.getByRole('button', { name: 'Confirm' });
+    fireEvent.click(totpSubmitButton);
+
+    // assert
+    await waitFor(() => {
+      expect(localStorage.getItem('token')).toEqual(token);
+    });
+  });
 });

+ 25 - 5
src/client/modules/Auth/containers/LoginContainer/LoginContainer.tsx

@@ -1,20 +1,36 @@
 import { useRouter } from 'next/router';
-import React from 'react';
-import { useToastStore } from '../../../../state/toastStore';
+import React, { useState } from 'react';
+import { toast } from 'react-hot-toast';
 import { trpc } from '../../../../utils/trpc';
 import { AuthFormLayout } from '../../components/AuthFormLayout';
 import { LoginForm } from '../../components/LoginForm';
+import { TotpForm } from '../../components/TotpForm';
 
 type FormValues = { email: string; password: string };
 
 export const LoginContainer: React.FC = () => {
+  const [totpSessionId, setTotpSessionId] = useState<string | null>(null);
   const router = useRouter();
-  const { addToast } = useToastStore();
   const utils = trpc.useContext();
   const login = trpc.auth.login.useMutation({
     onError: (e) => {
       localStorage.removeItem('token');
-      addToast({ title: 'Login error', description: e.message, status: 'error' });
+      toast.error(`Login failed: ${e.message}`);
+    },
+    onSuccess: (data) => {
+      if (data.totpSessionId) {
+        setTotpSessionId(data.totpSessionId);
+      } else if (data.token) {
+        localStorage.setItem('token', data.token);
+        utils.auth.me.invalidate();
+        router.push('/');
+      }
+    },
+  });
+
+  const verifyTotp = trpc.auth.verifyTotp.useMutation({
+    onError: (e) => {
+      toast.error(`Verification failed: ${e.message}`);
     },
     onSuccess: (data) => {
       localStorage.setItem('token', data.token);
@@ -29,7 +45,11 @@ export const LoginContainer: React.FC = () => {
 
   return (
     <AuthFormLayout>
-      <LoginForm onSubmit={handlerSubmit} loading={login.isLoading} />
+      {totpSessionId ? (
+        <TotpForm onSubmit={(o) => verifyTotp.mutate({ totpCode: o, totpSessionId })} loading={verifyTotp.isLoading} />
+      ) : (
+        <LoginForm onSubmit={handlerSubmit} loading={login.isLoading} />
+      )}
     </AuthFormLayout>
   );
 };

+ 2 - 6
src/client/modules/Auth/containers/RegisterContainer/RegisterContainer.test.tsx

@@ -1,9 +1,8 @@
 import { faker } from '@faker-js/faker';
 import React from 'react';
-import { fireEvent, render, renderHook, screen, waitFor } from '../../../../../../tests/test-utils';
+import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
 import { getTRPCMock, getTRPCMockError } from '../../../../mocks/getTrpcMock';
 import { server } from '../../../../mocks/server';
-import { useToastStore } from '../../../../state/toastStore';
 import { RegisterContainer } from './RegisterContainer';
 
 describe('Test: RegisterContainer', () => {
@@ -42,7 +41,6 @@ describe('Test: RegisterContainer', () => {
     const email = faker.internet.email();
     const password = faker.internet.password();
 
-    const { result } = renderHook(() => useToastStore());
     server.use(getTRPCMockError({ path: ['auth', 'register'], type: 'mutation', status: 500, message: 'my big error' }));
     render(<RegisterContainer />);
 
@@ -59,9 +57,7 @@ describe('Test: RegisterContainer', () => {
 
     // Assert
     await waitFor(() => {
-      expect(result.current.toasts).toHaveLength(1);
-      expect(result.current.toasts[0].description).toEqual('my big error');
-      expect(result.current.toasts[0].status).toEqual('error');
+      expect(screen.getByText('Registration failed: my big error')).toBeInTheDocument();
     });
   });
 });

+ 2 - 3
src/client/modules/Auth/containers/RegisterContainer/RegisterContainer.tsx

@@ -1,6 +1,6 @@
 import { useRouter } from 'next/router';
 import React from 'react';
-import { useToastStore } from '../../../../state/toastStore';
+import { toast } from 'react-hot-toast';
 import { trpc } from '../../../../utils/trpc';
 import { AuthFormLayout } from '../../components/AuthFormLayout';
 import { RegisterForm } from '../../components/RegisterForm';
@@ -8,13 +8,12 @@ import { RegisterForm } from '../../components/RegisterForm';
 type FormValues = { email: string; password: string };
 
 export const RegisterContainer: React.FC = () => {
-  const { addToast } = useToastStore();
   const router = useRouter();
   const utils = trpc.useContext();
   const register = trpc.auth.register.useMutation({
     onError: (e) => {
       localStorage.removeItem('token');
-      addToast({ title: 'Register error', description: e.message, status: 'error' });
+      toast.error(`Registration failed: ${e.message}`);
     },
     onSuccess: (data) => {
       localStorage.setItem('token', data.token);

+ 6 - 13
src/client/modules/Auth/containers/ResetPasswordContainer/ResetPasswordContainer.test.tsx

@@ -1,8 +1,7 @@
 import React from 'react';
-import { fireEvent, render, screen, waitFor, renderHook } from '../../../../../../tests/test-utils';
+import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
 import { getTRPCMock, getTRPCMockError } from '../../../../mocks/getTrpcMock';
 import { server } from '../../../../mocks/server';
-import { useToastStore } from '../../../../state/toastStore';
 import { ResetPasswordContainer } from './ResetPasswordContainer';
 
 const pushFn = jest.fn();
@@ -35,7 +34,7 @@ describe('ResetPasswordContainer', () => {
 
     const newPassword = 'new_password';
     const response = { email };
-    server.use(getTRPCMock({ path: ['auth', 'resetPassword'], type: 'mutation', response, delay: 100 }));
+    server.use(getTRPCMock({ path: ['auth', 'changeOperatorPassword'], type: 'mutation', response, delay: 100 }));
 
     const passwordInput = screen.getByLabelText('Password');
     const confirmPasswordInput = screen.getByLabelText('Confirm password');
@@ -55,14 +54,13 @@ describe('ResetPasswordContainer', () => {
 
   it('should show error toast if reset password mutation fails', async () => {
     // Arrange
-    const { result, unmount } = renderHook(() => useToastStore());
     render(<ResetPasswordContainer isRequested />);
     const resetPasswordForm = screen.getByRole('button', { name: 'Reset password' });
     fireEvent.click(resetPasswordForm);
 
     const newPassword = 'new_password';
     const error = { message: 'Something went wrong' };
-    server.use(getTRPCMockError({ path: ['auth', 'resetPassword'], type: 'mutation', message: error.message }));
+    server.use(getTRPCMockError({ path: ['auth', 'changeOperatorPassword'], type: 'mutation', message: error.message }));
 
     const passwordInput = screen.getByLabelText('Password');
     const confirmPasswordInput = screen.getByLabelText('Confirm password');
@@ -74,15 +72,12 @@ describe('ResetPasswordContainer', () => {
 
     // Assert
     await waitFor(() => {
-      expect(result.current.toasts[0].description).toBe(error.message);
+      expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
     });
-
-    unmount();
   });
 
   it('should call the cancel request mutation when cancel button is clicked', async () => {
     // Arrange
-    const { result, unmount } = renderHook(() => useToastStore());
     render(<ResetPasswordContainer isRequested />);
     server.use(getTRPCMock({ path: ['auth', 'cancelPasswordChangeRequest'], type: 'mutation', response: true }));
 
@@ -93,16 +88,14 @@ describe('ResetPasswordContainer', () => {
 
     // Assert
     await waitFor(() => {
-      expect(result.current.toasts[0].title).toBe('Password change request cancelled');
+      expect(screen.getByText('Password change request cancelled')).toBeInTheDocument();
     });
-
-    unmount();
   });
 
   it('should redirect to login page when Back to login button is clicked', async () => {
     // Arrange
     render(<ResetPasswordContainer isRequested />);
-    server.use(getTRPCMock({ path: ['auth', 'resetPassword'], type: 'mutation', response: { email: 'goofy@test.com' } }));
+    server.use(getTRPCMock({ path: ['auth', 'changeOperatorPassword'], type: 'mutation', response: { email: 'goofy@test.com' } }));
     const resetPasswordForm = screen.getByRole('button', { name: 'Reset password' });
     const passwordInput = screen.getByLabelText('Password');
     const confirmPasswordInput = screen.getByLabelText('Confirm password');

+ 4 - 5
src/client/modules/Auth/containers/ResetPasswordContainer/ResetPasswordContainer.tsx

@@ -1,7 +1,7 @@
 import { useRouter } from 'next/router';
 import React from 'react';
+import { toast } from 'react-hot-toast';
 import { Button } from '../../../../components/ui/Button';
-import { useToastStore } from '../../../../state/toastStore';
 import { trpc } from '../../../../utils/trpc';
 import { AuthFormLayout } from '../../components/AuthFormLayout';
 import { ResetPasswordForm } from '../../components/ResetPasswordForm';
@@ -13,21 +13,20 @@ type Props = {
 type FormValues = { password: string };
 
 export const ResetPasswordContainer: React.FC<Props> = ({ isRequested }) => {
-  const { addToast } = useToastStore();
   const router = useRouter();
   const utils = trpc.useContext();
-  const resetPassword = trpc.auth.resetPassword.useMutation({
+  const resetPassword = trpc.auth.changeOperatorPassword.useMutation({
     onSuccess: () => {
       utils.auth.checkPasswordChangeRequest.invalidate();
     },
     onError: (error) => {
-      addToast({ title: 'Reset password error', description: error.message, status: 'error' });
+      toast.error(`Failed to reset password ${error.message}`);
     },
   });
   const cancelRequest = trpc.auth.cancelPasswordChangeRequest.useMutation({
     onSuccess: () => {
       utils.auth.checkPasswordChangeRequest.invalidate();
-      addToast({ title: 'Password change request cancelled', status: 'success' });
+      toast.success('Password change request cancelled');
     },
   });
 

+ 72 - 0
src/client/modules/Settings/components/ChangePasswordForm/ChangePasswordForm.test.tsx

@@ -0,0 +1,72 @@
+import React from 'react';
+import { server } from '@/client/mocks/server';
+import { getTRPCMock, getTRPCMockError } from '@/client/mocks/getTrpcMock';
+import { faker } from '@faker-js/faker';
+import { render, screen, waitFor, fireEvent } from '../../../../../../tests/test-utils';
+import { ChangePasswordForm } from './ChangePasswordForm';
+
+describe('<ChangePasswordForm />', () => {
+  it('should show success toast upon password change', async () => {
+    // arrange
+    server.use(getTRPCMock({ path: ['auth', 'changePassword'], type: 'mutation', response: true }));
+    render(<ChangePasswordForm />);
+    const currentPasswordInput = screen.getByRole('textbox', { name: 'currentPassword' });
+    const newPasswordInput = screen.getByRole('textbox', { name: 'newPassword' });
+    const confirmPasswordInput = screen.getByRole('textbox', { name: 'newPasswordConfirm' });
+    const newPassword = faker.random.alphaNumeric(8);
+
+    // act
+    fireEvent.change(currentPasswordInput, { target: { value: 'test' } });
+    fireEvent.change(newPasswordInput, { target: { value: newPassword } });
+    fireEvent.change(confirmPasswordInput, { target: { value: newPassword } });
+    const submitButton = screen.getByRole('button', { name: /Change password/i });
+    submitButton.click();
+
+    // assert
+    await waitFor(() => {
+      expect(screen.getByText('Password successfully changed')).toBeInTheDocument();
+    });
+  });
+
+  it('should show error toast if change password failed', async () => {
+    // arrange
+    server.use(getTRPCMockError({ path: ['auth', 'changePassword'], type: 'mutation', message: 'Invalid password' }));
+    render(<ChangePasswordForm />);
+    const currentPasswordInput = screen.getByRole('textbox', { name: 'currentPassword' });
+    const newPasswordInput = screen.getByRole('textbox', { name: 'newPassword' });
+    const confirmPasswordInput = screen.getByRole('textbox', { name: 'newPasswordConfirm' });
+    const newPassword = faker.random.alphaNumeric(8);
+
+    // act
+    fireEvent.change(currentPasswordInput, { target: { value: faker.random.alphaNumeric(8) } });
+    fireEvent.change(newPasswordInput, { target: { value: newPassword } });
+    fireEvent.change(confirmPasswordInput, { target: { value: newPassword } });
+    const submitButton = screen.getByRole('button', { name: /Change password/i });
+    submitButton.click();
+
+    // assert
+    await waitFor(() => {
+      expect(screen.getByText(/Invalid password/)).toBeInTheDocument();
+    });
+  });
+
+  it('should show error in the form if passwords do not match', async () => {
+    // arrange
+    render(<ChangePasswordForm />);
+    const currentPasswordInput = screen.getByRole('textbox', { name: 'currentPassword' });
+    const newPasswordInput = screen.getByRole('textbox', { name: 'newPassword' });
+    const confirmPasswordInput = screen.getByRole('textbox', { name: 'newPasswordConfirm' });
+
+    // act
+    fireEvent.change(currentPasswordInput, { target: { value: 'test' } });
+    fireEvent.change(newPasswordInput, { target: { value: faker.random.alphaNumeric(8) } });
+    fireEvent.change(confirmPasswordInput, { target: { value: faker.random.alphaNumeric(8) } });
+    const submitButton = screen.getByRole('button', { name: /Change password/i });
+    submitButton.click();
+
+    // assert
+    await waitFor(() => {
+      expect(screen.getByText(/Passwords do not match/i)).toBeInTheDocument();
+    });
+  });
+});

+ 63 - 0
src/client/modules/Settings/components/ChangePasswordForm/ChangePasswordForm.tsx

@@ -0,0 +1,63 @@
+import React from 'react';
+import { Input } from '@/components/ui/Input';
+import { Button } from '@/components/ui/Button';
+import { trpc } from '@/utils/trpc';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useRouter } from 'next/router';
+import { toast } from 'react-hot-toast';
+
+const schema = z
+  .object({
+    currentPassword: z.string().min(1),
+    newPassword: z.string().min(8, 'Password must be at least 8 characters'),
+    newPasswordConfirm: z.string().min(8, 'Password must be at least 8 characters'),
+  })
+  .superRefine((data, ctx) => {
+    if (data.newPassword !== data.newPasswordConfirm) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        message: 'Passwords do not match',
+        path: ['newPasswordConfirm'],
+      });
+    }
+  });
+
+type FormValues = z.infer<typeof schema>;
+
+export const ChangePasswordForm = () => {
+  const router = useRouter();
+  const changePassword = trpc.auth.changePassword.useMutation({
+    onError: (e) => {
+      toast.error(`Error changing password: ${e.message}`);
+    },
+    onSuccess: () => {
+      toast.success('Password successfully changed');
+      router.push('/');
+    },
+  });
+
+  const {
+    register,
+    handleSubmit,
+    formState: { errors },
+  } = useForm<FormValues>({
+    resolver: zodResolver(schema),
+  });
+
+  const onSubmit = (values: FormValues) => {
+    changePassword.mutate(values);
+  };
+
+  return (
+    <form onSubmit={handleSubmit(onSubmit)} className="mb-4 w-100 ">
+      <Input disabled={changePassword.isLoading} {...register('currentPassword')} error={errors.currentPassword?.message} type="password" placeholder="Current password" />
+      <Input disabled={changePassword.isLoading} {...register('newPassword')} error={errors.newPassword?.message} className="mt-2" type="password" placeholder="New password" />
+      <Input disabled={changePassword.isLoading} {...register('newPasswordConfirm')} error={errors.newPasswordConfirm?.message} className="mt-2" type="password" placeholder="Confirm new password" />
+      <Button disabled={changePassword.isLoading} className="mt-3" type="submit">
+        Change password
+      </Button>
+    </form>
+  );
+};

+ 1 - 0
src/client/modules/Settings/components/ChangePasswordForm/index.ts

@@ -0,0 +1 @@
+export { ChangePasswordForm } from './ChangePasswordForm';

+ 285 - 0
src/client/modules/Settings/components/OtpForm/OptForm.test.tsx

@@ -0,0 +1,285 @@
+import React from 'react';
+import { server } from '@/client/mocks/server';
+import { getTRPCMock, getTRPCMockError } from '@/client/mocks/getTrpcMock';
+import { render, screen, waitFor, fireEvent } from '../../../../../../tests/test-utils';
+import { OtpForm } from './OtpForm';
+
+describe('<OtpForm />', () => {
+  it('should render', () => {
+    render(<OtpForm />);
+  });
+
+  it('should prompt for password when enabling 2FA', async () => {
+    // arrange
+    render(<OtpForm />);
+    const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
+    await waitFor(() => {
+      expect(twoFactorAuthButton).toBeEnabled();
+    });
+
+    // act
+    twoFactorAuthButton.click();
+
+    // assert
+    await waitFor(() => {
+      expect(screen.getByText('Password needed')).toBeInTheDocument();
+    });
+  });
+
+  it('should prompt for password when disabling 2FA', async () => {
+    // arrange
+    server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: true, id: 12, username: 'test' } }));
+    render(<OtpForm />);
+    const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
+    await waitFor(() => {
+      expect(twoFactorAuthButton).toBeEnabled();
+    });
+
+    // act
+    twoFactorAuthButton.click();
+
+    // assert
+    await waitFor(() => {
+      expect(screen.getByText('Password needed')).toBeInTheDocument();
+    });
+  });
+
+  it('should show show error toast if password is incorrect while enabling 2FA', async () => {
+    // arrange
+    server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: false, id: 12, username: 'test' } }));
+    server.use(getTRPCMockError({ path: ['auth', 'getTotpUri'], type: 'mutation', message: 'Invalid password' }));
+    render(<OtpForm />);
+    const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
+    await waitFor(() => {
+      expect(twoFactorAuthButton).toBeEnabled();
+    });
+
+    // act
+    twoFactorAuthButton.click();
+
+    await waitFor(() => {
+      expect(screen.getByText('Password needed')).toBeInTheDocument();
+    });
+
+    const passwordInput = screen.getByRole('textbox', { name: 'password' });
+    fireEvent.change(passwordInput, { target: { value: 'test' } });
+    const submitButton = screen.getByRole('button', { name: /Enable 2FA/i });
+    submitButton.click();
+
+    // assert
+    await waitFor(() => {
+      expect(screen.getByText(/Invalid password/)).toBeInTheDocument();
+    });
+  });
+
+  it('should show show error toast if password is incorrect while disabling 2FA', async () => {
+    // arrange
+    server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: true, id: 12, username: 'test' } }));
+    server.use(getTRPCMockError({ path: ['auth', 'disableTotp'], type: 'mutation', message: 'Invalid password' }));
+    render(<OtpForm />);
+
+    const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
+    await waitFor(() => {
+      expect(twoFactorAuthButton).toBeEnabled();
+    });
+
+    // act
+    twoFactorAuthButton.click();
+
+    await waitFor(() => {
+      expect(screen.getByText('Password needed')).toBeInTheDocument();
+    });
+
+    const passwordInput = screen.getByRole('textbox', { name: 'password' });
+    fireEvent.change(passwordInput, { target: { value: 'test' } });
+    const submitButton = screen.getByRole('button', { name: /Disable 2FA/i });
+    submitButton.click();
+
+    // assert
+    await waitFor(() => {
+      expect(screen.getByText(/Invalid password/)).toBeInTheDocument();
+    });
+  });
+
+  it('should show success toast if password is correct while disabling 2FA', async () => {
+    // arrange
+    server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: true, id: 12, username: 'test' } }));
+    server.use(getTRPCMock({ path: ['auth', 'disableTotp'], type: 'mutation', response: true }));
+
+    render(<OtpForm />);
+
+    const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
+    await waitFor(() => {
+      expect(twoFactorAuthButton).toBeEnabled();
+    });
+
+    // act
+    twoFactorAuthButton.click();
+
+    await waitFor(() => {
+      expect(screen.getByText('Password needed')).toBeInTheDocument();
+    });
+
+    const passwordInput = screen.getByRole('textbox', { name: 'password' });
+    fireEvent.change(passwordInput, { target: { value: 'test' } });
+    const submitButton = screen.getByRole('button', { name: /Disable 2FA/i });
+    submitButton.click();
+
+    // assert
+    await waitFor(() => {
+      expect(screen.getByText('Two-factor authentication disabled')).toBeInTheDocument();
+    });
+  });
+
+  it('should show secret key and QR code when enabling 2FA', async () => {
+    // arrange
+    server.use(getTRPCMock({ path: ['auth', 'getTotpUri'], type: 'mutation', response: { key: 'test', uri: 'test' } }));
+    render(<OtpForm />);
+    const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
+    await waitFor(() => {
+      expect(twoFactorAuthButton).toBeEnabled();
+    });
+
+    // act
+    twoFactorAuthButton.click();
+    await waitFor(() => {
+      expect(screen.getByText('Password needed')).toBeInTheDocument();
+    });
+    const passwordInput = screen.getByRole('textbox', { name: 'password' });
+    fireEvent.change(passwordInput, { target: { value: 'test' } });
+    const submitButton = screen.getByRole('button', { name: /Enable 2FA/i });
+    submitButton.click();
+
+    // assert
+    await waitFor(() => {
+      expect(screen.getByText('Scan this QR code with your authenticator app.')).toBeInTheDocument();
+      expect(screen.getByRole('textbox', { name: 'secret key' })).toHaveValue('test');
+      expect(screen.getByRole('button', { name: 'Enable 2FA' })).toBeDisabled();
+    });
+  });
+
+  it('should show error toast if submitted totp code is invalid', async () => {
+    // arrange
+    server.use(getTRPCMock({ path: ['auth', 'getTotpUri'], type: 'mutation', response: { key: 'test', uri: 'test' } }));
+    server.use(getTRPCMockError({ path: ['auth', 'setupTotp'], type: 'mutation', message: 'Invalid code' }));
+
+    render(<OtpForm />);
+
+    const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
+    await waitFor(() => {
+      expect(twoFactorAuthButton).toBeEnabled();
+    });
+
+    // act
+    twoFactorAuthButton.click();
+    await waitFor(() => {
+      expect(screen.getByText('Password needed')).toBeInTheDocument();
+    });
+    const passwordInput = screen.getByRole('textbox', { name: 'password' });
+    fireEvent.change(passwordInput, { target: { value: 'test' } });
+    const submitButton = screen.getByRole('button', { name: /Enable 2FA/i });
+    submitButton.click();
+
+    await waitFor(() => {
+      expect(screen.getByText('Scan this QR code with your authenticator app.')).toBeInTheDocument();
+    });
+
+    const inputEls = screen.getAllByRole('textbox', { name: /digit-/ });
+
+    inputEls.forEach((inputEl) => {
+      fireEvent.change(inputEl, { target: { value: '1' } });
+    });
+
+    const enable2FAButton = screen.getByRole('button', { name: 'Enable 2FA' });
+    enable2FAButton.click();
+
+    // assert
+    await waitFor(() => {
+      expect(screen.getByText(/Invalid code/)).toBeInTheDocument();
+    });
+  });
+
+  it('should show success toast if submitted totp code is valid', async () => {
+    // arrange
+    server.use(getTRPCMock({ path: ['auth', 'getTotpUri'], type: 'mutation', response: { key: 'test', uri: 'test' } }));
+    server.use(getTRPCMock({ path: ['auth', 'setupTotp'], type: 'mutation', response: true }));
+    render(<OtpForm />);
+    const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
+    await waitFor(() => {
+      expect(twoFactorAuthButton).toBeEnabled();
+    });
+
+    // act
+    twoFactorAuthButton.click();
+    await waitFor(() => {
+      expect(screen.getByText('Password needed')).toBeInTheDocument();
+    });
+    const passwordInput = screen.getByRole('textbox', { name: 'password' });
+    fireEvent.change(passwordInput, { target: { value: 'test' } });
+    const submitButton = screen.getByRole('button', { name: /Enable 2FA/i });
+    submitButton.click();
+
+    await waitFor(() => {
+      expect(screen.getByText('Scan this QR code with your authenticator app.')).toBeInTheDocument();
+    });
+
+    const inputEls = screen.getAllByRole('textbox', { name: /digit-/ });
+
+    inputEls.forEach((inputEl) => {
+      fireEvent.change(inputEl, { target: { value: '1' } });
+    });
+
+    const enable2FAButton = screen.getByRole('button', { name: 'Enable 2FA' });
+    enable2FAButton.click();
+
+    // assert
+    await waitFor(() => {
+      expect(screen.getByText('Two-factor authentication enabled')).toBeInTheDocument();
+    });
+  });
+
+  it('can close the setup modal by clicking on the esc key', async () => {
+    // arrange
+    render(<OtpForm />);
+    const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
+    await waitFor(() => {
+      expect(twoFactorAuthButton).toBeEnabled();
+    });
+
+    // act
+    twoFactorAuthButton.click();
+    await waitFor(() => {
+      expect(screen.getByText('Password needed')).toBeInTheDocument();
+    });
+
+    fireEvent.keyDown(document, { key: 'Escape' });
+
+    // assert
+    await waitFor(() => {
+      expect(screen.queryByText('Password needed')).not.toBeInTheDocument();
+    });
+  });
+
+  it('can close the disable modal by clicking on the esc key', async () => {
+    // arrange
+    server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: true, username: '', id: 1 } }));
+    render(<OtpForm />);
+    const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
+    await waitFor(() => {
+      expect(twoFactorAuthButton).toBeEnabled();
+    });
+
+    // act
+    twoFactorAuthButton.click();
+    await waitFor(() => {
+      expect(screen.getByText('Password needed')).toBeInTheDocument();
+    });
+
+    fireEvent.keyDown(document, { key: 'Escape' });
+
+    // assert
+    await waitFor(() => {
+      expect(screen.queryByText('Password needed')).not.toBeInTheDocument();
+    });
+  });
+});

+ 153 - 0
src/client/modules/Settings/components/OtpForm/OtpForm.tsx

@@ -0,0 +1,153 @@
+import React from 'react';
+import { trpc } from '@/utils/trpc';
+import { Switch } from '@/components/ui/Switch';
+import { Button } from '@/components/ui/Button';
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
+import { Input } from '@/components/ui/Input';
+import { QRCodeSVG } from 'qrcode.react';
+import { OtpInput } from '@/components/ui/OtpInput';
+import { toast } from 'react-hot-toast';
+import { useDisclosure } from '@/client/hooks/useDisclosure';
+
+export const OtpForm = () => {
+  const [password, setPassword] = React.useState('');
+  const [key, setKey] = React.useState('');
+  const [uri, setUri] = React.useState('');
+  const [totpCode, setTotpCode] = React.useState('');
+
+  // Dialog statuses
+  const setupOtpDisclosure = useDisclosure();
+  const disableOtpDisclosure = useDisclosure();
+
+  const ctx = trpc.useContext();
+  const me = trpc.auth.me.useQuery();
+
+  const getTotpUri = trpc.auth.getTotpUri.useMutation({
+    onMutate: () => {
+      setupOtpDisclosure.close();
+    },
+    onError: (e) => {
+      setPassword('');
+      toast.error(`Error getting TOTP URI: ${e.message}`);
+    },
+    onSuccess: (data) => {
+      setKey(data.key);
+      setUri(data.uri);
+    },
+  });
+
+  const setupTotp = trpc.auth.setupTotp.useMutation({
+    onMutate: () => {},
+    onError: (e) => {
+      setTotpCode('');
+      toast.error(`Error setting up TOTP: ${e.message}`);
+    },
+    onSuccess: () => {
+      setTotpCode('');
+      setKey('');
+      setUri('');
+      toast.success('Two-factor authentication enabled');
+      ctx.auth.me.invalidate();
+    },
+  });
+
+  const disableTotp = trpc.auth.disableTotp.useMutation({
+    onMutate: () => {
+      disableOtpDisclosure.close();
+    },
+    onError: (e) => {
+      setPassword('');
+      toast.error(`Error disabling TOTP: ${e.message}`);
+    },
+    onSuccess: () => {
+      toast.success('Two-factor authentication disabled');
+      ctx.auth.me.invalidate();
+    },
+  });
+
+  const renderSetupQr = () => {
+    if (!uri || me.data?.totp_enabled) return null;
+
+    return (
+      <div className="mt-4">
+        <div className="mb-4">
+          <p className="text-muted">Scan this QR code with your authenticator app.</p>
+          <QRCodeSVG value={uri} />
+        </div>
+        <div className="mb-4">
+          <p className="text-muted">Or enter this key manually.</p>
+          <Input name="secret key" value={key} readOnly />
+        </div>
+        <div className="mb-4">
+          <p className="text-muted">Enter the code from your authenticator app.</p>
+          <OtpInput value={totpCode} valueLength={6} onChange={(e) => setTotpCode(e)} />
+          <Button disabled={totpCode.trim().length < 6} onClick={() => setupTotp.mutate({ totpCode })} className="mt-3 btn-success">
+            Enable 2FA
+          </Button>
+        </div>
+      </div>
+    );
+  };
+
+  const handleTotp = (enabled: boolean) => {
+    if (enabled) {
+      setupOtpDisclosure.open();
+    } else {
+      disableOtpDisclosure.open();
+    }
+  };
+
+  return (
+    <>
+      {!key && <Switch disabled={!me.isSuccess} onCheckedChange={handleTotp} checked={me.data?.totp_enabled} label="Enable two-factor authentication" />}
+      {getTotpUri.isLoading && (
+        <div className="progress w-50">
+          <div className="progress-bar progress-bar-indeterminate bg-green" />
+        </div>
+      )}
+      {renderSetupQr()}
+      <Dialog open={setupOtpDisclosure.isOpen} onOpenChange={(o: boolean) => setupOtpDisclosure.toggle(o)}>
+        <DialogContent size="sm">
+          <DialogHeader>
+            <DialogTitle>Password needed</DialogTitle>
+          </DialogHeader>
+          <DialogDescription className="d-flex flex-column">
+            <form
+              onSubmit={(e) => {
+                e.preventDefault();
+                getTotpUri.mutate({ password });
+              }}
+            >
+              <p className="text-muted">Your password is required to setup two-factor authentication.</p>
+              <Input name="password" type="password" onChange={(e) => setPassword(e.target.value)} placeholder="Password" />
+              <Button loading={getTotpUri.isLoading} type="submit" className="btn-success mt-3">
+                Enable 2FA
+              </Button>
+            </form>
+          </DialogDescription>
+        </DialogContent>
+      </Dialog>
+      <Dialog open={disableOtpDisclosure.isOpen} onOpenChange={(o: boolean) => disableOtpDisclosure.toggle(o)}>
+        <DialogContent size="sm">
+          <DialogHeader>
+            <DialogTitle>Password needed</DialogTitle>
+          </DialogHeader>
+          <DialogDescription className="d-flex flex-column">
+            <form
+              onSubmit={(e) => {
+                e.preventDefault();
+                disableTotp.mutate({ password });
+              }}
+            >
+              <p className="text-muted">Your password is required to disable two-factor authentication.</p>
+              <Input name="password" type="password" onChange={(e) => setPassword(e.target.value)} placeholder="Password" />
+              <Button loading={disableTotp.isLoading} type="submit" className="btn-danger mt-3">
+                Disable 2FA
+              </Button>
+            </form>
+          </DialogDescription>
+        </DialogContent>
+      </Dialog>
+    </>
+  );
+};

+ 1 - 0
src/client/modules/Settings/components/OtpForm/index.ts

@@ -0,0 +1 @@
+export { OtpForm } from './OtpForm';

+ 16 - 14
src/client/modules/Settings/components/RestartModal/RestartModal.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
 import { Button } from '../../../../components/ui/Button';
-import { Modal, ModalBody, ModalFooter, ModalHeader } from '../../../../components/ui/Modal';
 
 interface IProps {
   isOpen: boolean;
@@ -10,17 +10,19 @@ interface IProps {
 }
 
 export const RestartModal: React.FC<IProps> = ({ isOpen, onClose, onConfirm, loading }) => (
-  <Modal size="sm" onClose={onClose} isOpen={isOpen}>
-    <ModalHeader>
-      <h5 className="modal-title">Restart Tipi</h5>
-    </ModalHeader>
-    <ModalBody>
-      <div className="text-muted">Would you like to restart your Tipi server?</div>
-    </ModalBody>
-    <ModalFooter>
-      <Button data-testid="settings-modal-restart-button" onClick={onConfirm} className="btn-danger" loading={loading}>
-        Restart
-      </Button>
-    </ModalFooter>
-  </Modal>
+  <Dialog open={isOpen} onOpenChange={onClose}>
+    <DialogContent type="danger" size="sm">
+      <DialogHeader>
+        <h5 className="modal-title">Restart Tipi</h5>
+      </DialogHeader>
+      <DialogDescription>
+        <div className="text-muted">Would you like to restart your Tipi server?</div>
+      </DialogDescription>
+      <DialogFooter>
+        <Button onClick={onConfirm} className="btn-danger" loading={loading}>
+          Restart
+        </Button>
+      </DialogFooter>
+    </DialogContent>
+  </Dialog>
 );

+ 1 - 1
src/client/modules/Settings/components/SettingsForm/SettingsForm.tsx

@@ -83,7 +83,7 @@ export const SettingsForm = (props: IProps) => {
   };
 
   return (
-    <form data-testid="settings-form" className="flex flex-col" onSubmit={handleSubmit(validate)}>
+    <form className="flex flex-col" onSubmit={handleSubmit(validate)}>
       <h2 className="text-2xl font-bold">General settings</h2>
       <p className="mb-4">This will update your settings.json file. Make sure you know what you are doing before updating these values.</p>
       <div className="mb-3">

+ 16 - 14
src/client/modules/Settings/components/UpdateModal/UpdateModal.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import { Button } from '../../../../components/ui/Button';
-import { Modal, ModalBody, ModalFooter, ModalHeader } from '../../../../components/ui/Modal';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '../../../../components/ui/Dialog';
 
 interface IProps {
   isOpen: boolean;
@@ -10,17 +10,19 @@ interface IProps {
 }
 
 export const UpdateModal: React.FC<IProps> = ({ isOpen, onClose, onConfirm, loading }) => (
-  <Modal size="sm" onClose={onClose} isOpen={isOpen}>
-    <ModalHeader>
-      <h5 className="modal-title">Update Tipi</h5>
-    </ModalHeader>
-    <ModalBody>
-      <div className="text-muted">Would you like to update Tipi to the latest version?</div>
-    </ModalBody>
-    <ModalFooter>
-      <Button onClick={onConfirm} className="btn-success" loading={loading}>
-        Update
-      </Button>
-    </ModalFooter>
-  </Modal>
+  <Dialog open={isOpen} onOpenChange={onClose}>
+    <DialogContent size="sm">
+      <DialogHeader>
+        <h5 className="modal-title">Update Tipi</h5>
+      </DialogHeader>
+      <DialogDescription>
+        <div className="text-muted">Would you like to update Tipi to the latest version?</div>
+      </DialogDescription>
+      <DialogFooter>
+        <Button onClick={onConfirm} className="btn-success" loading={loading}>
+          Update
+        </Button>
+      </DialogFooter>
+    </DialogContent>
+  </Dialog>
 );

+ 21 - 24
src/client/modules/Settings/containers/GeneralActions/GeneralActions.test.tsx

@@ -1,53 +1,52 @@
 import React from 'react';
-import { useToastStore } from '@/client/state/toastStore';
 import { getTRPCMock, getTRPCMockError } from '@/client/mocks/getTrpcMock';
 import { server } from '@/client/mocks/server';
 import { GeneralActions } from './GeneralActions';
-import { fireEvent, render, renderHook, screen, waitFor } from '../../../../../../tests/test-utils';
+import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
 
 describe('Test: GeneralActions', () => {
   it('should render without error', () => {
     render(<GeneralActions />);
 
-    expect(screen.getByText('Update')).toBeInTheDocument();
+    expect(screen.getByText('Actions')).toBeInTheDocument();
   });
 
   it('should show toast if update mutation fails', async () => {
     // arrange
-    const { result } = renderHook(() => useToastStore());
-    server.use(getTRPCMock({ path: ['system', 'getVersion'], response: { current: '1.0.0', latest: '2.0.0' } }));
+    server.use(getTRPCMock({ path: ['system', 'getVersion'], response: { current: '1.0.0', latest: '2.0.0', body: '' } }));
     server.use(getTRPCMockError({ path: ['system', 'update'], type: 'mutation', status: 500, message: 'Something went wrong' }));
     render(<GeneralActions />);
     await waitFor(() => {
       expect(screen.getByText('Update to 2.0.0')).toBeInTheDocument();
     });
-    const updateButton = screen.getByText('Update');
+    const updateButton = screen.getByRole('button', { name: /Update/i });
+    fireEvent.click(updateButton);
 
     // act
-    fireEvent.click(updateButton);
+    const updateButtonModal = screen.getByRole('button', { name: /Update/i });
+    fireEvent.click(updateButtonModal);
 
     // assert
     await waitFor(() => {
-      expect(result.current.toasts).toHaveLength(1);
-      expect(result.current.toasts[0].status).toEqual('error');
-      expect(result.current.toasts[0].title).toEqual('Error');
-      expect(result.current.toasts[0].description).toEqual('Something went wrong');
+      expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
     });
   });
 
   it('should log user out if update is successful', async () => {
     // arrange
     localStorage.setItem('token', '123');
-    server.use(getTRPCMock({ path: ['system', 'getVersion'], response: { current: '1.0.0', latest: '2.0.0' } }));
+    server.use(getTRPCMock({ path: ['system', 'getVersion'], response: { current: '1.0.0', latest: '2.0.0', body: '' } }));
     server.use(getTRPCMock({ path: ['system', 'update'], response: true }));
     render(<GeneralActions />);
     await waitFor(() => {
-      expect(screen.getByText('Update to 2.0.0')).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: 'Update to 2.0.0' })).toBeInTheDocument();
     });
-    const updateButton = screen.getByText('Update');
+    const updateButton = screen.getByRole('button', { name: /Update to 2.0.0/i });
+    fireEvent.click(updateButton);
 
     // act
-    fireEvent.click(updateButton);
+    const updateButtonModal = screen.getByRole('button', { name: /Update/i });
+    fireEvent.click(updateButtonModal);
 
     // assert
     await waitFor(() => {
@@ -57,22 +56,18 @@ describe('Test: GeneralActions', () => {
 
   it('should show toast if restart mutation fails', async () => {
     // arrange
-    const { result } = renderHook(() => useToastStore());
     server.use(getTRPCMockError({ path: ['system', 'restart'], type: 'mutation', status: 500, message: 'Something went wrong' }));
     render(<GeneralActions />);
-
-    // Find button near the top of the page
-    const restartButton = screen.getByTestId('settings-modal-restart-button');
+    const restartButton = screen.getByRole('button', { name: /Restart/i });
 
     // act
     fireEvent.click(restartButton);
+    const restartButtonModal = screen.getByRole('button', { name: /Restart/i });
+    fireEvent.click(restartButtonModal);
 
     // assert
     await waitFor(() => {
-      expect(result.current.toasts).toHaveLength(1);
-      expect(result.current.toasts[0].status).toEqual('error');
-      expect(result.current.toasts[0].title).toEqual('Error');
-      expect(result.current.toasts[0].description).toEqual('Something went wrong');
+      expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
     });
   });
 
@@ -83,10 +78,12 @@ describe('Test: GeneralActions', () => {
     render(<GeneralActions />);
 
     // Find button near the top of the page
-    const restartButton = screen.getByTestId('settings-modal-restart-button');
+    const restartButton = screen.getByRole('button', { name: /Restart/i });
 
     // act
     fireEvent.click(restartButton);
+    const restartButtonModal = screen.getByRole('button', { name: /Restart/i });
+    fireEvent.click(restartButtonModal);
 
     // assert
     await waitFor(() => {

+ 23 - 9
src/client/modules/Settings/containers/GeneralActions/GeneralActions.tsx

@@ -1,8 +1,10 @@
 import React from 'react';
 import semver from 'semver';
+import { toast } from 'react-hot-toast';
+import Markdown from '@/components/Markdown/Markdown';
+import { IconStar } from '@tabler/icons-react';
 import { Button } from '../../../../components/ui/Button';
 import { useDisclosure } from '../../../../hooks/useDisclosure';
-import { useToastStore } from '../../../../state/toastStore';
 import { RestartModal } from '../../components/RestartModal';
 import { UpdateModal } from '../../components/UpdateModal/UpdateModal';
 import { trpc } from '../../../../utils/trpc';
@@ -12,7 +14,6 @@ export const GeneralActions = () => {
   const versionQuery = trpc.system.getVersion.useQuery(undefined, { staleTime: 0 });
 
   const [loading, setLoading] = React.useState(false);
-  const { addToast } = useToastStore();
   const { setPollStatus } = useSystemStore();
   const restartDisclosure = useDisclosure();
   const updateDisclosure = useDisclosure();
@@ -30,7 +31,7 @@ export const GeneralActions = () => {
     },
     onError: (error) => {
       updateDisclosure.close();
-      addToast({ title: 'Error', description: error.message, status: 'error' });
+      toast.error(`Error updating instance: ${error.message}`);
     },
     onSettled: () => {
       setLoading(false);
@@ -47,7 +48,7 @@ export const GeneralActions = () => {
     },
     onError: (error) => {
       restartDisclosure.close();
-      addToast({ title: 'Error', description: error.message, status: 'error' });
+      toast.error(`Error restarting instance: ${error.message}`);
     },
     onSettled: () => {
       setLoading(false);
@@ -61,18 +62,31 @@ export const GeneralActions = () => {
 
     return (
       <div>
-        <Button onClick={updateDisclosure.open} className="mr-2 btn-success">
+        {versionQuery.data?.body && (
+          <div className="mt-3 card col-4">
+            <div className="card-stamp">
+              <div className="card-stamp-icon bg-yellow">
+                <IconStar size={80} />
+              </div>
+            </div>
+            <div className="card-body">
+              <Markdown className="">{versionQuery.data.body}</Markdown>
+            </div>
+          </div>
+        )}
+        <Button onClick={updateDisclosure.open} className="mt-3 mr-2 btn-success">
           Update to {versionQuery.data?.latest}
         </Button>
       </div>
     );
   };
+
   return (
-    <div className="col d-flex flex-column">
+    <>
       <div className="card-body">
         <h2 className="mb-4">Actions</h2>
-        <h3 className="card-title mt-4">Version {versionQuery.data?.current}</h3>
-        <p className="card-subtitle">Stay up to date with the latest version of Tipi</p>
+        <h3 className="card-title mt-4">Current version: {versionQuery.data?.current}</h3>
+        <p className="card-subtitle">{isLatest ? 'Stay up to date with the latest version of Tipi' : `A new version (${versionQuery.data?.latest}) of Tipi is available`}</p>
         {renderUpdate()}
         <h3 className="card-title mt-4">Maintenance</h3>
         <p className="card-subtitle">Common actions to perform on your instance</p>
@@ -82,6 +96,6 @@ export const GeneralActions = () => {
       </div>
       <RestartModal isOpen={restartDisclosure.isOpen} onClose={restartDisclosure.close} onConfirm={() => restart.mutate()} loading={loading} />
       <UpdateModal isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.close} onConfirm={() => update.mutate()} loading={loading} />
-    </div>
+    </>
   );
 };

+ 9 - 0
src/client/modules/Settings/containers/SecurityContainer/SecurityContainer.test.tsx

@@ -0,0 +1,9 @@
+import React from 'react';
+import { render } from '../../../../../../tests/test-utils';
+import { SecurityContainer } from './SecurityContainer';
+
+describe('<SecurityContainer />', () => {
+  it('should render', () => {
+    render(<SecurityContainer />);
+  });
+});

+ 27 - 0
src/client/modules/Settings/containers/SecurityContainer/SecurityContainer.tsx

@@ -0,0 +1,27 @@
+import React from 'react';
+import { IconLock, IconKey } from '@tabler/icons-react';
+import { OtpForm } from '../../components/OtpForm';
+import { ChangePasswordForm } from '../../components/ChangePasswordForm';
+
+export const SecurityContainer = () => {
+  return (
+    <div className="card-body">
+      <div className="d-flex">
+        <IconKey className="me-2" />
+        <h2>Change password</h2>
+      </div>
+      <p className="text-muted">Changing your password will log you out of all devices.</p>
+      <ChangePasswordForm />
+      <div className="d-flex">
+        <IconLock className="me-2" />
+        <h2>Two-Factor Authentication</h2>
+      </div>
+      <p className="text-muted">
+        Two-factor authentication (2FA) adds an additional layer of security to your account.
+        <br />
+        When enabled, you will be prompted to enter a code from your authenticator app when you log in.
+      </p>
+      <OtpForm />
+    </div>
+  );
+};

+ 1 - 0
src/client/modules/Settings/containers/SecurityContainer/index.ts

@@ -0,0 +1 @@
+export { SecurityContainer } from './SecurityContainer';

+ 3 - 9
src/client/modules/Settings/containers/SettingsContainer/SettingsContainer.test.tsx

@@ -1,9 +1,8 @@
 import React from 'react';
 import { server } from '@/client/mocks/server';
 import { getTRPCMockError } from '@/client/mocks/getTrpcMock';
-import { useToastStore } from '../../../../state/toastStore';
 import { SettingsContainer } from './SettingsContainer';
-import { fireEvent, render, renderHook, screen, waitFor } from '../../../../../../tests/test-utils';
+import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
 
 describe('Test: SettingsContainer', () => {
   it('should render without error', () => {
@@ -14,7 +13,6 @@ describe('Test: SettingsContainer', () => {
 
   it('should show toast if updateSettings mutation fails', async () => {
     // arrange
-    const { result } = renderHook(() => useToastStore());
     server.use(getTRPCMockError({ path: ['system', 'updateSettings'], type: 'mutation', status: 500, message: 'Something went wrong' }));
     render(<SettingsContainer />);
     const submitButton = screen.getByRole('button', { name: 'Save' });
@@ -28,9 +26,7 @@ describe('Test: SettingsContainer', () => {
 
     // assert
     await waitFor(() => {
-      expect(result.current.toasts).toHaveLength(1);
-      expect(result.current.toasts[0].status).toEqual('error');
-      expect(result.current.toasts[0].title).toEqual('Error saving settings');
+      expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
     });
   });
 
@@ -50,7 +46,6 @@ describe('Test: SettingsContainer', () => {
 
   it('should show toast if updateSettings mutation succeeds', async () => {
     // arrange
-    const { result } = renderHook(() => useToastStore());
     render(<SettingsContainer />);
     const submitButton = screen.getByRole('button', { name: 'Save' });
 
@@ -59,8 +54,7 @@ describe('Test: SettingsContainer', () => {
 
     // assert
     await waitFor(() => {
-      expect(result.current.toasts).toHaveLength(1);
-      expect(result.current.toasts[0].status).toEqual('success');
+      expect(screen.getByText(/Settings updated. Restart your instance to apply new settings./)).toBeInTheDocument();
     });
   });
 });

+ 3 - 4
src/client/modules/Settings/containers/SettingsContainer/SettingsContainer.tsx

@@ -1,22 +1,21 @@
 import React, { useState } from 'react';
 import { trpc } from '@/utils/trpc';
-import { useToastStore } from '../../../../state/toastStore';
+import { toast } from 'react-hot-toast';
 import { SettingsForm, SettingsFormValues } from '../../components/SettingsForm';
 
 export const SettingsContainer = () => {
   const [errors, setErrors] = useState<Record<string, string>>({});
-  const { addToast } = useToastStore();
   const getSettings = trpc.system.getSettings.useQuery();
   const updateSettings = trpc.system.updateSettings.useMutation({
     onSuccess: () => {
-      addToast({ title: 'Settings updated', description: 'Restart your instance for settings to take effect', status: 'success' });
+      toast.success('Settings updated. Restart your instance to apply new settings.');
     },
     onError: (e) => {
       if (e.shape?.data.zodError) {
         setErrors(e.shape.data.zodError);
       }
 
-      addToast({ title: 'Error saving settings', description: e.message, status: 'error' });
+      toast.error(`Error saving settings: ${e.message}`);
     },
   });
 

+ 5 - 0
src/client/modules/Settings/pages/SettingsPage/SettingsPage.tsx

@@ -4,6 +4,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
 import { Layout } from '../../../../components/Layout';
 import { GeneralActions } from '../../containers/GeneralActions';
 import { SettingsContainer } from '../../containers/SettingsContainer';
+import { SecurityContainer } from '../../containers/SecurityContainer';
 
 export const SettingsPage: NextPage = () => {
   return (
@@ -13,6 +14,7 @@ export const SettingsPage: NextPage = () => {
           <TabsList>
             <TabsTrigger value="actions">Actions</TabsTrigger>
             <TabsTrigger value="settings">Settings</TabsTrigger>
+            <TabsTrigger value="security">Security</TabsTrigger>
           </TabsList>
           <TabsContent value="actions">
             <GeneralActions />
@@ -20,6 +22,9 @@ export const SettingsPage: NextPage = () => {
           <TabsContent value="settings">
             <SettingsContainer />
           </TabsContent>
+          <TabsContent value="security">
+            <SecurityContainer />
+          </TabsContent>
         </Tabs>
       </div>
     </Layout>

+ 0 - 46
src/client/state/toastStore.ts

@@ -1,46 +0,0 @@
-import { create } from 'zustand';
-
-export type IToast = {
-  id: string;
-  title: string;
-  description?: string;
-  status: 'error' | 'success' | 'warning' | 'info';
-  position?: 'top';
-  isClosable?: true;
-};
-
-type Store = {
-  toasts: IToast[];
-  addToast: (toast: Omit<IToast, 'id'>) => void;
-  removeToast: (id: string) => void;
-  clearToasts: () => void;
-};
-
-export const useToastStore = create<Store>((set) => ({
-  toasts: [],
-  addToast: (toast: Omit<IToast, 'id'>) => {
-    const { title, description, status, position = 'top', isClosable = true } = toast;
-    const id = Math.random().toString(36).substring(2, 9);
-
-    const toastToAdd = {
-      id,
-      title,
-      description,
-      status,
-      position,
-      isClosable,
-    };
-
-    set((state) => ({
-      toasts: [...state.toasts, { ...toastToAdd, id }],
-    }));
-
-    setTimeout(() => {
-      set((state) => ({
-        toasts: state.toasts.filter((t) => t.id !== id),
-      }));
-    }, 5000);
-  },
-  removeToast: (id: string) => set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })),
-  clearToasts: () => set({ toasts: [] }),
-}));

+ 5 - 0
src/client/utils/trpc.ts

@@ -3,6 +3,11 @@ import { createTRPCNext } from '@trpc/next';
 import superjson from 'superjson';
 import type { AppRouter } from '../../server/routers/_app';
 
+/**
+ * Get base url for the current environment
+ *
+ * @returns {string} base url
+ */
 function getBaseUrl() {
   if (typeof window !== 'undefined') {
     // browser should use relative path

+ 6 - 0
src/client/utils/typescript.ts

@@ -1,5 +1,11 @@
 const objectKeys = <T extends object>(obj: T): (keyof T)[] => Object.keys(obj) as (keyof T)[];
 
+/**
+ * Type guard to check if a value is not null or undefined
+ *
+ * @param {any} value - The value to check
+ * @returns {value is NonNullable<any>} - True if the value is not null or undefined
+ */
 function nonNullable<T>(value: T): value is NonNullable<T> {
   return value !== null && value !== undefined;
 }

+ 12 - 6
src/pages/_app.tsx

@@ -4,12 +4,19 @@ import type { AppProps } from 'next/app';
 import Head from 'next/head';
 import '../client/styles/global.css';
 import '../client/styles/global.scss';
+import 'react-tooltip/dist/react-tooltip.css';
+import { Toaster } from 'react-hot-toast';
 import { useUIStore } from '../client/state/uiStore';
-import { ToastProvider } from '../client/components/hoc/ToastProvider';
 import { StatusProvider } from '../client/components/hoc/StatusProvider';
 import { trpc } from '../client/utils/trpc';
 import { SystemStatus, useSystemStore } from '../client/state/systemStore';
 
+/**
+ * Next.js App component
+ *
+ * @param {AppProps} props - props passed to the app
+ * @returns {JSX.Element} - JSX element
+ */
 function MyApp({ Component, pageProps }: AppProps) {
   const { setDarkMode } = useUIStore();
   const { setStatus, setVersion, pollStatus } = useSystemStore();
@@ -42,11 +49,10 @@ function MyApp({ Component, pageProps }: AppProps) {
       <Head>
         <title>Tipi</title>
       </Head>
-      <ToastProvider>
-        <StatusProvider>
-          <Component {...pageProps} />
-        </StatusProvider>
-      </ToastProvider>
+      <StatusProvider>
+        <Component {...pageProps} />
+      </StatusProvider>
+      <Toaster />
       <ReactQueryDevtools />
     </main>
   );

+ 5 - 0
src/pages/_document.tsx

@@ -3,6 +3,11 @@ import { Html, Head, Main, NextScript } from 'next/document';
 
 import { getUrl } from '../client/core/helpers/url-helpers';
 
+/**
+ * Next.js Document component
+ *
+ * @returns {JSX.Element} - JSX element
+ */
 export default function MyDocument() {
   return (
     <Html lang="en">

+ 5 - 0
src/server/common/get-server-auth-session.ts

@@ -1,9 +1,14 @@
 import { type GetServerSidePropsContext } from 'next';
 import jwt from 'jsonwebtoken';
+import { v4 } from 'uuid';
 import { getConfig } from '../core/TipiConfig';
 import TipiCache from '../core/TipiCache';
 import { Logger } from '../core/Logger';
 
+export const generateSessionId = (prefix: string) => {
+  return `${prefix}-${v4()}`;
+};
+
 export const getServerAuthSession = async (ctx: { req: GetServerSidePropsContext['req']; res: GetServerSidePropsContext['res'] }) => {
   const { req } = ctx;
   const token = req.headers.authorization?.split(' ')[1];

+ 8 - 4
src/server/context.ts

@@ -11,19 +11,23 @@ type CreateContextOptions = {
   session: Session | null;
 };
 
-/** Use this helper for:
+/**
+ * Use this helper for:
  * - testing, so we dont have to mock Next.js' req/res
  * - trpc's `createSSGHelpers` where we don't have req/res
+ *
+ * @param {CreateContextOptions} opts - options
  * @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts
- * */
+ */
 export const createContextInner = async (opts: CreateContextOptions) => ({
   session: opts.session,
 });
 
 /**
  * This is the actual context you'll use in your router
- * @link https://trpc.io/docs/context
- * */
+ *
+ * @param {CreateNextContextOptions} opts - options
+ */
 export const createContext = async (opts: CreateNextContextOptions) => {
   const { req, res } = opts;
 

+ 0 - 3
src/server/core/EventDispatcher/EventDispatcher.test.ts

@@ -1,6 +1,3 @@
-/**
- * @jest-environment node
- */
 import fs from 'fs-extra';
 import { EventDispatcher } from '.';
 

+ 3 - 1
src/server/core/EventDispatcher/EventDispatcher.ts

@@ -94,6 +94,8 @@ class EventDispatcher {
 
   /**
    * Poll queue and run events
+   *
+   * @returns {NodeJS.Timer} - Interval timer
    */
   private pollQueue() {
     Logger.info(`EventDispatcher(${this.dispatcherId}): Polling queue...`);
@@ -207,7 +209,7 @@ class EventDispatcher {
    *
    * @param {EventType} type - Event type
    * @param {[string[]]} args - Event arguments
-   * @returns - Promise that resolves when the event is done
+   * @returns {Promise<{ success: boolean; stdout?: string }>} - Promise that resolves when the event is done
    */
   public async dispatchEventAsync(type: EventType, args?: string[]): Promise<{ success: boolean; stdout?: string }> {
     const event = this.dispatchEvent(type, args);

+ 14 - 0
src/server/core/TipiCache/TipiCache.ts

@@ -48,6 +48,20 @@ class TipiCache {
     return client.del(key);
   }
 
+  public async delByValue(value: string, prefix: string) {
+    const client = await this.getClient();
+    const keys = await client.keys(`${prefix}*`);
+
+    const promises = keys.map(async (key) => {
+      const val = await client.get(key);
+      if (val === value) {
+        await client.del(key);
+      }
+    });
+
+    return Promise.all(promises);
+  }
+
   public async close() {
     return this.client.quit();
   }

+ 0 - 2
src/server/core/TipiConfig/TipiConfig.ts

@@ -95,13 +95,11 @@ export class TipiConfig {
         this.config = parsedConfig.data;
       } else {
         const errors = formatErrors(parsedConfig.error.flatten());
-        console.error(`❌ Invalid env config\n${errors}`);
         Logger.error(`❌ Invalid env config\n\n${errors}`);
         throw new Error('Invalid env config');
       }
     } else {
       const errors = formatErrors(parsedFileConfig.error.flatten());
-      console.error(`❌ Invalid settings.json file:\n${errors}`);
       Logger.error(`❌ Invalid settings.json file:\n${errors}`);
       throw new Error('Invalid settings.json file');
     }

+ 1 - 1
src/server/index.ts

@@ -36,7 +36,7 @@ nextApp.prepare().then(async () => {
   app.use('/static', express.static(`${getConfig().rootFolder}/repos/${getConfig().appsRepoId}/`));
 
   app.all('*', (req, res) => {
-    const parsedUrl = parse(req.url!, true);
+    const parsedUrl = parse(req.url, true);
 
     handle(req, res, parsedUrl);
   });

+ 23 - 0
src/server/migrations/00006-add-totp-user-fields.sql

@@ -0,0 +1,23 @@
+-- Create totp_secret field if it doesn't exist
+ALTER TABLE "user"
+    ADD COLUMN IF NOT EXISTS "totp_secret" text DEFAULT NULL;
+
+-- Create totp_enabled field if it doesn't exist
+ALTER TABLE "user"
+    ADD COLUMN IF NOT EXISTS "totp_enabled" boolean DEFAULT FALSE;
+
+-- Add salt field to user table
+ALTER TABLE "user"
+    ADD COLUMN IF NOT EXISTS "salt" text DEFAULT NULL;
+
+-- Set all users to have totp enabled false
+UPDATE
+    "user"
+SET
+    "totp_enabled" = FALSE
+WHERE
+    "totp_enabled" IS NULL;
+
+-- Set totp_enabled column to not null constraint
+ALTER TABLE "user"
+    ALTER COLUMN "totp_enabled" SET NOT NULL;

+ 212 - 0
src/server/routers/auth/auth.router.test.ts

@@ -0,0 +1,212 @@
+import { PrismaClient } from '@prisma/client';
+import { authRouter } from './auth.router';
+import { getTestDbClient } from '../../../../tests/server/db-connection';
+
+let db: PrismaClient;
+const TEST_SUITE = 'authrouter';
+
+beforeAll(async () => {
+  db = await getTestDbClient(TEST_SUITE);
+  jest.spyOn(console, 'log').mockImplementation(() => {});
+});
+
+beforeEach(async () => {
+  await db.user.deleteMany();
+  // Mute console.log
+});
+
+afterAll(async () => {
+  await db.user.deleteMany();
+  await db.$disconnect();
+});
+
+describe('Test: verifyTotp', () => {
+  it('should be accessible without an account', async () => {
+    // arrange
+    const caller = authRouter.createCaller({ session: null });
+    let error;
+
+    // act
+    try {
+      await caller.verifyTotp({ totpCode: '123456', totpSessionId: '123456' });
+    } catch (e) {
+      error = e as { code: string };
+    }
+
+    // assert
+    expect(error?.code).not.toBe('UNAUTHORIZED');
+    expect(error?.code).toBeDefined();
+    expect(error?.code).not.toBe(null);
+  });
+});
+
+describe('Test: getTotpUri', () => {
+  it('should not be accessible without an account', async () => {
+    // arrange
+    const caller = authRouter.createCaller({ session: null });
+    let error;
+
+    // act
+    try {
+      await caller.getTotpUri({ password: '123456' });
+    } catch (e) {
+      error = e as { code: string };
+    }
+
+    // assert
+    expect(error?.code).toBe('UNAUTHORIZED');
+  });
+
+  it('should be accessible with an account', async () => {
+    // arrange
+    const caller = authRouter.createCaller({ session: { userId: 123456 } });
+    let error;
+
+    // act
+    try {
+      await caller.getTotpUri({ password: '123456' });
+    } catch (e) {
+      error = e as { code: string };
+    }
+
+    // assert
+    expect(error?.code).not.toBe('UNAUTHORIZED');
+  });
+});
+
+describe('Test: setupTotp', () => {
+  it('should not be accessible without an account', async () => {
+    // arrange
+    const caller = authRouter.createCaller({ session: null });
+    let error;
+
+    // act
+    try {
+      await caller.setupTotp({ totpCode: '123456' });
+    } catch (e) {
+      error = e as { code: string };
+    }
+
+    // assert
+    expect(error?.code).toBe('UNAUTHORIZED');
+  });
+
+  it('should be accessible with an account', async () => {
+    // arrange
+    const caller = authRouter.createCaller({ session: { userId: 123456 } });
+    let error;
+
+    // act
+    try {
+      await caller.setupTotp({ totpCode: '123456' });
+    } catch (e) {
+      error = e as { code: string };
+    }
+
+    // assert
+    expect(error?.code).not.toBe('UNAUTHORIZED');
+  });
+});
+
+describe('Test: disableTotp', () => {
+  it('should not be accessible without an account', async () => {
+    // arrange
+    const caller = authRouter.createCaller({ session: null });
+    let error;
+
+    // act
+    try {
+      await caller.disableTotp({ password: '123456' });
+    } catch (e) {
+      error = e as { code: string };
+    }
+
+    // assert
+    expect(error?.code).toBe('UNAUTHORIZED');
+  });
+
+  it('should be accessible with an account', async () => {
+    // arrange
+    const caller = authRouter.createCaller({ session: { userId: 122 } });
+    let error;
+
+    // act
+
+    try {
+      await caller.disableTotp({ password: '112321' });
+    } catch (e) {
+      error = e as { code: string };
+    }
+
+    // assert
+    expect(error?.code).not.toBe('UNAUTHORIZED');
+  });
+});
+
+describe('Test: changeOperatorPassword', () => {
+  it('should be accessible without an account', async () => {
+    // arrange
+    const caller = authRouter.createCaller({ session: null });
+    let error;
+
+    // act
+    try {
+      await caller.changeOperatorPassword({ newPassword: '222' });
+    } catch (e) {
+      error = e as { code: string };
+    }
+
+    // assert
+    expect(error?.code).not.toBe('UNAUTHORIZED');
+  });
+
+  it('should be accessible with an account', async () => {
+    // arrange
+    const caller = authRouter.createCaller({ session: { userId: 122 } });
+    let error;
+
+    // act
+    try {
+      await caller.changeOperatorPassword({ newPassword: '222' });
+    } catch (e) {
+      error = e as { code: string };
+    }
+
+    // assert
+    expect(error?.code).not.toBe('UNAUTHORIZED');
+  });
+});
+
+describe('Test: resetPassword', () => {
+  it('should not be accessible without an account', async () => {
+    // arrange
+    const caller = authRouter.createCaller({ session: null });
+    let error;
+
+    // act
+    try {
+      await caller.changePassword({ currentPassword: '111', newPassword: '222' });
+    } catch (e) {
+      error = e as { code: string };
+    }
+
+    // assert
+    expect(error?.code).toBe('UNAUTHORIZED');
+  });
+
+  it('should be accessible with an account', async () => {
+    // arrange
+    const caller = authRouter.createCaller({ session: { userId: 122 } });
+    let error;
+
+    // act
+    try {
+      await caller.changePassword({ currentPassword: '111', newPassword: '222' });
+    } catch (e) {
+      error = e as { code: string };
+    }
+
+    // assert
+    expect(error?.code).not.toBe('UNAUTHORIZED');
+  });
+});

+ 10 - 1
src/server/routers/auth/auth.router.ts

@@ -12,7 +12,16 @@ export const authRouter = router({
   refreshToken: protectedProcedure.mutation(async ({ ctx }) => AuthServiceClass.refreshToken(ctx.session.id)),
   me: publicProcedure.query(async ({ ctx }) => AuthService.me(ctx.session?.userId)),
   isConfigured: publicProcedure.query(async () => AuthService.isConfigured()),
+  // Password
   checkPasswordChangeRequest: publicProcedure.query(AuthServiceClass.checkPasswordChangeRequest),
-  resetPassword: publicProcedure.input(z.object({ newPassword: z.string() })).mutation(({ input }) => AuthService.changePassword({ newPassword: input.newPassword })),
+  changeOperatorPassword: publicProcedure.input(z.object({ newPassword: z.string() })).mutation(({ input }) => AuthService.changeOperatorPassword({ newPassword: input.newPassword })),
   cancelPasswordChangeRequest: publicProcedure.mutation(AuthServiceClass.cancelPasswordChangeRequest),
+  changePassword: protectedProcedure
+    .input(z.object({ currentPassword: z.string(), newPassword: z.string() }))
+    .mutation(({ input, ctx }) => AuthService.changePassword({ userId: Number(ctx.session.userId), ...input })),
+  // Totp
+  verifyTotp: publicProcedure.input(z.object({ totpSessionId: z.string(), totpCode: z.string() })).mutation(({ input }) => AuthService.verifyTotp(input)),
+  getTotpUri: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.getTotpUri({ userId: Number(ctx.session.userId), password: input.password })),
+  setupTotp: protectedProcedure.input(z.object({ totpCode: z.string() })).mutation(({ input, ctx }) => AuthService.setupTotp({ userId: Number(ctx.session.userId), totpCode: input.totpCode })),
+  disableTotp: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.disableTotp({ userId: Number(ctx.session.userId), password: input.password })),
 });

+ 3 - 4
src/server/services/apps/apps.helpers.ts

@@ -212,9 +212,9 @@ export const generateEnvFile = (app: App) => {
   This function reads the apps directory and skips certain system files, then reads the config.json and metadata/description.md files for each app,
   parses the config file, filters out any apps that are not available and returns an array of app information.
   If the config.json file is invalid, it logs an error message.
-
+ 
   @returns {Promise<AppInfo[]>} - Returns a promise that resolves with an array of available apps' information.
-*/
+ */
 export const getAvailableApps = async () => {
   const appsDir = readdirSync(`/runtipi/repos/${getConfig().appsRepoId}/apps`);
 
@@ -248,7 +248,6 @@ export const getAvailableApps = async () => {
  *  If the app is not found, it returns null.
  *
  *  @param {string} id - The app id.
- *  @param {number} [version] - The current version of the app.
  *  @returns {Promise<{current: number, latest: number, dockerVersion: string} | null>} - Returns an object containing information about the updates available for the app or null if the app is not found or has an invalid config.json file.
  */
 export const getUpdateInfo = (id: string) => {
@@ -273,7 +272,7 @@ export const getUpdateInfo = (id: string) => {
  *  If an error occurs during the process, it logs the error message and throws an error.
  *
  *  @param {string} id - The app id.
- *  @param {AppStatus} [status] - The app status.
+ *  @param {App['status']} [status] - The app status.
  *  @returns {AppInfo | null} - Returns an object with app information or null if the app is not found.
  */
 export const getAppInfo = (id: string, status?: App['status']) => {

+ 345 - 7
src/server/services/auth/auth.service.test.ts

@@ -3,6 +3,9 @@ import fs from 'fs-extra';
 import * as argon2 from 'argon2';
 import jwt from 'jsonwebtoken';
 import { faker } from '@faker-js/faker';
+import { TotpAuthenticator } from '@/server/utils/totp';
+import { generateSessionId } from '@/server/common/get-server-auth-session';
+import { encrypt } from '../../utils/encryption';
 import { setConfig } from '../../core/TipiConfig';
 import { createUser } from '../../tests/user.factory';
 import { AuthServiceClass } from './auth.service';
@@ -38,7 +41,7 @@ describe('Login', () => {
 
     // Act
     const { token } = await AuthService.login({ username: email, password: 'password' });
-    const decoded = jwt.verify(token, 'test') as jwt.JwtPayload;
+    const decoded = jwt.verify(token as string, 'test') as jwt.JwtPayload;
 
     // Assert
     expect(decoded).toBeDefined();
@@ -60,6 +63,278 @@ describe('Login', () => {
     await createUser({ email }, db);
     await expect(AuthService.login({ username: email, password: 'wrong' })).rejects.toThrowError('Wrong password');
   });
+
+  // TOTP
+  it('should return a totp session id the user totp_enabled is true', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const totpSecret = TotpAuthenticator.generateSecret();
+    await createUser({ email, totp_enabled: true, totp_secret: totpSecret }, db);
+
+    // act
+    const { totpSessionId, token } = await AuthService.login({ username: email, password: 'password' });
+
+    // assert
+    expect(totpSessionId).toBeDefined();
+    expect(totpSessionId).not.toBeNull();
+    expect(token).toBeUndefined();
+  });
+});
+
+describe('Test: verifyTotp', () => {
+  it('should return a valid jsonwebtoken if the totp is correct', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const salt = faker.random.word();
+    const totpSecret = TotpAuthenticator.generateSecret();
+
+    const encryptedTotpSecret = encrypt(totpSecret, salt);
+    const user = await createUser({ email, totp_enabled: true, totp_secret: encryptedTotpSecret, salt }, db);
+    const totpSessionId = generateSessionId('otp');
+    const otp = TotpAuthenticator.generate(totpSecret);
+
+    await TipiCache.set(totpSessionId, user.id.toString());
+
+    // act
+    const { token } = await AuthService.verifyTotp({ totpSessionId, totpCode: otp });
+
+    // assert
+    expect(token).toBeDefined();
+    expect(token).not.toBeNull();
+  });
+
+  it('should throw if the totp is incorrect', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const salt = faker.random.word();
+    const totpSecret = TotpAuthenticator.generateSecret();
+    const encryptedTotpSecret = encrypt(totpSecret, salt);
+    const user = await createUser({ email, totp_enabled: true, totp_secret: encryptedTotpSecret, salt }, db);
+    const totpSessionId = generateSessionId('otp');
+    await TipiCache.set(totpSessionId, user.id.toString());
+
+    // act & assert
+    await expect(AuthService.verifyTotp({ totpSessionId, totpCode: 'wrong' })).rejects.toThrowError('Invalid TOTP');
+  });
+
+  it('should throw if the totpSessionId is invalid', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const salt = faker.random.word();
+    const totpSecret = TotpAuthenticator.generateSecret();
+    const encryptedTotpSecret = encrypt(totpSecret, salt);
+    const user = await createUser({ email, totp_enabled: true, totp_secret: encryptedTotpSecret, salt }, db);
+    const totpSessionId = generateSessionId('otp');
+    const otp = TotpAuthenticator.generate(totpSecret);
+
+    await TipiCache.set(totpSessionId, user.id.toString());
+
+    // act & assert
+    await expect(AuthService.verifyTotp({ totpSessionId: 'wrong', totpCode: otp })).rejects.toThrowError('TOTP session not found');
+  });
+
+  it('should throw if the user does not exist', async () => {
+    // arrange
+    const totpSessionId = generateSessionId('otp');
+    await TipiCache.set(totpSessionId, '1234');
+
+    // act & assert
+    await expect(AuthService.verifyTotp({ totpSessionId, totpCode: '1234' })).rejects.toThrowError('User not found');
+  });
+
+  it('should throw if the user totp_enabled is false', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const salt = faker.random.word();
+    const totpSecret = TotpAuthenticator.generateSecret();
+    const encryptedTotpSecret = encrypt(totpSecret, salt);
+    const user = await createUser({ email, totp_enabled: false, totp_secret: encryptedTotpSecret, salt }, db);
+    const totpSessionId = generateSessionId('otp');
+    const otp = TotpAuthenticator.generate(totpSecret);
+
+    await TipiCache.set(totpSessionId, user.id.toString());
+
+    // act & assert
+    await expect(AuthService.verifyTotp({ totpSessionId, totpCode: otp })).rejects.toThrowError('TOTP is not enabled for this user');
+  });
+});
+
+describe('Test: getTotpUri', () => {
+  it('should return a valid totp uri', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const user = await createUser({ email }, db);
+
+    // act
+    const { uri, key } = await AuthService.getTotpUri({ userId: user.id, password: 'password' });
+
+    // assert
+    expect(uri).toBeDefined();
+    expect(uri).not.toBeNull();
+    expect(key).toBeDefined();
+    expect(key).not.toBeNull();
+    expect(uri).toContain(key);
+  });
+
+  it('should create a new totp secret if the user does not have one', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const user = await createUser({ email }, db);
+
+    // act
+    await AuthService.getTotpUri({ userId: user.id, password: 'password' });
+    const userFromDb = await db.user.findUnique({ where: { id: user.id } });
+
+    // assert
+    expect(userFromDb).toBeDefined();
+    expect(userFromDb).not.toBeNull();
+    expect(userFromDb).toHaveProperty('totp_secret');
+    expect(userFromDb).toHaveProperty('salt');
+  });
+
+  it('should regenerate a new totp secret if the user already has one', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const salt = faker.random.word();
+    const totpSecret = TotpAuthenticator.generateSecret();
+    const encryptedTotpSecret = encrypt(totpSecret, salt);
+    const user = await createUser({ email, totp_secret: encryptedTotpSecret, salt }, db);
+
+    // act
+    await AuthService.getTotpUri({ userId: user.id, password: 'password' });
+    const userFromDb = await db.user.findUnique({ where: { id: user.id } });
+
+    // assert
+    expect(userFromDb).toBeDefined();
+    expect(userFromDb).not.toBeNull();
+    expect(userFromDb).toHaveProperty('totp_secret');
+    expect(userFromDb).toHaveProperty('salt');
+    expect(userFromDb?.totp_secret).not.toEqual(encryptedTotpSecret);
+    expect(userFromDb?.salt).toEqual(salt);
+  });
+
+  it('should thorw an error if user has already configured totp', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const user = await createUser({ email, totp_enabled: true }, db);
+
+    // act & assert
+    await expect(AuthService.getTotpUri({ userId: user.id, password: 'password' })).rejects.toThrowError('TOTP is already enabled for this user');
+  });
+
+  it('should throw an error if the user password is incorrect', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const user = await createUser({ email }, db);
+
+    // act & assert
+    await expect(AuthService.getTotpUri({ userId: user.id, password: 'wrong' })).rejects.toThrowError('Invalid password');
+  });
+
+  it('should throw an error if the user does not exist', async () => {
+    // arrange
+    const userId = 11;
+
+    // act & assert
+    await expect(AuthService.getTotpUri({ userId, password: 'password' })).rejects.toThrowError('User not found');
+  });
+});
+
+describe('Test: setupTotp', () => {
+  it('should enable totp for the user', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const totpSecret = TotpAuthenticator.generateSecret();
+    const salt = faker.random.word();
+    const encryptedTotpSecret = encrypt(totpSecret, salt);
+
+    const user = await createUser({ email, totp_secret: encryptedTotpSecret, salt }, db);
+    const otp = TotpAuthenticator.generate(totpSecret);
+
+    // act
+    await AuthService.setupTotp({ userId: user.id, totpCode: otp });
+    const userFromDb = await db.user.findUnique({ where: { id: user.id } });
+
+    // assert
+    expect(userFromDb).toBeDefined();
+    expect(userFromDb).not.toBeNull();
+    expect(userFromDb).toHaveProperty('totp_enabled');
+    expect(userFromDb?.totp_enabled).toBeTruthy();
+  });
+
+  it('should throw if the user has already enabled totp', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const user = await createUser({ email, totp_enabled: true }, db);
+
+    // act & assert
+    await expect(AuthService.setupTotp({ userId: user.id, totpCode: '1234' })).rejects.toThrowError('TOTP is already enabled for this user');
+  });
+
+  it('should throw if the user does not exist', async () => {
+    // arrange
+    const userId = 11;
+
+    // act & assert
+    await expect(AuthService.setupTotp({ userId, totpCode: '1234' })).rejects.toThrowError('User not found');
+  });
+
+  it('should throw if the otp is invalid', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const totpSecret = TotpAuthenticator.generateSecret();
+    const salt = faker.random.word();
+    const encryptedTotpSecret = encrypt(totpSecret, salt);
+
+    const user = await createUser({ email, totp_secret: encryptedTotpSecret, salt }, db);
+
+    // act & assert
+    await expect(AuthService.setupTotp({ userId: user.id, totpCode: '1234' })).rejects.toThrowError('Invalid TOTP code');
+  });
+});
+
+describe('Test: disableTotp', () => {
+  it('should disable totp for the user', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const user = await createUser({ email, totp_enabled: true }, db);
+
+    // act
+    await AuthService.disableTotp({ userId: user.id, password: 'password' });
+    const userFromDb = await db.user.findUnique({ where: { id: user.id } });
+
+    // assert
+    expect(userFromDb).toBeDefined();
+    expect(userFromDb).not.toBeNull();
+    expect(userFromDb).toHaveProperty('totp_enabled');
+    expect(userFromDb?.totp_enabled).toBeFalsy();
+  });
+
+  it('should throw if the user has already disabled totp', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const user = await createUser({ email, totp_enabled: false }, db);
+
+    // act & assert
+    await expect(AuthService.disableTotp({ userId: user.id, password: 'password' })).rejects.toThrowError('TOTP is not enabled for this user');
+  });
+
+  it('should throw if the user does not exist', async () => {
+    // arrange
+    const userId = 11;
+
+    // act & assert
+    await expect(AuthService.disableTotp({ userId, password: 'password' })).rejects.toThrowError('User not found');
+  });
+
+  it('should throw if the password is invalid', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const user = await createUser({ email, totp_enabled: true }, db);
+
+    // act & assert
+    await expect(AuthService.disableTotp({ userId: user.id, password: 'wrong' })).rejects.toThrowError('Invalid password');
+  });
 });
 
 describe('Register', () => {
@@ -264,7 +539,7 @@ describe('Test: isConfigured', () => {
   });
 });
 
-describe('Test: changePassword', () => {
+describe('Test: changeOperatorPassword', () => {
   it('should change the password of the operator user', async () => {
     // Arrange
     const email = faker.internet.email();
@@ -274,7 +549,7 @@ describe('Test: changePassword', () => {
     fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
 
     // Act
-    const result = await AuthService.changePassword({ newPassword });
+    const result = await AuthService.changeOperatorPassword({ newPassword });
 
     // Assert
     expect(result.email).toBe(email.toLowerCase());
@@ -291,7 +566,7 @@ describe('Test: changePassword', () => {
     fs.__createMockFiles({});
 
     // Act & Assert
-    await expect(AuthService.changePassword({ newPassword })).rejects.toThrowError('No password change request found');
+    await expect(AuthService.changeOperatorPassword({ newPassword })).rejects.toThrowError('No password change request found');
   });
 
   it('should throw if there is no operator user', async () => {
@@ -303,7 +578,26 @@ describe('Test: changePassword', () => {
     fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
 
     // Act & Assert
-    await expect(AuthService.changePassword({ newPassword })).rejects.toThrowError('Operator user not found');
+    await expect(AuthService.changeOperatorPassword({ newPassword })).rejects.toThrowError('Operator user not found');
+  });
+
+  it('should reset totp_secret and totp_enabled if totp is enabled', async () => {
+    // Arrange
+    const email = faker.internet.email();
+    const user = await createUser({ email, totp_enabled: true }, db);
+    const newPassword = faker.internet.password();
+    // @ts-expect-error - mocking fs
+    fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
+
+    // Act
+    const result = await AuthService.changeOperatorPassword({ newPassword });
+
+    // Assert
+    expect(result.email).toBe(email.toLowerCase());
+    const updatedUser = await db.user.findUnique({ where: { id: user.id } });
+    expect(updatedUser?.password).not.toBe(user.password);
+    expect(updatedUser?.totp_enabled).toBe(false);
+    expect(updatedUser?.totp_secret).toBeNull();
   });
 });
 
@@ -314,7 +608,7 @@ describe('Test: checkPasswordChangeRequest', () => {
     fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
 
     // Act
-    const result = await AuthServiceClass.checkPasswordChangeRequest();
+    const result = AuthServiceClass.checkPasswordChangeRequest();
 
     // Assert
     expect(result).toBe(true);
@@ -326,7 +620,7 @@ describe('Test: checkPasswordChangeRequest', () => {
     fs.__createMockFiles({});
 
     // Act
-    const result = await AuthServiceClass.checkPasswordChangeRequest();
+    const result = AuthServiceClass.checkPasswordChangeRequest();
 
     // Assert
     expect(result).toBe(false);
@@ -346,3 +640,47 @@ describe('Test: cancelPasswordChangeRequest', () => {
     expect(fs.existsSync('/runtipi/state/password-change-request')).toBe(false);
   });
 });
+
+describe('Test: changePassword', () => {
+  it('should change the password of the user', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const user = await createUser({ email }, db);
+    const newPassword = faker.internet.password();
+
+    // act
+    await AuthService.changePassword({ userId: user.id, newPassword, currentPassword: 'password' });
+
+    // assert
+    const updatedUser = await db.user.findUnique({ where: { id: user.id } });
+    expect(updatedUser?.password).not.toBe(user.password);
+  });
+
+  it('should throw if the user does not exist', async () => {
+    // arrange
+    const newPassword = faker.internet.password();
+
+    // act & assert
+    await expect(AuthService.changePassword({ userId: 1, newPassword, currentPassword: 'password' })).rejects.toThrowError('User not found');
+  });
+
+  it('should throw if the password is incorrect', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const user = await createUser({ email }, db);
+    const newPassword = faker.internet.password();
+
+    // act & assert
+    await expect(AuthService.changePassword({ userId: user.id, newPassword, currentPassword: 'wrongpassword' })).rejects.toThrowError('Current password is invalid');
+  });
+
+  it('should throw if password is less than 8 characters', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const user = await createUser({ email }, db);
+    const newPassword = faker.internet.password(7);
+
+    // act & assert
+    await expect(AuthService.changePassword({ userId: user.id, newPassword, currentPassword: 'password' })).rejects.toThrowError('Password must be at least 8 characters');
+  });
+});

+ 189 - 7
src/server/services/auth/auth.service.ts

@@ -1,11 +1,13 @@
 import { PrismaClient } from '@prisma/client';
 import * as argon2 from 'argon2';
-import { v4 } from 'uuid';
 import jwt from 'jsonwebtoken';
 import validator from 'validator';
+import { TotpAuthenticator } from '@/server/utils/totp';
+import { generateSessionId } from '@/server/common/get-server-auth-session';
 import { getConfig } from '../../core/TipiConfig';
 import TipiCache from '../../core/TipiCache';
 import { fileExists, unlinkFile } from '../../common/fs.helpers';
+import { decrypt, encrypt } from '../../utils/encryption';
 
 type UsernamePasswordInput = {
   username: string;
@@ -44,7 +46,14 @@ export class AuthServiceClass {
       throw new Error('Wrong password');
     }
 
-    const session = v4();
+    const session = generateSessionId('auth');
+
+    if (user.totp_enabled) {
+      const totpSessionId = generateSessionId('otp');
+      await TipiCache.set(totpSessionId, user.id.toString());
+      return { totpSessionId };
+    }
+
     const token = jwt.sign({ id: user.id, session }, getConfig().jwtSecret, { expiresIn: '7d' });
 
     await TipiCache.set(session, user.id.toString());
@@ -52,6 +61,152 @@ export class AuthServiceClass {
     return { token };
   };
 
+  /**
+   * Verify TOTP code and return a JWT token
+   *
+   * @param {object} params - An object containing the TOTP session ID and the TOTP code
+   * @param {string} params.totpSessionId - The TOTP session ID
+   * @param {string} params.totpCode - The TOTP code
+   * @returns {Promise<{token:string}>} - A promise that resolves to an object containing the JWT token
+   */
+  public verifyTotp = async (params: { totpSessionId: string; totpCode: string }) => {
+    const { totpSessionId, totpCode } = params;
+    const userId = await TipiCache.get(totpSessionId);
+
+    if (!userId) {
+      throw new Error('TOTP session not found');
+    }
+
+    const user = await this.prisma.user.findUnique({ where: { id: Number(userId) } });
+
+    if (!user) {
+      throw new Error('User not found');
+    }
+
+    if (!user.totp_enabled || !user.totp_secret || !user.salt) {
+      throw new Error('TOTP is not enabled for this user');
+    }
+
+    const totpSecret = decrypt(user.totp_secret, user.salt);
+    const isValid = TotpAuthenticator.check(totpCode, totpSecret);
+
+    if (!isValid) {
+      throw new Error('Invalid TOTP code');
+    }
+
+    const session = generateSessionId('otp');
+    const token = jwt.sign({ id: user.id, session }, getConfig().jwtSecret, { expiresIn: '7d' });
+
+    await TipiCache.set(session, user.id.toString());
+
+    return { token };
+  };
+
+  /**
+   * Given a userId returns the TOTP URI and the secret key
+   *
+   * @param {object} params - An object containing the userId and the user's password
+   * @param {number} params.userId - The user's ID
+   * @param {string} params.password - The user's password
+   * @returns {Promise<{uri: string, key: string}>} - A promise that resolves to an object containing the TOTP URI and the secret key
+   */
+  public getTotpUri = async (params: { userId: number; password: string }) => {
+    const { userId, password } = params;
+    const user = await this.prisma.user.findUnique({ where: { id: userId } });
+
+    if (!user) {
+      throw new Error('User not found');
+    }
+
+    const isPasswordValid = await argon2.verify(user.password, password);
+    if (!isPasswordValid) {
+      throw new Error('Invalid password');
+    }
+
+    if (user.totp_enabled) {
+      throw new Error('TOTP is already enabled for this user');
+    }
+
+    let { salt } = user;
+    const newTotpSecret = TotpAuthenticator.generateSecret();
+
+    if (!salt) {
+      salt = generateSessionId('');
+    }
+
+    const encryptedTotpSecret = encrypt(newTotpSecret, salt);
+
+    await this.prisma.user.update({
+      where: { id: userId },
+      data: {
+        totp_secret: encryptedTotpSecret,
+        salt,
+      },
+    });
+
+    const uri = TotpAuthenticator.keyuri(user.username, 'Runtipi', newTotpSecret);
+
+    return { uri, key: newTotpSecret };
+  };
+
+  public setupTotp = async (params: { userId: number; totpCode: string }) => {
+    const { userId, totpCode } = params;
+    const user = await this.prisma.user.findUnique({ where: { id: userId } });
+
+    if (!user) {
+      throw new Error('User not found');
+    }
+
+    if (user.totp_enabled || !user.totp_secret || !user.salt) {
+      throw new Error('TOTP is already enabled for this user');
+    }
+
+    const totpSecret = decrypt(user.totp_secret, user.salt);
+    const isValid = TotpAuthenticator.check(totpCode, totpSecret);
+
+    if (!isValid) {
+      throw new Error('Invalid TOTP code');
+    }
+
+    await this.prisma.user.update({
+      where: { id: userId },
+      data: {
+        totp_enabled: true,
+      },
+    });
+
+    return true;
+  };
+
+  public disableTotp = async (params: { userId: number; password: string }) => {
+    const { userId, password } = params;
+
+    const user = await this.prisma.user.findUnique({ where: { id: userId } });
+
+    if (!user) {
+      throw new Error('User not found');
+    }
+
+    if (!user.totp_enabled) {
+      throw new Error('TOTP is not enabled for this user');
+    }
+
+    const isPasswordValid = await argon2.verify(user.password, password);
+    if (!isPasswordValid) {
+      throw new Error('Invalid password');
+    }
+
+    await this.prisma.user.update({
+      where: { id: userId },
+      data: {
+        totp_enabled: false,
+        totp_secret: null,
+      },
+    });
+
+    return true;
+  };
+
   /**
    * Creates a new user with the provided email and password and returns a session token
    *
@@ -86,7 +241,7 @@ export class AuthServiceClass {
     const hash = await argon2.hash(password);
     const newUser = await this.prisma.user.create({ data: { username: email, password: hash, operator: true } });
 
-    const session = v4();
+    const session = generateSessionId('auth');
     const token = jwt.sign({ id: newUser.id, session }, getConfig().jwtSecret, { expiresIn: '1d' });
 
     await TipiCache.set(session, newUser.id.toString());
@@ -103,7 +258,7 @@ export class AuthServiceClass {
   public me = async (userId: number | undefined) => {
     if (!userId) return null;
 
-    const user = await this.prisma.user.findUnique({ where: { id: Number(userId) }, select: { id: true, username: true } });
+    const user = await this.prisma.user.findUnique({ where: { id: Number(userId) }, select: { id: true, username: true, totp_enabled: true } });
 
     if (!user) return null;
 
@@ -139,7 +294,7 @@ export class AuthServiceClass {
     // Expire token in 6 seconds
     await TipiCache.set(session, userId, 6);
 
-    const newSession = v4();
+    const newSession = generateSessionId('auth');
     const token = jwt.sign({ id: userId, session: newSession }, getConfig().jwtSecret, { expiresIn: '1d' });
     await TipiCache.set(newSession, userId);
 
@@ -165,7 +320,7 @@ export class AuthServiceClass {
    * @returns {Promise<string>} - The username of the operator user
    * @throws {Error} - If the operator user is not found or if there is no password change request
    */
-  public changePassword = async (params: { newPassword: string }) => {
+  public changeOperatorPassword = async (params: { newPassword: string }) => {
     if (!AuthServiceClass.checkPasswordChangeRequest()) {
       throw new Error('No password change request found');
     }
@@ -178,7 +333,7 @@ export class AuthServiceClass {
     }
 
     const hash = await argon2.hash(newPassword);
-    await this.prisma.user.update({ where: { id: user.id }, data: { password: hash } });
+    await this.prisma.user.update({ where: { id: user.id }, data: { password: hash, totp_enabled: false, totp_secret: null } });
 
     await unlinkFile(`/runtipi/state/password-change-request`);
 
@@ -213,4 +368,31 @@ export class AuthServiceClass {
 
     return true;
   };
+
+  public changePassword = async (params: { currentPassword: string; newPassword: string; userId: number }) => {
+    const { currentPassword, newPassword, userId } = params;
+
+    const user = await this.prisma.user.findUnique({ where: { id: userId } });
+
+    if (!user) {
+      throw new Error('User not found');
+    }
+
+    const valid = await argon2.verify(user.password, currentPassword);
+
+    if (!valid) {
+      throw new Error('Current password is invalid');
+    }
+
+    if (newPassword.length < 8) {
+      throw new Error('Password must be at least 8 characters long');
+    }
+
+    const hash = await argon2.hash(newPassword);
+    await this.prisma.user.update({ where: { id: user.id }, data: { password: hash } });
+
+    await TipiCache.delByValue(userId.toString(), 'auth');
+
+    return true;
+  };
 }

+ 4 - 2
src/server/services/system/system.service.test.ts

@@ -66,10 +66,11 @@ describe('Test: getVersion', () => {
     jest.restoreAllMocks();
   });
 
-  it('It should return version', async () => {
+  it('It should return version with body', async () => {
     // Arrange
+    const body = faker.random.words(10);
     // @ts-expect-error Mocking fetch
-    fetch.mockImplementationOnce(() => Promise.resolve({ json: () => Promise.resolve({ name: `v${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}` }) }));
+    fetch.mockImplementationOnce(() => Promise.resolve({ json: () => Promise.resolve({ name: `v${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}`, body }) }));
 
     // Act
     const version = await SystemService.getVersion();
@@ -78,6 +79,7 @@ describe('Test: getVersion', () => {
     expect(version).toBeDefined();
     expect(version.current).toBeDefined();
     expect(semver.valid(version.latest)).toBeTruthy();
+    expect(version.body).toBeDefined();
   });
 
   it('Should return undefined for latest if request fails', async () => {

+ 8 - 4
src/server/services/system/system.service.ts

@@ -39,21 +39,25 @@ export class SystemServiceClass {
   /**
    * Get the current and latest version of Tipi
    *
-   * @returns {Promise<{ current: string; latest: string }>}
+   * @returns {Promise<{ current: string; latest: string }>} The current and latest version
    */
-  public getVersion = async (): Promise<{ current: string; latest?: string }> => {
+  public getVersion = async () => {
     try {
       let version = await this.cache.get('latestVersion');
+      let body = await this.cache.get('latestVersionBody');
 
       if (!version) {
         const data = await fetch('https://api.github.com/repos/meienberger/runtipi/releases/latest');
-        const release = (await data.json()) as { name: string };
+        const release = (await data.json()) as { name: string; body: string };
 
         version = release.name.replace('v', '');
+        body = release.body;
+
         await this.cache.set('latestVersion', version?.replace('v', '') || '', 60 * 60);
+        await this.cache.set('latestVersionBody', body || '', 60 * 60);
       }
 
-      return { current: TipiConfig.getConfig().version, latest: version?.replace('v', '') };
+      return { current: TipiConfig.getConfig().version, latest: version?.replace('v', ''), body };
     } catch (e) {
       Logger.error(e);
       return { current: TipiConfig.getConfig().version, latest: undefined };

+ 4 - 8
src/server/tests/user.factory.ts

@@ -1,19 +1,15 @@
 // eslint-disable-next-line import/no-extraneous-dependencies
 import { faker } from '@faker-js/faker';
 import * as argon2 from 'argon2';
+import { User } from '@prisma/client';
 import { prisma } from '../db/client';
 
-type CreateUserParams = {
-  email?: string;
-  operator?: boolean;
-};
-
-const createUser = async (params: CreateUserParams, db = prisma) => {
-  const { email, operator = true } = params;
+const createUser = async (params: Partial<User & { email?: string }>, db = prisma) => {
+  const { email, operator = true, ...rest } = params;
   const hash = await argon2.hash('password');
 
   const username = email?.toLowerCase().trim() || faker.internet.email().toLowerCase().trim();
-  const user = await db.user.create({ data: { username, password: hash, operator } });
+  const user = await db.user.create({ data: { username, password: hash, operator, ...rest } });
 
   return user;
 };

+ 3 - 1
src/server/trpc.ts

@@ -4,8 +4,10 @@ import { typeToFlattenedError, ZodError } from 'zod';
 import { type Context } from './context';
 
 /**
+ * Convert ZodError to a record
  *
- * @param errors
+ * @param {typeToFlattenedError<string>} errors - errors
+ * @returns {Record<string, string>} record
  */
 export function zodErrorsToRecord(errors: typeToFlattenedError<string>) {
   const record: Record<string, string> = {};

+ 32 - 0
src/server/utils/__tests__/encryption.test.ts

@@ -0,0 +1,32 @@
+import { faker } from '@faker-js/faker';
+import { setConfig } from '../../core/TipiConfig';
+import { encrypt, decrypt } from '../encryption';
+
+describe('Test: encrypt', () => {
+  it('should encrypt the provided data', () => {
+    // arrange
+    setConfig('jwtSecret', faker.random.word());
+    const data = faker.random.word();
+    const salt = faker.random.word();
+
+    // act
+    const encryptedData = encrypt(data, salt);
+
+    // assert
+    expect(encryptedData).not.toEqual(data);
+  });
+
+  it('should decrypt the provided data', () => {
+    // arrange
+    setConfig('jwtSecret', faker.random.word());
+    const data = faker.random.word();
+    const salt = faker.random.word();
+
+    // act
+    const encryptedData = encrypt(data, salt);
+    const decryptedData = decrypt(encryptedData, salt);
+
+    // assert
+    expect(decryptedData).toEqual(data);
+  });
+});

Some files were not shown because too many files changed in this diff