Merge pull request #4205 from boutterudy/feat-2FA

feat(login): add 2FA
This commit is contained in:
Mike Cao authored and GitHub committed 2026-07-23 11:45:20 -07:00
commit 36bd9d33b8
50 files changed
+2606 -184

No files matched your search

+14
View File
@@ -99,6 +99,16 @@
"next-intl": "4.13.0",
"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",
"react-resizable-panels": "^4.10.0",
"papaparse": "^5.5.4",
"pg": "^8.22.0",
"prisma": "^7.8.0",
@@ -134,6 +144,10 @@
"@rollup/plugin-replace": "^6.0.3",
"@rollup/plugin-terser": "^1.0.0",
"@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",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
+395 -12
View File
@@ -137,6 +137,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.4
version: 5.5.4
@@ -247,8 +250,11 @@ importers:
specifier: ^14.6.1
version: 14.6.1(@testing-library/dom@10.4.1)
'@types/node':
specifier: ^26.0.1
version: 26.0.1
specifier: ^25.6.0
version: 25.6.0
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
'@types/react':
specifier: ^19.2.17
version: 19.2.17
@@ -470,6 +476,97 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/plugin-syntax-async-generators@7.8.4':
resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-syntax-bigint@7.8.3':
resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-syntax-class-properties@7.12.13':
resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-syntax-class-static-block@7.14.5':
resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-syntax-import-attributes@7.28.6':
resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-syntax-import-meta@7.10.4':
resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-syntax-json-strings@7.8.3':
resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-syntax-jsx@7.28.6':
resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-syntax-logical-assignment-operators@7.10.4':
resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-syntax-nullish-coalescing-operator@7.8.3':
resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-syntax-numeric-separator@7.10.4':
resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-syntax-object-rest-spread@7.8.3':
resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-syntax-optional-catch-binding@7.8.3':
resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-syntax-optional-chaining@7.8.3':
resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-syntax-private-property-in-object@7.14.5':
resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-syntax-top-level-await@7.14.5':
resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-syntax-typescript@7.28.6':
resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/runtime@7.29.2':
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
engines: {node: '>=6.9.0'}
@@ -1804,6 +1901,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'}
@@ -1816,20 +1917,23 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@open-draft/deferred-promise@2.2.0':
resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
'@otplib/core@13.4.0':
resolution: {integrity: sha512-JqOGcvZQi2wIkEQo8f3/iAjstavpXy6gouIDMHygjNuH6Q0FjbHOiXMdcE94RwfgDNMABhzwUmvaPsxvgm9NYw==}
'@open-draft/deferred-promise@3.0.0':
resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==}
'@otplib/hotp@13.4.0':
resolution: {integrity: sha512-MJjE0x06mn2ptymz5qZmQveb+vWFuaIftqE0b5/TZZqUOK7l97cV8lRTmid5BpAQMwJDNLW6RnYxGeCRiNdekw==}
'@open-draft/logger@0.3.0':
resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==}
'@otplib/plugin-base32-scure@13.4.0':
resolution: {integrity: sha512-/t9YWJmMbB8bF5z8mXrBZc2FXBe8B/3hG5FhWr9K8cFwFhyxScbPysmZe8s1UTzSA6N+s8Uv8aIfCtVXPNjJWw==}
'@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
'@otplib/plugin-crypto-noble@13.4.0':
resolution: {integrity: sha512-KrvE4m7Zv+TT1944HzgqFJWJpKb6AyoxDbvhPStmBqdMlv5Gekb80d66cuFRL08kkPgJ5gXUSb5SFpYeB+bACg==}
'@oxc-project/types@0.128.0':
resolution: {integrity: sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==}
'@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==}
@@ -2506,6 +2610,12 @@ 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==}
'@sindresorhus/merge-streams@2.3.0':
resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==}
engines: {node: '>=18'}
@@ -2796,6 +2906,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:
@@ -3110,6 +3223,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'}
@@ -3386,6 +3502,17 @@ 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:
babel-plugin-macros: ^3.1.0
peerDependenciesMeta:
babel-plugin-macros:
optional: true
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -3437,6 +3564,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'}
@@ -4542,6 +4672,9 @@ packages:
outvariant@1.4.3:
resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
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'}
@@ -4715,6 +4848,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
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==}
@@ -5199,6 +5336,21 @@ packages:
pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
pure-rand@7.0.1:
resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==}
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'}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -5342,6 +5494,13 @@ 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'}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -5494,6 +5653,8 @@ 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-cookie-parser@3.1.0:
resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
@@ -5769,6 +5930,12 @@ packages:
engines: {node: '>=10'}
hasBin: true
test-exclude@6.0.0:
resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==}
engines: {node: '>=8'}
thenby@1.4.0:
resolution: {integrity: sha512-D+BkEl5MpeTbQ1Rz0io/TLZmwmYUAgvUpS1r2eKUvPicYOnpqzjZE6bVa7rSEYv189zkSM+CVc+hG5X7nVe9LQ==}
thenby@1.4.1:
resolution: {integrity: sha512-D5a/bO0KdalOE3q8MlrRmSxjbKZHT3MQmXkJP+r97Vw8MMwOZKOwUSEyTtK7eSMj2y0kyAjpYMRMZmmLw1FtNQ==}
@@ -5962,6 +6129,12 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@13.0.0:
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
hasBin: true
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
uuid@14.0.1:
resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==}
hasBin: true
@@ -6100,6 +6273,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'}
@@ -6140,6 +6316,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'}
@@ -6155,10 +6334,23 @@ packages:
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
engines: {node: '>= 6'}
yaml@2.8.1:
resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==}
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'}
@@ -6392,6 +6584,73 @@ snapshots:
'@babel/parser@7.29.7':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
'@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
'@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
'@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
'@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
'@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
'@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
'@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
'@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
'@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
'@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
'@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
'@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
'@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
'@babel/types': 7.29.7
optional: true
@@ -7475,6 +7734,8 @@ snapshots:
'@next/swc-win32-x64-msvc@16.2.9':
optional: true
'@noble/hashes@2.2.0': {}
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -7487,6 +7748,32 @@ 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
'@open-draft/deferred-promise@2.2.0': {}
'@open-draft/deferred-promise@3.0.0': {}
@@ -8035,6 +8322,9 @@ snapshots:
'@rrweb/types': 2.0.1
rrweb: 2.0.1
'@scure/base@2.2.0': {}
'@sinclair/typebox@0.34.40': {}
'@rrweb/types@2.0.1': {}
'@rrweb/utils@2.0.1': {}
@@ -8319,6 +8609,11 @@ snapshots:
pg-protocol: 1.15.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)':
'@types/react-dom@19.2.3(@types/react@19.2.17)':
dependencies:
'@types/react': 19.2.17
@@ -8633,6 +8928,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
@@ -8929,6 +9230,9 @@ snapshots:
dependencies:
ms: 2.1.3
decamelize@1.2.0: {}
dedent@1.7.2: {}
decimal.js@10.6.0: {}
deepmerge-ts@7.1.5: {}
@@ -8973,6 +9277,8 @@ snapshots:
diff@4.0.2: {}
dijkstrajs@1.0.3: {}
dir-glob@3.0.1:
dependencies:
path-type: 4.0.0
@@ -9883,6 +10189,7 @@ snapshots:
lru.min@1.1.4: {}
lucide-react@0.555.0(react@19.2.5):
lucide-react@0.555.0(react@19.2.7):
dependencies:
react: 19.2.7
@@ -10147,6 +10454,15 @@ snapshots:
outvariant@1.4.3: {}
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
@@ -10286,6 +10602,7 @@ snapshots:
exsolve: 1.0.8
pathe: 2.0.3
pngjs@5.0.0: {}
playwright-core@1.61.1: {}
playwright@1.61.1:
@@ -10820,6 +11137,20 @@ snapshots:
pure-rand@6.1.0: {}
pure-rand@7.0.1: {}
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
queue-microtask@1.2.3: {}
raf-schd@4.0.3: {}
@@ -10880,6 +11211,12 @@ snapshots:
'@types/react': 19.2.17
redux: 5.0.1
react-resizable-panels@4.10.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
react-simple-maps@3.0.0(prop-types@15.8.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
react-resizable-panels@4.11.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
react: 19.2.7
@@ -10909,6 +11246,9 @@ snapshots:
dependencies:
react: 19.2.7
optionalDependencies:
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):
react-dom: 19.2.7(react@19.2.7)
react-window@2.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
@@ -10988,6 +11328,12 @@ snapshots:
require-from-string@2.0.2: {}
require-main-filename@2.0.0: {}
resolve-cwd@3.0.0:
dependencies:
resolve-from: 5.0.0
resolve-from@4.0.0: {}
resolve-from@5.0.0: {}
@@ -11196,6 +11542,7 @@ snapshots:
serialize-javascript@7.0.5: {}
set-blocking@2.0.0: {}
set-cookie-parser@3.1.0: {}
set-function-length@1.2.2:
@@ -11509,6 +11856,13 @@ snapshots:
commander: 2.20.3
source-map-support: 0.5.21
test-exclude@6.0.0:
dependencies:
'@istanbuljs/schema': 0.1.6
glob: 7.2.3
minimatch: 3.1.5
thenby@1.4.0: {}
thenby@1.4.1: {}
thenify-all@1.6.0:
@@ -11720,6 +12074,9 @@ snapshots:
util-deprecate@1.0.2: {}
uuid@13.0.0: {}
uuid@8.3.2: {}
uuid@14.0.1: {}
v8-compile-cache-lib@3.0.1: {}
@@ -11827,6 +12184,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
@@ -11870,6 +12229,8 @@ snapshots:
xtend@4.0.2: {}
y18n@4.0.3: {}
y18n@5.0.8: {}
yallist@3.1.1: {}
@@ -11878,8 +12239,30 @@ snapshots:
yaml@1.10.2: {}
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
@@ -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");
+177 -159
View File
@@ -10,41 +10,44 @@ datasource db {
}
model User {
id String @id() @map("user_id") @db.Uuid
username String @unique @db.VarChar(255)
password String @db.VarChar(60)
role String @map("role") @db.VarChar(50)
logoUrl String? @map("logo_url") @db.VarChar(2183)
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)
websites Website[] @relation("user")
createdBy Website[] @relation("createUser")
links Link[] @relation("user")
pixels Pixel[] @relation("user")
teams TeamUser[]
reports Report[]
boards Board[] @relation("user")
id String @id @map("user_id") @db.Uuid
username String @unique @db.VarChar(255)
password String @db.VarChar(60)
role String @map("role") @db.VarChar(50)
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)
displayName String? @map("display_name") @db.VarChar(255)
logoUrl String? @map("logo_url") @db.VarChar(2183)
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")
twoFactorAuth TwoFactorAuth?
twoFactorBackupCodes TwoFactorBackupCode[]
twoFactorOtpUsed TwoFactorOtpUsed[]
twoFactorRateLimit TwoFactorRateLimit?
@@map("user")
}
model Session {
id String @id() @map("session_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
browser String? @db.VarChar(20)
os String? @db.VarChar(20)
device String? @db.VarChar(20)
screen String? @db.VarChar(11)
language String? @db.VarChar(35)
country String? @db.Char(2)
region String? @db.VarChar(20)
city String? @db.VarChar(50)
distinctId String? @map("distinct_id") @db.VarChar(50)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
id String @id @map("session_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
browser String? @db.VarChar(20)
os String? @db.VarChar(20)
device String? @db.VarChar(20)
screen String? @db.VarChar(11)
language String? @db.VarChar(35)
country String? @db.Char(2)
region String? @db.VarChar(20)
city String? @db.VarChar(50)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
distinctId String? @map("distinct_id") @db.VarChar(50)
websiteEvents WebsiteEvent[]
sessionData SessionData[]
revenue Revenue[]
@@ -109,40 +112,39 @@ model Website {
}
model WebsiteEvent {
id String @id() @map("event_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
sessionId String @map("session_id") @db.Uuid
visitId String @map("visit_id") @db.Uuid
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
urlPath String @map("url_path") @db.VarChar(500)
urlQuery String? @map("url_query") @db.VarChar(500)
utmSource String? @map("utm_source") @db.VarChar(255)
utmMedium String? @map("utm_medium") @db.VarChar(255)
utmCampaign String? @map("utm_campaign") @db.VarChar(255)
utmContent String? @map("utm_content") @db.VarChar(255)
utmTerm String? @map("utm_term") @db.VarChar(255)
referrerPath String? @map("referrer_path") @db.VarChar(500)
referrerQuery String? @map("referrer_query") @db.VarChar(500)
referrerDomain String? @map("referrer_domain") @db.VarChar(500)
pageTitle String? @map("page_title") @db.VarChar(500)
gclid String? @db.VarChar(255)
fbclid String? @db.VarChar(255)
msclkid String? @db.VarChar(255)
ttclid String? @db.VarChar(255)
lifatid String? @map("li_fat_id") @db.VarChar(255)
twclid String? @db.VarChar(255)
eventType Int @default(1) @map("event_type") @db.Integer
eventName String? @map("event_name") @db.VarChar(50)
tag String? @db.VarChar(50)
hostname String? @db.VarChar(100)
lcp Decimal? @db.Decimal(10, 1)
inp Decimal? @db.Decimal(10, 1)
cls Decimal? @db.Decimal(10, 4)
fcp Decimal? @db.Decimal(10, 1)
ttfb Decimal? @db.Decimal(10, 1)
eventData EventData[]
session Session @relation(fields: [sessionId], references: [id])
id String @id @map("event_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
sessionId String @map("session_id") @db.Uuid
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
urlPath String @map("url_path") @db.VarChar(500)
urlQuery String? @map("url_query") @db.VarChar(500)
referrerPath String? @map("referrer_path") @db.VarChar(500)
referrerQuery String? @map("referrer_query") @db.VarChar(500)
referrerDomain String? @map("referrer_domain") @db.VarChar(500)
pageTitle String? @map("page_title") @db.VarChar(500)
eventType Int @default(1) @map("event_type")
eventName String? @map("event_name") @db.VarChar(50)
visitId String @map("visit_id") @db.Uuid
tag String? @db.VarChar(50)
fbclid String? @db.VarChar(255)
gclid String? @db.VarChar(255)
lifatid String? @map("li_fat_id") @db.VarChar(255)
msclkid String? @db.VarChar(255)
ttclid String? @db.VarChar(255)
twclid String? @db.VarChar(255)
utmCampaign String? @map("utm_campaign") @db.VarChar(255)
utmContent String? @map("utm_content") @db.VarChar(255)
utmMedium String? @map("utm_medium") @db.VarChar(255)
utmSource String? @map("utm_source") @db.VarChar(255)
utmTerm String? @map("utm_term") @db.VarChar(255)
hostname String? @db.VarChar(100)
cls Decimal? @db.Decimal(10, 4)
fcp Decimal? @db.Decimal(10, 1)
inp Decimal? @db.Decimal(10, 1)
lcp Decimal? @db.Decimal(10, 1)
ttfb Decimal? @db.Decimal(10, 1)
eventData EventData[]
session Session @relation(fields: [sessionId], references: [id])
@@index([createdAt])
@@index([sessionId])
@@ -162,18 +164,17 @@ model WebsiteEvent {
}
model EventData {
id String @id() @map("event_data_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
websiteEventId String @map("website_event_id") @db.Uuid
dataKey String @map("data_key") @db.VarChar(500)
stringValue String? @map("string_value") @db.VarChar(500)
numberValue Decimal? @map("number_value") @db.Decimal(19, 4)
dateValue DateTime? @map("date_value") @db.Timestamptz(6)
dataType Int @map("data_type") @db.Integer
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
website Website @relation(fields: [websiteId], references: [id])
websiteEvent WebsiteEvent @relation(fields: [websiteEventId], references: [id])
id String @id @map("event_data_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
websiteEventId String @map("website_event_id") @db.Uuid
dataKey String @map("data_key") @db.VarChar(500)
stringValue String? @map("string_value") @db.VarChar(500)
numberValue Decimal? @map("number_value") @db.Decimal(19, 4)
dateValue DateTime? @map("date_value") @db.Timestamptz(6)
dataType Int @map("data_type")
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
website Website @relation(fields: [websiteId], references: [id])
websiteEvent WebsiteEvent @relation(fields: [websiteEventId], references: [id])
@@index([createdAt])
@@index([websiteId])
@@ -184,19 +185,18 @@ model EventData {
}
model SessionData {
id String @id() @map("session_data_id") @db.Uuid
id String @id @map("session_data_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
sessionId String @map("session_id") @db.Uuid
dataKey String @map("data_key") @db.VarChar(500)
stringValue String? @map("string_value") @db.VarChar(500)
numberValue Decimal? @map("number_value") @db.Decimal(19, 4)
dateValue DateTime? @map("date_value") @db.Timestamptz(6)
dataType Int @map("data_type") @db.Integer
distinctId String? @map("distinct_id") @db.VarChar(50)
dataType Int @map("data_type")
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
website Website @relation(fields: [websiteId], references: [id])
session Session @relation(fields: [sessionId], references: [id])
distinctId String? @map("distinct_id") @db.VarChar(50)
website Website @relation(fields: [websiteId], references: [id])
session Session @relation(fields: [sessionId], references: [id])
@@index([createdAt])
@@index([websiteId])
@@ -207,34 +207,33 @@ model SessionData {
}
model Team {
id String @id() @map("team_id") @db.Uuid
name String @db.VarChar(50)
accessCode String? @unique @map("access_code") @db.VarChar(50)
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)
websites Website[]
members TeamUser[]
links Link[]
pixels Pixel[]
boards Board[]
id String @id @map("team_id") @db.Uuid
name String @db.VarChar(50)
accessCode String? @unique @map("access_code") @db.VarChar(50)
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)
logoUrl String? @map("logo_url") @db.VarChar(2183)
twoFactorRequired Boolean @default(false) @map("two_factor_required")
websites Website[]
members TeamUser[]
links Link[]
pixels Pixel[]
boards Board[]
@@index([accessCode])
@@map("team")
}
model TeamUser {
id String @id() @map("team_user_id") @db.Uuid
id String @id @map("team_user_id") @db.Uuid
teamId String @map("team_id") @db.Uuid
userId String @map("user_id") @db.Uuid
role String @db.VarChar(50)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
team Team @relation(fields: [teamId], references: [id])
user User @relation(fields: [userId], references: [id])
team Team @relation(fields: [teamId], references: [id])
user User @relation(fields: [userId], references: [id])
@@index([teamId])
@@index([userId])
@@ -242,7 +241,7 @@ model TeamUser {
}
model Report {
id String @id() @map("report_id") @db.Uuid
id String @id @map("report_id") @db.Uuid
userId String @map("user_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
type String @db.VarChar(50)
@@ -251,9 +250,8 @@ model Report {
parameters Json
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
user User @relation(fields: [userId], references: [id])
website Website @relation(fields: [websiteId], references: [id])
user User @relation(fields: [userId], references: [id])
website Website @relation(fields: [websiteId], references: [id])
@@index([userId])
@@index([websiteId])
@@ -263,22 +261,21 @@ model Report {
}
model Segment {
id String @id() @map("segment_id") @db.Uuid
id String @id @map("segment_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
type String @db.VarChar(50)
name String @db.VarChar(200)
parameters Json
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
website Website @relation(fields: [websiteId], references: [id])
website Website @relation(fields: [websiteId], references: [id])
@@index([websiteId])
@@map("segment")
}
model Revenue {
id String @id() @map("revenue_id") @db.Uuid
id String @id @map("revenue_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
sessionId String @map("session_id") @db.Uuid
eventId String @map("event_id") @db.Uuid
@@ -286,9 +283,8 @@ model Revenue {
currency String @db.VarChar(10)
revenue Decimal? @db.Decimal(19, 4)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
website Website @relation(fields: [websiteId], references: [id])
session Session @relation(fields: [sessionId], references: [id])
website Website @relation(fields: [websiteId], references: [id])
session Session @relation(fields: [sessionId], references: [id])
@@index([websiteId])
@@index([sessionId])
@@ -298,18 +294,17 @@ model Revenue {
}
model Link {
id String @id() @map("link_id") @db.Uuid
id String @id @map("link_id") @db.Uuid
name String @db.VarChar(100)
url String @db.VarChar(500)
slug String @unique() @db.VarChar(100)
slug String @unique @db.VarChar(100)
userId String? @map("user_id") @db.Uuid
teamId String? @map("team_id") @db.Uuid
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)
user User? @relation("user", fields: [userId], references: [id])
team Team? @relation(fields: [teamId], references: [id])
user User? @relation("user", fields: [userId], references: [id])
team Team? @relation(fields: [teamId], references: [id])
@@index([slug])
@@index([userId])
@@ -319,17 +314,16 @@ model Link {
}
model Pixel {
id String @id() @map("pixel_id") @db.Uuid
id String @id @map("pixel_id") @db.Uuid
name String @db.VarChar(100)
slug String @unique() @db.VarChar(100)
slug String @unique @db.VarChar(100)
userId String? @map("user_id") @db.Uuid
teamId String? @map("team_id") @db.Uuid
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)
user User? @relation("user", fields: [userId], references: [id])
team Team? @relation(fields: [teamId], references: [id])
user User? @relation("user", fields: [userId], references: [id])
team Team? @relation(fields: [teamId], references: [id])
@@index([slug])
@@index([userId])
@@ -339,7 +333,7 @@ model Pixel {
}
model Board {
id String @id() @map("board_id") @db.Uuid
id String @id @map("board_id") @db.Uuid
type String @db.VarChar(50)
name String @db.VarChar(200)
description String @db.VarChar(500)
@@ -348,21 +342,21 @@ model Board {
teamId String? @map("team_id") @db.Uuid
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
user User? @relation("user", fields: [userId], references: [id])
team Team? @relation(fields: [teamId], references: [id])
user User? @relation("user", fields: [userId], references: [id])
team Team? @relation(fields: [teamId], references: [id])
@@index([userId])
@@index([teamId])
@@index([createdAt])
@@map("board")
}
model Share {
id String @id() @map("share_id") @db.Uuid
id String @id @map("share_id") @db.Uuid
entityId String @map("entity_id") @db.Uuid
name String @db.VarChar(200)
shareType Int @map("share_type") @db.Integer
slug String @unique() @db.VarChar(100)
shareType Int @map("share_type")
slug String @unique @db.VarChar(100)
parameters Json
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
@@ -372,18 +366,17 @@ model Share {
}
model SessionReplay {
id String @id() @map("replay_id") @db.Uuid
id String @id @map("replay_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
sessionId String @map("session_id") @db.Uuid
visitId String @map("visit_id") @db.Uuid
chunkIndex Int @map("chunk_index") @db.Integer
chunkIndex Int @map("chunk_index")
events Bytes @map("events")
eventCount Int @map("event_count") @db.Integer
eventCount Int @map("event_count")
startedAt DateTime @map("started_at") @db.Timestamptz(6)
endedAt DateTime @map("ended_at") @db.Timestamptz(6)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
website Website @relation(fields: [websiteId], references: [id])
website Website @relation(fields: [websiteId], references: [id])
@@index([websiteId])
@@index([sessionId])
@@ -396,14 +389,13 @@ model SessionReplay {
}
model SessionReplaySaved {
id String @id() @map("saved_replay_id") @db.Uuid
id String @id @map("saved_replay_id") @db.Uuid
name String @db.VarChar(100)
websiteId String @map("website_id") @db.Uuid
visitId String @map("visit_id") @db.Uuid
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
website Website @relation(fields: [websiteId], references: [id])
website Website @relation(fields: [websiteId], references: [id])
@@unique([websiteId, visitId])
@@index([websiteId])
@@ -412,29 +404,55 @@ model SessionReplaySaved {
@@map("session_replay_saved")
}
model HeatmapEvent {
id String @id() @map("heatmap_event_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
sessionId String @map("session_id") @db.Uuid
visitId String @map("visit_id") @db.Uuid
urlPath String @map("url_path") @db.VarChar(500)
eventType Int @map("event_type") @db.Integer
x Int? @db.Integer
y Int? @db.Integer
pageX Int? @map("page_x") @db.Integer
pageY Int? @map("page_y") @db.Integer
pageW Int? @map("page_w") @db.Integer
viewportW Int? @map("viewport_w") @db.Integer
viewportH Int? @map("viewport_h") @db.Integer
pageH Int? @map("page_h") @db.Integer
scrollPct Int? @map("scroll_pct") @db.Integer
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
model TwoFactorAuth {
id String @id @default(cuid())
userId String @unique @map("user_id") @db.Uuid
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)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
website Website @relation(fields: [websiteId], references: [id])
@@index([websiteId])
@@index([visitId])
@@index([websiteId, createdAt])
@@index([websiteId, urlPath, eventType, createdAt])
@@map("heatmap_event")
@@map("two_factor_auth")
}
model TwoFactorBackupCode {
id String @id @default(cuid())
userId String @map("user_id") @db.Uuid
codeHash String @map("code_hash")
used Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@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)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@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)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("two_factor_rate_limit")
}
model AppSetting {
key String @id
value String
@@map("app_setting")
}
+60 -2
View File
@@ -391,7 +391,37 @@
"website-id": "Website ID",
"websites": "Websites",
"window": "Window",
"yesterday": "Yesterday"
"yesterday": "Yesterday",
"security": "Security",
"two-factor-auth": "Two-factor authentication",
"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-backup-file-title": "Umami - Two-factor authentication 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.",
@@ -445,6 +475,34 @@
"replay-min-duration-description": "Only show replays at or above this duration.",
"viewed-page": "Viewed page",
"upgrade-required": "This feature requires a {plan} plan subscription.",
"visitor-log": "Visitor from <b>{country}</b> using <b>{browser}</b> on <b>{os}</b> <b>{device}</b>"
"visitor-log": "Visitor from <b>{country}</b> using <b>{browser}</b> on <b>{os}</b> <b>{device}</b>",
"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-backup-codes-once": "Keep these codes safe. Each code can only be used once.",
"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-missing-token": "Authentication token is missing.",
"two-factor-error-invalid-partial-token": "Invalid or expired authentication token.",
"two-factor-error-invalid-backup-code": "Invalid backup code.",
"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-already-enabled": "Two-factor authentication is already enabled.",
"two-factor-error-not-enabled": "Two-factor authentication is not enabled.",
"two-factor-error-no-pending-setup": "No pending two-factor authentication setup found.",
"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."
}
}
+60 -2
View File
@@ -382,7 +382,37 @@
"website-id": "ID de site",
"websites": "Sites",
"window": "Fenêtre",
"yesterday": "Hier"
"yesterday": "Hier",
"security": "Sécurité",
"two-factor-auth": "Authentification à deux facteurs",
"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-backup-file-title": "Umami - Codes de récupération pour l’authentification à deux facteurs",
"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.",
@@ -436,6 +466,34 @@
"user-deleted": "Utilisateur supprimé.",
"replay-min-duration-description": "Afficher uniquement les lectures de cette durée ou plus.",
"viewed-page": "Page vue",
"visitor-log": "Visiteur de <b>{country}</b> utilisant <b>{browser}</b> sur <b>{os}</b> <b>{device}</b>"
"visitor-log": "Visiteur de <b>{country}</b> utilisant <b>{browser}</b> sur <b>{os}</b> <b>{device}</b>",
"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-backup-codes-once": "Gardez ces codes en lieu sûr. Chaque code ne peut être utilisé qu’une seule fois.",
"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-missing-token": "Le jeton d'authentification est manquant.",
"two-factor-error-invalid-partial-token": "Jeton d'authentification invalide ou expiré.",
"two-factor-error-invalid-backup-code": "Code de récupération invalide.",
"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-already-enabled": "L’authentification à deux facteurs est déjà activée.",
"two-factor-error-not-enabled": "L’authentification à deux facteurs n’est pas activée.",
"two-factor-error-no-pending-setup": "Aucune configuration d’authentification à deux facteurs en attente n’a été trouvé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."
}
}
+18 -2
View File
@@ -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 }) {
</Column>
<Column overflowX="hidden" minHeight="0" position="relative">
<TopNav />
<Column alignItems="center">{children}</Column>
<Column
alignItems="center"
aria-hidden={needsTwoFactorSetup || undefined}
style={needsTwoFactorSetup ? { pointerEvents: 'none' } : undefined}
>
{children}
</Column>
</Column>
{needsTwoFactorSetup && <TwoFactorSetupModal required={true} />}
<UpdateNotice user={user} config={config} />
{process.env.NODE_ENV === 'production' && !pathname.includes('/share/') && (
<Script src={`${process.env.basePath || ''}/telemetry.js`} />
+9 -1
View File
@@ -3,7 +3,9 @@ import { IconLabel } from '@/components/common/IconLabel';
import Link from '@/components/common/Link';
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';
export function AdminNav({ onItemClick }: { onItemClick?: () => void }) {
const { t, labels } = useMessages();
@@ -31,6 +33,12 @@ export function AdminNav({ onItemClick }: { onItemClick?: () => void }) {
path: '/admin/teams',
icon: <Users />,
},
{
id: 'security',
label: t(labels.security),
path: '/admin/security',
icon: <ShieldCheck />,
},
],
},
];
@@ -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 (
<Column gap="6" margin="2">
<PageHeader title={t(labels.security)} />
<Panel>
<Column gap="4">
<Column gap="1">
<Text weight="bold">{t(labels.twoFactorAuth)}</Text>
<Text>{t(messages.twoFactorAdminGlobalDescription)}</Text>
</Column>
<Row alignItems="center" gap="3">
<Switch isSelected={isGlobalRequired} onChange={handleToggle} />
<Text>{t(labels.twoFactorRequireGlobal)}</Text>
</Row>
{isGlobalRequired && (
<Text size="sm" color="muted">
{t(messages.twoFactorOverrideNote)}
</Text>
)}
</Column>
</Panel>
</Column>
);
}
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from 'next';
import { AdminSecurityPage } from './AdminSecurityPage';
export default function () {
return <AdminSecurityPage />;
}
export const metadata: Metadata = {
title: 'Security',
};
@@ -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 }) {
<TabList>
<Tab id="details">{t(labels.details)}</Tab>
<Tab id="websites">{t(labels.websites)}</Tab>
<Tab id="security">{t(labels.security)}</Tab>
</TabList>
<TabPanel id="details" style={{ width: 500 }}>
<UserEditForm userId={userId} />
@@ -19,6 +21,9 @@ export function UserSettings({ userId }: { userId: string }) {
<TabPanel id="websites">
<UserWebsites userId={userId} />
</TabPanel>
<TabPanel id="security" style={{ width: 500 }}>
<UserTwoFactorSettings userId={userId} />
</TabPanel>
</Tabs>
</Column>
);
@@ -0,0 +1,59 @@
'use client';
import { Column, Row, Switch, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
import { Badge } from '@/components/common/Badge';
import {
useMessages,
useUpdateQuery,
useTwoFactorStatusQuery,
useUserQuery,
useTwoFactorUserStatusQuery,
} from '@/components/hooks';
import { useQueryClient } from '@tanstack/react-query';
export function UserTwoFactorSettings({ userId }: { userId: string }) {
const { t, labels, messages } = useMessages();
const queryClient = useQueryClient();
const { data: globalStatus } = useTwoFactorStatusQuery(true);
const isGlobalRequired = globalStatus?.requiredReason === 'global';
const { data: userTfaData } = useTwoFactorUserStatusQuery(userId);
const { data: userData } = useUserQuery(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: ['users', { userId }] });
};
return (
<Column gap="4">
<Row alignItems="center" gap="3">
<Text weight="bold">{t(labels.twoFactorAuth)}</Text>
<Badge variant={twoFactorEnabled ? 'good' : 'gray'}>
{twoFactorEnabled ? t(labels.twoFactorActive) : t(labels.twoFactorStatusNotConfigured)}
</Badge>
</Row>
<Text size="sm" color="muted">
{t(messages.twoFactorRequireUserDescription)}
</Text>
<TooltipTrigger isDisabled={!isGlobalRequired}>
<Row alignItems="center" gap="3">
<Switch
isSelected={isGlobalRequired || twoFactorRequired}
isDisabled={isGlobalRequired}
onChange={handleToggle}
/>
<Text>{t(labels.twoFactorRequireUser)}</Text>
</Row>
<Tooltip>{t(labels.twoFactorGlobalActiveTooltip)}</Tooltip>
</TooltipTrigger>
</Column>
);
}
+7 -1
View File
@@ -2,7 +2,7 @@ import { Column, Focusable, Row, Text, Tooltip, TooltipTrigger } from '@umami/re
import { IconLabel } from '@/components/common/IconLabel';
import Link from '@/components/common/Link';
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: <Users />,
},
{
id: 'security',
label: t(labels.security),
path: renderUrl('/settings/security'),
icon: <ShieldCheck />,
},
],
},
];
@@ -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 (
<PageBody>
<Column gap="6">
<PageHeader title={t(labels.security)} />
<Panel>
<Column gap="4">
<Row alignItems="center" gap="3">
<Text weight="bold">{t(labels.twoFactorAuth)}</Text>
{isEnabled ? (
<Badge variant="good">{t(labels.twoFactorActive)}</Badge>
) : (
<Badge variant="gray">{t(labels.twoFactorStatusNotConfigured)}</Badge>
)}
</Row>
{isEnabled ? (
<Text>{t(messages.twoFactorActiveDescription)}</Text>
) : (
<Text>{t(messages.twoFactorUserDescription)}</Text>
)}
<TooltipTrigger isDisabled={!(isEnabled && isRequired)}>
<Row alignItems="center" gap="3">
<Switch
isSelected={isEnabled}
isDisabled={isEnabled && isRequired}
onChange={handleToggle}
/>
<Text>{t(labels.twoFactorEnable)}</Text>
</Row>
<Tooltip>{t(messages.twoFactorRequiredMessage)}</Tooltip>
</TooltipTrigger>
{isEnabled && isRequired && (
<Text size="sm" color="muted">
{t(messages.twoFactorRequiredMessage)}
</Text>
)}
</Column>
</Panel>
</Column>
{showSetup && <TwoFactorSetupModal required={false} onClose={handleSetupClose} />}
{showDisable && (
<TwoFactorDisableModal
onClose={() => setShowDisable(false)}
onSuccess={handleDisableSuccess}
/>
)}
</PageBody>
);
}
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from 'next';
import { UserSecurityPage } from './UserSecurityPage';
export default function () {
return <UserSecurityPage />;
}
export const metadata: Metadata = {
title: 'Security',
};
@@ -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 }) {
</Row>
<TeamMembersDataTable teamId={teamId} allowEdit={canEdit} />
</Panel>
{isAdmin && (
<Panel>
<TeamTwoFactorSettings teamId={teamId} />
</Panel>
)}
{isTeamOwner && (
<Panel>
<TeamManage teamId={teamId} />
@@ -0,0 +1,49 @@
'use client';
import { Column, Row, Switch, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
import {
useMessages,
useUpdateQuery,
useTwoFactorStatusQuery,
useTeamQuery,
} from '@/components/hooks';
import { useQueryClient } from '@tanstack/react-query';
export function TeamTwoFactorSettings({ teamId }: { teamId: string }) {
const { t, labels, messages } = useMessages();
const queryClient = useQueryClient();
const { data: globalStatus } = useTwoFactorStatusQuery(true);
const isGlobalRequired = globalStatus?.requiredReason === 'global';
const { data: teamData } = useTeamQuery(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: ['teams', { teamId }] });
};
return (
<Column gap="4">
<Text weight="bold">{t(labels.twoFactorAuth)}</Text>
<TooltipTrigger isDisabled={!isGlobalRequired}>
<Row alignItems="center" gap="3">
<Switch
isSelected={isGlobalRequired || twoFactorRequired}
isDisabled={isGlobalRequired}
onChange={handleToggle}
/>
<Text>{t(labels.twoFactorRequireTeam)}</Text>
</Row>
<Tooltip>{t(labels.twoFactorGlobalActiveTooltip)}</Tooltip>
</TooltipTrigger>
<Text size="sm" color="muted">
{t(messages.twoFactorRequireTeamDescription)}
</Text>
</Column>
);
}
+112
View File
@@ -0,0 +1,112 @@
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 Response.json(
{
error: {
code: 'two-factor-error-too-many-attempts',
message: 'Too many failed attempts',
lockedUntil: rateCheck.lockedUntil,
},
},
{ status: 429 },
);
}
// 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))) {
const { lockedUntil } = await recordFailedAttempt(userId);
return badRequest({
code: 'two-factor-error-invalid-code',
message: 'Invalid verification code',
...(lockedUntil && { lockedUntil }),
});
}
await prisma.transaction(async tx => {
await markOtpUsed(userId, token, tx);
await tx.twoFactorAuth.delete({ where: { userId } });
await tx.twoFactorBackupCode.deleteMany({ where: { userId } });
});
await resetRateLimit(userId);
return json({ ok: true });
}
+19
View File
@@ -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 });
}
+78
View File
@@ -0,0 +1,78 @@
import { z } from 'zod';
import prisma from '@/lib/prisma';
import { parseRequest } from '@/lib/request';
import { badRequest, json } 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({
code: 'two-factor-error-no-pending-setup',
message: 'No pending 2FA setup found',
});
}
// Verify rate limit
const rateCheck = await checkRateLimit(userId);
if (!rateCheck.allowed) {
return Response.json(
{
error: {
code: 'two-factor-error-too-many-attempts',
message: 'Too many failed attempts',
lockedUntil: rateCheck.lockedUntil,
},
},
{ status: 429 },
);
}
// 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))) {
const { lockedUntil } = await recordFailedAttempt(userId);
return badRequest({
code: 'two-factor-error-invalid-code',
message: 'Invalid verification code',
...(lockedUntil && { lockedUntil }),
});
}
const { plaintext, hashed } = await generateBackupCodes();
await prisma.transaction(async tx => {
await tx.twoFactorAuth.update({ where: { userId }, data: { isEnabled: true } });
await tx.twoFactorBackupCode.deleteMany({ where: { userId } });
await tx.twoFactorBackupCode.createMany({
data: hashed.map(codeHash => ({ userId, codeHash })),
});
await markOtpUsed(userId, token, tx);
});
await resetRateLimit(userId);
return json({ backupCodes: plaintext });
}
+51
View File
@@ -0,0 +1,51 @@
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 existing = await prisma.client.twoFactorAuth.findUnique({ where: { userId } });
if (existing?.isEnabled) {
return badRequest({
code: 'two-factor-error-already-enabled',
message: '2FA is already enabled',
});
}
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 },
});
/*
`manualKey` is intentionally plaintext as the user needs it once for manual entry.
The encrypted copy in DB is what matters for long-term storage.
*/
return json({ qrCodeDataUrl, manualKey: secret });
}
+44
View File
@@ -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 });
}
+136
View File
@@ -0,0 +1,136 @@
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() }),
]);
const rawToken = getBearerToken(request);
if (!rawToken) {
return unauthorized({ code: 'two-factor-error-missing-token' });
}
const payload = parseSecureToken(rawToken, secret()) as any;
if (!payload || payload.type !== 'partial-auth' || !payload.userId) {
return unauthorized({ code: 'two-factor-error-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({
code: 'two-factor-error-not-enabled',
message: '2FA not enabled for this user',
});
}
const rateCheck = await checkRateLimit(userId);
if (!rateCheck.allowed) {
return Response.json(
{
error: {
code: 'two-factor-error-too-many-attempts',
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) {
const { lockedUntil } = await recordFailedAttempt(userId);
return badRequest({
code: 'two-factor-error-invalid-backup-code',
message: 'Invalid backup code',
...(lockedUntil && { lockedUntil }),
});
}
const consumed = await prisma.client.twoFactorBackupCode.updateMany({
where: { id: unusedCodes[matchIndex].id, used: false },
data: { used: true },
});
if (consumed.count === 0) {
const { lockedUntil } = await recordFailedAttempt(userId);
return badRequest({
code: 'two-factor-error-invalid-backup-code',
message: 'Invalid backup code',
...(lockedUntil && { lockedUntil }),
});
}
await resetRateLimit(userId);
} else {
const { token } = body;
if (await isOtpReplayed(userId, token)) {
return badRequest({ code: 'two-factor-error-code-used', message: 'Code already used' });
}
const decryptedSecret = decryptSecret(twoFactor.secret);
if (!(await verifyTotp(token, decryptedSecret))) {
const { lockedUntil } = await recordFailedAttempt(userId);
return badRequest({
code: 'two-factor-error-invalid-code',
message: 'Invalid verification code',
...(lockedUntil && { lockedUntil }),
});
}
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 },
});
}
+29
View File
@@ -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 });
}
@@ -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 });
}
@@ -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 });
}
+9
View File
@@ -4,6 +4,7 @@ import { ROLES } from '@/lib/constants';
import { hash, 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,14 @@ 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 });
}
// Bind token to password hash so a password change invalidates old tokens.
const pwd = hash(user.password);
+8 -3
View File
@@ -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('/');
},
});
+11 -2
View File
@@ -4,8 +4,9 @@ import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useLoginQuery } from '@/components/hooks';
import { LoginForm } from './LoginForm';
import { PropsWithChildren } from 'react';
export function LoginPage() {
export function LoginPageWrapper({ children }: PropsWithChildren) {
const { user, isLoading } = useLoginQuery();
const router = useRouter();
@@ -27,7 +28,15 @@ export function LoginPage() {
backgroundColor="surface-raised"
style={{ paddingTop: '15vh' }}
>
<LoginForm />
{children}
</Column>
);
}
export function LoginPage() {
return (
<LoginPageWrapper>
<LoginForm />
</LoginPageWrapper>
);
}
@@ -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<string | null>(null);
const [useBackup, setUseBackup] = useState(false);
const [otpValue, setOtpValue] = useState('');
const [lockUntil, setLockUntil] = useState<Date | null>(null);
const [error, setError] = useState<string | null>(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 (
<Column justifyContent="center" alignItems="center" gap="6">
<Icon size="lg">
<Logo />
</Icon>
<Heading>umami</Heading>
<Column gap="4" style={{ minWidth: 300 }}>
<Heading size="xl">{t(labels.twoFactorLogin)}</Heading>
<Text>{t(messages.twoFactorLoginDescription)}</Text>
{!!lockUntil && lockMessage && (
<Text style={{ color: 'var(--color-danger, red)' }}>{lockMessage}</Text>
)}
<Form onSubmit={handleSubmit} error={error ?? undefined} style={{ minWidth: 300 }}>
{!useBackup ? (
<Column gap="2">
<Text weight="bold">{t(labels.twoFactorEnterCode)}</Text>
<OtpInput
value={otpValue}
onChange={val => {
setOtpValue(val);
if (error) setError(null);
}}
onComplete={val => submitVerification(val)}
disabled={!!lockUntil || isPending}
/>
</Column>
) : (
<FormField label={t(labels.twoFactorBackupCode)} name="backupCode">
<TextField autoComplete="off" />
</FormField>
)}
<FormButtons>
<FormSubmitButton
variant="primary"
style={{ flex: 1 }}
isDisabled={
!!lockUntil || isPending || (!useBackup && (otpValue.length !== 6 || !!error))
}
>
{t(labels.twoFactorVerify)}
</FormSubmitButton>
</FormButtons>
</Form>
<Button variant="quiet" onPress={() => setUseBackup(v => !v)}>
{useBackup ? t(labels.twoFactorLogin) : t(labels.twoFactorUseBackup)}
</Button>
</Column>
</Column>
);
}
+19
View File
@@ -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 (
<LoginPageWrapper>
<LoginTwoFactorPage />
</LoginPageWrapper>
);
}
export const metadata: Metadata = {
title: 'Two-factor authentication',
};
+94
View File
@@ -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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<Row gap="2">
{digits.map((digit, i) => (
<div
key={i}
ref={el => {
refs.current[i] = el;
}}
>
<TextField
value={digit}
onChange={char => 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 }}
/>
</div>
))}
</Row>
);
}
+3
View File
@@ -24,6 +24,9 @@ export * from './queries/useLinkQuery';
export * from './queries/useLinkSharesQuery';
export * from './queries/useLinksQuery';
export * from './queries/useLoginQuery';
export * from './queries/useTwoFactorStatusQuery';
export * from './queries/useTwoFactorUserStatus';
export * from './queries/useTwoFactorVerifyMutation';
export * from './queries/usePixelQuery';
export * from './queries/usePixelSharesQuery';
export * from './queries/usePixelsQuery';
@@ -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,
});
}
@@ -0,0 +1,12 @@
import { useApi } from '../useApi';
export function useTwoFactorUserStatusQuery(userId: string) {
const { get, useQuery } = useApi();
return useQuery({
queryKey: ['user-2fa-status', userId],
queryFn: () => get(`/admin/users/${userId}/2fa`),
refetchOnWindowFocus: true,
refetchOnReconnect: true,
});
}
@@ -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 };
},
});
}
+58
View File
@@ -395,6 +395,36 @@ export const labels: Record<string, string> = {
duration: 'label.duration',
recorded: 'label.recorded',
upgrade: 'label.upgrade',
security: 'label.security',
close: 'label.close',
twoFactorAuth: 'label.two-factor-auth',
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',
twoFactorBackupFileTitle: 'label.two-factor-backup-file-title',
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<string, string> = {
@@ -445,5 +475,33 @@ export const messages: Record<string, string> = {
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',
twoFactorBackupCodesOnce: 'message.two-factor-backup-codes-once',
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',
twoFactorErrorInvalidCode: 'message.two-factor-error-invalid-code',
twoFactorErrorMissingToken: 'message.two-factor-error-missing-token',
twoFactorErrorInvalidPartialToken: 'message.two-factor-error-invalid-partial-token',
twoFactorErrorInvalidBackupCode: 'message.two-factor-error-invalid-backup-code',
twoFactorErrorCodeUsed: 'message.two-factor-error-code-used',
twoFactorErrorTooManyAttempts: 'message.two-factor-error-too-many-attempts',
twoFactorErrorDisableNotAllowed: 'message.two-factor-error-disable-not-allowed',
twoFactorErrorIncorrectPassword: 'message.two-factor-error-incorrect-password',
twoFactorErrorAlreadyEnabled: 'message.two-factor-error-already-enabled',
twoFactorErrorNotEnabled: 'message.two-factor-error-not-enabled',
twoFactorErrorNoPendingSetup: 'message.two-factor-error-no-pending-setup',
replayMinDurationDescription: 'message.replay-min-duration-description',
};
@@ -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 (
<Modal isOpen={true} onOpenChange={onClose}>
<Dialog title={t(labels.twoFactorDisableTitle)} style={{ width: 420 }}>
{({ close }) => (
<Column gap="4">
<Text>{t(messages.twoFactorDisableDescription)}</Text>
<Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
<FormField label={t(labels.password)} name="password">
<PasswordField autoComplete="current-password" onChange={resetError} />
</FormField>
<Column gap="1">
<Text size="sm">{t(labels.twoFactorEnterCode)}</Text>
<OtpInput
value={otpValue}
onChange={val => {
setOtpValue(val);
resetError();
}}
disabled={isPending}
/>
</Column>
<FormButtons>
<Button variant="outline" onPress={close} isDisabled={isPending}>
{t(labels.cancel)}
</Button>
<FormSubmitButton
variant="danger"
isDisabled={isPending || otpValue.length !== 6 || !!error}
>
{t(labels.twoFactorDisable)}
</FormSubmitButton>
</FormButtons>
</Form>
</Column>
)}
</Dialog>
</Modal>
);
}
@@ -0,0 +1,9 @@
.twoFactorSetupModal {
width: 700px;
max-width: 95vw;
}
.qrCodeImage {
width: 33%;
aspect-ratio: 1;
}
@@ -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 (
<Column gap="5">
<Column gap="3">
<Row gap="3" alignItems="center">
<TagGroup>
<Tag variant="primary">{tag}</Tag>
</TagGroup>
<Text weight="bold">{title}</Text>
</Row>
<Text>{details}</Text>
</Column>
{children}
</Column>
);
}
interface TwoFactorSetupModalProps {
required: boolean;
onClose?: () => void;
}
export function TwoFactorSetupModal({ required, onClose }: TwoFactorSetupModalProps) {
const { t, labels, messages } = useMessages();
const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string | null>(null);
const [manualKey, setManualKey] = useState<string | null>(null);
const [otpValue, setOtpValue] = useState('');
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
const queryClient = useQueryClient();
const { mutate: initiate } = useUpdateQuery('/2fa/setup/initiate');
const { mutateAsync: confirm, isPending: isConfirming } = useUpdateQuery('/2fa/setup/confirm');
const { mutate: cancel } = useUpdateQuery('/2fa/setup/cancel');
useEffect(() => {
initiate(
{},
{
onSuccess: (data: any) => {
setQrCodeDataUrl(data.qrCodeDataUrl);
setManualKey(data.manualKey);
},
onError: () => setError(t(messages.error)),
},
);
}, []);
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 () => {
cancel({});
onClose?.();
};
const handleCopy = () => {
if (manualKey) {
navigator.clipboard.writeText(manualKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
if (backupCodes) {
return (
<TwoFactorSuccessModal
backupCodes={backupCodes}
onClose={() => {
queryClient.invalidateQueries({ queryKey: ['2fa-status'] });
onClose?.();
}}
/>
);
}
const preventDismiss = required ? () => {} : undefined;
return (
<Modal isOpen={true} onOpenChange={preventDismiss}>
<Dialog title={t(labels.twoFactorSetupTitle)} className={styles.twoFactorSetupModal}>
{({ close }) => (
<Column gap="9">
<Text>
{required && t(messages.twoFactorSetupRequiredDescription) + ' '}
{t(messages.twoFactorSetupDescription)}
</Text>
{/* Step 1 - Scan QR Code */}
<Step
tag={t(labels.twoFactorStep1)}
title={t(labels.twoFactorScanQr)}
details={t(messages.twoFactorStep1Description)}
>
<Box padding="2" shadow="lg" borderRadius="lg" border width="fit">
<Row alignItems="center" gap="2">
{qrCodeDataUrl && (
<Image
src={qrCodeDataUrl}
alt="QR code"
className={styles.qrCodeImage}
borderRadius="lg"
/>
)}
<Column>
<Text weight="bold">{t(labels.twoFactorCantScan)}</Text>
<Column gap="2">
<Text size="sm">{t(labels.twoFactorManualEntry)}</Text>
<Code>{manualKey}</Code>
<div>
<Button variant="outline" onPress={handleCopy}>
<Icon size="sm">
<LucideCopy />
</Icon>
{copied ? t(labels.twoFactorCodeCopied) : t(labels.twoFactorCopyCode)}
</Button>
</div>
</Column>
</Column>
</Row>
</Box>
</Step>
{/* Step 2 - Enter Verification Code */}
<Step
tag={t(labels.twoFactorStep2)}
title={t(labels.twoFactorGetCode)}
details={t(messages.twoFactorStep2Description)}
>
<Column gap="3.5">
<Text size="sm" weight="bold">
{t(labels.twoFactorEnterCode)}
</Text>
<OtpInput
value={otpValue}
onChange={val => {
setOtpValue(val);
if (error) setError(null);
}}
onComplete={handleConfirm}
disabled={isConfirming}
/>
</Column>
</Step>
{error && <AlertBanner variant="error" title={t(messages.error)} description={error} />}
<Row gap="2" justifyContent="flex-end">
{!required && (
<Button variant="outline" onPress={handleCancel} isDisabled={isConfirming}>
{t(labels.cancel)}
</Button>
)}
<Button
variant="primary"
onPress={() => handleConfirm()}
isDisabled={otpValue.length !== 6 || isConfirming || !!error}
>
{t(labels.confirm)}
</Button>
</Row>
</Column>
)}
</Dialog>
</Modal>
);
}
@@ -0,0 +1,18 @@
.dialog {
width: 620px;
max-width: 95vw;
}
.codesGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
padding: 12px;
background: var(--surface-sunken);
border-radius: 8px;
}
.code {
font-family: monospace;
font-size: 0.9rem;
}
@@ -0,0 +1,80 @@
'use client';
import { Button, Column, Dialog, Modal, Row, Text } from '@umami/react-zen';
import { useState } from 'react';
import { useMessages } from '@/components/hooks';
import styles from './TwoFactorSuccessModal.module.css';
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 = [
t(labels.twoFactorBackupFileTitle),
t(messages.twoFactorBackupCodesOnce),
'',
...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 (
<Modal isOpen={true}>
<Dialog title={t(labels.twoFactorSuccessTitle)} className={styles.dialog}>
{() => (
<Column gap="5">
<Text>{t(messages.twoFactorEnabledDescription)}</Text>
<Column gap="2">
<Text weight="bold">{t(labels.twoFactorSaveBackupCodes)}</Text>
<Text>{t(messages.twoFactorBackupDescription)}</Text>
<div className={styles.codesGrid}>
{backupCodes.map((code, i) => (
<code key={i} className={styles.code}>
{code}
</code>
))}
</div>
<Row>
<Button variant="outline" onPress={handleDownload}>
{t(labels.twoFactorDownloadCodes)}
</Button>
</Row>
</Column>
<Row gap="2" alignItems="center">
<input
type="checkbox"
id="saved-codes"
checked={saved}
onChange={e => setSaved(e.target.checked)}
/>
<label htmlFor="saved-codes">
<Text>{t(labels.twoFactorSavedCodes)}</Text>
</label>
</Row>
<Row justifyContent="flex-end">
<Button variant="primary" onPress={onClose} isDisabled={!saved}>
{t(labels.close)}
</Button>
</Row>
</Column>
)}
</Dialog>
</Modal>
);
}
+31
View File
@@ -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<string>;
hashed: Array<string>;
}> {
const plaintext = Array.from(
{ length: BACKUP_CODE_COUNT },
() =>
randomBytes(8).toString('hex').toUpperCase() +
'-' +
randomBytes(8).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<string>,
): Promise<number | null> {
for (let i = 0; i < storedHashes.length; i++) {
const match = await bcrypt.compare(inputCode.toUpperCase(), storedHashes[i]);
if (match) return i;
}
return null;
}
+35
View File
@@ -0,0 +1,35 @@
import { createCipheriv, createDecipheriv, randomBytes } 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 || !/^[0-9a-fA-F]{64}$/.test(hex)) {
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');
}
+57
View File
@@ -0,0 +1,57 @@
import { Prisma } from '@/generated/prisma/client';
import prisma from '@/lib/prisma';
const MAX_ATTEMPTS = 5;
const LOCKOUT_MINUTES = 15;
const MAX_RETRIES = 3;
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<{ lockedUntil?: Date }> {
let retries = 0;
while (retries < MAX_RETRIES) {
try {
/*
prisma.transaction accepts `any` to support both batch and callback forms,
so TypeScript resolves to the batch overload and infers `any[]`. Cast is safe:
the callback form always returns the callback's return type.
*/
return await (prisma.transaction(
async (tx: Prisma.TransactionClient): Promise<{ lockedUntil?: Date }> => {
const record = await tx.twoFactorRateLimit.upsert({
where: { userId },
update: { attempts: { increment: 1 } },
create: { userId, attempts: 1 },
});
if (record.attempts >= MAX_ATTEMPTS) {
const lockedUntil = new Date(Date.now() + LOCKOUT_MINUTES * 60 * 1000);
await tx.twoFactorRateLimit.update({ where: { userId }, data: { lockedUntil } });
return { lockedUntil };
}
return {};
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
) as Promise<{ lockedUntil?: Date }>);
} catch (err: any) {
if (err.code === 'P2034') {
retries++;
continue;
}
throw err;
}
}
throw new Error('recordFailedAttempt: max retries exceeded');
}
export async function resetRateLimit(userId: string): Promise<void> {
await prisma.client.twoFactorRateLimit.deleteMany({ where: { userId } });
}
+28
View File
@@ -0,0 +1,28 @@
import prisma from '@/lib/prisma';
import { Prisma } from '@/generated/prisma/client';
type TxClient = Prisma.TransactionClient;
/** 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, tx?: TxClient): Promise<void> {
const client = tx ?? prisma.client;
const expiresAt = new Date(Date.now() + 90 * 1000);
await 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<boolean> {
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;
}
+23
View File
@@ -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, username: string): string {
return generateURI({
issuer: 'Umami',
label: username,
secret,
});
}
export async function generateQrCodeDataUrl(otpAuthUri: string): Promise<string> {
return qrcode.toDataURL(otpAuthUri, { errorCorrectionLevel: 'H' });
}
export async function verifyTotp(token: string, secret: string): Promise<boolean> {
const result = await verify({ secret, token });
return result.valid;
}
+8
View File
@@ -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;
}
+8
View File
@@ -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;
}
+1
View File
@@ -30,6 +30,7 @@ async function findUser(criteria: Prisma.UserFindUniqueArgs, options: GetUserOpt
password: includePassword,
role: true,
createdAt: true,
twoFactorRequired: true,
},
});
}