From 72ec15c2abf78a5390a2d4bd2e871ac101084129 Mon Sep 17 00:00:00 2001
From: Z User
Date: Thu, 29 Oct 2026 23:59:48 +0000
Subject: [PATCH] 3bb178b5-949c-4434-a4e8-093caab5d33b
---
bun.lock | 73 ++++
db/custom.db | Bin 548864 -> 565248 bytes
package.json | 3 +
prisma/schema.prisma | 38 +-
src/app/api/auth/2fa/disable/route.ts | 69 ++++
src/app/api/auth/2fa/enable/route.ts | 107 ++++++
src/app/api/auth/2fa/setup/route.ts | 44 +++
src/app/api/auth/login/route.ts | 123 +++++--
src/app/api/auth/login/verify/route.ts | 128 +++++++
src/app/api/auth/me/route.ts | 26 ++
src/app/api/auth/register/route.ts | 106 ++++++
src/app/api/auth/resend-otp/route.ts | 76 ++++
src/app/api/auth/verify-email/route.ts | 77 ++++
src/app/login/page.tsx | 199 +++++++++--
src/app/register/page.tsx | 169 +++++++++
src/components/aos/top-bar.tsx | 17 +-
src/components/aos/two-factor-settings.tsx | 397 +++++++++++++++++++++
src/components/aos/views/settings.tsx | 4 +
src/lib/auth.ts | 262 ++++++++++++++
src/lib/session.ts | 47 +++
src/middleware.ts | 156 ++++----
21 files changed, 1991 insertions(+), 130 deletions(-)
mode change 100755 => 100644 bun.lock
create mode 100644 src/app/api/auth/2fa/disable/route.ts
create mode 100644 src/app/api/auth/2fa/enable/route.ts
create mode 100644 src/app/api/auth/2fa/setup/route.ts
create mode 100644 src/app/api/auth/login/verify/route.ts
create mode 100644 src/app/api/auth/me/route.ts
create mode 100644 src/app/api/auth/register/route.ts
create mode 100644 src/app/api/auth/resend-otp/route.ts
create mode 100644 src/app/api/auth/verify-email/route.ts
create mode 100644 src/app/register/page.tsx
create mode 100644 src/components/aos/two-factor-settings.tsx
create mode 100644 src/lib/auth.ts
create mode 100644 src/lib/session.ts
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 02ff24a76517aa1b1c376c3de3320d3d0aa000da..4a216174053c0c48c7d588f5800b8c996ad294e7 100755
GIT binary patch
delta 2911
zcmcImU2GKB6`tRn@#5XVko95=c)gT@RlxVoonJ$+tG-3Ov-Ub5
zAW|PXtGhdU&fIgpbIy0qnX@;#&)&%Xu(RzGj^i${b%ZURt)s7RNBk#OpP$qC)3r~v
zZR_GpsA`bY{-*s#yQ2L_gW3u0Ic;#UyT3zgZxgrjg}Es@(_f~g@=ik44$JM%B(%ie?(xf8T&
zoOWBgR&To^yr+$9mU3wxPtI0sYqn4Z?3(tS%BiXG%Ji)B;zZ$v{FKlm<+iju@)5az2J+T%I6Bb9@_y>i2lQ!HZU3r+hJ
z7AK7UiO%%h7q3?-kMGn2j|EWj{0VxPJ@H5!FJ&ibdAvZfec1-2?3tIcOZ(<2
z6P9Nx^h{~o(@m@II6dXRSe!=r694Xn)L^E%D#h@9E0yCQY~rI)E?6=s1T!Q!5s(vK
zgO5&q9lYK~E+53VjK-{#Ch@d6S3X{d;0(OFxS{;_RbqQ$GE_3+4wgbUQS0qh%!>W6
zw6$*(b4wL;iVuQLE&lYiBR`Q^`P*FB3iyq&+x%_*GGA{4vD1A0GURoyeZC43&V+X
ziGjpGtvmBf{o;Q4uEhVWe&(?J@>8$xUnPqX=gzObB-dA8ihc3BF7~&TJI85%)QZVJ
zC%)PGkNDB}Ms-+S8x!S^xpQHrsF3=XhB_eEt9|O26yD6Kx%#?k<)RRV6=hfbvyyU^
zuU|T)^zhfuD*Tl#(wE#Seu0aRYELKcNMqvL{DL^4x@uCnuJkc??BWlzEcWsusLJ6p
zed>lMr|8_Vh)u^}^Nzdl^7B%+?*3yE5oSQ~4cpsnFwrWHMg3sa@aWXKzQDqR1T
zxHf#v;W?x;Z~Z=!xc*B|pcCm6>9p
zM33c3QzI_NyLaR}Lu1W&X#?S2a_{rm%;?C9KD#ZujC^}0yKFSubGRA{KRqmOWUaj3uzVnTuS1Qf1
zzV@V78qa194UHYz9?!6A>Bv7DHKBYX!7USBsk|?0Utu>(b+SBHc*4Ww!pu-~RKtNi
ze2>2(dwQvH+-q+|caXIvTE5TMif)gGk5`Hh*Dd*J)OzGC(F#|ODoU97m9Qxs?NfC{
zyD5#0_AJF-|8bw1jSe4|3;WMY>+65bsoj}3Z^R^3NZ#SJeDV&X?{Aay)r1hw<*JFa
z%E`?>6-=-;rAK{gvgl7t1_kiQ4yhX^v5A3Pil9xP5mSpyZz5!^<3LtB?m|p_qR0{6g>||vYJra-;
z7}PWjf*k;nsZ-zaUC+{O*S9>6xIRGC2z*LB-?4$E_fW`?ViN)j2N>GGym$66MC%ql
zW8Z84tz6H@x3qmxzkDz~!Ui$doMsPVO|UActsipQd)mI_Ka+1Ht;C(gsn!o!0p5tu
zzcQ@0<+gUGtD~IUlI9jV<>fj&YmKAwZt#yVzuLnt+;?
z9$@PGdY>Io>>&r~2*Q8>+a?I=^HBE;gVn=xeS$rfF@xHH1R8VIz)_
zZwG+|=b=Tgi5%iU%hny=(Ge?;?*T8s1Tbz9r_e>lybTSTLPi0bnZVG6
z2n86C0J8`&!M*`J1TD)zCMArxh&r~hFE;Mij>NQ_+ugY$TfJ$d?WnBf
z?CxlMFGpA_^med!Q9~P(wA`-EZB0$>gH6NQQIT2LnqHA5x<<7zfyuhQlHsmlZH$ks
YekJ>P*XW{j<1sD$@b^)by$Ai_zgeAC+yDRo
delta 734
zcmY+CT}TvB6vywKuXAVC+1XWd71!KV`q(I3H8fXc6rXab8Brt1WY_dT&~?o;!8zPH2YA{x%|1@-{GA9fqSAG1)n
zRIYsmA#~T&-?%h=x)iT01qKuxNxV}|X31s-r*=Yew*ja8z=ug$+!Q8u
zRcwke#hT)bU6n7kWjbo7O7zOI4&^{4Sg!BELtobp9w|{PMV5E7vCMt_CBn^Iti9
z)fjE&@6X33@d98d>0u#1*-muLJhuit=0updGvdma2q5jJg~7BQ&m(5~F1p;ta>{AeFj@
zq0%@$3QL&ok3gl%vcl)=TX3md6J4O>?~4V%8<4#txLDL-;$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: `Willkommen bei AOS!
Ihr Bestätigungscode lautet:
${code}
Der Code ist 5 Minuten gültig.
Mit freundlichen Grüßen
DFM Solutions
`,
+ }).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 })
+ }
+}
diff --git a/src/app/api/auth/resend-otp/route.ts b/src/app/api/auth/resend-otp/route.ts
new file mode 100644
index 0000000..63ea4db
--- /dev/null
+++ b/src/app/api/auth/resend-otp/route.ts
@@ -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: `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({ 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
}
- router.push(data.redirect || '/')
- router.refresh()
+ 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 (
+
+ )
+ }
+
return (
-
)
}
diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx
new file mode 100644
index 0000000..8f8abf6
--- /dev/null
+++ b/src/app/register/page.tsx
@@ -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 (
+
+
+
+
+
+
+
Konto erstellen
+
+ Zugang zum AOS — dfmpanel.eu
+
+
+
+
+
+
+ Bereits ein Konto?{' '}
+
+ Anmelden
+
+
+
+
+ )
+}
diff --git a/src/components/aos/top-bar.tsx b/src/components/aos/top-bar.tsx
index c925c0b..4f9d25d 100755
--- a/src/components/aos/top-bar.tsx
+++ b/src/components/aos/top-bar.tsx
@@ -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 (
@@ -95,19 +104,19 @@ export function TopBar() {
- Max Brandt
- max@nordlicht.digital
+ {user?.name ?? '—'}
+ {user?.email ?? ''}
Rolle
- Owner
+ {user?.role ?? '—'}
Profil
Sitzungen
diff --git a/src/components/aos/two-factor-settings.tsx b/src/components/aos/two-factor-settings.tsx
new file mode 100644
index 0000000..9eb369b
--- /dev/null
+++ b/src/components/aos/two-factor-settings.tsx
@@ -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('/api/auth/me')
+ const [enableOpen, setEnableOpen] = useState(false)
+ const [disableOpen, setDisableOpen] = useState(false)
+ const [step, setStep] = useState({ kind: 'choose-method' })
+ const [password, setPassword] = useState('')
+ const [code, setCode] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [copied, setCopied] = useState(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 = { 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 (
+
+
+
+
+ Zwei-Faktor-Authentifizierung (2FA)
+
+
+
+ {loading ? (
+
+ Laden…
+
+ ) : !user ? (
+ Nicht angemeldet.
+ ) : enabled ? (
+ <>
+
+
+
+ Aktiv
+
+
+ {method === 'totp' ? (
+ Authenticator-App
+ ) : method === 'email' ? (
+ E-Mail-OTP
+ ) : method}
+
+
+
+
+
+ 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.
+
+
+ >
+ )}
+
+
+ {/* --- Enable flow --- */}
+
+
+ {/* --- Disable flow --- */}
+
+
+ )
+}
diff --git a/src/components/aos/views/settings.tsx b/src/components/aos/views/settings.tsx
index 32dd9df..55de5ba 100755
--- a/src/components/aos/views/settings.tsx
+++ b/src/components/aos/views/settings.tsx
@@ -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() {
+ {/* Account security — 2FA */}
+
+
{/* Agency settings preview */}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
new file mode 100644
index 0000000..f77d788
--- /dev/null
+++ b/src/lib/auth.ts
@@ -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: ":" (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): 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
+ }
+}
diff --git a/src/lib/session.ts b/src/lib/session.ts
new file mode 100644
index 0000000..17e6fb8
--- /dev/null
+++ b/src/lib/session.ts
@@ -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 {
+ 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 {
+ const user = await getCurrentUser()
+ return user ? `user:${user.email}` : 'anonymous'
+}
diff --git a/src/middleware.ts b/src/middleware.ts
index 4a4346b..b14aac7 100755
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -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
}
+// --- 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
+}
+
async function verifySession(token: string | undefined): Promise {
- if (!token) {
- console.log('[auth] No session token')
- return false
- }
- const adminPassword = process.env.ADMIN_PASSWORD
- if (!adminPassword) {
- console.log('[auth] ADMIN_PASSWORD not set')
+ 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)')
- 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')
+ 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
+ }
+}
+
+// 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()
}