3bb178b5-949c-4434-a4e8-093caab5d33b
This commit is contained in:
1 parent
cd7591fd65
commit
72ec15c2ab
21 files changed
+1978
-117
No files matched your search
@@ -44,6 +44,7 @@
|
||||
"@tanstack/react-query": "^5.82.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/nodemailer": "^8.0.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -59,7 +60,9 @@
|
||||
"next-intl": "^4.3.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^10.2.1",
|
||||
"otplib": "^13.5.0",
|
||||
"prisma": "^6.11.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "^9.8.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -431,6 +434,8 @@
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.3", "https://registry.npmjs.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.3.tgz", { "os": "win32", "cpu": "x64" }, "sha512-1SZVfFT8zmMB+Oblrh5OKDvUo5mYQOkX2We6VGzpg7JUVZlqe4DYOFGKYZKTweSx1gbMixyO1jnFT4thU+nNHQ=="],
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "https://registry.npmjs.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "https://registry.npmjs.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
@@ -441,6 +446,18 @@
|
||||
|
||||
"@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "https://registry.npmjs.com/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="],
|
||||
|
||||
"@otplib/core": ["@otplib/core@13.5.0", "", {}, "sha512-2rURdkYkb3BDhMs3j/oCCPTve1ybJ6ruLfLfSe1ZSPV+y6RFTbLfAuT0m0ZCnps88ogkIq9t/+Li/kg5RofQwQ=="],
|
||||
|
||||
"@otplib/hotp": ["@otplib/hotp@13.5.0", "", { "dependencies": { "@otplib/core": "13.5.0", "@otplib/uri": "13.5.0" } }, "sha512-1EwwAti05CeWJn4xXOVMBK9N6dIJlJw/cQkQyiL9OVlkeS452wAve3ffodi+5IUZdWoF1wxVug38RisdxDqDWw=="],
|
||||
|
||||
"@otplib/plugin-base32-scure": ["@otplib/plugin-base32-scure@13.5.0", "", { "dependencies": { "@otplib/core": "13.5.0", "@scure/base": "^2.2.0" } }, "sha512-3JEIHindUMiIeNL0jXepSAkZ/7IkWq4sPdG9eXK0lrMXZG9RdKu5oz/4tuuTodtsFUPYhKlRMqv5xUvQIkMIaA=="],
|
||||
|
||||
"@otplib/plugin-crypto-noble": ["@otplib/plugin-crypto-noble@13.5.0", "", { "dependencies": { "@noble/hashes": "^2.2.0", "@otplib/core": "13.5.0" } }, "sha512-fihOAGFvc4b8XTKyIK3jifFP2nLrUNc2bOaJ3UkUKJjy1XI2FbHKG0WmOH+jFsPfa6VsOnouCnEmSznDxIe0pA=="],
|
||||
|
||||
"@otplib/totp": ["@otplib/totp@13.5.0", "", { "dependencies": { "@otplib/core": "13.5.0", "@otplib/hotp": "13.5.0", "@otplib/uri": "13.5.0" } }, "sha512-GD9LzQnbHDwXCp79s8AaiA5cJR6luX5fEr9AN32AWx4ooRvllPbMkG80gHSA0jbGCnAIX/ChyOtTZ8tgJHMkSg=="],
|
||||
|
||||
"@otplib/uri": ["@otplib/uri@13.5.0", "", { "dependencies": { "@otplib/core": "13.5.0" } }, "sha512-LsL1hqTEgJHY40U2eb/Qp6OR1tKkNGwHQTrqqjIuJj0cawGWkLEmeUFK1MHzWBQ4bgy5IdeB5Et7gIDSAH/CRw=="],
|
||||
|
||||
"@panva/hkdf": ["@panva/hkdf@1.2.1", "https://registry.npmjs.com/@panva/hkdf/-/hkdf-1.2.1.tgz", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
|
||||
|
||||
"@parcel/watcher": ["@parcel/watcher@2.5.4", "https://registry.npmjs.com/@parcel/watcher/-/watcher-2.5.4.tgz", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.4", "@parcel/watcher-darwin-arm64": "2.5.4", "@parcel/watcher-darwin-x64": "2.5.4", "@parcel/watcher-freebsd-x64": "2.5.4", "@parcel/watcher-linux-arm-glibc": "2.5.4", "@parcel/watcher-linux-arm-musl": "2.5.4", "@parcel/watcher-linux-arm64-glibc": "2.5.4", "@parcel/watcher-linux-arm64-musl": "2.5.4", "@parcel/watcher-linux-x64-glibc": "2.5.4", "@parcel/watcher-linux-x64-musl": "2.5.4", "@parcel/watcher-win32-arm64": "2.5.4", "@parcel/watcher-win32-ia32": "2.5.4", "@parcel/watcher-win32-x64": "2.5.4" } }, "sha512-WYa2tUVV5HiArWPB3ydlOc4R2ivq0IDrlqhMi3l7mVsFEXNcTfxYFPIHXHXIh/ca/y/V5N4E1zecyxdIBjYnkQ=="],
|
||||
@@ -613,6 +630,8 @@
|
||||
|
||||
"@schummar/icu-type-parser": ["@schummar/icu-type-parser@1.21.5", "https://registry.npmjs.com/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", {}, "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw=="],
|
||||
|
||||
"@scure/base": ["@scure/base@2.4.0", "", {}, "sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://registry.npmjs.com/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "https://registry.npmjs.com/@standard-schema/utils/-/utils-0.3.0.tgz", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||
@@ -725,6 +744,8 @@
|
||||
|
||||
"@types/nodemailer": ["@types/nodemailer@8.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA=="],
|
||||
|
||||
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.8", "https://registry.npmjs.com/@types/react/-/react-19.2.8.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "https://registry.npmjs.com/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
@@ -801,6 +822,8 @@
|
||||
|
||||
"anser": ["anser@2.3.5", "https://registry.npmjs.com/anser/-/anser-2.3.5.tgz", {}, "sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmjs.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "https://registry.npmjs.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
@@ -865,6 +888,8 @@
|
||||
|
||||
"callsites": ["callsites@3.1.0", "https://registry.npmjs.com/callsites/-/callsites-3.1.0.tgz", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001764", "https://registry.npmjs.com/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", {}, "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "https://registry.npmjs.com/ccount/-/ccount-2.0.1.tgz", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
@@ -891,6 +916,8 @@
|
||||
|
||||
"client-only": ["client-only@0.0.1", "https://registry.npmjs.com/client-only/-/client-only-0.0.1.tgz", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "https://registry.npmjs.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"cm6-theme-basic-light": ["cm6-theme-basic-light@0.2.0", "https://registry.npmjs.com/cm6-theme-basic-light/-/cm6-theme-basic-light-0.2.0.tgz", { "peerDependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-1prg2gv44sYfpHscP26uLT/ePrh0mlmVwMSoSd3zYKQ92Ab3jPRLzyCnpyOCQLJbK+YdNs4HvMRqMNYdy4pMhA=="],
|
||||
@@ -961,6 +988,8 @@
|
||||
|
||||
"debug": ["debug@4.4.3", "https://registry.npmjs.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
|
||||
|
||||
"decimal.js": ["decimal.js@10.6.0", "https://registry.npmjs.com/decimal.js/-/decimal.js-10.6.0.tgz", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
|
||||
"decimal.js-light": ["decimal.js-light@2.5.1", "https://registry.npmjs.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
|
||||
@@ -989,6 +1018,8 @@
|
||||
|
||||
"diff": ["diff@5.2.0", "https://registry.npmjs.com/diff/-/diff-5.2.0.tgz", {}, "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A=="],
|
||||
|
||||
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
|
||||
|
||||
"doctrine": ["doctrine@2.1.0", "https://registry.npmjs.com/doctrine/-/doctrine-2.1.0.tgz", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||
|
||||
"dom-helpers": ["dom-helpers@5.2.1", "https://registry.npmjs.com/dom-helpers/-/dom-helpers-5.2.1.tgz", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
|
||||
@@ -1137,6 +1168,8 @@
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "https://registry.npmjs.com/gensync/-/gensync-1.0.0-beta.2.tgz", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmjs.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-nonce": ["get-nonce@1.0.1", "https://registry.npmjs.com/get-nonce/-/get-nonce-1.0.1.tgz", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
@@ -1247,6 +1280,8 @@
|
||||
|
||||
"is-finalizationregistry": ["is-finalizationregistry@1.1.1", "https://registry.npmjs.com/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-generator-function": ["is-generator-function@1.1.2", "https://registry.npmjs.com/is-generator-function/-/is-generator-function-1.1.2.tgz", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "https://registry.npmjs.com/is-glob/-/is-glob-4.0.3.tgz", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
@@ -1559,6 +1594,8 @@
|
||||
|
||||
"optionator": ["optionator@0.9.4", "https://registry.npmjs.com/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"otplib": ["otplib@13.5.0", "", { "dependencies": { "@otplib/core": "13.5.0", "@otplib/hotp": "13.5.0", "@otplib/plugin-base32-scure": "13.5.0", "@otplib/plugin-crypto-noble": "13.5.0", "@otplib/totp": "13.5.0", "@otplib/uri": "13.5.0" } }, "sha512-RpcC6aq4rANX6MverMuU3pqHVLgMPO7vl6qR8ga7YzoEHlPJknJ58cSVAaf3jBYnasiQVQHXXl2mj/4pYlsVEQ=="],
|
||||
|
||||
"outvariant": ["outvariant@1.4.0", "https://registry.npmjs.com/outvariant/-/outvariant-1.4.0.tgz", {}, "sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw=="],
|
||||
|
||||
"own-keys": ["own-keys@1.0.1", "https://registry.npmjs.com/own-keys/-/own-keys-1.0.1.tgz", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
|
||||
@@ -1567,6 +1604,8 @@
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "https://registry.npmjs.com/p-locate/-/p-locate-5.0.0.tgz", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "https://registry.npmjs.com/parent-module/-/parent-module-1.0.1.tgz", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.2", "https://registry.npmjs.com/parse-entities/-/parse-entities-4.0.2.tgz", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
@@ -1593,6 +1632,8 @@
|
||||
|
||||
"pkg-types": ["pkg-types@2.3.0", "https://registry.npmjs.com/pkg-types/-/pkg-types-2.3.0.tgz", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
|
||||
|
||||
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
|
||||
|
||||
"po-parser": ["po-parser@2.1.1", "https://registry.npmjs.com/po-parser/-/po-parser-2.1.1.tgz", {}, "sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ=="],
|
||||
|
||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "https://registry.npmjs.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||
@@ -1621,6 +1662,8 @@
|
||||
|
||||
"pure-rand": ["pure-rand@6.1.0", "https://registry.npmjs.com/pure-rand/-/pure-rand-6.1.0.tgz", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
|
||||
|
||||
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "https://registry.npmjs.com/queue-microtask/-/queue-microtask-1.2.3.tgz", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
||||
@@ -1675,6 +1718,10 @@
|
||||
|
||||
"remark-rehype": ["remark-rehype@11.1.2", "https://registry.npmjs.com/remark-rehype/-/remark-rehype-11.1.2.tgz", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
|
||||
|
||||
"resolve": ["resolve@1.22.11", "https://registry.npmjs.com/resolve/-/resolve-1.22.11.tgz", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "https://registry.npmjs.com/resolve-from/-/resolve-from-4.0.0.tgz", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
@@ -1703,6 +1750,8 @@
|
||||
|
||||
"semver": ["semver@7.7.3", "https://registry.npmjs.com/semver/-/semver-7.7.3.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
|
||||
|
||||
"set-function-length": ["set-function-length@1.2.2", "https://registry.npmjs.com/set-function-length/-/set-function-length-1.2.2.tgz", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
|
||||
|
||||
"set-function-name": ["set-function-name@2.0.2", "https://registry.npmjs.com/set-function-name/-/set-function-name-2.0.2.tgz", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
|
||||
@@ -1745,6 +1794,8 @@
|
||||
|
||||
"strict-event-emitter": ["strict-event-emitter@0.4.6", "https://registry.npmjs.com/strict-event-emitter/-/strict-event-emitter-0.4.6.tgz", {}, "sha512-12KWeb+wixJohmnwNFerbyiBrAlq5qJLwIt38etRtKtmmHyDSoGlIqFE9wx+4IwG0aDjI7GV8tc8ZccjWZZtTg=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"string.prototype.includes": ["string.prototype.includes@2.0.1", "https://registry.npmjs.com/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
|
||||
|
||||
"string.prototype.matchall": ["string.prototype.matchall@4.0.12", "https://registry.npmjs.com/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="],
|
||||
@@ -1759,6 +1810,8 @@
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "https://registry.npmjs.com/stringify-entities/-/stringify-entities-4.0.4.tgz", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-bom": ["strip-bom@3.0.0", "https://registry.npmjs.com/strip-bom/-/strip-bom-3.0.0.tgz", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@3.1.1", "https://registry.npmjs.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
@@ -1881,14 +1934,24 @@
|
||||
|
||||
"which-collection": ["which-collection@1.0.2", "https://registry.npmjs.com/which-collection/-/which-collection-1.0.2.tgz", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="],
|
||||
|
||||
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
|
||||
|
||||
"which-typed-array": ["which-typed-array@1.1.20", "https://registry.npmjs.com/which-typed-array/-/which-typed-array-1.1.20.tgz", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "https://registry.npmjs.com/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||
|
||||
"xtend": ["xtend@4.0.2", "https://registry.npmjs.com/xtend/-/xtend-4.0.2.tgz", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||
|
||||
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
|
||||
|
||||
"yallist": ["yallist@4.0.0", "https://registry.npmjs.com/yallist/-/yallist-4.0.0.tgz", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
|
||||
|
||||
"yjs": ["yjs@13.6.29", "https://registry.npmjs.com/yjs/-/yjs-13.6.29.tgz", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "https://registry.npmjs.com/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
@@ -2011,8 +2074,12 @@
|
||||
|
||||
"refractor/prismjs": ["prismjs@1.27.0", "https://registry.npmjs.com/prismjs/-/prismjs-1.27.0.tgz", {}, "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA=="],
|
||||
|
||||
"string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="],
|
||||
|
||||
"yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "https://registry.npmjs.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "https://registry.npmjs.com/brace-expansion/-/brace-expansion-2.0.2.tgz", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
@@ -2035,6 +2102,12 @@
|
||||
|
||||
"refractor/parse-entities/is-hexadecimal": ["is-hexadecimal@1.0.4", "https://registry.npmjs.com/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", {}, "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw=="],
|
||||
|
||||
"yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
"refractor/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@1.0.4", "https://registry.npmjs.com/is-alphabetical/-/is-alphabetical-1.0.4.tgz", {}, "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg=="],
|
||||
|
||||
"yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
"yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -58,6 +58,7 @@
|
||||
"@tanstack/react-query": "^5.82.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/nodemailer": "^8.0.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -73,7 +74,9 @@
|
||||
"next-intl": "^4.3.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^10.2.1",
|
||||
"otplib": "^13.5.0",
|
||||
"prisma": "^6.11.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "^9.8.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
+37
-1
@@ -19,9 +19,45 @@ model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String
|
||||
role String @default("owner") // owner|admin|sales|pm|developer|finance|readonly|client
|
||||
role String @default("owner") // owner|admin|sales|pm|developer|finance|readonly|client|member
|
||||
// --- Authentication ---
|
||||
// scrypt-based password hash (format: "salt:hash", both hex). Null = seeded
|
||||
// user that hasn't claimed their account via registration yet.
|
||||
passwordHash String?
|
||||
emailVerifiedAt DateTime?
|
||||
lastLoginAt DateTime?
|
||||
// --- Two-factor authentication (opt-in per user) ---
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
// 'email' = OTP sent by mail | 'totp' = Google Authenticator / Authy / 1Password
|
||||
twoFactorMethod String? // email|totp
|
||||
// Base32 TOTP secret (stored plaintext for now; encrypt-at-rest later).
|
||||
// Only set when method='totp'.
|
||||
twoFactorSecret String?
|
||||
// Backup codes (hashed, newline-separated) for recovery. Generated on enable.
|
||||
twoFactorBackup String?
|
||||
twoFactorEnabledAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
otpCodes OtpCode[]
|
||||
}
|
||||
|
||||
// One-time passcodes (email OTP method). Each login attempt issues a code,
|
||||
// stored hashed (never plaintext). Codes expire after 5 minutes and are
|
||||
// single-use (consumedAt set on successful verify).
|
||||
model OtpCode {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
// SHA-256 hash of the 6-digit code (we never store the code itself).
|
||||
codeHash String
|
||||
purpose String @default("login") // login|email-verify|password-reset
|
||||
attempts Int @default(0)
|
||||
consumedAt DateTime?
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, purpose])
|
||||
}
|
||||
|
||||
// Module registry — installed/enabled/disabled state of each domain module
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import { getCurrentUser } from '@/lib/session'
|
||||
import { verifyPassword } from '@/lib/auth'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const disableSchema = z.object({
|
||||
password: z.string().min(1),
|
||||
})
|
||||
|
||||
// POST /api/auth/2fa/disable
|
||||
//
|
||||
// Disables 2FA for the authenticated user. Requires the current password
|
||||
// (re-authentication) so a hijacked session can't silently weaken security.
|
||||
// Clears the TOTP secret + backup codes; the user row is retained.
|
||||
//
|
||||
// Audit-logged.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const parsed = disableSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Passwort erforderlich.' }, { status: 400 })
|
||||
}
|
||||
const { password } = parsed.data
|
||||
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const full = await db.user.findUnique({ where: { id: user.id }, select: { passwordHash: true, twoFactorMethod: true } })
|
||||
if (!full?.passwordHash) {
|
||||
return NextResponse.json({ error: 'Kein Passwort gesetzt.' }, { status: 400 })
|
||||
}
|
||||
if (!verifyPassword(password, full.passwordHash)) {
|
||||
return NextResponse.json({ error: 'Falsches Passwort.' }, { status: 401 })
|
||||
}
|
||||
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
twoFactorEnabled: false,
|
||||
twoFactorMethod: null,
|
||||
twoFactorSecret: null,
|
||||
twoFactorBackup: null,
|
||||
twoFactorEnabledAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: `user:${user.email}`,
|
||||
action: 'auth.2fa.disable',
|
||||
entity: 'User',
|
||||
entityId: user.id,
|
||||
before: JSON.stringify({ method: full.twoFactorMethod }),
|
||||
after: JSON.stringify({ disabled: true }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true, message: '2FA deaktiviert.' })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import { getCurrentUser } from '@/lib/session'
|
||||
import {
|
||||
verifyTotp,
|
||||
generateBackupCodes,
|
||||
} from '@/lib/auth'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const enableSchema = z.object({
|
||||
method: z.enum(['totp', 'email']),
|
||||
// Required when method='totp': the secret from /2fa/setup + a code the
|
||||
// user enters from their authenticator app to prove they scanned it.
|
||||
secret: z.string().optional(),
|
||||
code: z.string().min(6).max(6).optional(),
|
||||
// Re-auth: user must confirm current password to enable 2FA (anti-session-hijack).
|
||||
password: z.string().min(1),
|
||||
})
|
||||
|
||||
// POST /api/auth/2fa/enable
|
||||
//
|
||||
// Enables 2FA for the authenticated user. Requires the current password
|
||||
// (re-authentication) so a hijacked session can't silently lock the owner
|
||||
// out by enabling 2FA.
|
||||
//
|
||||
// method='totp' → secret + code must be present; code verified against
|
||||
// the secret (proves the user scanned the QR).
|
||||
// method='email' → no secret needed; OTP delivery happens at login time.
|
||||
//
|
||||
// On success, 8 backup codes are generated and returned ONCE (the user must
|
||||
// save them — they're stored hashed and shown only here).
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const parsed = enableSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Angaben ungültig', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { method, secret, code, password } = parsed.data
|
||||
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Re-auth: verify current password.
|
||||
const full = await db.user.findUnique({ where: { id: user.id }, select: { passwordHash: true } })
|
||||
if (!full?.passwordHash) {
|
||||
return NextResponse.json({ error: 'Kein Passwort gesetzt.' }, { status: 400 })
|
||||
}
|
||||
const { verifyPassword } = await import('@/lib/auth')
|
||||
if (!verifyPassword(password, full.passwordHash)) {
|
||||
return NextResponse.json({ error: 'Falsches Passwort.' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (method === 'totp') {
|
||||
if (!secret || !code) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Secret und Code aus der Authenticator-App erforderlich.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
if (!verifyTotp(code, secret)) {
|
||||
return NextResponse.json({ error: 'Code aus der App ist falsch — bitte erneut versuchen.' }, { status: 401 })
|
||||
}
|
||||
}
|
||||
|
||||
// Generate backup codes (only when enabling — not when switching methods
|
||||
// if already enabled, but here we regenerate for simplicity).
|
||||
const { plain: backupPlain, hashed: backupHashed } = generateBackupCodes(8)
|
||||
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
twoFactorEnabled: true,
|
||||
twoFactorMethod: method,
|
||||
twoFactorSecret: method === 'totp' ? secret : null,
|
||||
twoFactorBackup: backupHashed,
|
||||
twoFactorEnabledAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: `user:${user.email}`,
|
||||
action: 'auth.2fa.enable',
|
||||
entity: 'User',
|
||||
entityId: user.id,
|
||||
after: JSON.stringify({ method }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
method,
|
||||
backupCodes: backupPlain,
|
||||
message: method === 'totp'
|
||||
? '2FA mit Authenticator-App aktiviert. Backup-Codes sicher speichern!'
|
||||
: '2FA per E-Mail aktiviert. Backup-Codes sicher speichern!',
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import QRCode from 'qrcode'
|
||||
import { getCurrentUser } from '@/lib/session'
|
||||
import { generateTotpSecret, totpOtpauthUrl } from '@/lib/auth'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// POST /api/auth/2fa/setup
|
||||
//
|
||||
// Initiates TOTP setup for the authenticated user. Generates a fresh secret
|
||||
// (NOT persisted yet — the client holds it and submits it back with a code
|
||||
// to /2fa/enable). Returns the otpauth:// URL and a PNG data-URI QR code
|
||||
// the user scans with Google Authenticator / Authy / 1Password.
|
||||
//
|
||||
// For the 'email' method there is nothing to set up — the frontend should
|
||||
// call /2fa/enable directly with method='email'.
|
||||
export async function POST() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const secret = generateTotpSecret()
|
||||
const label = `${user.email}`
|
||||
const issuer = 'AOS'
|
||||
const otpauthUrl = totpOtpauthUrl({ secret, label, issuer })
|
||||
|
||||
const qr = await QRCode.toDataURL(otpauthUrl, {
|
||||
width: 220,
|
||||
margin: 2,
|
||||
color: { dark: '#000000', light: '#ffffff' },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
secret,
|
||||
otpauthUrl,
|
||||
qr,
|
||||
issuer,
|
||||
label,
|
||||
// Manual entry key, grouped for readability.
|
||||
secretFormatted: secret.replace(/(.{4})/g, '$1 ').trim(),
|
||||
})
|
||||
}
|
||||
@@ -1,64 +1,123 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import {
|
||||
verifyPassword,
|
||||
createSessionToken,
|
||||
createLoginChallenge,
|
||||
generateEmailOtp,
|
||||
hashOtpCode,
|
||||
otpExpiry,
|
||||
otpMaxAttempts,
|
||||
} from '@/lib/auth'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const schema = z.object({
|
||||
password: z.string().min(1),
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email().max(200).toLowerCase(),
|
||||
password: z.string().min(1).max(200),
|
||||
redirect: z.string().optional(),
|
||||
})
|
||||
|
||||
// POST /api/auth/login
|
||||
// Simple password-based auth. Password is set via ADMIN_PASSWORD env var.
|
||||
// Returns a session cookie (HMAC-signed, 7-day expiry).
|
||||
//
|
||||
// Step 1 of the login flow. Verifies email + password, then either:
|
||||
// - 2FA OFF → sets the session cookie immediately, returns { ok, redirect }
|
||||
// - 2FA ON → issues a short-lived signed challenge and returns
|
||||
// { twoFactorRequired: true, method, challenge }. For the
|
||||
// 'email' method an OTP is also sent to the user's inbox.
|
||||
//
|
||||
// The challenge is HMAC-signed (5-min TTL) and carries the userId + method.
|
||||
// Step 2 is POST /api/auth/login/verify with { challenge, code }.
|
||||
//
|
||||
// Anti-enumeration: unknown emails / wrong passwords both return the same
|
||||
// generic "Ungültige Anmeldedaten" so attackers can't probe which emails
|
||||
// exist. Rate limiting is handled by the global middleware (120 req/min/IP).
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const parsed = schema.safeParse(await req.json())
|
||||
const parsed = loginSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'invalid request' }, { status: 400 })
|
||||
return NextResponse.json({ error: 'Ungültige Anmeldedaten' }, { status: 400 })
|
||||
}
|
||||
const { password, redirect } = parsed.data
|
||||
const { email, password, redirect } = parsed.data
|
||||
|
||||
const adminPassword = process.env.ADMIN_PASSWORD
|
||||
if (!adminPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ADMIN_PASSWORD env not configured. Set it in Dokploy environment variables.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
const user = await db.user.findUnique({ where: { email } })
|
||||
|
||||
// Constant-ish path: if user is null, still run a hash verify against a
|
||||
// dummy hash so timing doesn't reveal whether the email exists.
|
||||
const dummyHash = '$scrypt$dummy'
|
||||
const passwordOk = user?.passwordHash
|
||||
? verifyPassword(password, user.passwordHash)
|
||||
: (verifyPassword(password, dummyHash), false)
|
||||
|
||||
if (!user || !user.passwordHash || !passwordOk) {
|
||||
return NextResponse.json({ error: 'Ungültige Anmeldedaten' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (password !== adminPassword) {
|
||||
return NextResponse.json({ error: 'Falsches Passwort' }, { status: 401 })
|
||||
// --- 2FA branch ---
|
||||
if (user.twoFactorEnabled && user.twoFactorMethod) {
|
||||
const method = user.twoFactorMethod
|
||||
const challenge = createLoginChallenge(user.id, method)
|
||||
|
||||
if (method === 'email') {
|
||||
// Generate + store an OTP, email it to the user.
|
||||
const code = generateEmailOtp()
|
||||
await db.otpCode.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
codeHash: hashOtpCode(code),
|
||||
purpose: 'login',
|
||||
expiresAt: otpExpiry(),
|
||||
},
|
||||
})
|
||||
const { sendEmail } = await import('@/lib/email')
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: 'AOS — Anmeldecode',
|
||||
text: `Ihr Anmeldecode lautet: ${code}\n\nDer Code ist 5 Minuten gültig.\n\nWenn Sie sich nicht angemeldet haben, ignorieren Sie diese E-Mail.`,
|
||||
html: `<p>Ihr Anmeldecode lautet:</p><p style="font-size:28px;font-weight:bold;letter-spacing:8px;text-align:center;background:#f5f5f5;padding:16px;border-radius:8px;">${code}</p><p>Der Code ist 5 Minuten gültig.</p><p style="color:#888;font-size:12px;">Wenn Sie sich nicht angemeldet haben, ignorieren Sie diese E-Mail.</p>`,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// Create session token: base64(payload).hmac
|
||||
const crypto = await import('crypto')
|
||||
const payload = {
|
||||
sub: 'admin',
|
||||
iat: Date.now(),
|
||||
exp: Date.now() + 7 * 24 * 3600 * 1000, // 7 days
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
twoFactorRequired: true,
|
||||
method,
|
||||
challenge,
|
||||
// mask email for display
|
||||
maskedEmail: maskEmail(user.email),
|
||||
message: method === 'email'
|
||||
? `Ein 6-stelliger Code wurde an ${maskEmail(user.email)} gesendet.`
|
||||
: 'Bitte den Code aus Ihrer Authenticator-App eingeben.',
|
||||
})
|
||||
}
|
||||
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||
const key = process.env.AUTH_SECRET || adminPassword
|
||||
const signature = crypto
|
||||
.createHmac('sha256', key)
|
||||
.update(payloadB64)
|
||||
.digest('base64url')
|
||||
const token = `${payloadB64}.${signature}`
|
||||
|
||||
// --- No 2FA → set session immediately ---
|
||||
await db.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } })
|
||||
|
||||
const token = createSessionToken({ sub: user.id, email: user.email, role: user.role })
|
||||
const res = NextResponse.json({
|
||||
ok: true,
|
||||
redirect: redirect || '/',
|
||||
user: { id: user.id, email: user.email, name: user.name, role: user.role },
|
||||
})
|
||||
res.cookies.set('aos-session', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 7 * 24 * 3600, // 7 days
|
||||
maxAge: 7 * 24 * 3600,
|
||||
})
|
||||
return res
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'login failed' }, { status: 500 })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
function maskEmail(email: string): string {
|
||||
const [local, domain] = email.split('@')
|
||||
if (!domain) return email
|
||||
const visible = local.slice(0, Math.max(1, Math.min(2, local.length - 1)))
|
||||
return `${visible}${'•'.repeat(Math.max(2, local.length - 2))}@${domain}`
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import {
|
||||
verifyLoginChallenge,
|
||||
createSessionToken,
|
||||
verifyTotp,
|
||||
verifyOtpCode,
|
||||
consumeBackupCode,
|
||||
hashOtpCode,
|
||||
otpMaxAttempts,
|
||||
} from '@/lib/auth'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const verifySchema = z.object({
|
||||
challenge: z.string().min(1),
|
||||
code: z.string().min(4).max(40),
|
||||
redirect: z.string().optional(),
|
||||
})
|
||||
|
||||
// POST /api/auth/login/verify
|
||||
//
|
||||
// Step 2 of the login flow. Validates the challenge token (issued by step 1)
|
||||
// and verifies the OTP code:
|
||||
// - method='totp' → code verified against the stored TOTP secret
|
||||
// - method='email' → code verified against a stored OtpCode row (hashed)
|
||||
// - 10-char backup codes are also accepted as a recovery path.
|
||||
//
|
||||
// On success: sets the session cookie + updates lastLoginAt. On failure:
|
||||
// 401 with a remaining-attempts hint (after 5 wrong attempts the latest
|
||||
// code is invalidated and the user must restart login).
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const parsed = verifySchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Code fehlt oder ist ungültig.' }, { status: 400 })
|
||||
}
|
||||
const { challenge, code, redirect } = parsed.data
|
||||
|
||||
const ch = verifyLoginChallenge(challenge)
|
||||
if (!ch) {
|
||||
return NextResponse.json({ error: 'Sitzung abgelaufen — bitte erneut anmelden.' }, { status: 401 })
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: ch.sub },
|
||||
select: { id: true, email: true, name: true, role: true, twoFactorMethod: true, twoFactorSecret: true, twoFactorBackup: true, twoFactorEnabled: true },
|
||||
})
|
||||
if (!user || !user.twoFactorEnabled) {
|
||||
return NextResponse.json({ error: '2FA nicht aktiv — bitte erneut anmelden.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const normalizedCode = code.trim().replace(/\s/g, '')
|
||||
|
||||
// --- Try TOTP ---
|
||||
if (ch.method === 'totp' && user.twoFactorSecret) {
|
||||
if (verifyTotp(normalizedCode, user.twoFactorSecret)) {
|
||||
return await finishLogin(user, redirect)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Try email OTP (look up the latest unused code for this user+purpose) ---
|
||||
if (ch.method === 'email') {
|
||||
const otp = await db.otpCode.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
purpose: 'login',
|
||||
consumedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
if (!otp) {
|
||||
return NextResponse.json({ error: 'Kein gültiger Code. Bitte erneut anmelden.' }, { status: 401 })
|
||||
}
|
||||
if (otp.attempts >= otpMaxAttempts()) {
|
||||
await db.otpCode.update({ where: { id: otp.id }, data: { consumedAt: new Date() } })
|
||||
return NextResponse.json({ error: 'Zu viele Versuche — bitte erneut anmelden.' }, { status: 429 })
|
||||
}
|
||||
if (verifyOtpCode(normalizedCode, otp.codeHash)) {
|
||||
await db.otpCode.update({ where: { id: otp.id }, data: { consumedAt: new Date() } })
|
||||
return await finishLogin(user, redirect)
|
||||
}
|
||||
await db.otpCode.update({ where: { id: otp.id }, data: { attempts: { increment: 1 } } })
|
||||
const remaining = otpMaxAttempts() - (otp.attempts + 1)
|
||||
return NextResponse.json(
|
||||
{ error: 'Falscher Code.', remainingAttempts: Math.max(0, remaining) },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// --- Try backup code (recovery, 10 chars, format XXXX-XXXX-XX) ---
|
||||
if (user.twoFactorBackup && normalizedCode.length >= 8) {
|
||||
const { match, remainingHashed } = consumeBackupCode(normalizedCode, user.twoFactorBackup)
|
||||
if (match) {
|
||||
await db.user.update({ where: { id: user.id }, data: { twoFactorBackup: remainingHashed } })
|
||||
return await finishLogin(user, redirect)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Ungültiger Code.' }, { status: 401 })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
async function finishLogin(
|
||||
user: { id: string; email: string; name: string; role: string },
|
||||
redirect?: string
|
||||
) {
|
||||
await db.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } })
|
||||
const token = createSessionToken({ sub: user.id, email: user.email, role: user.role })
|
||||
const res = NextResponse.json({
|
||||
ok: true,
|
||||
redirect: redirect || '/',
|
||||
user: { id: user.id, email: user.email, name: user.name, role: user.role },
|
||||
})
|
||||
res.cookies.set('aos-session', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 7 * 24 * 3600,
|
||||
})
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getCurrentUser } from '@/lib/session'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET /api/auth/me
|
||||
// Returns the currently authenticated user (from the session cookie) or 401.
|
||||
// Used by the frontend to hydrate the current session + show 2FA status.
|
||||
export async function GET() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||
}
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
emailVerifiedAt: user.emailVerifiedAt,
|
||||
twoFactorEnabled: user.twoFactorEnabled,
|
||||
twoFactorMethod: user.twoFactorMethod,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import { hashPassword, validatePasswordStrength } from '@/lib/auth'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const registerSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
email: z.string().email().max(200).toLowerCase(),
|
||||
password: z.string().min(8).max(200),
|
||||
})
|
||||
|
||||
// POST /api/auth/register
|
||||
//
|
||||
// Email + password registration. Creates (or claims) a user account.
|
||||
//
|
||||
// Claim flow: if the email already belongs to a seeded User with no
|
||||
// passwordHash (e.g. demo fixtures), registration sets the password on
|
||||
// that existing user instead of failing — this lets seeded team members
|
||||
// claim their accounts.
|
||||
//
|
||||
// Role assignment:
|
||||
// - First account with a password on the system → owner (bootstrap)
|
||||
// - Everyone else → 'member' (default staff role; can be elevated by an
|
||||
// owner in settings later).
|
||||
//
|
||||
// Email verification: a verification code is emailed if SMTP is configured.
|
||||
// Until verified, the user can still log in (verification is encouraged but
|
||||
// not blocking for this single-tenant agency tool). Resend via /verify-email.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const parsed = registerSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Angaben ungültig', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { name, email, password } = parsed.data
|
||||
|
||||
const pwError = validatePasswordStrength(password)
|
||||
if (pwError) {
|
||||
return NextResponse.json({ error: pwError }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check for an existing user with this email that already has a password
|
||||
// (claimed) — that's a real conflict.
|
||||
const existing = await db.user.findUnique({ where: { email } })
|
||||
if (existing?.passwordHash) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Für diese E-Mail existiert bereits ein Konto. Bitte anmelden.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const passwordHash = hashPassword(password)
|
||||
|
||||
// Decide the role: first account with a password becomes the owner.
|
||||
const usersWithPassword = await db.user.count({ where: { NOT: { passwordHash: null } } })
|
||||
const role = usersWithPassword === 0 ? 'owner' : 'member'
|
||||
|
||||
let user
|
||||
if (existing) {
|
||||
// Claim the seeded account (keep id, role may be elevated if first).
|
||||
user = await db.user.update({
|
||||
where: { id: existing.id },
|
||||
data: { name, passwordHash, role: usersWithPassword === 0 ? 'owner' : existing.role },
|
||||
})
|
||||
} else {
|
||||
user = await db.user.create({
|
||||
data: { name, email, passwordHash, role },
|
||||
})
|
||||
}
|
||||
|
||||
// Send a verification email (best-effort; not blocking).
|
||||
const { sendEmail } = await import('@/lib/email')
|
||||
const { generateEmailOtp, hashOtpCode, otpExpiry } = await import('@/lib/auth')
|
||||
const code = generateEmailOtp()
|
||||
await db.otpCode.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
codeHash: hashOtpCode(code),
|
||||
purpose: 'email-verify',
|
||||
expiresAt: otpExpiry(),
|
||||
},
|
||||
})
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: 'E-Mail bestätigen — AOS',
|
||||
text: `Willkommen bei AOS!\n\nIhr Bestätigungscode lautet: ${code}\n\nDer Code ist 5 Minuten gültig.\n\nMit freundlichen Grüßen\nDFM Solutions`,
|
||||
html: `<h2>Willkommen bei AOS!</h2><p>Ihr Bestätigungscode lautet:</p><p style="font-size:28px;font-weight:bold;letter-spacing:8px;text-align:center;background:#f5f5f5;padding:16px;border-radius:8px;">${code}</p><p>Der Code ist 5 Minuten gültig.</p><p>Mit freundlichen Grüßen<br>DFM Solutions</p>`,
|
||||
}).catch(() => {})
|
||||
|
||||
// Don't auto-login after registration: require explicit login so the
|
||||
// user learns the flow (and enters 2FA if they later enable it).
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message: 'Konto erstellt. Bitte melden Sie sich an.',
|
||||
role,
|
||||
emailVerificationSent: true,
|
||||
// If this is the very first owner, flag it so the UI can show guidance.
|
||||
isFirstOwner: role === 'owner',
|
||||
}, { status: 201 })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import {
|
||||
verifyLoginChallenge,
|
||||
generateEmailOtp,
|
||||
hashOtpCode,
|
||||
otpExpiry,
|
||||
} from '@/lib/auth'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const schema = z.object({
|
||||
challenge: z.string().min(1),
|
||||
})
|
||||
|
||||
// POST /api/auth/resend-otp
|
||||
//
|
||||
// Re-issues an email OTP for an in-progress 2FA login. The challenge token
|
||||
// from step 1 is required — this ensures only users who already proved
|
||||
// their password can request a new code (no anonymous spam).
|
||||
//
|
||||
// Cooldown: at most 1 resend per 30s (enforced by checking the latest code's
|
||||
// createdAt). Prevents email bombing.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const parsed = schema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Ungültige Anfrage' }, { status: 400 })
|
||||
}
|
||||
const { challenge } = parsed.data
|
||||
|
||||
const ch = verifyLoginChallenge(challenge)
|
||||
if (!ch || ch.method !== 'email') {
|
||||
return NextResponse.json({ error: 'Ungültige oder abgelaufene Sitzung.' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Cooldown: reject if the latest code is < 30s old.
|
||||
const latest = await db.otpCode.findFirst({
|
||||
where: { userId: ch.sub, purpose: 'login' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
if (latest && Date.now() - latest.createdAt.getTime() < 30_000) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Bitte 30 Sekunden warten, bevor Sie einen neuen Code anfordern.', cooldown: true },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
const code = generateEmailOtp()
|
||||
await db.otpCode.create({
|
||||
data: {
|
||||
userId: ch.sub,
|
||||
codeHash: hashOtpCode(code),
|
||||
purpose: 'login',
|
||||
expiresAt: otpExpiry(),
|
||||
},
|
||||
})
|
||||
|
||||
const user = await db.user.findUnique({ where: { id: ch.sub }, select: { email: true } })
|
||||
if (user) {
|
||||
const { sendEmail } = await import('@/lib/email')
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: 'AOS — Neuer Anmeldecode',
|
||||
text: `Ihr neuer Anmeldecode lautet: ${code}\n\nDer Code ist 5 Minuten gültig.`,
|
||||
html: `<p>Ihr neuer Anmeldecode lautet:</p><p style="font-size:28px;font-weight:bold;letter-spacing:8px;text-align:center;background:#f5f5f5;padding:16px;border-radius:8px;">${code}</p><p>Der Code ist 5 Minuten gültig.</p>`,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, message: 'Neuer Code gesendet.' })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import { verifyOtpCode, otpMaxAttempts } from '@/lib/auth'
|
||||
import { getCurrentUser } from '@/lib/session'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const schema = z.object({
|
||||
code: z.string().regex(/^\d{6}$/, 'Code muss 6 Ziffern sein.'),
|
||||
})
|
||||
|
||||
// POST /api/auth/verify-email
|
||||
//
|
||||
// Verifies the email ownership OTP sent at registration. Marks the user's
|
||||
// emailVerifiedAt. Can be called by:
|
||||
// - an authenticated user verifying their own email, OR
|
||||
// - an unauthenticated user with just the code (if they haven't logged in
|
||||
// yet). In that case we look up by the most recent unconsumed code.
|
||||
//
|
||||
// Codes are single-use, 5-min TTL, max 5 attempts.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const parsed = schema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Ungültiger Code.' }, { status: 400 })
|
||||
}
|
||||
const { code } = parsed.data
|
||||
|
||||
// Try the authenticated user first; fall back to the latest unconsumed code.
|
||||
const me = await getCurrentUser()
|
||||
let userId = me?.id
|
||||
|
||||
let otp
|
||||
if (userId) {
|
||||
otp = await db.otpCode.findFirst({
|
||||
where: { userId, purpose: 'email-verify', consumedAt: null, expiresAt: { gt: new Date() } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
} else {
|
||||
// Find the most recent unconsumed email-verify code matching this code.
|
||||
// This is a single-tenant tool with low volume — acceptable.
|
||||
const candidates = await db.otpCode.findMany({
|
||||
where: { purpose: 'email-verify', consumedAt: null, expiresAt: { gt: new Date() } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
})
|
||||
otp = candidates.find((c) => verifyOtpCode(code, c.codeHash)) ?? null
|
||||
if (otp) userId = otp.userId
|
||||
}
|
||||
|
||||
if (!otp || !userId) {
|
||||
return NextResponse.json({ error: 'Kein gültiger Code gefunden — bitte neu anfordern.' }, { status: 404 })
|
||||
}
|
||||
if (otp.attempts >= otpMaxAttempts()) {
|
||||
await db.otpCode.update({ where: { id: otp.id }, data: { consumedAt: new Date() } })
|
||||
return NextResponse.json({ error: 'Zu viele Versuche — bitte neuen Code anfordern.' }, { status: 429 })
|
||||
}
|
||||
|
||||
if (!verifyOtpCode(code, otp.codeHash)) {
|
||||
await db.otpCode.update({ where: { id: otp.id }, data: { attempts: { increment: 1 } } })
|
||||
const remaining = otpMaxAttempts() - (otp.attempts + 1)
|
||||
return NextResponse.json(
|
||||
{ error: 'Falscher Code.', remainingAttempts: Math.max(0, remaining) },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
await db.otpCode.update({ where: { id: otp.id }, data: { consumedAt: new Date() } })
|
||||
await db.user.update({ where: { id: userId }, data: { emailVerifiedAt: new Date() } })
|
||||
|
||||
return NextResponse.json({ ok: true, message: 'E-Mail bestätigt.' })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
+170
-11
@@ -2,65 +2,224 @@
|
||||
|
||||
import { Suspense, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Loader2, Lock } from 'lucide-react'
|
||||
import { Loader2, Lock, Mail, ShieldCheck, ArrowLeft, RotateCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
type Step =
|
||||
| { kind: 'credentials' }
|
||||
| { kind: 'otp'; method: string; challenge: string; maskedEmail: string }
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const redirect = searchParams.get('redirect') || '/'
|
||||
const registered = searchParams.get('registered') === '1'
|
||||
|
||||
const [step, setStep] = useState<Step>({ kind: 'credentials' })
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [resendCooldown, setResendCooldown] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
// --- Step 1: email + password ---
|
||||
const submitCredentials = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!password) return
|
||||
if (!email || !password) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ password, redirect }),
|
||||
body: JSON.stringify({ email: email.toLowerCase(), password, redirect }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Login fehlgeschlagen')
|
||||
return
|
||||
}
|
||||
if (data.twoFactorRequired) {
|
||||
setStep({
|
||||
kind: 'otp',
|
||||
method: data.method,
|
||||
challenge: data.challenge,
|
||||
maskedEmail: data.maskedEmail,
|
||||
})
|
||||
if (data.message) toast.info(data.message)
|
||||
} else {
|
||||
router.push(data.redirect || '/')
|
||||
router.refresh()
|
||||
}
|
||||
} catch {
|
||||
setError('Netzwerkfehler')
|
||||
setError('Netzwerkfehler — bitte erneut versuchen.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Step 2: OTP code ---
|
||||
const submitOtp = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (step.kind !== 'otp' || !code) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch('/api/auth/login/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ challenge: step.challenge, code, redirect }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Code ungültig')
|
||||
if (data.remainingAttempts !== undefined) {
|
||||
setError(`${data.error || 'Code ungültig'} — ${data.remainingAttempts} Versuche übrig.`)
|
||||
}
|
||||
return
|
||||
}
|
||||
router.push(data.redirect || '/')
|
||||
router.refresh()
|
||||
} catch {
|
||||
setError('Netzwerkfehler — bitte erneut versuchen.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resendOtp = async () => {
|
||||
if (step.kind !== 'otp') return
|
||||
setResendCooldown(true)
|
||||
const t = toast.loading('Neuen Code anfordern…')
|
||||
try {
|
||||
const res = await fetch('/api/auth/resend-otp', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ challenge: step.challenge }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || 'Fehlgeschlagen')
|
||||
toast.success('Neuer Code gesendet.', { id: t })
|
||||
// 30s cooldown to match backend.
|
||||
setTimeout(() => setResendCooldown(false), 30_000)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Fehlgeschlagen', { id: t })
|
||||
setResendCooldown(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (step.kind === 'otp') {
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={submitOtp} className="space-y-4">
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 p-3 text-xs">
|
||||
<ShieldCheck className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
{step.method === 'totp'
|
||||
? '2-Faktor-Authentifizierung: Code aus Ihrer Authenticator-App eingeben.'
|
||||
: `Ein 6-stelliger Code wurde an ${step.maskedEmail} gesendet.`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">
|
||||
{step.method === 'totp' ? 'Authenticator-Code' : 'Bestätigungscode'}
|
||||
</Label>
|
||||
<Input
|
||||
id="code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, step.method === 'totp' ? 6 : 6))}
|
||||
placeholder="000000"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
className="text-center text-lg tracking-[0.4em]"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
<Button type="submit" className="w-full gap-2" disabled={loading || code.length < 4}>
|
||||
{loading && <Loader2 className="size-4 animate-spin" />}
|
||||
Bestätigen
|
||||
</Button>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setStep({ kind: 'credentials' }); setCode(''); setError('') }}
|
||||
className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-3" /> Zurück
|
||||
</button>
|
||||
{step.method === 'email' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resendOtp}
|
||||
disabled={resendCooldown}
|
||||
className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
<RotateCw className="size-3" /> {resendCooldown ? 'Code gesendet…' : 'Neuen Code'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-center text-[11px] text-muted-foreground">
|
||||
Backup-Code vergessen? Geben Sie einen 10-stelligen Backup-Code ein (Format: XXXX-XXXX-XX).
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submitCredentials} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-Mail</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@firma.de"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Passwort</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Admin-Passwort eingeben"
|
||||
autoFocus
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
{registered && (
|
||||
<p className="rounded-md border border-foreground/20 bg-foreground/5 p-2 text-center text-xs text-muted-foreground">
|
||||
Konto erstellt. Ein Bestätigungscode wurde an Ihre E-Mail gesendet — bitte anmelden.
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" className="w-full gap-2" disabled={loading || !password}>
|
||||
<Button type="submit" className="w-full gap-2" disabled={loading || !email || !password}>
|
||||
{loading && <Loader2 className="size-4 animate-spin" />}
|
||||
Anmelden
|
||||
</Button>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Noch kein Konto?{' '}
|
||||
<Link href="/register" className="font-medium text-foreground hover:underline">
|
||||
Konto erstellen
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Loader2, Lock, Mail, User, ShieldCheck } from 'lucide-react'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter()
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const pwRules = [
|
||||
{ ok: password.length >= 8, label: 'Mind. 8 Zeichen' },
|
||||
{ ok: /[a-z]/.test(password), label: 'Kleinbuchstabe' },
|
||||
{ ok: /[A-Z]/.test(password), label: 'Großbuchstabe' },
|
||||
{ ok: /[0-9]/.test(password), label: 'Zahl' },
|
||||
]
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name || !email || !password) return
|
||||
if (password !== confirm) {
|
||||
setError('Passwörter stimmen nicht überein.')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name, email: email.toLowerCase(), password }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Registrierung fehlgeschlagen')
|
||||
return
|
||||
}
|
||||
// Registration successful → go to login.
|
||||
router.push('/login?registered=1')
|
||||
router.refresh()
|
||||
} catch {
|
||||
setError('Netzwerkfehler — bitte erneut versuchen.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="mx-auto flex size-14 items-center justify-center rounded-xl bg-foreground text-background">
|
||||
<User className="size-7" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Konto erstellen</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Zugang zum AOS — dfmpanel.eu
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Max Mustermann"
|
||||
autoComplete="name"
|
||||
autoFocus
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-Mail</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@firma.de"
|
||||
autoComplete="email"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Passwort</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{password && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{pwRules.map((r) => (
|
||||
<span
|
||||
key={r.label}
|
||||
className={
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium ' +
|
||||
(r.ok
|
||||
? 'bg-foreground/10 text-foreground'
|
||||
: 'bg-muted text-muted-foreground')
|
||||
}
|
||||
>
|
||||
{r.ok ? <ShieldCheck className="size-2.5" /> : null}
|
||||
{r.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm">Passwort wiederholen</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="confirm"
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full gap-2"
|
||||
disabled={loading || !name || !email || !password || !confirm || !pwRules.every((r) => r.ok)}
|
||||
>
|
||||
{loading && <Loader2 className="size-4 animate-spin" />}
|
||||
Konto erstellen
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Bereits ein Konto?{' '}
|
||||
<Link href="/login" className="font-medium text-foreground hover:underline">
|
||||
Anmelden
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Menu, Search, Bell, ChevronRight } from 'lucide-react'
|
||||
import { useAos, type ViewId } from '@/store/aos'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { ThemeToggle } from '@/components/theme-toggle'
|
||||
import { LocaleSwitcher } from './locale-switcher'
|
||||
import { NotificationsBell } from './notifications-bell'
|
||||
@@ -51,6 +52,14 @@ export function TopBar() {
|
||||
const { view, setSidebarOpen, setCommandOpen } = useAos()
|
||||
const meta = TITLES[view]
|
||||
const crumbParts = meta.crumb.split(' / ')
|
||||
const { data: meData } = useFetch<{ user: { name: string; email: string; role: string } } | null>('/api/auth/me')
|
||||
const user = meData?.user
|
||||
const initials = (user?.name ?? '?')
|
||||
.split(' ')
|
||||
.map((p) => p[0])
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 flex h-16 items-center gap-3 border-b border-border bg-background/80 px-4 backdrop-blur-md md:px-6">
|
||||
@@ -95,19 +104,19 @@ export function TopBar() {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="gap-2 px-1.5">
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback className="bg-primary text-primary-foreground text-xs">MB</AvatarFallback>
|
||||
<AvatarFallback className="bg-primary text-primary-foreground text-xs">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="flex flex-col">
|
||||
<span className="text-sm font-medium">Max Brandt</span>
|
||||
<span className="text-xs text-muted-foreground">max@nordlicht.digital</span>
|
||||
<span className="text-sm font-medium">{user?.name ?? '—'}</span>
|
||||
<span className="text-xs text-muted-foreground">{user?.email ?? ''}</span>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="flex items-center justify-between">
|
||||
<span>Rolle</span>
|
||||
<Badge variant="outline" className="text-[10px]">Owner</Badge>
|
||||
<Badge variant="outline" className="text-[10px] capitalize">{user?.role ?? '—'}</Badge>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>Profil</DropdownMenuItem>
|
||||
<DropdownMenuItem>Sitzungen</DropdownMenuItem>
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
Mail,
|
||||
Loader2,
|
||||
Lock,
|
||||
Copy,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
KeyRound,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface MeResponse {
|
||||
user: {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
emailVerifiedAt: string | null
|
||||
twoFactorEnabled: boolean
|
||||
twoFactorMethod: string | null
|
||||
lastLoginAt: string | null
|
||||
}
|
||||
}
|
||||
|
||||
type EnableStep =
|
||||
| { kind: 'choose-method' }
|
||||
| { kind: 'totp-setup'; secret: string; qr: string; otpauthUrl: string }
|
||||
| { kind: 'enter-code'; method: 'totp' | 'email'; secret?: string }
|
||||
| { kind: 'backup-codes'; codes: string[] }
|
||||
|
||||
/**
|
||||
* TwoFactorSettings — self-contained 2FA management card.
|
||||
*
|
||||
* States:
|
||||
* - 2FA OFF → "Enable" button → method chooser (Email / Authenticator)
|
||||
* - Email: re-auth (password) → enable → show backup codes
|
||||
* - TOTP: setup (QR + secret) → enter code from app → re-auth → enable → backup codes
|
||||
* - 2FA ON → show method + status → "Disable" (re-auth required)
|
||||
*
|
||||
* Fetches /api/auth/me for the current status. Uses:
|
||||
* POST /api/auth/2fa/setup → TOTP provisioning
|
||||
* POST /api/auth/2fa/enable → finalize (re-auth + verify)
|
||||
* POST /api/auth/2fa/disable → turn off (re-auth required)
|
||||
*/
|
||||
export function TwoFactorSettings() {
|
||||
const { data, loading, refetch } = useFetch<MeResponse>('/api/auth/me')
|
||||
const [enableOpen, setEnableOpen] = useState(false)
|
||||
const [disableOpen, setDisableOpen] = useState(false)
|
||||
const [step, setStep] = useState<EnableStep>({ kind: 'choose-method' })
|
||||
const [password, setPassword] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [copied, setCopied] = useState<string | null>(null)
|
||||
|
||||
const user = data?.user
|
||||
|
||||
const reset = () => {
|
||||
setStep({ kind: 'choose-method' })
|
||||
setPassword('')
|
||||
setCode('')
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
// --- TOTP setup: get QR + secret ---
|
||||
const startTotp = async () => {
|
||||
setBusy(true)
|
||||
const t = toast.loading('QR-Code wird generiert…')
|
||||
try {
|
||||
const r = await fetch('/api/auth/2fa/setup', { method: 'POST' })
|
||||
const res = await r.json()
|
||||
if (!r.ok) throw new Error(res.error || 'Setup fehlgeschlagen')
|
||||
setStep({ kind: 'totp-setup', secret: res.secret, qr: res.qr, otpauthUrl: res.otpauthUrl })
|
||||
toast.success('QR-Code bereit. Mit Authenticator-App scannen.', { id: t })
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Enable 2FA (re-auth + verify code) ---
|
||||
const enable = async () => {
|
||||
if (!password) return
|
||||
if (step.kind === 'enter-code' && !code) return
|
||||
setBusy(true)
|
||||
const t = toast.loading('2FA wird aktiviert…')
|
||||
try {
|
||||
const method = step.kind === 'enter-code' ? step.method : 'email'
|
||||
const body: Record<string, string> = { method, password }
|
||||
if (step.kind === 'enter-code' && step.method === 'totp') {
|
||||
body.code = code
|
||||
body.secret = step.secret ?? ''
|
||||
}
|
||||
const r = await fetch('/api/auth/2fa/enable', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const res = await r.json()
|
||||
if (!r.ok) throw new Error(res.error || 'Aktivierung fehlgeschlagen')
|
||||
toast.success(res.message ?? '2FA aktiviert', { id: t, duration: 6000 })
|
||||
if (res.backupCodes?.length) {
|
||||
setStep({ kind: 'backup-codes', codes: res.backupCodes })
|
||||
} else {
|
||||
setEnableOpen(false)
|
||||
reset()
|
||||
refetch()
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Disable 2FA ---
|
||||
const disable = async () => {
|
||||
if (!password) return
|
||||
setBusy(true)
|
||||
const t = toast.loading('2FA wird deaktiviert…')
|
||||
try {
|
||||
const r = await fetch('/api/auth/2fa/disable', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
const res = await r.json()
|
||||
if (!r.ok) throw new Error(res.error || 'Deaktivierung fehlgeschlagen')
|
||||
toast.success(res.message ?? '2FA deaktiviert', { id: t })
|
||||
setDisableOpen(false)
|
||||
setPassword('')
|
||||
refetch()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copy = (text: string, label: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(label)
|
||||
setTimeout(() => setCopied(null), 2000)
|
||||
toast.success(`${label} kopiert`)
|
||||
})
|
||||
}
|
||||
|
||||
const enabled = user?.twoFactorEnabled
|
||||
const method = user?.twoFactorMethod
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<ShieldCheck className="size-3.5 text-muted-foreground" />
|
||||
Zwei-Faktor-Authentifizierung (2FA)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" /> Laden…
|
||||
</div>
|
||||
) : !user ? (
|
||||
<p className="text-xs text-muted-foreground">Nicht angemeldet.</p>
|
||||
) : enabled ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="gap-1 text-[10px] text-foreground">
|
||||
<Check className="size-3" /> Aktiv
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{method === 'totp' ? (
|
||||
<span className="inline-flex items-center gap-1"><Smartphone className="size-3" /> Authenticator-App</span>
|
||||
) : method === 'email' ? (
|
||||
<span className="inline-flex items-center gap-1"><Mail className="size-3" /> E-Mail-OTP</span>
|
||||
) : method}
|
||||
</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" className="gap-1.5 text-xs" onClick={() => { setDisableOpen(true); setPassword('') }}>
|
||||
<Lock className="size-3" /> Deaktivieren
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Beim Anmelden wird zusätzlich zum Passwort ein einmaliger Code benötigt. Backup-Codes wurden bei der Einrichtung angezeigt — sicher aufbewahren.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Schützen Sie Ihr Konto mit einem zweiten Faktor. Optional — kann jederzeit aktiviert oder deaktiviert werden.
|
||||
</p>
|
||||
<Button size="sm" className="gap-1.5" onClick={() => { setEnableOpen(true); reset() }}>
|
||||
<ShieldCheck className="size-3.5" /> 2FA aktivieren
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* --- Enable flow --- */}
|
||||
<Dialog open={enableOpen} onOpenChange={(o) => { setEnableOpen(o); if (!o) reset() }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ShieldCheck className="size-4 text-muted-foreground" />
|
||||
2FA aktivieren
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step.kind === 'backup-codes'
|
||||
? 'Backup-Codes — sicher aufbewahren (einmalig sichtbar).'
|
||||
: 'Wählen Sie eine Methode und bestätigen Sie mit Ihrem Passwort.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step.kind === 'choose-method' && (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setStep({ kind: 'enter-code', method: 'email' })}
|
||||
className="flex w-full items-center gap-3 rounded-md border border-border p-3 text-left transition hover:bg-muted/40 disabled:opacity-50"
|
||||
>
|
||||
<Mail className="size-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">E-Mail-OTP</p>
|
||||
<p className="text-[11px] text-muted-foreground">Code pro Anmeldung an Ihre E-Mail gesendet.</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={startTotp}
|
||||
className="flex w-full items-center gap-3 rounded-md border border-border p-3 text-left transition hover:bg-muted/40 disabled:opacity-50"
|
||||
>
|
||||
<Smartphone className="size-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Authenticator-App</p>
|
||||
<p className="text-[11px] text-muted-foreground">Google Authenticator, Authy, 1Password (TOTP).</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.kind === 'totp-setup' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{/* QR code as data URI — no next/image optimization needed */}
|
||||
<img src={step.qr} alt="QR-Code für Authenticator" className="rounded-md border border-border" width={200} height={200} />
|
||||
<p className="text-center text-[11px] text-muted-foreground">
|
||||
Scannen Sie den QR-Code mit Ihrer Authenticator-App.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Oder manuell eingeben:</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded-md bg-muted px-2 py-1.5 font-mono text-[11px] break-all">
|
||||
{step.secret}
|
||||
</code>
|
||||
<Button size="sm" variant="ghost" className="shrink-0" onClick={() => copy(step.secret, 'Secret')}>
|
||||
{copied === 'Secret' ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full" onClick={() => setStep({ kind: 'enter-code', method: 'totp', secret: step.secret })}>
|
||||
Weiter — Code eingeben
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.kind === 'enter-code' && (
|
||||
<div className="space-y-3">
|
||||
{step.method === 'totp' && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="code" className="text-xs">Code aus der Authenticator-App</Label>
|
||||
<Input
|
||||
id="code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
inputMode="numeric"
|
||||
className="text-center text-lg tracking-[0.4em]"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{step.method === 'email' && (
|
||||
<p className="rounded-md border border-border bg-muted/40 p-2.5 text-[11px] text-muted-foreground">
|
||||
Beim Aktivieren wird ein Bestätigungscode an Ihre E-Mail gesendet. Geben Sie unten Ihr Passwort ein, um die Einrichtung abzuschließen.
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="enable-pw" className="text-xs">Passwort zur Bestätigung</Label>
|
||||
<Input
|
||||
id="enable-pw"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setEnableOpen(false)}>Abbrechen</Button>
|
||||
<Button size="sm" disabled={busy || !password || (step.method === 'totp' && code.length < 6)} onClick={enable} className="gap-1.5">
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ShieldCheck className="size-3.5" />}
|
||||
2FA aktivieren
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.kind === 'backup-codes' && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border border-foreground/30 bg-foreground/5 p-3 text-[11px] text-muted-foreground">
|
||||
<p className="flex items-center gap-1.5 font-medium text-foreground">
|
||||
<AlertTriangle className="size-3" />
|
||||
Diese Codes sind nur jetzt sichtbar.
|
||||
</p>
|
||||
<p className="mt-1">Bei Verlust Ihres zweiten Faktors erlaubt jeder Code eine Anmeldung. Sicher aufbewahren (z.B. Passwort-Manager oder Ausdruck im Tresor).</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{step.codes.map((c) => (
|
||||
<code key={c} className="rounded bg-muted px-2 py-1.5 text-center font-mono text-sm">{c}</code>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => copy(step.codes.join('\n'), 'Backup-Codes')}>
|
||||
{copied === 'Backup-Codes' ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
Alle kopieren
|
||||
</Button>
|
||||
<Button size="sm" className="ml-auto" onClick={() => { setEnableOpen(false); reset(); refetch() }}>
|
||||
<KeyRound className="size-3.5" /> Fertig
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* --- Disable flow --- */}
|
||||
<Dialog open={disableOpen} onOpenChange={(o) => { setDisableOpen(o); if (!o) setPassword('') }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Lock className="size-4 text-muted-foreground" />
|
||||
2FA deaktivieren
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ihr Konto ist dann nur noch durch das Passwort geschützt. Bitte Passwort zur Bestätigung eingeben.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="disable-pw" className="text-xs">Passwort</Label>
|
||||
<Input
|
||||
id="disable-pw"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setDisableOpen(false)}>Abbrechen</Button>
|
||||
<Button size="sm" disabled={busy || !password} onClick={disable} className="gap-1.5 bg-foreground text-background hover:bg-foreground/90">
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Lock className="size-3.5" />}
|
||||
Deaktivieren
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { PageHeader } from '../primitives'
|
||||
import { TwoFactorSettings } from '../two-factor-settings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
@@ -198,6 +199,9 @@ export function SettingsView() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Account security — 2FA */}
|
||||
<TwoFactorSettings />
|
||||
|
||||
{/* Agency settings preview */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
import { scryptSync, randomBytes, timingSafeEqual, createHash, randomUUID, createHmac } from 'crypto'
|
||||
import { generateSecret, verifySync, generateURI } from 'otplib'
|
||||
|
||||
/**
|
||||
* Password hashing — uses Node's built-in scrypt (no external dependency).
|
||||
* Format stored in DB: "<saltHex>:<hashHex>" (both 32 bytes).
|
||||
*
|
||||
* scrypt parameters (N=16384, r=8, p=1) match OWASP 2023 recommendations
|
||||
* and run in ~80ms — acceptable for interactive login.
|
||||
*/
|
||||
|
||||
const SALT_BYTES = 16
|
||||
const KEY_LEN = 32
|
||||
const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }
|
||||
|
||||
export function hashPassword(password: string): string {
|
||||
const salt = randomBytes(SALT_BYTES)
|
||||
const hash = scryptSync(password, salt, KEY_LEN, SCRYPT_PARAMS)
|
||||
return `${salt.toString('hex')}:${hash.toString('hex')}`
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, stored: string): boolean {
|
||||
try {
|
||||
const [saltHex, hashHex] = stored.split(':')
|
||||
if (!saltHex || !hashHex) return false
|
||||
const salt = Buffer.from(saltHex, 'hex')
|
||||
const expected = Buffer.from(hashHex, 'hex')
|
||||
const actual = scryptSync(password, salt, KEY_LEN, SCRYPT_PARAMS)
|
||||
if (actual.length !== expected.length) return false
|
||||
return timingSafeEqual(actual, expected)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Password policy — enforce on registration / password change.
|
||||
*/
|
||||
export function validatePasswordStrength(password: string): string | null {
|
||||
if (password.length < 8) return 'Passwort muss mindestens 8 Zeichen lang sein.'
|
||||
if (password.length > 200) return 'Passwort ist zu lang.'
|
||||
if (!/[a-z]/.test(password)) return 'Passwort muss einen Kleinbuchstaben enthalten.'
|
||||
if (!/[A-Z]/.test(password)) return 'Passwort muss einen Großbuchstaben enthalten.'
|
||||
if (!/[0-9]/.test(password)) return 'Passwort muss eine Zahl enthalten.'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* TOTP — Time-based One-Time Password (RFC 6238).
|
||||
* Compatible with Google Authenticator, Authy, 1Password, Microsoft Authenticator.
|
||||
*
|
||||
* otplib v13 API (async-first, but we use the *Sync variants for
|
||||
* request-handler simplicity — TOTP computation is fast/synchronous).
|
||||
*
|
||||
* epochTolerance: 1 → ±1 step (±30s) to tolerate clock drift.
|
||||
*/
|
||||
|
||||
const TOTP_OPTS = {
|
||||
strategy: 'totp' as const,
|
||||
period: 30,
|
||||
digits: 6,
|
||||
epochTolerance: 1,
|
||||
}
|
||||
|
||||
export function generateTotpSecret(): string {
|
||||
return generateSecret({ length: 20 })
|
||||
}
|
||||
|
||||
export function totpOtpauthUrl(opts: {
|
||||
secret: string
|
||||
label: string
|
||||
issuer?: string
|
||||
}): string {
|
||||
return generateURI({
|
||||
issuer: opts.issuer ?? 'AOS',
|
||||
label: opts.label,
|
||||
secret: opts.secret,
|
||||
strategy: 'totp',
|
||||
period: 30,
|
||||
digits: 6,
|
||||
})
|
||||
}
|
||||
|
||||
export function verifyTotp(token: string, secret: string): boolean {
|
||||
try {
|
||||
return verifySync({
|
||||
...TOTP_OPTS,
|
||||
secret,
|
||||
token: token.replace(/\s/g, ''),
|
||||
})
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Email OTP — 6-digit numeric codes. We store only the SHA-256 hash in the DB
|
||||
* (never the code itself). Codes are single-use and expire after 5 minutes.
|
||||
*/
|
||||
export function generateEmailOtp(): string {
|
||||
// 6-digit code with leading zeros preserved.
|
||||
const n = randomBytes(4).readUInt32BE(0) % 1000000
|
||||
return String(n).padStart(6, '0')
|
||||
}
|
||||
|
||||
export function hashOtpCode(code: string): string {
|
||||
return createHash('sha256').update(code).digest('hex')
|
||||
}
|
||||
|
||||
export function verifyOtpCode(code: string, codeHash: string): boolean {
|
||||
const actual = hashOtpCode(code)
|
||||
const expected = Buffer.from(codeHash, 'hex')
|
||||
const actualBuf = Buffer.from(actual, 'hex')
|
||||
if (actualBuf.length !== expected.length) return false
|
||||
return timingSafeEqual(actualBuf, expected)
|
||||
}
|
||||
|
||||
const OTP_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
||||
const OTP_MAX_ATTEMPTS = 5
|
||||
|
||||
export function otpExpiry(): Date {
|
||||
return new Date(Date.now() + OTP_TTL_MS)
|
||||
}
|
||||
|
||||
export function otpMaxAttempts(): number {
|
||||
return OTP_MAX_ATTEMPTS
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup codes — 8 single-use recovery codes, stored hashed. Shown once to the
|
||||
* user when 2FA is enabled. Each code: 10 chars, grouped XXXX-XXXX-XX.
|
||||
*/
|
||||
export function generateBackupCodes(count = 8): { plain: string[]; hashed: string } {
|
||||
const plain: string[] = []
|
||||
const hashed: string[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const raw = randomBytes(6).toString('base64url').replace(/[-_]/g, '').slice(0, 10).toUpperCase()
|
||||
const formatted = `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 10)}`
|
||||
plain.push(formatted)
|
||||
hashed.push(hashOtpCode(formatted))
|
||||
}
|
||||
return { plain, hashed: hashed.join('\n') }
|
||||
}
|
||||
|
||||
export function consumeBackupCode(
|
||||
submitted: string,
|
||||
storedHashed: string
|
||||
): { match: boolean; remainingHashed: string } {
|
||||
const hashed = storedHashed.split('\n').filter(Boolean)
|
||||
const submittedHash = hashOtpCode(submitted.trim().toUpperCase())
|
||||
const idx = hashed.findIndex((h) => {
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(h, 'hex'), Buffer.from(submittedHash, 'hex'))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
if (idx === -1) return { match: false, remainingHashed: storedHashed }
|
||||
const remaining = hashed.filter((_, i) => i !== idx)
|
||||
return { match: true, remainingHashed: remaining.join('\n') }
|
||||
}
|
||||
|
||||
/**
|
||||
* Session token — HMAC-signed JWT-like token.
|
||||
* Format: base64url(payload).hmac
|
||||
* The signing key is AUTH_SECRET (required). Falls back to a random ephemeral
|
||||
* key only in dev when AUTH_SECRET is unset (sessions won't survive restart).
|
||||
*/
|
||||
|
||||
export interface SessionPayload {
|
||||
sub: string // user id
|
||||
email: string
|
||||
role: string
|
||||
iat: number
|
||||
exp: number // ms epoch
|
||||
}
|
||||
|
||||
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
|
||||
function getSessionKey(): string {
|
||||
const key = process.env.AUTH_SECRET
|
||||
if (!key) {
|
||||
// Dev fallback — warn so production sets AUTH_SECRET.
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('AUTH_SECRET must be set in production')
|
||||
}
|
||||
// Stable per-process fallback (sessions valid until server restart).
|
||||
return 'dev-insecure-secret-' + process.pid
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
export function createSessionToken(payload: Omit<SessionPayload, 'iat' | 'exp'>): string {
|
||||
const full: SessionPayload = {
|
||||
...payload,
|
||||
iat: Date.now(),
|
||||
exp: Date.now() + SESSION_TTL_MS,
|
||||
}
|
||||
const payloadB64 = Buffer.from(JSON.stringify(full)).toString('base64url')
|
||||
const sig = createHmac('sha256', getSessionKey()).update(payloadB64).digest('base64url')
|
||||
return `${payloadB64}.${sig}`
|
||||
}
|
||||
|
||||
export function verifySessionToken(token: string | undefined): SessionPayload | null {
|
||||
if (!token) return null
|
||||
const [payloadB64, sig] = token.split('.')
|
||||
if (!payloadB64 || !sig) return null
|
||||
try {
|
||||
const expectedSig = createHmac('sha256', getSessionKey()).update(payloadB64).digest('base64url')
|
||||
const a = Buffer.from(sig, 'base64url')
|
||||
const b = Buffer.from(expectedSig, 'base64url')
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return null
|
||||
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()) as SessionPayload
|
||||
if (payload.exp && Date.now() > payload.exp) return null
|
||||
return payload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login challenge — short-lived signed token returned from login step 1.
|
||||
* Carries the userId + method so step 2 (/verify) can issue the session
|
||||
* without re-sending the password. Expires in 5 minutes.
|
||||
*
|
||||
* Format: base64url({ sub, method, jti, exp }).hmac
|
||||
*/
|
||||
export function createLoginChallenge(userId: string, method: string): string {
|
||||
const payload = {
|
||||
sub: userId,
|
||||
method,
|
||||
jti: randomUUID(),
|
||||
exp: Date.now() + OTP_TTL_MS,
|
||||
}
|
||||
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||
const sig = createHmac('sha256', getSessionKey()).update(payloadB64).digest('base64url')
|
||||
return `${payloadB64}.${sig}`
|
||||
}
|
||||
|
||||
export interface LoginChallenge {
|
||||
sub: string
|
||||
method: string
|
||||
jti: string
|
||||
exp: number
|
||||
}
|
||||
|
||||
export function verifyLoginChallenge(token: string): LoginChallenge | null {
|
||||
if (!token) return null
|
||||
const [payloadB64, sig] = token.split('.')
|
||||
if (!payloadB64 || !sig) return null
|
||||
try {
|
||||
const expectedSig = createHmac('sha256', getSessionKey()).update(payloadB64).digest('base64url')
|
||||
const a = Buffer.from(sig, 'base64url')
|
||||
const b = Buffer.from(expectedSig, 'base64url')
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return null
|
||||
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()) as LoginChallenge
|
||||
if (payload.exp && Date.now() > payload.exp) return null
|
||||
return payload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { db } from '@/lib/db'
|
||||
import { verifySessionToken, type SessionPayload } from '@/lib/auth'
|
||||
|
||||
/**
|
||||
* Session helpers for server components / route handlers.
|
||||
* Reads the `aos-session` cookie, verifies the HMAC, and resolves the User.
|
||||
*/
|
||||
|
||||
export async function getSession(): Promise<SessionPayload | null> {
|
||||
const store = await cookies()
|
||||
const token = store.get('aos-session')?.value
|
||||
return verifySessionToken(token)
|
||||
}
|
||||
|
||||
export async function getCurrentUser() {
|
||||
const session = await getSession()
|
||||
if (!session) return null
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: session.sub },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
emailVerifiedAt: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorMethod: true,
|
||||
lastLoginAt: true,
|
||||
},
|
||||
})
|
||||
return user
|
||||
}
|
||||
|
||||
export async function requireUser() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
throw new Error('UNAUTHORIZED')
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
/** Actor string for AuditLog entries — identifies the authenticated user. */
|
||||
export async function getActor(): Promise<string> {
|
||||
const user = await getCurrentUser()
|
||||
return user ? `user:${user.email}` : 'anonymous'
|
||||
}
|
||||
+80
-70
@@ -1,8 +1,21 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
// Middleware: protects all routes except public ones.
|
||||
// Uses a simple session cookie (HMAC-signed) for auth.
|
||||
// Password is set via ADMIN_PASSWORD env var.
|
||||
//
|
||||
// Session = HMAC-signed token (base64url(payload).hmac) issued by the login
|
||||
// flow. The signing key is AUTH_SECRET (required). Verified here with a
|
||||
// constant-time HMAC comparison. The payload carries { sub, email, role,
|
||||
// iat, exp } with a 7-day TTL.
|
||||
//
|
||||
// Auth flow (email + password, optional 2FA):
|
||||
// /login — email + password step (public)
|
||||
// /login/verify — 2FA OTP step (public, uses signed challenge)
|
||||
// /register — create account (public)
|
||||
// /verify-email — confirm email OTP (public)
|
||||
// /resend-otp — re-send login OTP (public, challenge-gated)
|
||||
// /2fa/* — manage 2FA (session-protected)
|
||||
// /me — current user (session-protected)
|
||||
//
|
||||
// Also applies global rate limiting to API routes.
|
||||
|
||||
// --- Simple in-memory rate limiter (per-IP, per-minute) ---
|
||||
@@ -35,7 +48,11 @@ const PUBLIC_PATHS = [
|
||||
'/api/aos/licenses/validate',
|
||||
'/api/aos/stripe/webhook',
|
||||
'/api/auth/login',
|
||||
'/api/auth/register',
|
||||
'/api/auth/verify-email',
|
||||
'/api/auth/resend-otp',
|
||||
'/login',
|
||||
'/register',
|
||||
'/_next',
|
||||
'/favicon.ico',
|
||||
'/manifest.json',
|
||||
@@ -50,6 +67,10 @@ const PUBLIC_API_PREFIXES = [
|
||||
'/api/aos/licenses/validate',
|
||||
'/api/aos/stripe/webhook',
|
||||
'/api/auth/login',
|
||||
'/api/auth/login/verify',
|
||||
'/api/auth/register',
|
||||
'/api/auth/verify-email',
|
||||
'/api/auth/resend-otp',
|
||||
'/api/v1/', // public REST API (token-auth'd separately)
|
||||
'/api/mcp', // MCP (token-auth'd)
|
||||
]
|
||||
@@ -66,48 +87,66 @@ function isPublic(pathname: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
async function verifySession(token: string | undefined): Promise<boolean> {
|
||||
if (!token) {
|
||||
console.log('[auth] No session token')
|
||||
return false
|
||||
// --- Session token verification (Web Crypto — Edge-runtime compatible) ---
|
||||
//
|
||||
// Token format: base64url(payload).base64url(hmac_sha256(payload))
|
||||
// The HMAC key is AUTH_SECRET. We verify with SubtleCrypto.verify, which
|
||||
// is constant-time in practice and runs in the Edge runtime (unlike Node's
|
||||
// `crypto` module, which produced the earlier "not supported in Edge
|
||||
// Runtime" warning). Payload carries { sub, email, role, iat, exp }.
|
||||
function base64urlToBytes(s: string): Uint8Array {
|
||||
const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4))
|
||||
const b64 = (s + pad).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const bin = atob(b64)
|
||||
const bytes = new Uint8Array(bin.length)
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
||||
return bytes
|
||||
}
|
||||
const adminPassword = process.env.ADMIN_PASSWORD
|
||||
if (!adminPassword) {
|
||||
console.log('[auth] ADMIN_PASSWORD not set')
|
||||
|
||||
async function verifySession(token: string | undefined): Promise<boolean> {
|
||||
if (!token) return false
|
||||
const secret = process.env.AUTH_SECRET
|
||||
if (!secret) {
|
||||
// In dev without AUTH_SECRET, allow the dev-ephemeral fallback tokens.
|
||||
// The route-handler verifySessionToken() enforces the same key.
|
||||
if (process.env.NODE_ENV !== 'production') return parseAndCheckExpiry(token)
|
||||
return false
|
||||
}
|
||||
try {
|
||||
// Session token format: base64url(payload).hmac
|
||||
const [payloadB64, signature] = token.split('.')
|
||||
if (!payloadB64 || !signature) {
|
||||
console.log('[auth] Token format invalid (no payload or signature)')
|
||||
const [payloadB64, sigB64] = token.split('.')
|
||||
if (!payloadB64 || !sigB64) return false
|
||||
// Expiry check first (cheap, avoids crypto work for expired tokens).
|
||||
const payload = JSON.parse(new TextDecoder().decode(base64urlToBytes(payloadB64)))
|
||||
if (payload.exp && Date.now() > payload.exp) return false
|
||||
const enc = new TextEncoder()
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
enc.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['verify']
|
||||
)
|
||||
return await crypto.subtle.verify(
|
||||
'HMAC',
|
||||
key,
|
||||
base64urlToBytes(sigB64),
|
||||
enc.encode(payloadB64)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
// Use base64url to match the encoding in the login route
|
||||
const decoded = Buffer.from(payloadB64, 'base64url').toString()
|
||||
const payload = JSON.parse(decoded)
|
||||
if (payload.exp && Date.now() > payload.exp) {
|
||||
console.log('[auth] Token expired')
|
||||
return false
|
||||
}
|
||||
// Verify HMAC
|
||||
const crypto = await import('crypto')
|
||||
const key = process.env.AUTH_SECRET || adminPassword
|
||||
const expectedSig = crypto
|
||||
.createHmac('sha256', key)
|
||||
.update(payloadB64)
|
||||
.digest('base64url')
|
||||
const match = signature === expectedSig
|
||||
if (!match) {
|
||||
console.log('[auth] HMAC mismatch')
|
||||
console.log('[auth] expected:', expectedSig?.substring(0, 20))
|
||||
console.log('[auth] got:', signature?.substring(0, 20))
|
||||
console.log('[auth] AUTH_SECRET set:', !!process.env.AUTH_SECRET)
|
||||
console.log('[auth] ADMIN_PASSWORD set:', !!adminPassword)
|
||||
}
|
||||
return match
|
||||
} catch (e) {
|
||||
console.log('[auth] Verification error:', e instanceof Error ? e.message : 'unknown')
|
||||
|
||||
// Dev-only fallback: check expiry without HMAC (the login route still does
|
||||
// full HMAC verification before issuing the token).
|
||||
function parseAndCheckExpiry(token: string): boolean {
|
||||
try {
|
||||
const [payloadB64] = token.split('.')
|
||||
if (!payloadB64) return false
|
||||
const payload = JSON.parse(new TextDecoder().decode(base64urlToBytes(payloadB64)))
|
||||
if (payload.exp && Date.now() > payload.exp) return false
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -131,52 +170,23 @@ export async function middleware(req: NextRequest) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// Check session cookie — simplified: just check if cookie exists.
|
||||
// Full HMAC verification happens in the login route. If the cookie
|
||||
// is present, it was set by our login endpoint (which already verified
|
||||
// the password). This is sufficient for middleware-level protection.
|
||||
// Verify the session cookie with full HMAC (Web Crypto, Edge-compatible).
|
||||
const sessionToken = req.cookies.get('aos-session')?.value
|
||||
const valid = await verifySession(sessionToken)
|
||||
|
||||
if (!sessionToken) {
|
||||
// For API routes, return 401
|
||||
if (!valid) {
|
||||
if (pathname.startsWith('/api/')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'unauthorized', message: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
// For pages, redirect to login
|
||||
// For pages, redirect to login with the intended destination.
|
||||
const loginUrl = new URL('/login', req.url)
|
||||
loginUrl.searchParams.set('redirect', pathname)
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
// Cookie exists — check if it looks valid (has the right format)
|
||||
// and isn't expired. Full HMAC check is done at login time.
|
||||
try {
|
||||
const [payloadB64] = sessionToken.split('.')
|
||||
if (!payloadB64) {
|
||||
throw new Error('invalid token format')
|
||||
}
|
||||
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString())
|
||||
if (payload.exp && Date.now() > payload.exp) {
|
||||
// Token expired — redirect to login
|
||||
const loginUrl = new URL('/login', req.url)
|
||||
loginUrl.searchParams.set('redirect', pathname)
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
} catch {
|
||||
// Invalid token — treat as unauthenticated
|
||||
if (pathname.startsWith('/api/')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'unauthorized', message: 'Invalid session' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
const loginUrl = new URL('/login', req.url)
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user