diff --git a/bun.lock b/bun.lock old mode 100755 new mode 100644 index a2806f8..04008a0 --- a/bun.lock +++ b/bun.lock @@ -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=="], } } diff --git a/db/custom.db b/db/custom.db index 02ff24a..4a21617 100755 Binary files a/db/custom.db and b/db/custom.db differ diff --git a/package.json b/package.json index cc2a22a..0690487 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6b93769..ec4f823 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/api/auth/2fa/disable/route.ts b/src/app/api/auth/2fa/disable/route.ts new file mode 100644 index 0000000..6891c85 --- /dev/null +++ b/src/app/api/auth/2fa/disable/route.ts @@ -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 }) + } +} diff --git a/src/app/api/auth/2fa/enable/route.ts b/src/app/api/auth/2fa/enable/route.ts new file mode 100644 index 0000000..8275db0 --- /dev/null +++ b/src/app/api/auth/2fa/enable/route.ts @@ -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 }) + } +} diff --git a/src/app/api/auth/2fa/setup/route.ts b/src/app/api/auth/2fa/setup/route.ts new file mode 100644 index 0000000..8a6bc88 --- /dev/null +++ b/src/app/api/auth/2fa/setup/route.ts @@ -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(), + }) +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index a135cae..8a969f7 100755 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -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: `
Ihr Anmeldecode lautet:
${code}
Der Code ist 5 Minuten gültig.
Wenn Sie sich nicht angemeldet haben, ignorieren Sie diese E-Mail.
`, + }).catch(() => {}) + } + + 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.', + }) } - // 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 - } - 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}` +} diff --git a/src/app/api/auth/login/verify/route.ts b/src/app/api/auth/login/verify/route.ts new file mode 100644 index 0000000..5d26852 --- /dev/null +++ b/src/app/api/auth/login/verify/route.ts @@ -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 +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..0117237 --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -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, + }, + }) +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..a1b59df --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -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: `Ihr Bestätigungscode lautet:
${code}
Der Code ist 5 Minuten gültig.
Mit freundlichen Grüßen
DFM Solutions
Ihr neuer Anmeldecode lautet:
${code}
Der Code ist 5 Minuten gültig.
`, + }).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 }) + } +} diff --git a/src/app/api/auth/verify-email/route.ts b/src/app/api/auth/verify-email/route.ts new file mode 100644 index 0000000..15806ca --- /dev/null +++ b/src/app/api/auth/verify-email/route.ts @@ -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 }) + } +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 5983f38..580d3fb 100755 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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+ Zugang zum AOS — dfmpanel.eu +
++ Bereits ein Konto?{' '} + + Anmelden + +
+Nicht angemeldet.
+ ) : enabled ? ( + <> ++ Beim Anmelden wird zusätzlich zum Passwort ein einmaliger Code benötigt. Backup-Codes wurden bei der Einrichtung angezeigt — sicher aufbewahren. +
+ > + ) : ( + <> ++ Schützen Sie Ihr Konto mit einem zweiten Faktor. Optional — kann jederzeit aktiviert oder deaktiviert werden. +
+ + > + )} +