From 54ca0b7d069aea04334e259cd830788a8a078a1f Mon Sep 17 00:00:00 2001 From: "Rudy B." Date: Sat, 25 Apr 2026 01:39:21 +0200 Subject: [PATCH 01/43] chore(deps): add `otplib` and `qrcode` --- package.json | 3 + pnpm-lock.yaml | 277 ++++++++++++++++++++++++++++++------------------- 2 files changed, 171 insertions(+), 109 deletions(-) diff --git a/package.json b/package.json index 9306161fc..2261b776e 100644 --- a/package.json +++ b/package.json @@ -93,10 +93,12 @@ "next-intl": "4.9.1", "node-fetch": "^3.2.8", "npm-run-all": "^4.1.5", + "otplib": "^13.4.0", "papaparse": "^5.5.3", "pg": "^8.20.0", "prisma": "^7.7.0", "pure-rand": "^8.4.0", + "qrcode": "^1.5.4", "react": "^19.2.5", "react-dom": "^19.2.5", "react-error-boundary": "^6.1.1", @@ -128,6 +130,7 @@ "@rollup/plugin-typescript": "^12.3.0", "@types/jest": "^30.0.0", "@types/node": "^25.6.0", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.2", "babel-plugin-react-compiler": "19.1.0-rc.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 616a54142..afe27b77d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,6 +134,9 @@ importers: npm-run-all: specifier: ^4.1.5 version: 4.1.5 + otplib: + specifier: ^13.4.0 + version: 13.4.0 papaparse: specifier: ^5.5.3 version: 5.5.3 @@ -146,6 +149,9 @@ importers: pure-rand: specifier: ^8.4.0 version: 8.4.0 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 react: specifier: ^19.2.5 version: 19.2.5 @@ -234,6 +240,9 @@ importers: '@types/node': specifier: ^25.6.0 version: 25.6.0 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/react': specifier: ^19.2.14 version: 19.2.14 @@ -313,45 +322,6 @@ importers: specifier: ^6.0.3 version: 6.0.3 - dist: - dependencies: - chart.js: - specifier: ^4.5.0 - version: 4.5.1 - chartjs-adapter-date-fns: - specifier: ^3.0.0 - version: 3.0.0(chart.js@4.5.1)(date-fns@4.1.0) - colord: - specifier: ^2.9.2 - version: 2.9.3 - jsonwebtoken: - specifier: ^9.0.2 - version: 9.0.3 - lucide-react: - specifier: ^0.542.0 - version: 0.542.0(react@19.2.5) - pure-rand: - specifier: ^7.0.1 - version: 7.0.1 - react-simple-maps: - specifier: ^2.3.0 - version: 2.3.0(prop-types@15.8.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react-use-measure: - specifier: ^2.0.4 - version: 2.1.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react-window: - specifier: ^1.8.6 - version: 1.8.11(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - serialize-error: - specifier: ^12.0.0 - version: 12.0.0 - thenby: - specifier: ^1.3.4 - version: 1.3.4 - uuid: - specifier: ^11.1.0 - version: 11.1.0 - packages: '@ampproject/remapping@2.3.0': @@ -551,10 +521,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/runtime@7.28.3': - resolution: {integrity: sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==} - engines: {node: '>=6.9.0'} - '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -1908,6 +1874,10 @@ packages: cpu: [x64] os: [win32] + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1920,6 +1890,24 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@otplib/core@13.4.0': + resolution: {integrity: sha512-JqOGcvZQi2wIkEQo8f3/iAjstavpXy6gouIDMHygjNuH6Q0FjbHOiXMdcE94RwfgDNMABhzwUmvaPsxvgm9NYw==} + + '@otplib/hotp@13.4.0': + resolution: {integrity: sha512-MJjE0x06mn2ptymz5qZmQveb+vWFuaIftqE0b5/TZZqUOK7l97cV8lRTmid5BpAQMwJDNLW6RnYxGeCRiNdekw==} + + '@otplib/plugin-base32-scure@13.4.0': + resolution: {integrity: sha512-/t9YWJmMbB8bF5z8mXrBZc2FXBe8B/3hG5FhWr9K8cFwFhyxScbPysmZe8s1UTzSA6N+s8Uv8aIfCtVXPNjJWw==} + + '@otplib/plugin-crypto-noble@13.4.0': + resolution: {integrity: sha512-KrvE4m7Zv+TT1944HzgqFJWJpKb6AyoxDbvhPStmBqdMlv5Gekb80d66cuFRL08kkPgJ5gXUSb5SFpYeB+bACg==} + + '@otplib/totp@13.4.0': + resolution: {integrity: sha512-dK+vl0f0ekzf6mCENRI9AKS2NJUC7OjI3+X8e7QSnhQ2WM7I+i4PGpb3QxKi5hxjTtwVuoZwXR2CFtXdcRtNdQ==} + + '@otplib/uri@13.4.0': + resolution: {integrity: sha512-x1ozBa5bPbdZCrrTL/HK21qchiK7jYElTu+0ft22abeEhiLYgH1+SIULvOcVk3CK8YwF4kdcidvkq4ciejucJA==} + '@parcel/watcher-android-arm64@2.5.6': resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} engines: {node: '>= 10.0.0'} @@ -2492,6 +2480,9 @@ packages: '@schummar/icu-type-parser@1.21.5': resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==} + '@scure/base@2.2.0': + resolution: {integrity: sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==} + '@sinclair/typebox@0.34.40': resolution: {integrity: sha512-gwBNIP8ZAYev/ORDWW0QvxdwPXwxBtLsdsJgSc7eDIRt8ubP+rxUBzPsrwnu16fgEF8Bx4lh/+mvQvJzcTM6Kw==} @@ -2777,6 +2768,9 @@ packages: '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -3306,6 +3300,9 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -3616,6 +3613,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + dedent@1.7.2: resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} peerDependencies: @@ -3676,6 +3677,9 @@ packages: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -4786,11 +4790,6 @@ packages: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} - lucide-react@0.542.0: - resolution: {integrity: sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - lucide-react@0.555.0: resolution: {integrity: sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==} peerDependencies: @@ -4831,9 +4830,6 @@ packages: mdn-data@2.0.30: resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} - memoize-one@5.2.1: - resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} - memorystream@0.3.1: resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} engines: {node: '>= 0.10.0'} @@ -5079,6 +5075,9 @@ packages: ospath@1.2.2: resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==} + otplib@13.4.0: + resolution: {integrity: sha512-RUcYcRMCgRWhUE/XabRppXpUwCwaWBNHe5iPXhdvP8wwDGpGpsIf/kxX/ec3zFsOaM1Oq8lEhUqDwk6W7DHkwg==} + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -5270,6 +5269,10 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + po-parser@2.1.1: resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==} @@ -5778,6 +5781,11 @@ packages: pure-rand@8.4.0: resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.14.2: resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} engines: {node: '>=0.6'} @@ -5843,13 +5851,6 @@ packages: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - react-simple-maps@2.3.0: - resolution: {integrity: sha512-IZVeiPSRZKwD6I/2NvXpQ2uENYGDGZp8DvZjkapcxuJ/LQHTfl+Byb+KNgY7s+iatRA2ad8LnZ3AgqcjziCCsw==} - peerDependencies: - prop-types: ^15.7.2 - react: ^16.8.0 || 17.x - react-dom: ^16.8.0 || 17.x - react-simple-maps@3.0.0: resolution: {integrity: sha512-vKNFrvpPG8Vyfdjnz5Ne1N56rZlDfHXv5THNXOVZMqbX1rWZA48zQuYT03mx6PAKanqarJu/PDLgshIZAfHHqw==} peerDependencies: @@ -5871,13 +5872,6 @@ packages: react-dom: optional: true - react-window@1.8.11: - resolution: {integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==} - engines: {node: '>8.0.0'} - peerDependencies: - react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-window@2.2.7: resolution: {integrity: sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w==} peerDependencies: @@ -5934,6 +5928,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} @@ -6083,10 +6080,6 @@ packages: seq-queue@0.0.5: resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} - serialize-error@12.0.0: - resolution: {integrity: sha512-ZYkZLAvKTKQXWuh5XpBw7CdbSzagarX39WyZ2H07CDLC5/KfsRGlIXV8d4+tfqX1M7916mRqR1QfNHSij+c9Pw==} - engines: {node: '>=18'} - serialize-error@13.0.1: resolution: {integrity: sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA==} engines: {node: '>=20'} @@ -6095,6 +6088,9 @@ packages: resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==} engines: {node: '>=20.0.0'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -6401,9 +6397,6 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} - thenby@1.3.4: - resolution: {integrity: sha512-89Gi5raiWA3QZ4b2ePcEwswC3me9JIg+ToSgtE0JWeCynLnLxNr/f9G+xfo9K+Oj4AFdom8YNJjibIARTJmapQ==} - thenby@1.4.0: resolution: {integrity: sha512-D+BkEl5MpeTbQ1Rz0io/TLZmwmYUAgvUpS1r2eKUvPicYOnpqzjZE6bVa7rSEYv189zkSM+CVc+hG5X7nVe9LQ==} @@ -6662,10 +6655,6 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - uuid@11.1.0: - resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} - hasBin: true - uuid@13.0.0: resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} hasBin: true @@ -6719,6 +6708,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.19: resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} engines: {node: '>= 0.4'} @@ -6758,6 +6750,9 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6778,10 +6773,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -7063,8 +7066,6 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/runtime@7.28.3': {} - '@babel/runtime@7.29.2': {} '@babel/template@7.27.2': @@ -8301,6 +8302,8 @@ snapshots: '@next/swc-win32-x64-msvc@16.2.4': optional: true + '@noble/hashes@2.2.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -8313,6 +8316,33 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@otplib/core@13.4.0': {} + + '@otplib/hotp@13.4.0': + dependencies: + '@otplib/core': 13.4.0 + '@otplib/uri': 13.4.0 + + '@otplib/plugin-base32-scure@13.4.0': + dependencies: + '@otplib/core': 13.4.0 + '@scure/base': 2.2.0 + + '@otplib/plugin-crypto-noble@13.4.0': + dependencies: + '@noble/hashes': 2.2.0 + '@otplib/core': 13.4.0 + + '@otplib/totp@13.4.0': + dependencies: + '@otplib/core': 13.4.0 + '@otplib/hotp': 13.4.0 + '@otplib/uri': 13.4.0 + + '@otplib/uri@13.4.0': + dependencies: + '@otplib/core': 13.4.0 + '@parcel/watcher-android-arm64@2.5.6': optional: true @@ -8793,6 +8823,8 @@ snapshots: '@schummar/icu-type-parser@1.21.5': {} + '@scure/base@2.2.0': {} + '@sinclair/typebox@0.34.40': {} '@sindresorhus/merge-streams@2.3.0': {} @@ -9076,6 +9108,10 @@ snapshots: pg-protocol: 1.13.0 pg-types: 2.2.0 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 25.6.0 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -9568,6 +9604,12 @@ snapshots: client-only@0.0.1: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -9927,6 +9969,8 @@ snapshots: optionalDependencies: supports-color: 8.1.1 + decamelize@1.2.0: {} + dedent@1.7.2: {} deepmerge-ts@7.1.5: {} @@ -9973,6 +10017,8 @@ snapshots: diff@4.0.2: {} + dijkstrajs@1.0.3: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -11409,10 +11455,6 @@ snapshots: lru.min@1.1.4: {} - lucide-react@0.542.0(react@19.2.5): - dependencies: - react: 19.2.5 - lucide-react@0.555.0(react@19.2.5): dependencies: react: 19.2.5 @@ -11448,8 +11490,6 @@ snapshots: mdn-data@2.0.30: {} - memoize-one@5.2.1: {} - memorystream@0.3.1: {} merge-stream@2.0.0: {} @@ -11691,6 +11731,15 @@ snapshots: ospath@1.2.2: {} + otplib@13.4.0: + dependencies: + '@otplib/core': 13.4.0 + '@otplib/hotp': 13.4.0 + '@otplib/plugin-base32-scure': 13.4.0 + '@otplib/plugin-crypto-noble': 13.4.0 + '@otplib/totp': 13.4.0 + '@otplib/uri': 13.4.0 + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -11852,6 +11901,8 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + pngjs@5.0.0: {} + po-parser@2.1.1: {} possible-typed-array-names@1.1.0: {} @@ -12401,6 +12452,12 @@ snapshots: pure-rand@8.4.0: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.14.2: dependencies: side-channel: 1.1.0 @@ -12470,16 +12527,6 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - react-simple-maps@2.3.0(prop-types@15.8.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): - dependencies: - d3-geo: 2.0.2 - d3-selection: 2.0.0 - d3-zoom: 2.0.0 - prop-types: 15.8.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - topojson-client: 3.1.0 - react-simple-maps@3.0.0(prop-types@15.8.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: d3-geo: 2.0.2 @@ -12506,13 +12553,6 @@ snapshots: optionalDependencies: react-dom: 19.2.5(react@19.2.5) - react-window@1.8.11(react-dom@19.2.5(react@19.2.5))(react@19.2.5): - dependencies: - '@babel/runtime': 7.28.3 - memoize-one: 5.2.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-window@2.2.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: react: 19.2.5 @@ -12587,6 +12627,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + resolve-cwd@3.0.0: dependencies: resolve-from: 5.0.0 @@ -12774,10 +12816,6 @@ snapshots: seq-queue@0.0.5: {} - serialize-error@12.0.0: - dependencies: - type-fest: 4.41.0 - serialize-error@13.0.1: dependencies: non-error: 0.1.0 @@ -12785,6 +12823,8 @@ snapshots: serialize-javascript@7.0.5: {} + set-blocking@2.0.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -13142,8 +13182,6 @@ snapshots: glob: 7.2.3 minimatch: 3.1.5 - thenby@1.3.4: {} - thenby@1.4.0: {} thenify-all@1.6.0: @@ -13421,8 +13459,6 @@ snapshots: util-deprecate@1.0.2: {} - uuid@11.1.0: {} - uuid@13.0.0: {} uuid@8.3.2: {} @@ -13489,6 +13525,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.19: dependencies: available-typed-arrays: 1.0.7 @@ -13536,6 +13574,8 @@ snapshots: xtend@4.0.2: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -13547,8 +13587,27 @@ snapshots: yaml@2.8.1: optional: true + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 From 8ce618c5a3ed6f9e2da6e4d180802e9fdcc6db6c Mon Sep 17 00:00:00 2001 From: "Rudy B." Date: Fri, 24 Apr 2026 11:47:55 +0200 Subject: [PATCH 02/43] feat(database): add 2FA schema and migration --- prisma/migrations/20_add_2fa/migration.sql | 72 +++++++++++++++++++++ prisma/schema.prisma | 75 +++++++++++++++++++--- 2 files changed, 137 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/20_add_2fa/migration.sql diff --git a/prisma/migrations/20_add_2fa/migration.sql b/prisma/migrations/20_add_2fa/migration.sql new file mode 100644 index 000000000..b3accc2df --- /dev/null +++ b/prisma/migrations/20_add_2fa/migration.sql @@ -0,0 +1,72 @@ +-- AlterTable +ALTER TABLE "team" ADD COLUMN "two_factor_required" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "user" ADD COLUMN "two_factor_required" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +CREATE TABLE "two_factor_auth" ( + "id" TEXT NOT NULL, + "user_id" UUID NOT NULL, + "secret" TEXT NOT NULL, + "is_enabled" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6) NOT NULL, + + CONSTRAINT "two_factor_auth_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "two_factor_backup_code" ( + "id" TEXT NOT NULL, + "user_id" UUID NOT NULL, + "code_hash" TEXT NOT NULL, + "used" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "two_factor_backup_code_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "two_factor_otp_used" ( + "id" TEXT NOT NULL, + "user_id" UUID NOT NULL, + "otp" TEXT NOT NULL, + "expires_at" TIMESTAMPTZ(6) NOT NULL, + + CONSTRAINT "two_factor_otp_used_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "two_factor_rate_limit" ( + "id" TEXT NOT NULL, + "user_id" UUID NOT NULL, + "attempts" INTEGER NOT NULL DEFAULT 0, + "locked_until" TIMESTAMPTZ(6), + "updated_at" TIMESTAMPTZ(6) NOT NULL, + + CONSTRAINT "two_factor_rate_limit_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "app_setting" ( + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + + CONSTRAINT "app_setting_pkey" PRIMARY KEY ("key") +); + +-- CreateIndex +CREATE UNIQUE INDEX "two_factor_auth_user_id_key" ON "two_factor_auth"("user_id"); + +-- CreateIndex +CREATE INDEX "two_factor_backup_code_user_id_idx" ON "two_factor_backup_code"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "two_factor_otp_used_user_id_otp_key" ON "two_factor_otp_used"("user_id", "otp"); + +-- CreateIndex +CREATE UNIQUE INDEX "two_factor_rate_limit_user_id_key" ON "two_factor_rate_limit"("user_id"); + +-- CreateIndex +CREATE INDEX "session_replay_visit_id_idx" ON "session_replay"("visit_id"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 63c81ec0d..c4c0c0a4f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -18,15 +18,18 @@ model User { displayName String? @map("display_name") @db.VarChar(255) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) - deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + twoFactorRequired Boolean @default(false) @map("two_factor_required") - websites Website[] @relation("user") - createdBy Website[] @relation("createUser") - links Link[] @relation("user") - pixels Pixel[] @relation("user") - teams TeamUser[] - reports Report[] - boards Board[] @relation("user") + websites Website[] @relation("user") + createdBy Website[] @relation("createUser") + links Link[] @relation("user") + pixels Pixel[] @relation("user") + teams TeamUser[] + reports Report[] + boards Board[] @relation("user") + twoFactorAuth TwoFactorAuth? + twoFactorBackupCodes TwoFactorBackupCode[] @@map("user") } @@ -201,13 +204,14 @@ model Team { logoUrl String? @map("logo_url") @db.VarChar(2183) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) - deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + twoFactorRequired Boolean @default(false) @map("two_factor_required") websites Website[] members TeamUser[] links Link[] pixels Pixel[] - boards Board[] + boards Board[] @@index([accessCode]) @@map("team") @@ -398,4 +402,55 @@ model SessionReplaySaved { @@index([visitId]) @@index([websiteId, createdAt]) @@map("session_replay_saved") +} + +model TwoFactorAuth { + id String @id @default(cuid()) + userId String @unique @map("user_id") @db.Uuid + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + secret String + isEnabled Boolean @default(false) @map("is_enabled") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) + + @@map("two_factor_auth") +} + +model TwoFactorBackupCode { + id String @id @default(cuid()) + userId String @map("user_id") @db.Uuid + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + codeHash String @map("code_hash") + used Boolean @default(false) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@index([userId]) + @@map("two_factor_backup_code") +} + +model TwoFactorOtpUsed { + id String @id @default(cuid()) + userId String @map("user_id") @db.Uuid + otp String + expiresAt DateTime @map("expires_at") @db.Timestamptz(6) + + @@unique([userId, otp]) + @@map("two_factor_otp_used") +} + +model TwoFactorRateLimit { + id String @id @default(cuid()) + userId String @unique @map("user_id") @db.Uuid + attempts Int @default(0) + lockedUntil DateTime? @map("locked_until") @db.Timestamptz(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) + + @@map("two_factor_rate_limit") +} + +model AppSetting { + key String @id + value String + + @@map("app_setting") } \ No newline at end of file From 683b956b9faa65f968c803da4e2e69aa66c85ad5 Mon Sep 17 00:00:00 2001 From: "Rudy B." Date: Fri, 24 Apr 2026 12:22:02 +0200 Subject: [PATCH 03/43] feat(2FA): add core library functions for TOTP, backup codes and rate limiting --- src/lib/two-factor/backup-codes.ts | 31 ++++++++++++++++++++ src/lib/two-factor/crypto.ts | 39 +++++++++++++++++++++++++ src/lib/two-factor/rate-limit.ts | 31 ++++++++++++++++++++ src/lib/two-factor/replay-prevention.ts | 24 +++++++++++++++ src/lib/two-factor/totp.ts | 23 +++++++++++++++ 5 files changed, 148 insertions(+) create mode 100644 src/lib/two-factor/backup-codes.ts create mode 100644 src/lib/two-factor/crypto.ts create mode 100644 src/lib/two-factor/rate-limit.ts create mode 100644 src/lib/two-factor/replay-prevention.ts create mode 100644 src/lib/two-factor/totp.ts diff --git a/src/lib/two-factor/backup-codes.ts b/src/lib/two-factor/backup-codes.ts new file mode 100644 index 000000000..a201cf24e --- /dev/null +++ b/src/lib/two-factor/backup-codes.ts @@ -0,0 +1,31 @@ +import { randomBytes } from 'crypto'; +import bcrypt from 'bcryptjs'; + +const BACKUP_CODE_COUNT = 10; +const BCRYPT_ROUNDS = 10; + +export async function generateBackupCodes(): Promise<{ + plaintext: Array; + hashed: Array; +}> { + const plaintext = Array.from( + { length: BACKUP_CODE_COUNT }, + () => + randomBytes(5).toString('hex').toUpperCase() + + '-' + + randomBytes(5).toString('hex').toUpperCase(), + ); + const hashed = await Promise.all(plaintext.map(code => bcrypt.hash(code, BCRYPT_ROUNDS))); + return { plaintext, hashed }; +} + +export async function verifyBackupCode( + inputCode: string, + storedHashes: Array, +): Promise { + for (let i = 0; i < storedHashes.length; i++) { + const match = await bcrypt.compare(inputCode.toUpperCase(), storedHashes[i]); + if (match) return i; + } + return null; +} diff --git a/src/lib/two-factor/crypto.ts b/src/lib/two-factor/crypto.ts new file mode 100644 index 000000000..009109e77 --- /dev/null +++ b/src/lib/two-factor/crypto.ts @@ -0,0 +1,39 @@ +import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from 'crypto'; + +const ALGORITHM = 'aes-256-gcm'; + +/** Loads and validates the 256-bit encryption key from TWO_FACTOR_ENCRYPTION_KEY. */ +function getKey(): Buffer { + const hex = process.env.TWO_FACTOR_ENCRYPTION_KEY; + if (!hex || hex.length !== 64) throw new Error('TWO_FACTOR_ENCRYPTION_KEY is missing or invalid'); + return Buffer.from(hex, 'hex'); +} + +/** Encrypts a TOTP secret using AES-256-GCM. Returns `ciphertext:iv:authTag` as hex. */ +export function encryptSecret(plaintext: string): string { + const key = getKey(); + const iv = randomBytes(12); + const cipher = createCipheriv(ALGORITHM, key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + return `${encrypted.toString('hex')}:${iv.toString('hex')}:${authTag.toString('hex')}`; +} + +/** Decrypts a TOTP secret previously encrypted by {@link encryptSecret}. */ +export function decryptSecret(stored: string): string { + const key = getKey(); + const [ciphertextHex, ivHex, authTagHex] = stored.split(':'); + const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(ivHex, 'hex')); + decipher.setAuthTag(Buffer.from(authTagHex, 'hex')); + const decrypted = Buffer.concat([ + decipher.update(Buffer.from(ciphertextHex, 'hex')), + decipher.final(), + ]); + return decrypted.toString('utf8'); +} + +/** Compares two strings in constant time to prevent timing attacks. */ +export function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + return timingSafeEqual(Buffer.from(a), Buffer.from(b)); +} diff --git a/src/lib/two-factor/rate-limit.ts b/src/lib/two-factor/rate-limit.ts new file mode 100644 index 000000000..9671549df --- /dev/null +++ b/src/lib/two-factor/rate-limit.ts @@ -0,0 +1,31 @@ +import prisma from '@/lib/prisma'; + +const MAX_ATTEMPTS = 5; +const LOCKOUT_MINUTES = 15; + +export async function checkRateLimit( + userId: string, +): Promise<{ allowed: boolean; lockedUntil?: Date }> { + const record = await prisma.client.twoFactorRateLimit.findUnique({ where: { userId } }); + if (!record) return { allowed: true }; + if (record.lockedUntil && record.lockedUntil > new Date()) { + return { allowed: false, lockedUntil: record.lockedUntil }; + } + return { allowed: true }; +} + +export async function recordFailedAttempt(userId: string): Promise { + const record = await prisma.client.twoFactorRateLimit.upsert({ + where: { userId }, + update: { attempts: { increment: 1 }, updatedAt: new Date() }, + create: { userId, attempts: 1, updatedAt: new Date() }, + }); + if (record.attempts >= MAX_ATTEMPTS) { + const lockedUntil = new Date(Date.now() + LOCKOUT_MINUTES * 60 * 1000); + await prisma.client.twoFactorRateLimit.update({ where: { userId }, data: { lockedUntil } }); + } +} + +export async function resetRateLimit(userId: string): Promise { + await prisma.client.twoFactorRateLimit.deleteMany({ where: { userId } }); +} diff --git a/src/lib/two-factor/replay-prevention.ts b/src/lib/two-factor/replay-prevention.ts new file mode 100644 index 000000000..46f8dc3f9 --- /dev/null +++ b/src/lib/two-factor/replay-prevention.ts @@ -0,0 +1,24 @@ +import prisma from '@/lib/prisma'; + +/** Records a one-time password as used so it cannot be reused within the next 90 seconds. */ +export async function markOtpUsed(userId: string, otp: string): Promise { + const expiresAt = new Date(Date.now() + 90 * 1000); + await prisma.client.twoFactorOtpUsed.upsert({ + where: { userId_otp: { userId, otp } }, + update: { expiresAt }, + create: { userId, otp, expiresAt }, + }); +} + +/** Returns true if this one-time password was already used recently, indicating a replay attack. Expired records are cleaned up automatically. */ +export async function isOtpReplayed(userId: string, otp: string): Promise { + const record = await prisma.client.twoFactorOtpUsed.findUnique({ + where: { userId_otp: { userId, otp } }, + }); + if (!record) return false; + if (record.expiresAt < new Date()) { + await prisma.client.twoFactorOtpUsed.delete({ where: { userId_otp: { userId, otp } } }); + return false; + } + return true; +} diff --git a/src/lib/two-factor/totp.ts b/src/lib/two-factor/totp.ts new file mode 100644 index 000000000..b5cbe6c48 --- /dev/null +++ b/src/lib/two-factor/totp.ts @@ -0,0 +1,23 @@ +import { generateSecret, generateURI, verify } from 'otplib'; +import qrcode from 'qrcode'; + +export function generateTotpSecret(): string { + return generateSecret(); +} + +export function generateOtpAuthUri(secret: string, userEmail: string): string { + return generateURI({ + issuer: 'Umami', + label: userEmail, + secret, + }); +} + +export async function generateQrCodeDataUrl(otpAuthUri: string): Promise { + return qrcode.toDataURL(otpAuthUri, { errorCorrectionLevel: 'H' }); +} + +export async function verifyTotp(token: string, secret: string): Promise { + const result = await verify({ secret, token }); + return result.valid; +} From 7c51e1ce4a8e2983a46b5c2dda7d84021e70a1dd Mon Sep 17 00:00:00 2001 From: "Rudy B." Date: Fri, 24 Apr 2026 14:05:20 +0200 Subject: [PATCH 04/43] feat(2FA): create API routes for users --- src/app/api/2fa/disable/route.ts | 104 +++++++++++++++++++++ src/app/api/2fa/setup/cancel/route.ts | 19 ++++ src/app/api/2fa/setup/confirm/route.ts | 67 ++++++++++++++ src/app/api/2fa/setup/initiate/route.ts | 38 ++++++++ src/app/api/2fa/status/route.ts | 44 +++++++++ src/app/api/2fa/verify/route.ts | 116 ++++++++++++++++++++++++ src/queries/prisma/user.ts | 1 + 7 files changed, 389 insertions(+) create mode 100644 src/app/api/2fa/disable/route.ts create mode 100644 src/app/api/2fa/setup/cancel/route.ts create mode 100644 src/app/api/2fa/setup/confirm/route.ts create mode 100644 src/app/api/2fa/setup/initiate/route.ts create mode 100644 src/app/api/2fa/status/route.ts create mode 100644 src/app/api/2fa/verify/route.ts diff --git a/src/app/api/2fa/disable/route.ts b/src/app/api/2fa/disable/route.ts new file mode 100644 index 000000000..566a0d592 --- /dev/null +++ b/src/app/api/2fa/disable/route.ts @@ -0,0 +1,104 @@ +import { z } from 'zod'; +import prisma from '@/lib/prisma'; +import { parseRequest } from '@/lib/request'; +import { badRequest, forbidden, json } from '@/lib/response'; +import { decryptSecret } from '@/lib/two-factor/crypto'; +import { checkRateLimit, recordFailedAttempt, resetRateLimit } from '@/lib/two-factor/rate-limit'; +import { isOtpReplayed, markOtpUsed } from '@/lib/two-factor/replay-prevention'; +import { verifyTotp } from '@/lib/two-factor/totp'; +import { checkPassword } from '@/lib/password'; +import { getUser } from '@/queries/prisma/user'; + +export async function POST(request: Request) { + const schema = z.object({ + password: z.string(), + token: z.string().length(6), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const userId = auth.user.id; + const { password, token } = body; + + // Globally required + const globalSetting = await prisma.client.appSetting.findUnique({ + where: { key: 'twoFactorRequiredGlobal' }, + }); + const isGlobalRequired = globalSetting?.value === 'true'; + + // Required for this user + const userRecord = await prisma.client.user.findUnique({ + where: { id: userId }, + select: { twoFactorRequired: true }, + }); + const isUserRequired = userRecord?.twoFactorRequired ?? false; + + // Required for this user's teams + const userTeams = await prisma.client.teamUser.findMany({ where: { userId } }); + const teamIds = userTeams.map(t => t.teamId); + const teamsWithRequirement = teamIds.length + ? await prisma.client.team.findMany({ + where: { id: { in: teamIds }, twoFactorRequired: true }, + }) + : []; + const isTeamRequired = teamsWithRequirement.length > 0; + + // Cannot disable 2FA if required + if (isGlobalRequired || isUserRequired || isTeamRequired) { + return forbidden({ + code: 'two-factor-error-disable-not-allowed', + message: '2FA is required and cannot be disabled', + }); + } + + // Verify password + const userWithPw = await getUser(userId, { includePassword: true }); + if (!userWithPw || !checkPassword(password, userWithPw.password)) { + return badRequest({ + code: 'two-factor-error-incorrect-password', + message: 'Incorrect password', + }); + } + + // Verify if 2FA is enabled + const twoFactor = await prisma.client.twoFactorAuth.findUnique({ where: { userId } }); + if (!twoFactor?.isEnabled) { + return badRequest({ code: 'two-factor-error-not-enabled', message: '2FA is not enabled' }); + } + + // Verify rate limit + const rateCheck = await checkRateLimit(userId); + if (!rateCheck.allowed) { + return badRequest({ + code: 'two-factor-error-too-many-attempts', + message: 'Too many failed attempts', + lockedUntil: rateCheck.lockedUntil, + }); + } + + // Prevent OTP replay + if (await isOtpReplayed(userId, token)) { + return badRequest({ code: 'two-factor-error-code-used', message: 'Code already used' }); + } + + // Verify TOTP + const secret = decryptSecret(twoFactor.secret); + if (!(await verifyTotp(token, secret))) { + await recordFailedAttempt(userId); + return badRequest({ + code: 'two-factor-error-invalid-code', + message: 'Invalid verification code', + }); + } + + await markOtpUsed(userId, token); + await prisma.client.twoFactorAuth.delete({ where: { userId } }); + await prisma.client.twoFactorBackupCode.deleteMany({ where: { userId } }); + await resetRateLimit(userId); + + return json({ ok: true }); +} diff --git a/src/app/api/2fa/setup/cancel/route.ts b/src/app/api/2fa/setup/cancel/route.ts new file mode 100644 index 000000000..76df6cb68 --- /dev/null +++ b/src/app/api/2fa/setup/cancel/route.ts @@ -0,0 +1,19 @@ +import prisma from '@/lib/prisma'; +import { parseRequest } from '@/lib/request'; +import { json } from '@/lib/response'; + +export async function POST(request: Request) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const userId = auth.user.id; + + await prisma.client.twoFactorAuth.deleteMany({ + where: { userId, isEnabled: false }, + }); + + return json({ ok: true }); +} diff --git a/src/app/api/2fa/setup/confirm/route.ts b/src/app/api/2fa/setup/confirm/route.ts new file mode 100644 index 000000000..6c202b4fb --- /dev/null +++ b/src/app/api/2fa/setup/confirm/route.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; +import prisma from '@/lib/prisma'; +import { parseRequest } from '@/lib/request'; +import { badRequest, json, unauthorized } from '@/lib/response'; +import { generateBackupCodes } from '@/lib/two-factor/backup-codes'; +import { decryptSecret } from '@/lib/two-factor/crypto'; +import { checkRateLimit, recordFailedAttempt, resetRateLimit } from '@/lib/two-factor/rate-limit'; +import { isOtpReplayed, markOtpUsed } from '@/lib/two-factor/replay-prevention'; +import { verifyTotp } from '@/lib/two-factor/totp'; + +export async function POST(request: Request) { + const schema = z.object({ token: z.string().length(6) }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const userId = auth.user.id; + const { token } = body; + + const twoFactor = await prisma.client.twoFactorAuth.findUnique({ where: { userId } }); + + // Verify if 2FA is waiting for setup + if (!twoFactor || twoFactor.isEnabled) { + return badRequest({ message: 'No pending 2FA setup found' }); + } + + // Verify rate limit + const rateCheck = await checkRateLimit(userId); + if (!rateCheck.allowed) { + return badRequest({ + code: 'rate-limited', + message: 'Too many failed attempts', + lockedUntil: rateCheck.lockedUntil, + }); + } + + // Prevent OTP replay + if (await isOtpReplayed(userId, token)) { + return badRequest({ code: 'otp-replayed', message: 'Code already used' }); + } + + // Verify TOTP + const secret = decryptSecret(twoFactor.secret); + if (!(await verifyTotp(token, secret))) { + await recordFailedAttempt(userId); + return badRequest({ + code: 'two-factor-error-invalid-code', + message: 'Invalid verification code', + }); + } + + await prisma.client.twoFactorAuth.update({ where: { userId }, data: { isEnabled: true } }); + await markOtpUsed(userId, token); + await resetRateLimit(userId); + + const { plaintext, hashed } = await generateBackupCodes(); + + await prisma.client.twoFactorBackupCode.deleteMany({ where: { userId } }); + await prisma.client.twoFactorBackupCode.createMany({ + data: hashed.map(codeHash => ({ userId, codeHash })), + }); + + return json({ backupCodes: plaintext }); +} diff --git a/src/app/api/2fa/setup/initiate/route.ts b/src/app/api/2fa/setup/initiate/route.ts new file mode 100644 index 000000000..d55c2f7e6 --- /dev/null +++ b/src/app/api/2fa/setup/initiate/route.ts @@ -0,0 +1,38 @@ +import { parseRequest } from '@/lib/request'; +import { badRequest, json } from '@/lib/response'; +import { encryptSecret } from '@/lib/two-factor/crypto'; +import { + generateOtpAuthUri, + generateQrCodeDataUrl, + generateTotpSecret, +} from '@/lib/two-factor/totp'; +import prisma from '@/lib/prisma'; +import { getUser } from '@/queries/prisma/user'; + +export async function POST(request: Request) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const userId = auth.user.id; + const user = await getUser(userId); + + if (!user) { + return badRequest({ message: 'User not found' }); + } + + const secret = generateTotpSecret(); + const encryptedSecret = encryptSecret(secret); + const otpAuthUri = generateOtpAuthUri(secret, user.username); + const qrCodeDataUrl = await generateQrCodeDataUrl(otpAuthUri); + + await prisma.client.twoFactorAuth.upsert({ + where: { userId }, + update: { secret: encryptedSecret, isEnabled: false }, + create: { userId, secret: encryptedSecret, isEnabled: false }, + }); + + return json({ qrCodeDataUrl, manualKey: secret }); +} diff --git a/src/app/api/2fa/status/route.ts b/src/app/api/2fa/status/route.ts new file mode 100644 index 000000000..4da943af8 --- /dev/null +++ b/src/app/api/2fa/status/route.ts @@ -0,0 +1,44 @@ +import prisma from '@/lib/prisma'; +import { parseRequest } from '@/lib/request'; +import { json } from '@/lib/response'; + +export async function GET(request: Request) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const userId = auth.user.id; + + const twoFactor = await prisma.client.twoFactorAuth.findUnique({ where: { userId } }); + const isEnabled = twoFactor?.isEnabled ?? false; + + // Globally required + const globalSetting = await prisma.client.appSetting.findUnique({ + where: { key: 'twoFactorRequiredGlobal' }, + }); + const isGlobalRequired = globalSetting?.value === 'true'; + if (isGlobalRequired) return json({ isEnabled, isRequired: true, requiredReason: 'global' }); + + // Required for this user + const userRecord = await prisma.client.user.findUnique({ + where: { id: userId }, + select: { twoFactorRequired: true }, + }); + const isUserRequired = userRecord?.twoFactorRequired ?? false; + if (isUserRequired) return json({ isEnabled, isRequired: true, requiredReason: 'user' }); + + // Required for this user's teams + const userTeams = await prisma.client.teamUser.findMany({ where: { userId } }); + const teamIds = userTeams.map(t => t.teamId); + const teamsWithRequirement = teamIds.length + ? await prisma.client.team.findMany({ + where: { id: { in: teamIds }, twoFactorRequired: true }, + }) + : []; + const isTeamRequired = teamsWithRequirement.length > 0; + if (isTeamRequired) return json({ isEnabled, isRequired: true, requiredReason: 'team' }); + + return json({ isEnabled, isRequired: false, requiredReason: null }); +} diff --git a/src/app/api/2fa/verify/route.ts b/src/app/api/2fa/verify/route.ts new file mode 100644 index 000000000..76d108d0e --- /dev/null +++ b/src/app/api/2fa/verify/route.ts @@ -0,0 +1,116 @@ +import { z } from 'zod'; +import prisma from '@/lib/prisma'; +import { getBearerToken, saveAuth } from '@/lib/auth'; +import { ROLES } from '@/lib/constants'; +import { secret } from '@/lib/crypto'; +import { createSecureToken, parseSecureToken } from '@/lib/jwt'; +import { parseRequest } from '@/lib/request'; +import { badRequest, json, unauthorized } from '@/lib/response'; +import { verifyBackupCode } from '@/lib/two-factor/backup-codes'; +import { decryptSecret } from '@/lib/two-factor/crypto'; +import { checkRateLimit, recordFailedAttempt, resetRateLimit } from '@/lib/two-factor/rate-limit'; +import { isOtpReplayed, markOtpUsed } from '@/lib/two-factor/replay-prevention'; +import { verifyTotp } from '@/lib/two-factor/totp'; +import { getAllUserTeams, getUser } from '@/queries/prisma'; +import redis from '@/lib/redis'; + +export async function POST(request: Request) { + const schema = z.union([ + z.object({ token: z.string().length(6), backupCode: z.undefined() }), + z.object({ backupCode: z.string().min(1), token: z.undefined() }), + ]); + + // Validate partial auth token — skip normal session auth + const rawToken = getBearerToken(request); + if (!rawToken) { + return unauthorized({ code: 'missing-token' }); + } + + const payload = parseSecureToken(rawToken, secret()) as any; + if (!payload || payload.type !== 'partial-auth' || !payload.userId) { + return unauthorized({ code: 'invalid-partial-token' }); + } + + const { body, error } = await parseRequest(request, schema, { skipAuth: true }); + if (error) { + return error(); + } + + const userId = payload.userId as string; + const user = await getUser(userId); + + if (!user) { + return unauthorized(); + } + + const twoFactor = await prisma.client.twoFactorAuth.findUnique({ where: { userId } }); + + if (!twoFactor?.isEnabled) { + return badRequest({ message: '2FA not enabled for this user' }); + } + + const rateCheck = await checkRateLimit(userId); + if (!rateCheck.allowed) { + return Response.json( + { + error: { + code: 'rate-limited', + message: 'Too many failed attempts', + lockedUntil: rateCheck.lockedUntil, + }, + }, + { status: 429 }, + ); + } + + if (body.backupCode) { + const unusedCodes = await prisma.client.twoFactorBackupCode.findMany({ + where: { userId, used: false }, + }); + const hashes = unusedCodes.map(c => c.codeHash); + const matchIndex = await verifyBackupCode(body.backupCode, hashes); + + if (matchIndex === null) { + await recordFailedAttempt(userId); + return badRequest({ code: 'invalid-backup-code', message: 'Invalid backup code' }); + } + + await prisma.client.twoFactorBackupCode.update({ + where: { id: unusedCodes[matchIndex].id }, + data: { used: true }, + }); + await resetRateLimit(userId); + } else { + const { token } = body; + + if (await isOtpReplayed(userId, token)) { + return badRequest({ code: 'otp-replayed', message: 'Code already used' }); + } + + const decryptedSecret = decryptSecret(twoFactor.secret); + + if (!(await verifyTotp(token, decryptedSecret))) { + await recordFailedAttempt(userId); + return badRequest({ code: 'invalid-code', message: 'Invalid verification code' }); + } + + await markOtpUsed(userId, token); + await resetRateLimit(userId); + } + + const { id, role, createdAt, username } = user; + + let fullToken: string; + if (redis.enabled) { + fullToken = await saveAuth({ userId: id, role }); + } else { + fullToken = createSecureToken({ userId: id, role }, secret()); + } + + const teams = await getAllUserTeams(id); + + return json({ + token: fullToken, + user: { id, username, role, createdAt, isAdmin: role === ROLES.admin, teams }, + }); +} diff --git a/src/queries/prisma/user.ts b/src/queries/prisma/user.ts index 467ea1e02..0de5a71b7 100644 --- a/src/queries/prisma/user.ts +++ b/src/queries/prisma/user.ts @@ -26,6 +26,7 @@ async function findUser(criteria: Prisma.UserFindUniqueArgs, options: GetUserOpt password: includePassword, role: true, createdAt: true, + twoFactorRequired: true, }, }); } From ab73667d7bad86b285bee4ee7988fbbaaaa64a95 Mon Sep 17 00:00:00 2001 From: "Rudy B." Date: Fri, 24 Apr 2026 14:38:58 +0200 Subject: [PATCH 05/43] feat(2FA): create API routes for admins --- src/app/api/admin/2fa/global/route.ts | 29 ++++++++++++ src/app/api/admin/teams/[teamId]/2fa/route.ts | 29 ++++++++++++ src/app/api/admin/users/[userId]/2fa/route.ts | 45 +++++++++++++++++++ src/permissions/link.ts | 6 ++- src/permissions/pixel.ts | 6 ++- src/permissions/team.ts | 8 ++++ src/permissions/user.ts | 8 ++++ 7 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 src/app/api/admin/2fa/global/route.ts create mode 100644 src/app/api/admin/teams/[teamId]/2fa/route.ts create mode 100644 src/app/api/admin/users/[userId]/2fa/route.ts diff --git a/src/app/api/admin/2fa/global/route.ts b/src/app/api/admin/2fa/global/route.ts new file mode 100644 index 000000000..ee9640154 --- /dev/null +++ b/src/app/api/admin/2fa/global/route.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; +import prisma from '@/lib/prisma'; +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { canEnforceTwoFactorAuthForEveryone } from '@/permissions'; + +export async function POST(request: Request) { + const schema = z.object({ required: z.boolean() }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + if (!(await canEnforceTwoFactorAuthForEveryone(auth))) { + return unauthorized(); + } + + const { required } = body; + + await prisma.client.appSetting.upsert({ + where: { key: 'twoFactorRequiredGlobal' }, + update: { value: String(required) }, + create: { key: 'twoFactorRequiredGlobal', value: String(required) }, + }); + + return json({ ok: true, required }); +} diff --git a/src/app/api/admin/teams/[teamId]/2fa/route.ts b/src/app/api/admin/teams/[teamId]/2fa/route.ts new file mode 100644 index 000000000..bc3a66806 --- /dev/null +++ b/src/app/api/admin/teams/[teamId]/2fa/route.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; +import prisma from '@/lib/prisma'; +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { canEnforceTwoFactorAuthForTeam } from '@/permissions'; + +export async function POST(request: Request, { params }: { params: Promise<{ teamId: string }> }) { + const schema = z.object({ required: z.boolean() }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { teamId } = await params; + + if (!(await canEnforceTwoFactorAuthForTeam(auth, teamId))) { + return unauthorized(); + } + const { required } = body; + + await prisma.client.team.update({ + where: { id: teamId }, + data: { twoFactorRequired: required }, + }); + + return json({ ok: true, teamId, twoFactorRequired: required }); +} diff --git a/src/app/api/admin/users/[userId]/2fa/route.ts b/src/app/api/admin/users/[userId]/2fa/route.ts new file mode 100644 index 000000000..7cb09cd64 --- /dev/null +++ b/src/app/api/admin/users/[userId]/2fa/route.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; +import prisma from '@/lib/prisma'; +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { updateUser } from '@/queries/prisma/user'; +import { canEnforceTwoFactorAuthForUser } from '@/permissions'; + +export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + if (!(await canEnforceTwoFactorAuthForUser(auth))) { + return unauthorized(); + } + + const { userId } = await params; + + const twoFactor = await prisma.client.twoFactorAuth.findUnique({ where: { userId } }); + + return json({ isEnabled: twoFactor?.isEnabled ?? false }); +} + +export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) { + const schema = z.object({ required: z.boolean() }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + if (!(await canEnforceTwoFactorAuthForUser(auth))) { + return unauthorized(); + } + + const { userId } = await params; + const { required } = body; + + const user = await updateUser(userId, { twoFactorRequired: required }); + + return json({ ok: true, userId: user.id, twoFactorRequired: required }); +} diff --git a/src/permissions/link.ts b/src/permissions/link.ts index f274c7b25..be6eef71a 100644 --- a/src/permissions/link.ts +++ b/src/permissions/link.ts @@ -8,7 +8,11 @@ export async function canViewLink({ user, shareToken }: Auth, linkId: string) { return true; } - if (shareToken?.linkId === linkId || shareToken?.websiteId === linkId || shareToken?.linkIds?.includes(linkId)) { + if ( + shareToken?.linkId === linkId || + shareToken?.websiteId === linkId || + shareToken?.linkIds?.includes(linkId) + ) { return true; } diff --git a/src/permissions/pixel.ts b/src/permissions/pixel.ts index 79b1b5976..061363063 100644 --- a/src/permissions/pixel.ts +++ b/src/permissions/pixel.ts @@ -8,7 +8,11 @@ export async function canViewPixel({ user, shareToken }: Auth, pixelId: string) return true; } - if (shareToken?.pixelId === pixelId || shareToken?.websiteId === pixelId || shareToken?.pixelIds?.includes(pixelId)) { + if ( + shareToken?.pixelId === pixelId || + shareToken?.websiteId === pixelId || + shareToken?.pixelIds?.includes(pixelId) + ) { return true; } diff --git a/src/permissions/team.ts b/src/permissions/team.ts index 130290af2..1925f59c6 100644 --- a/src/permissions/team.ts +++ b/src/permissions/team.ts @@ -90,3 +90,11 @@ export async function canCreateTeamWebsite({ user }: Auth, teamId: string) { export async function canViewAllTeams({ user }: Auth) { return user?.isAdmin ?? false; } + +export async function canEnforceTwoFactorAuthForTeam({ user }: Auth, teamId: string) { + if (!user) { + return false; + } + + return user.isAdmin; +} diff --git a/src/permissions/user.ts b/src/permissions/user.ts index 8aa453aec..bdb59c418 100644 --- a/src/permissions/user.ts +++ b/src/permissions/user.ts @@ -35,3 +35,11 @@ export async function canUpdateUser({ user }: Auth, viewedUserId: string) { export async function canDeleteUser({ user }: Auth) { return user?.isAdmin ?? false; } + +export async function canEnforceTwoFactorAuthForEveryone({ user }: Auth) { + return user?.isAdmin ?? false; +} + +export async function canEnforceTwoFactorAuthForUser({ user }: Auth) { + return user?.isAdmin ?? false; +} From 4a7e3379545ced88d05b9e62ace5ed189abd7a9c Mon Sep 17 00:00:00 2001 From: "Rudy B." Date: Fri, 24 Apr 2026 16:02:39 +0200 Subject: [PATCH 06/43] feat(2FA): create hooks to use API --- src/components/hooks/index.ts | 2 ++ .../hooks/queries/useTwoFactorStatusQuery.ts | 13 +++++++++ .../queries/useTwoFactorVerifyMutation.ts | 29 +++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 src/components/hooks/queries/useTwoFactorStatusQuery.ts create mode 100644 src/components/hooks/queries/useTwoFactorVerifyMutation.ts diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts index d5719d404..844497dc3 100644 --- a/src/components/hooks/index.ts +++ b/src/components/hooks/index.ts @@ -32,6 +32,8 @@ export * from './queries/useLinkQuery'; export * from './queries/useLinkSharesQuery'; export * from './queries/useLinksQuery'; export * from './queries/useLoginQuery'; +export * from './queries/useTwoFactorStatusQuery'; +export * from './queries/useTwoFactorVerifyMutation'; export * from './queries/usePixelQuery'; export * from './queries/usePixelSharesQuery'; export * from './queries/usePixelsQuery'; diff --git a/src/components/hooks/queries/useTwoFactorStatusQuery.ts b/src/components/hooks/queries/useTwoFactorStatusQuery.ts new file mode 100644 index 000000000..17720fece --- /dev/null +++ b/src/components/hooks/queries/useTwoFactorStatusQuery.ts @@ -0,0 +1,13 @@ +import { useApi } from '../useApi'; + +export function useTwoFactorStatusQuery(enabled: boolean) { + const { get, useQuery } = useApi(); + + return useQuery({ + queryKey: ['2fa-status'], + queryFn: () => get('/2fa/status'), + enabled, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + }); +} diff --git a/src/components/hooks/queries/useTwoFactorVerifyMutation.ts b/src/components/hooks/queries/useTwoFactorVerifyMutation.ts new file mode 100644 index 000000000..b869a26dd --- /dev/null +++ b/src/components/hooks/queries/useTwoFactorVerifyMutation.ts @@ -0,0 +1,29 @@ +import { httpPost } from '@/lib/fetch'; +import { useApi } from '../useApi'; + +interface TwoFactorVerifyParams { + partialToken: string; + token?: string; + backupCode?: string; +} + +export function useTwoFactorVerifyMutation() { + const { useMutation } = useApi(); + const basePath = process.env.basePath || ''; + + return useMutation({ + mutationFn: async ({ partialToken, token, backupCode }: TwoFactorVerifyParams) => { + const body = backupCode ? { backupCode } : { token }; + const res = await httpPost(`${basePath}/api/2fa/verify`, body, { + Authorization: `Bearer ${partialToken}`, + }); + + if (!res.ok) { + const { message, code, lockedUntil } = res?.data?.error || {}; + throw Object.assign(new Error(message), { code, status: res.status, lockedUntil }); + } + + return res.data as { token: string; user: any }; + }, + }); +} From c3808ca353c29d6adc05aa6f500bc6d68bdf6c0b Mon Sep 17 00:00:00 2001 From: "Rudy B." Date: Fri, 24 Apr 2026 16:50:30 +0200 Subject: [PATCH 07/43] feat(2FA): implement 2FA step in login flow --- src/app/api/auth/login/route.ts | 10 ++ src/app/login/LoginForm.tsx | 11 +- src/app/login/LoginPage.tsx | 13 +- .../login/two-factor/LoginTwoFactorPage.tsx | 142 ++++++++++++++++++ src/app/login/two-factor/page.tsx | 19 +++ src/components/common/OtpInput.tsx | 94 ++++++++++++ 6 files changed, 284 insertions(+), 5 deletions(-) create mode 100644 src/app/login/two-factor/LoginTwoFactorPage.tsx create mode 100644 src/app/login/two-factor/page.tsx create mode 100644 src/components/common/OtpInput.tsx diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 17ca2f7d9..c5d082e53 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -4,6 +4,7 @@ import { ROLES } from '@/lib/constants'; import { secret } from '@/lib/crypto'; import { createSecureToken } from '@/lib/jwt'; import { checkPassword } from '@/lib/password'; +import prisma from '@/lib/prisma'; import redis from '@/lib/redis'; import { parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; @@ -31,6 +32,15 @@ export async function POST(request: Request) { const { id, role, createdAt } = user; + // Check if 2FA is enabled for this user + const twoFactor = await prisma.client.twoFactorAuth.findUnique({ where: { userId: id } }); + if (twoFactor?.isEnabled) { + const partialToken = createSecureToken({ userId: id, type: 'partial-auth' }, secret(), { + expiresIn: '5m', + }); + return json({ requiresTwoFactor: true, partialToken }); + } + let token: string; if (redis.enabled) { diff --git a/src/app/login/LoginForm.tsx b/src/app/login/LoginForm.tsx index 23101b947..90da15c56 100644 --- a/src/app/login/LoginForm.tsx +++ b/src/app/login/LoginForm.tsx @@ -22,9 +22,14 @@ export function LoginForm() { const handleSubmit = async (data: any) => { await mutateAsync(data, { - onSuccess: async ({ token, user }) => { - setClientAuthToken(token); - setUser(user); + onSuccess: async (response: any) => { + if (response.requiresTwoFactor) { + sessionStorage.setItem('umami.partial-token', response.partialToken); + router.push('/login/two-factor'); + return; + } + setClientAuthToken(response.token); + setUser(response.user); router.push('/'); }, }); diff --git a/src/app/login/LoginPage.tsx b/src/app/login/LoginPage.tsx index 7bb2e35fc..d0aa5ff69 100644 --- a/src/app/login/LoginPage.tsx +++ b/src/app/login/LoginPage.tsx @@ -1,8 +1,9 @@ 'use client'; import { Column } from '@umami/react-zen'; import { LoginForm } from './LoginForm'; +import { PropsWithChildren } from 'react'; -export function LoginPage() { +export function LoginPageWrapper({ children }: PropsWithChildren) { return ( - + {children} ); } + +export function LoginPage() { + return ( + + + + ); +} diff --git a/src/app/login/two-factor/LoginTwoFactorPage.tsx b/src/app/login/two-factor/LoginTwoFactorPage.tsx new file mode 100644 index 000000000..e9c8e6b28 --- /dev/null +++ b/src/app/login/two-factor/LoginTwoFactorPage.tsx @@ -0,0 +1,142 @@ +'use client'; +import { + Button, + Column, + Form, + FormButtons, + FormField, + FormSubmitButton, + Heading, + Icon, + Text, + TextField, +} from '@umami/react-zen'; +import { useRouter } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; +import { OtpInput } from '@/components/common/OtpInput'; +import { useMessages, useTwoFactorVerifyMutation } from '@/components/hooks'; +import { Logo } from '@/components/svg'; +import { setClientAuthToken } from '@/lib/client'; +import { setUser } from '@/store/app'; + +export function LoginTwoFactorPage() { + const { t, labels, messages, getErrorMessage } = useMessages(); + const router = useRouter(); + const { mutateAsync, isPending } = useTwoFactorVerifyMutation(); + const partialTokenRef = useRef(null); + const [useBackup, setUseBackup] = useState(false); + const [otpValue, setOtpValue] = useState(''); + const [lockUntil, setLockUntil] = useState(null); + const [error, setError] = useState(null); + + // Redirect to the login page when no token + useEffect(() => { + const token = sessionStorage.getItem('umami.partial-token'); + if (!token) { + router.replace('/login'); + return; + } + sessionStorage.removeItem('umami.partial-token'); + partialTokenRef.current = token; + }, [router]); + + // Keep the locked state until the time is up + useEffect(() => { + if (!lockUntil) return; + const interval = setInterval(() => { + if (new Date() >= lockUntil) { + setLockUntil(null); + } + }, 1000); + return () => clearInterval(interval); + }, [lockUntil]); + + const submitVerification = async (token?: string, backupCode?: string) => { + const partialToken = partialTokenRef.current; + if (!partialToken) { + router.replace('/login'); + return; + } + + setError(null); + + try { + const data = await mutateAsync({ partialToken, token, backupCode }); + setClientAuthToken(data.token); + setUser(data.user); + router.push('/'); + } catch (err: any) { + if (err.lockedUntil) { + setLockUntil(new Date(err.lockedUntil)); + } + setError(getErrorMessage(err)); + } + }; + + const handleSubmit = async (data: any) => { + await submitVerification( + useBackup ? undefined : otpValue, + useBackup ? data.backupCode : undefined, + ); + }; + + const lockMessage = lockUntil + ? t(messages.twoFactorLockedMessage, { + time: lockUntil.toLocaleTimeString(), + }) + : null; + + return ( + + + + + umami + + {t(labels.twoFactorLogin)} + {t(messages.twoFactorLoginDescription)} + + {!!lockUntil && lockMessage && ( + {lockMessage} + )} + +
+ {!useBackup ? ( + + {t(labels.twoFactorEnterCode)} + { + setOtpValue(val); + if (error) setError(null); + }} + onComplete={val => submitVerification(val)} + disabled={!!lockUntil || isPending} + /> + + ) : ( + + + + )} + + + + {t(labels.twoFactorVerify)} + + +
+ + +
+
+ ); +} diff --git a/src/app/login/two-factor/page.tsx b/src/app/login/two-factor/page.tsx new file mode 100644 index 000000000..3f05bf7ec --- /dev/null +++ b/src/app/login/two-factor/page.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from 'next'; +import { LoginTwoFactorPage } from './LoginTwoFactorPage'; +import { LoginPageWrapper } from '@/app/login/LoginPage'; + +export default async function () { + if (process.env.DISABLE_LOGIN || process.env.CLOUD_MODE) { + return null; + } + + return ( + + + + ); +} + +export const metadata: Metadata = { + title: 'Two-factor authentication', +}; diff --git a/src/components/common/OtpInput.tsx b/src/components/common/OtpInput.tsx new file mode 100644 index 000000000..dfce36dc5 --- /dev/null +++ b/src/components/common/OtpInput.tsx @@ -0,0 +1,94 @@ +'use client'; +import { useRef, ClipboardEvent, KeyboardEvent } from 'react'; +import {Row, TextField} from '@umami/react-zen'; + +interface OtpInputProps { + value: string; + onChange: (value: string) => void; + onComplete?: (value: string) => void; + disabled?: boolean; +} + +const LENGTH = 6; + +const focusInput = (el: HTMLDivElement | null) => el?.querySelector('input')?.focus(); + +export function OtpInput({ value, onChange, onComplete, disabled }: OtpInputProps) { + const digits = Array.from({ length: LENGTH }, (_, i) => value?.[i] ?? ''); + const refs = useRef<(HTMLDivElement | null)[]>([]); + + const update = (index: number, char: string) => { + const next = digits.slice(); + next[index] = char; + const newVal = next.join(''); + onChange(newVal); + if (newVal.length === LENGTH) { + onComplete?.(newVal); + } + }; + + const handleInput = (index: number, raw: string) => { + const char = raw.replace(/\D/g, '').slice(-1); + if (!char) return; + update(index, char); + if (index < LENGTH - 1) { + focusInput(refs.current[index + 1]); + } + }; + + const handleKeyDown = (index: number, e: KeyboardEvent) => { + if (e.key === 'Backspace') { + if (digits[index]) { + update(index, ''); + } else if (index > 0) { + update(index - 1, ''); + focusInput(refs.current[index - 1]); + } + } else if (e.key === 'ArrowLeft' && index > 0) { + focusInput(refs.current[index - 1]); + } else if (e.key === 'ArrowRight' && index < LENGTH - 1) { + focusInput(refs.current[index + 1]); + } + }; + + const handlePaste = (e: ClipboardEvent) => { + e.preventDefault(); + const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, LENGTH); + const next = Array.from({ length: LENGTH }, (_, i) => pasted[i] ?? ''); + const newVal = next.join(''); + onChange(newVal); + if (newVal.length === LENGTH) { + onComplete?.(newVal); + focusInput(refs.current[LENGTH - 1]); + } else { + focusInput(refs.current[pasted.length]); + } + }; + + return ( + + {digits.map((digit, i) => ( +
{ + refs.current[i] = el; + }} + > + handleInput(i, char)} + onKeyDown={e => handleKeyDown(i, e)} + onPaste={handlePaste} + isDisabled={disabled} + inputMode="numeric" + maxLength={1} + autoComplete="one-time-code" + aria-label={`Digit ${i + 1}`} + placeholder="0" + style={{width: 32}} + /> +
+ ))} +
+ ); +} From 96bdafac807a29588150682d69aa5f5fe80352a3 Mon Sep 17 00:00:00 2001 From: "Rudy B." Date: Fri, 24 Apr 2026 18:39:46 +0200 Subject: [PATCH 08/43] feat(2FA): create modals (setup/disable/success) --- .../modals/TwoFactorDisableModal.tsx | 64 ++++++ .../modals/TwoFactorSetupModal.module.css | 9 + src/components/modals/TwoFactorSetupModal.tsx | 211 ++++++++++++++++++ .../modals/TwoFactorSuccessModal.tsx | 88 ++++++++ 4 files changed, 372 insertions(+) create mode 100644 src/components/modals/TwoFactorDisableModal.tsx create mode 100644 src/components/modals/TwoFactorSetupModal.module.css create mode 100644 src/components/modals/TwoFactorSetupModal.tsx create mode 100644 src/components/modals/TwoFactorSuccessModal.tsx diff --git a/src/components/modals/TwoFactorDisableModal.tsx b/src/components/modals/TwoFactorDisableModal.tsx new file mode 100644 index 000000000..859d2df75 --- /dev/null +++ b/src/components/modals/TwoFactorDisableModal.tsx @@ -0,0 +1,64 @@ +'use client'; +import { Button, Column, Dialog, Modal, Row, Text } from '@umami/react-zen'; +import { Form, FormButtons, FormField, FormSubmitButton, PasswordField } from '@umami/react-zen'; +import { useState } from 'react'; +import { OtpInput } from '@/components/common/OtpInput'; +import { useMessages, useUpdateQuery } from '@/components/hooks'; + +interface TwoFactorDisableModalProps { + onClose: () => void; + onSuccess: () => void; +} + +export function TwoFactorDisableModal({ onClose, onSuccess }: TwoFactorDisableModalProps) { + const { t, labels, messages, getErrorMessage } = useMessages(); + const [otpValue, setOtpValue] = useState(''); + const { mutateAsync: disable, error, isPending, reset } = useUpdateQuery('/2fa/disable'); + + const handleSubmit = async (data: any) => { + await disable({ password: data.password, token: otpValue }, { onSuccess }).catch(() => {}); + }; + + const resetError = () => { + if (error) reset(); + }; + + return ( + + + {({ close }) => ( + + {t(messages.twoFactorDisableDescription)} +
+ + + + + {t(labels.twoFactorEnterCode)} + { + setOtpValue(val); + resetError(); + }} + disabled={isPending} + /> + + + + + {t(labels.twoFactorDisable)} + + +
+
+ )} +
+
+ ); +} diff --git a/src/components/modals/TwoFactorSetupModal.module.css b/src/components/modals/TwoFactorSetupModal.module.css new file mode 100644 index 000000000..aa2d0071b --- /dev/null +++ b/src/components/modals/TwoFactorSetupModal.module.css @@ -0,0 +1,9 @@ +.twoFactorSetupModal { + width: 700px; + max-width: 95vw; +} + +.qrCodeImage { + width: 33%; + aspect-ratio: 1; +} diff --git a/src/components/modals/TwoFactorSetupModal.tsx b/src/components/modals/TwoFactorSetupModal.tsx new file mode 100644 index 000000000..85e9b9512 --- /dev/null +++ b/src/components/modals/TwoFactorSetupModal.tsx @@ -0,0 +1,211 @@ +'use client'; +import { + AlertBanner, + Box, + Button, + Code, + Column, + Dialog, + Icon, + Image, + Modal, + Row, + Tag, + TagGroup, + Text, +} from '@umami/react-zen'; +import { useQueryClient } from '@tanstack/react-query'; +import { ReactNode, useEffect, useState } from 'react'; +import { OtpInput } from '@/components/common/OtpInput'; +import { useMessages, useUpdateQuery } from '@/components/hooks'; +import { TwoFactorSuccessModal } from './TwoFactorSuccessModal'; +import styles from './TwoFactorSetupModal.module.css'; +import { LucideCopy } from 'lucide-react'; + +function Step({ + tag, + title, + details, + children, +}: { + tag: string; + title: string; + details: string; + children: ReactNode; +}) { + return ( + + + + + {tag} + + {title} + + {details} + + {children} + + ); +} + +interface TwoFactorSetupModalProps { + required: boolean; + onClose?: () => void; +} + +export function TwoFactorSetupModal({ required, onClose }: TwoFactorSetupModalProps) { + const { t, labels, messages } = useMessages(); + const [qrCodeDataUrl, setQrCodeDataUrl] = useState(null); + const [manualKey, setManualKey] = useState(null); + const [otpValue, setOtpValue] = useState(''); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + const [backupCodes, setBackupCodes] = useState(null); + + const queryClient = useQueryClient(); + const { mutateAsync: initiate } = useUpdateQuery('/2fa/setup/initiate'); + const { mutateAsync: confirm, isPending: isConfirming } = useUpdateQuery('/2fa/setup/confirm'); + const { mutateAsync: cancel } = useUpdateQuery('/2fa/setup/cancel'); + + useEffect(() => { + initiate({}) + .then((data: any) => { + setQrCodeDataUrl(data.qrCodeDataUrl); + setManualKey(data.manualKey); + }) + .catch(() => setError(t(messages.error))); + }, []); + + useEffect(() => { + console.log({ lengthTracked: otpValue.length }); + }, [otpValue.length]); + + const handleConfirm = async (value?: string) => { + const token = value ?? otpValue; + if (token.length !== 6) return; + setError(null); + try { + const data: any = await confirm({ token }); + setBackupCodes(data.backupCodes); + } catch (err: any) { + setError(err.message || t(messages.error)); + } + }; + + const handleCancel = async () => { + await cancel({}).catch(() => {}); + onClose?.(); + }; + + const handleCopy = () => { + if (manualKey) { + navigator.clipboard.writeText(manualKey); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + if (backupCodes) { + return ( + { + queryClient.invalidateQueries({ queryKey: ['2fa-status'] }); + onClose?.(); + }} + /> + ); + } + + const preventDismiss = required ? () => {} : undefined; + + return ( + + + {({ close }) => ( + + + {required && t(messages.twoFactorSetupRequiredDescription) + ' '} + {t(messages.twoFactorSetupDescription)} + + + {/* Step 1 - Scan QR Code */} + + + + {qrCodeDataUrl && ( + QR code + )} + + {t(labels.twoFactorCantScan)} + + {t(labels.twoFactorManualEntry)} + {manualKey} +
+ +
+
+
+
+
+
+ + {/* Step 2 - Enter Verification Code */} + + + + {t(labels.twoFactorEnterCode)} + + { + setOtpValue(val); + if (error) setError(null); + }} + onComplete={handleConfirm} + disabled={isConfirming} + /> + + + + {error && } + + + {!required && ( + + )} + + +
+ )} +
+
+ ); +} diff --git a/src/components/modals/TwoFactorSuccessModal.tsx b/src/components/modals/TwoFactorSuccessModal.tsx new file mode 100644 index 000000000..51f2dcba4 --- /dev/null +++ b/src/components/modals/TwoFactorSuccessModal.tsx @@ -0,0 +1,88 @@ +'use client'; +import { Button, Column, Dialog, Modal, Row, Text } from '@umami/react-zen'; +import { useState } from 'react'; +import { useMessages } from '@/components/hooks'; + +interface TwoFactorSuccessModalProps { + backupCodes: string[]; + onClose: () => void; +} + +export function TwoFactorSuccessModal({ backupCodes, onClose }: TwoFactorSuccessModalProps) { + const { t, labels, messages } = useMessages(); + const [saved, setSaved] = useState(false); + + const handleDownload = () => { + const content = [ + 'Umami - Two-factor authentication backup codes', + 'Keep these codes safe. Each code can only be used once.', + '', + ...backupCodes, + ].join('\n'); + const blob = new Blob([content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'umami-backup-codes.txt'; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( + {}}> + + {() => ( + + {t(messages.twoFactorEnabledDescription)} + + + {t(labels.twoFactorSaveBackupCodes)} + {t(messages.twoFactorBackupDescription)} + +
+ {backupCodes.map((code, i) => ( + + {code} + + ))} +
+ + + + +
+ + + setSaved(e.target.checked)} + /> + + + + + + +
+ )} +
+
+ ); +} From 2428f292a114af1f6e94f843e91d33fd808560c1 Mon Sep 17 00:00:00 2001 From: "Rudy B." Date: Fri, 24 Apr 2026 19:08:34 +0200 Subject: [PATCH 09/43] feat(translations): add 2FA wording in `en-US` --- public/intl/messages/en-US.json | 54 +++++++++++++++++++++++++++++++-- src/components/messages.ts | 44 +++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/public/intl/messages/en-US.json b/public/intl/messages/en-US.json index 38a7460d3..f88f90959 100644 --- a/public/intl/messages/en-US.json +++ b/public/intl/messages/en-US.json @@ -378,7 +378,35 @@ "website-id": "Website ID", "websites": "Websites", "window": "Window", - "yesterday": "Yesterday" + "yesterday": "Yesterday", + "security": "Security", + "two-factor-setup-title": "Setup two-factor authentication", + "two-factor-step1": "Step 1", + "two-factor-step2": "Step 2", + "two-factor-scan-qr": "Scan QR code", + "two-factor-cant-scan": "Can't scan QR code?", + "two-factor-manual-entry": "Enter this secret instead:", + "two-factor-copy-code": "Copy code", + "two-factor-code-copied": "Copied!", + "two-factor-get-code": "Get verification code", + "two-factor-enter-code": "Enter verification code", + "two-factor-use-backup": "Use a backup code instead", + "two-factor-save-backup-codes": "Save your backup codes", + "two-factor-download-codes": "Download codes", + "two-factor-saved-codes": "I have saved my backup codes", + "two-factor-success-title": "Two-factor authentication enabled", + "two-factor-enable": "Enable 2FA", + "two-factor-disable": "Disable 2FA", + "two-factor-disable-title": "Disable two-factor authentication", + "two-factor-status-not-configured": "Not configured", + "two-factor-active": "Active", + "two-factor-require-global": "Require 2FA for all users", + "two-factor-require-user": "Require 2FA for this user", + "two-factor-require-team": "Require 2FA for all members of this team", + "two-factor-global-active-tooltip": "Global 2FA is enabled", + "two-factor-verify": "Verify", + "two-factor-login": "Two-factor authentication", + "close": "Close" }, "message": { "action-confirmation": "Type {confirmation} in the box below to confirm.", @@ -431,6 +459,28 @@ "user-deleted": "User deleted.", "viewed-page": "Viewed page", "upgrade-required": "This feature requires a {plan} plan subscription.", - "visitor-log": "Visitor from {country} using {browser} on {os} {device}" + "visitor-log": "Visitor from {country} using {browser} on {os} {device}", + "two-factor-setup-description": "Each time you log in, in addition to your password, you'll use an authenticator app to generate and use a one-time code.", + "two-factor-setup-required-description": "Two-factor authentication (2FA) is required to access Umami.", + "two-factor-step1-description": "Scan the QR code below or manually enter the secret key into your authenticator app.", + "two-factor-step2-description": "Enter the 6-digit code you see in your authenticator app.", + "two-factor-enabled-description": "Your account is now protected with two-factor authentication. From now on, you will need to enter a code from your authenticator app each time you log in.", + "two-factor-backup-description": "If you lose access to your authenticator app, you can use one of these one-time backup codes to sign in. Save them somewhere safe; they will not be shown again.", + "two-factor-login-description": "Enter the 6-digit code from your authenticator app.", + "two-factor-disable-description": "To confirm, enter your password and a verification code from your authenticator app.", + "two-factor-required-message": "Two-factor authentication is required for your account and cannot be disabled. Contact your administrator for help.", + "two-factor-override-note": "Per-user and per-team 2FA settings are overridden while global 2FA is enabled.", + "two-factor-admin-global-description": "Require all users to set up two-factor authentication when they next log in. Users who have not yet configured 2FA will be prompted to do so before they can access Umami.", + "two-factor-require-user-description": "When enabled, this user will be prompted to set up 2FA on their next login if they haven't already.", + "two-factor-require-team-description": "When enabled, all members of this team will be prompted to set up 2FA on their next login if they haven't already.", + "two-factor-user-description": "Add an extra layer of security to your account. Each time you log in, you will need your password and a code from your authenticator app.", + "two-factor-error-invalid-code": "Invalid verification code. Please try again.", + "two-factor-error-code-used": "This code has already been used. Wait for the next code.", + "two-factor-error-too-many-attempts": "Too many failed attempts. Your account is temporarily locked.", + "two-factor-error-disable-not-allowed": "Two-factor authentication is required and cannot be disabled.", + "two-factor-error-incorrect-password": "Incorrect password.", + "two-factor-error-not-enabled": "Two-factor authentication is not enabled.", + "two-factor-locked-message": "Too many failed attempts. Try again at {time}.", + "two-factor-active-description": "Your account is protected with two-factor authentication. Each login requires a code from your authenticator app." } } diff --git a/src/components/messages.ts b/src/components/messages.ts index 79adab814..8809d0310 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -382,6 +382,34 @@ export const labels: Record = { duration: 'label.duration', recorded: 'label.recorded', upgrade: 'label.upgrade', + security: 'label.security', + close: 'label.close', + twoFactorSetupTitle: 'label.two-factor-setup-title', + twoFactorStep1: 'label.two-factor-step1', + twoFactorStep2: 'label.two-factor-step2', + twoFactorScanQr: 'label.two-factor-scan-qr', + twoFactorCantScan: 'label.two-factor-cant-scan', + twoFactorManualEntry: 'label.two-factor-manual-entry', + twoFactorCopyCode: 'label.two-factor-copy-code', + twoFactorCodeCopied: 'label.two-factor-code-copied', + twoFactorGetCode: 'label.two-factor-get-code', + twoFactorEnterCode: 'label.two-factor-enter-code', + twoFactorUseBackup: 'label.two-factor-use-backup', + twoFactorSaveBackupCodes: 'label.two-factor-save-backup-codes', + twoFactorDownloadCodes: 'label.two-factor-download-codes', + twoFactorSavedCodes: 'label.two-factor-saved-codes', + twoFactorSuccessTitle: 'label.two-factor-success-title', + twoFactorEnable: 'label.two-factor-enable', + twoFactorDisable: 'label.two-factor-disable', + twoFactorDisableTitle: 'label.two-factor-disable-title', + twoFactorStatusNotConfigured: 'label.two-factor-status-not-configured', + twoFactorActive: 'label.two-factor-active', + twoFactorRequireGlobal: 'label.two-factor-require-global', + twoFactorRequireUser: 'label.two-factor-require-user', + twoFactorRequireTeam: 'label.two-factor-require-team', + twoFactorGlobalActiveTooltip: 'label.two-factor-global-active-tooltip', + twoFactorVerify: 'label.two-factor-verify', + twoFactorLogin: 'label.two-factor-login', }; export const messages: Record = { @@ -432,4 +460,20 @@ export const messages: Record = { notFound: 'message.not-found', serverError: 'message.sever-error', upgradeRequired: 'message.upgrade-required', + twoFactorSetupDescription: 'message.two-factor-setup-description', + twoFactorSetupRequiredDescription: 'message.two-factor-setup-required-description', + twoFactorStep1Description: 'message.two-factor-step1-description', + twoFactorStep2Description: 'message.two-factor-step2-description', + twoFactorEnabledDescription: 'message.two-factor-enabled-description', + twoFactorBackupDescription: 'message.two-factor-backup-description', + twoFactorLoginDescription: 'message.two-factor-login-description', + twoFactorDisableDescription: 'message.two-factor-disable-description', + twoFactorRequiredMessage: 'message.two-factor-required-message', + twoFactorOverrideNote: 'message.two-factor-override-note', + twoFactorAdminGlobalDescription: 'message.two-factor-admin-global-description', + twoFactorRequireUserDescription: 'message.two-factor-require-user-description', + twoFactorRequireTeamDescription: 'message.two-factor-require-team-description', + twoFactorUserDescription: 'message.two-factor-user-description', + twoFactorLockedMessage: 'message.two-factor-locked-message', + twoFactorActiveDescription: 'message.two-factor-active-description', }; From d8006317c8999dd3292dad811a27e71c78d6da9b Mon Sep 17 00:00:00 2001 From: "Rudy B." Date: Fri, 24 Apr 2026 19:09:20 +0200 Subject: [PATCH 10/43] feat(translations): add 2FA wording in `fr-FR` --- public/intl/messages/fr-FR.json | 54 +++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/public/intl/messages/fr-FR.json b/public/intl/messages/fr-FR.json index 982370683..792c079d6 100644 --- a/public/intl/messages/fr-FR.json +++ b/public/intl/messages/fr-FR.json @@ -376,7 +376,35 @@ "website-id": "ID de site", "websites": "Sites", "window": "Fenêtre", - "yesterday": "Hier" + "yesterday": "Hier", + "security": "Sécurité", + "two-factor-setup-title": "Configurer l’authentification à deux facteurs", + "two-factor-step1": "Étape 1", + "two-factor-step2": "Étape 2", + "two-factor-scan-qr": "Scanner le QR code", + "two-factor-cant-scan": "Impossible de scanner le QR code ?", + "two-factor-manual-entry": "Saisissez plutôt cette clé secrète :", + "two-factor-copy-code": "Copier le code", + "two-factor-code-copied": "Copié !", + "two-factor-get-code": "Obtenir le code de vérification", + "two-factor-enter-code": "Saisir le code de vérifiation", + "two-factor-use-backup": "Utiliser plutôt un code de récupération", + "two-factor-save-backup-codes": "Enregistrer vos codes de récupération", + "two-factor-download-codes": "Télécharger les codes", + "two-factor-saved-codes": "J’ai enregistré mes codes de récupération", + "two-factor-success-title": "Authentification à deux facteurs activée", + "two-factor-enable": "Activer l’authentification à deux facteurs", + "two-factor-disable": "Désactiver l’authentification à deux facteurs", + "two-factor-disable-title": "Désactiver l’authentification à deux facteurs", + "two-factor-status-not-configured": "Non configurée", + "two-factor-active": "Activée", + "two-factor-require-global": "Rendre l’authentification à deux facteurs obligatoire pour tous les utilisateurs", + "two-factor-require-user": "Rendre l’authentification à deux facteurs obligatoire pour cet utilisateur", + "two-factor-require-team": "Rendre l’authentification à deux facteurs oblitatoire pour tous les membres de cette équipe", + "two-factor-global-active-tooltip": "L’authentification à deux facteurs est obligatoire pour tous les utilisateurs", + "two-factor-verify": "Vérifier", + "two-factor-login": "Authentification à deux facteurs", + "close": "Fermer" }, "message": { "action-confirmation": "Taper {confirmation} ci-dessous pour confirmer.", @@ -429,6 +457,28 @@ "upgrade-required": "Cette fonctionnalité nécessite un abonnement {plan}.", "user-deleted": "Utilisateur supprimé.", "viewed-page": "Page vue", - "visitor-log": "Visiteur de {country} utilisant {browser} sur {os} {device}" + "visitor-log": "Visiteur de {country} utilisant {browser} sur {os} {device}", + "two-factor-setup-description": "À chaque connexion, en plus de votre mot de passe, vous devrez utiliser une application d’authentification pour générer et utiliser un code unique.", + "two-factor-setup-required-description": "L’authentification à deux facteurs est obligatoire pour accéder à Umami.", + "two-factor-step1-description": "Scannez le QR code ou saisissez manuellement la clé secrète dans votre application d’authentification.", + "two-factor-step2-description": "Saisissez le code à 6 chiffres qui s’affiche dans votre application d’authentification.", + "two-factor-enabled-description": "Votre compte est désormais protégé par l’authentification à deux facteurs. À partir de maintenant, vous devrez saisir un code provenant de votre application d’authentification à chaque fois que vous vous connecterez.", + "two-factor-backup-description": "Si vous perdez l’accès à votre application d’authentification, vous pouvez utiliser l’un de ces codes de récupération pour vous connecter. Enregistrez-les à un endroit sûr ; ils ne seront pas affichés à nouveau.", + "two-factor-login-description": "Saisissez le code à 6 chiffres de votre application d’authentification.", + "two-factor-disable-description": "Pour confirmer, saisissez votre mot de passe et un code de vérification provenant de votre application d’authentification.", + "two-factor-required-message": "L’authentification à deux facteurs est obligatoire pour votre compte et ne peut pas être désactivée. Contactez votre administrateur pour obtenir de l’aide.", + "two-factor-override-note": "Les paramètres d’authentification à deux facteurs au niveau de l’utilisateur et de l’équipe sont ignorés lorsque l’authentification à deux facteurs est activée pour tous les utilisateurs.", + "two-factor-admin-global-description": "Obliger tous les utilisateurs à configurer l’authentification à deux facteurs lors de leur prochaine connexion. Les utilisateurs qui n’ont pas encore configuré l’authentification à deux facteurs seront invités à le faire avant d’accéder à Umami.", + "two-factor-require-user-description": "Lorsque cette option est activée, cet utilisateur sera invité à configurer l’authentification à deux facteurs lors de sa prochaine connexion s’il ne l'a pas déjà fait.", + "two-factor-require-team-description": "Lorsque cette option est activée, tous les membres de cette équipe seront invités à configurer l’authentification à deux facteurs lors de leur prochaine connexion s’ils ne l’ont pas déjà fait.", + "two-factor-user-description": "Ajoutez une couche de sécurité supplémentaire à votre compte. À chaque connexion, vous aurez besoin de votre mot de passe et d'un code provenant de votre application d’authentification.", + "two-factor-error-invalid-code": "Code de vérification invalide. Veuillez réessayer.", + "two-factor-error-code-used": "Ce code a déjà été utilisé. Attendez le prochain code.", + "two-factor-error-too-many-attempts": "Trop de tentatives infructueuses. Votre compte est temporairement verrouillé.", + "two-factor-error-disable-not-allowed": "L’authentification à deux facteurs est obligatoire et ne peut pas être désactivée.", + "two-factor-error-incorrect-password": "Mot de passe incorrect.", + "two-factor-error-not-enabled": "L’authentification à deux facteurs n’est pas activée.", + "two-factor-locked-message": "Trop de tentatives infructueuses. Réessayez à {time}.", + "two-factor-active-description": "Votre compte est protégé par l’authentification à deux facteurs. Chaque connexion nécessite un code provenant de votre application d’authentification." } } From 1b781922537a108b788129eb30849ccdd2be4375 Mon Sep 17 00:00:00 2001 From: "Rudy B." Date: Fri, 24 Apr 2026 19:30:17 +0200 Subject: [PATCH 11/43] feat(2FA): add user security settings page --- src/app/(main)/settings/SettingsNav.tsx | 8 +- .../settings/security/UserSecurityPage.tsx | 94 +++++++++++++++++++ src/app/(main)/settings/security/page.tsx | 10 ++ 3 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 src/app/(main)/settings/security/UserSecurityPage.tsx create mode 100644 src/app/(main)/settings/security/page.tsx diff --git a/src/app/(main)/settings/SettingsNav.tsx b/src/app/(main)/settings/SettingsNav.tsx index 8c97b9d6c..8102ad7a6 100644 --- a/src/app/(main)/settings/SettingsNav.tsx +++ b/src/app/(main)/settings/SettingsNav.tsx @@ -2,7 +2,7 @@ import { Column, Focusable, Row, Text, Tooltip, TooltipTrigger } from '@umami/re import Link from '@/components/common/Link'; import { IconLabel } from '@/components/common/IconLabel'; import { useMessages, useNavigation } from '@/components/hooks'; -import { ArrowLeft, Settings2, UserCircle, Users } from '@/components/icons'; +import { ArrowLeft, Settings2, ShieldCheck, UserCircle, Users } from '@/components/icons'; export function SettingsNav({ isCollapsed, @@ -41,6 +41,12 @@ export function SettingsNav({ path: renderUrl('/settings/teams'), icon: , }, + { + id: 'security', + label: t(labels.security), + path: renderUrl('/settings/security'), + icon: , + }, ], }, ]; diff --git a/src/app/(main)/settings/security/UserSecurityPage.tsx b/src/app/(main)/settings/security/UserSecurityPage.tsx new file mode 100644 index 000000000..2e23786e5 --- /dev/null +++ b/src/app/(main)/settings/security/UserSecurityPage.tsx @@ -0,0 +1,94 @@ +'use client'; +import { Column, Row, Switch, Text, Tooltip, TooltipTrigger } from '@umami/react-zen'; +import { useState } from 'react'; +import { Badge } from '@/components/common/Badge'; +import { PageBody } from '@/components/common/PageBody'; +import { PageHeader } from '@/components/common/PageHeader'; +import { Panel } from '@/components/common/Panel'; +import { useMessages, useTwoFactorStatusQuery } from '@/components/hooks'; +import { TwoFactorDisableModal } from '@/components/modals/TwoFactorDisableModal'; +import { TwoFactorSetupModal } from '@/components/modals/TwoFactorSetupModal'; +import { useQueryClient } from '@tanstack/react-query'; + +export function UserSecurityPage() { + const { t, labels, messages } = useMessages(); + const { data: status, isLoading } = useTwoFactorStatusQuery(true); + const queryClient = useQueryClient(); + const [showSetup, setShowSetup] = useState(false); + const [showDisable, setShowDisable] = useState(false); + + const isEnabled = status?.isEnabled ?? false; + const isRequired = status?.isRequired ?? false; + + const handleToggle = (value: boolean) => { + if (value) { + setShowSetup(true); + } else { + setShowDisable(true); + } + }; + + const handleSetupClose = () => { + setShowSetup(false); + queryClient.invalidateQueries({ queryKey: ['2fa-status'] }); + }; + + const handleDisableSuccess = () => { + setShowDisable(false); + queryClient.invalidateQueries({ queryKey: ['2fa-status'] }); + }; + + if (isLoading) return null; + + return ( + + + + + + + {t(labels.twoFactorAuth)} + {isEnabled ? ( + {t(labels.twoFactorActive)} + ) : ( + {t(labels.twoFactorStatusNotConfigured)} + )} + + + {isEnabled ? ( + {t(messages.twoFactorActiveDescription)} + ) : ( + {t(messages.twoFactorUserDescription)} + )} + + + + + {t(labels.twoFactorEnable)} + + {t(messages.twoFactorRequiredMessage)} + + + {isEnabled && isRequired && ( + + {t(messages.twoFactorRequiredMessage)} + + )} + + + + + {showSetup && } + {showDisable && ( + setShowDisable(false)} + onSuccess={handleDisableSuccess} + /> + )} + + ); +} diff --git a/src/app/(main)/settings/security/page.tsx b/src/app/(main)/settings/security/page.tsx new file mode 100644 index 000000000..e955d0eb5 --- /dev/null +++ b/src/app/(main)/settings/security/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; +import { UserSecurityPage } from './UserSecurityPage'; + +export default function () { + return ; +} + +export const metadata: Metadata = { + title: 'Security', +}; From 2359dbad644ea44b1b37ab2368bddb33f7be0219 Mon Sep 17 00:00:00 2001 From: "Rudy B." Date: Fri, 24 Apr 2026 19:33:29 +0200 Subject: [PATCH 12/43] feat(2FA): add admin security settings pages --- src/app/(main)/admin/AdminNav.tsx | 8 ++- .../admin/security/AdminSecurityPage.tsx | 47 ++++++++++++++ src/app/(main)/admin/security/page.tsx | 10 +++ .../admin/users/[userId]/UserSettings.tsx | 5 ++ .../users/[userId]/UserTwoFactorSettings.tsx | 62 +++++++++++++++++++ 5 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 src/app/(main)/admin/security/AdminSecurityPage.tsx create mode 100644 src/app/(main)/admin/security/page.tsx create mode 100644 src/app/(main)/admin/users/[userId]/UserTwoFactorSettings.tsx diff --git a/src/app/(main)/admin/AdminNav.tsx b/src/app/(main)/admin/AdminNav.tsx index 7df3ff546..511f9c8b1 100644 --- a/src/app/(main)/admin/AdminNav.tsx +++ b/src/app/(main)/admin/AdminNav.tsx @@ -1,7 +1,7 @@ import { IconLabel } from '@/components/common/IconLabel'; import { NavMenu } from '@/components/common/NavMenu'; import { useMessages, useNavigation } from '@/components/hooks'; -import { ArrowLeft, Globe, User, Users } from '@/components/icons'; +import { ArrowLeft, Globe, ShieldCheck, User, Users } from '@/components/icons'; import { Column, Focusable, Row, Tooltip, TooltipTrigger } from '@umami/react-zen'; import Link from '@/components/common/Link'; @@ -31,6 +31,12 @@ export function AdminNav({ onItemClick }: { onItemClick?: () => void }) { path: '/admin/teams', icon: , }, + { + id: 'security', + label: t(labels.security), + path: '/admin/security', + icon: , + }, ], }, ]; diff --git a/src/app/(main)/admin/security/AdminSecurityPage.tsx b/src/app/(main)/admin/security/AdminSecurityPage.tsx new file mode 100644 index 000000000..a962787ae --- /dev/null +++ b/src/app/(main)/admin/security/AdminSecurityPage.tsx @@ -0,0 +1,47 @@ +'use client'; +import { Column, Row, Switch, Text } from '@umami/react-zen'; +import { PageHeader } from '@/components/common/PageHeader'; +import { Panel } from '@/components/common/Panel'; +import { useMessages, useUpdateQuery, useTwoFactorStatusQuery } from '@/components/hooks'; +import { useQueryClient } from '@tanstack/react-query'; + +export function AdminSecurityPage() { + const { t, labels, messages } = useMessages(); + const { data: status, isLoading } = useTwoFactorStatusQuery(true); + const { mutateAsync: setGlobal } = useUpdateQuery('/admin/2fa/global'); + const queryClient = useQueryClient(); + + const isGlobalRequired = status?.requiredReason === 'global' || false; + + const handleToggle = async (value: boolean) => { + await setGlobal({ required: value }); + queryClient.invalidateQueries({ queryKey: ['2fa-status'] }); + }; + + if (isLoading) return null; + + return ( + + + + + + {t(labels.twoFactorAuth)} + {t(messages.twoFactorAdminGlobalDescription)} + + + + + {t(labels.twoFactorRequireGlobal)} + + + {isGlobalRequired && ( + + {t(messages.twoFactorOverrideNote)} + + )} + + + + ); +} diff --git a/src/app/(main)/admin/security/page.tsx b/src/app/(main)/admin/security/page.tsx new file mode 100644 index 000000000..db24cca14 --- /dev/null +++ b/src/app/(main)/admin/security/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; +import { AdminSecurityPage } from './AdminSecurityPage'; + +export default function () { + return ; +} + +export const metadata: Metadata = { + title: 'Security', +}; diff --git a/src/app/(main)/admin/users/[userId]/UserSettings.tsx b/src/app/(main)/admin/users/[userId]/UserSettings.tsx index e2119bb59..3e461fd5a 100644 --- a/src/app/(main)/admin/users/[userId]/UserSettings.tsx +++ b/src/app/(main)/admin/users/[userId]/UserSettings.tsx @@ -1,6 +1,7 @@ import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; import { useMessages } from '@/components/hooks'; import { UserEditForm } from './UserEditForm'; +import { UserTwoFactorSettings } from './UserTwoFactorSettings'; import { UserWebsites } from './UserWebsites'; export function UserSettings({ userId }: { userId: string }) { @@ -12,6 +13,7 @@ export function UserSettings({ userId }: { userId: string }) { {t(labels.details)} {t(labels.websites)} + {t(labels.security)} @@ -19,6 +21,9 @@ export function UserSettings({ userId }: { userId: string }) { + + + ); diff --git a/src/app/(main)/admin/users/[userId]/UserTwoFactorSettings.tsx b/src/app/(main)/admin/users/[userId]/UserTwoFactorSettings.tsx new file mode 100644 index 000000000..864410f4c --- /dev/null +++ b/src/app/(main)/admin/users/[userId]/UserTwoFactorSettings.tsx @@ -0,0 +1,62 @@ +'use client'; +import { Column, Row, Switch, Text, Tooltip, TooltipTrigger } from '@umami/react-zen'; +import { Badge } from '@/components/common/Badge'; +import { useMessages, useUpdateQuery, useTwoFactorStatusQuery } from '@/components/hooks'; +import { useApi } from '@/components/hooks/useApi'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; + +export function UserTwoFactorSettings({ userId }: { userId: string }) { + const { t, labels, messages } = useMessages(); + const { get } = useApi(); + const queryClient = useQueryClient(); + + const { data: globalStatus } = useTwoFactorStatusQuery(true); + const isGlobalRequired = globalStatus?.requiredReason === 'global'; + + const { data: userTfaData } = useQuery({ + queryKey: ['user-2fa-status', userId], + queryFn: () => get(`/admin/users/${userId}/2fa`), + }); + + const { data: userData } = useQuery({ + queryKey: ['user', userId], + queryFn: () => get(`/users/${userId}`), + }); + + const { mutateAsync: setUserRequired } = useUpdateQuery(`/admin/users/${userId}/2fa`); + + const twoFactorEnabled = !!(userTfaData as any)?.isEnabled; + const twoFactorRequired = !!(userData as any)?.twoFactorRequired; + + const handleToggle = async (value: boolean) => { + await setUserRequired({ required: value }); + queryClient.invalidateQueries({ queryKey: ['user', userId] }); + }; + + return ( + + + {t(labels.twoFactorAuth)} + + {twoFactorEnabled ? t(labels.twoFactorActive) : t(labels.twoFactorStatusNotConfigured)} + + + + + {t(messages.twoFactorRequireUserDescription)} + + + + + + {t(labels.twoFactorRequireUser)} + + {t(labels.twoFactorGlobalActiveTooltip)} + + + ); +} From 817dffff4d1c9ce2d50df5b192aa92b2717db310 Mon Sep 17 00:00:00 2001 From: "Rudy B." Date: Fri, 24 Apr 2026 19:36:33 +0200 Subject: [PATCH 13/43] feat(2FA): add 2FA settings for teams --- .../(main)/teams/[teamId]/TeamSettings.tsx | 6 +++ .../teams/[teamId]/TeamTwoFactorSettings.tsx | 50 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 src/app/(main)/teams/[teamId]/TeamTwoFactorSettings.tsx diff --git a/src/app/(main)/teams/[teamId]/TeamSettings.tsx b/src/app/(main)/teams/[teamId]/TeamSettings.tsx index e854c127e..0184b3db6 100644 --- a/src/app/(main)/teams/[teamId]/TeamSettings.tsx +++ b/src/app/(main)/teams/[teamId]/TeamSettings.tsx @@ -9,6 +9,7 @@ import { TeamsMemberAddButton } from '../TeamsMemberAddButton'; import { TeamEditForm } from './TeamEditForm'; import { TeamManage } from './TeamManage'; import { TeamMembersDataTable } from './TeamMembersDataTable'; +import { TeamTwoFactorSettings } from './TeamTwoFactorSettings'; export function TeamSettings({ teamId }: { teamId: string }) { const team: any = useTeam(); @@ -45,6 +46,11 @@ export function TeamSettings({ teamId }: { teamId: string }) { + {isAdmin && ( + + + + )} {isTeamOwner && ( diff --git a/src/app/(main)/teams/[teamId]/TeamTwoFactorSettings.tsx b/src/app/(main)/teams/[teamId]/TeamTwoFactorSettings.tsx new file mode 100644 index 000000000..b5005af5f --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamTwoFactorSettings.tsx @@ -0,0 +1,50 @@ +'use client'; +import { Column, Row, Switch, Text, Tooltip, TooltipTrigger } from '@umami/react-zen'; +import { useMessages, useUpdateQuery, useTwoFactorStatusQuery } from '@/components/hooks'; +import { useApi } from '@/components/hooks/useApi'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; + +export function TeamTwoFactorSettings({ teamId }: { teamId: string }) { + const { t, labels, messages } = useMessages(); + const { get } = useApi(); + const queryClient = useQueryClient(); + + const { data: globalStatus } = useTwoFactorStatusQuery(true); + const isGlobalRequired = globalStatus?.requiredReason === 'global'; + + const { data: teamData } = useQuery({ + queryKey: ['team', teamId], + queryFn: () => get(`/teams/${teamId}`), + }); + + const { mutateAsync: setTeamRequired } = useUpdateQuery(`/admin/teams/${teamId}/2fa`); + + const twoFactorRequired = !!(teamData as any)?.twoFactorRequired; + + const handleToggle = async (value: boolean) => { + await setTeamRequired({ required: value }); + queryClient.invalidateQueries({ queryKey: ['team', teamId] }); + }; + + return ( + + {t(labels.twoFactorAuth)} + + + + + {t(labels.twoFactorRequireTeam)} + + {t(labels.twoFactorGlobalActiveTooltip)} + + + + {t(messages.twoFactorRequireTeamDescription)} + + + ); +} From 399f47c6c7785e0bec091003cd6b98363f40fd4f Mon Sep 17 00:00:00 2001 From: "Rudy B." Date: Fri, 24 Apr 2026 19:37:51 +0200 Subject: [PATCH 14/43] feat(2FA): enfore 2FA from App layout --- src/app/(main)/App.tsx | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/app/(main)/App.tsx b/src/app/(main)/App.tsx index 2f85a86e4..0323ec052 100644 --- a/src/app/(main)/App.tsx +++ b/src/app/(main)/App.tsx @@ -5,7 +5,14 @@ import { useEffect } from 'react'; import { MobileNav } from '@/app/(main)/MobileNav'; import { SideNav } from '@/app/(main)/SideNav'; import { TopNav } from '@/app/(main)/TopNav'; -import { useConfig, useLoginQuery, useNavigation, useTeamQuery } from '@/components/hooks'; +import { + useConfig, + useLoginQuery, + useNavigation, + useTeamQuery, + useTwoFactorStatusQuery, +} from '@/components/hooks'; +import { TwoFactorSetupModal } from '@/components/modals/TwoFactorSetupModal'; import { LAST_TEAM_CONFIG } from '@/lib/constants'; import { removeItem, setItem } from '@/lib/storage'; import { UpdateNotice } from './UpdateNotice'; @@ -15,6 +22,8 @@ export function App({ children }) { const config = useConfig(); const { pathname, router, teamId } = useNavigation(); const { isLoading: isTeamLoading, error: teamError } = useTeamQuery(teamId); + const { data: twoFactorStatus } = useTwoFactorStatusQuery(!!user); + const needsTwoFactorSetup = !!(twoFactorStatus?.isRequired && !twoFactorStatus?.isEnabled); useEffect(() => { if (teamId) { @@ -64,8 +73,15 @@ export function App({ children }) { - {children} + + {children} + + {needsTwoFactorSetup && } {process.env.NODE_ENV === 'production' && !pathname.includes('/share/') && (