50 files changed
+2606
-184
No files matched your search
@@ -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",
|
||||
|
||||
Generated
+395
-12
@@ -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
@@ -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")
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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`} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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('/');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ async function findUser(criteria: Prisma.UserFindUniqueArgs, options: GetUserOpt
|
||||
password: includePassword,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
twoFactorRequired: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user