Add user management
This commit is contained in:
8
bun.lock
8
bun.lock
@ -47,6 +47,7 @@
|
|||||||
"next": "^16.1.1",
|
"next": "^16.1.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-vibrant": "^4.0.3",
|
"node-vibrant": "^4.0.3",
|
||||||
|
"nodemailer": "^7.0.12",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"platejs": "^52.0.15",
|
"platejs": "^52.0.15",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
@ -68,6 +69,7 @@
|
|||||||
"@types/culori": "^4.0.1",
|
"@types/culori": "^4.0.1",
|
||||||
"@types/date-fns": "^2.6.3",
|
"@types/date-fns": "^2.6.3",
|
||||||
"@types/node": "^20.19.27",
|
"@types/node": "^20.19.27",
|
||||||
|
"@types/nodemailer": "^7.0.4",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@ -98,6 +100,8 @@
|
|||||||
|
|
||||||
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.958.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.957.0", "@aws-sdk/credential-provider-node": "3.958.0", "@aws-sdk/middleware-bucket-endpoint": "3.957.0", "@aws-sdk/middleware-expect-continue": "3.957.0", "@aws-sdk/middleware-flexible-checksums": "3.957.0", "@aws-sdk/middleware-host-header": "3.957.0", "@aws-sdk/middleware-location-constraint": "3.957.0", "@aws-sdk/middleware-logger": "3.957.0", "@aws-sdk/middleware-recursion-detection": "3.957.0", "@aws-sdk/middleware-sdk-s3": "3.957.0", "@aws-sdk/middleware-ssec": "3.957.0", "@aws-sdk/middleware-user-agent": "3.957.0", "@aws-sdk/region-config-resolver": "3.957.0", "@aws-sdk/signature-v4-multi-region": "3.957.0", "@aws-sdk/types": "3.957.0", "@aws-sdk/util-endpoints": "3.957.0", "@aws-sdk/util-user-agent-browser": "3.957.0", "@aws-sdk/util-user-agent-node": "3.957.0", "@smithy/config-resolver": "^4.4.5", "@smithy/core": "^3.20.0", "@smithy/eventstream-serde-browser": "^4.2.7", "@smithy/eventstream-serde-config-resolver": "^4.3.7", "@smithy/eventstream-serde-node": "^4.2.7", "@smithy/fetch-http-handler": "^5.3.8", "@smithy/hash-blob-browser": "^4.2.8", "@smithy/hash-node": "^4.2.7", "@smithy/hash-stream-node": "^4.2.7", "@smithy/invalid-dependency": "^4.2.7", "@smithy/md5-js": "^4.2.7", "@smithy/middleware-content-length": "^4.2.7", "@smithy/middleware-endpoint": "^4.4.1", "@smithy/middleware-retry": "^4.4.17", "@smithy/middleware-serde": "^4.2.8", "@smithy/middleware-stack": "^4.2.7", "@smithy/node-config-provider": "^4.3.7", "@smithy/node-http-handler": "^4.4.7", "@smithy/protocol-http": "^5.3.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "@smithy/url-parser": "^4.2.7", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.16", "@smithy/util-defaults-mode-node": "^4.2.19", "@smithy/util-endpoints": "^3.2.7", "@smithy/util-middleware": "^4.2.7", "@smithy/util-retry": "^4.2.7", "@smithy/util-stream": "^4.5.8", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.7", "tslib": "^2.6.2" } }, "sha512-ol8Sw37AToBWb6PjRuT/Wu40SrrZSA0N4F7U3yTkjUNX0lirfO1VFLZ0hZtZplVJv8GNPITbiczxQ8VjxESXxg=="],
|
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.958.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.957.0", "@aws-sdk/credential-provider-node": "3.958.0", "@aws-sdk/middleware-bucket-endpoint": "3.957.0", "@aws-sdk/middleware-expect-continue": "3.957.0", "@aws-sdk/middleware-flexible-checksums": "3.957.0", "@aws-sdk/middleware-host-header": "3.957.0", "@aws-sdk/middleware-location-constraint": "3.957.0", "@aws-sdk/middleware-logger": "3.957.0", "@aws-sdk/middleware-recursion-detection": "3.957.0", "@aws-sdk/middleware-sdk-s3": "3.957.0", "@aws-sdk/middleware-ssec": "3.957.0", "@aws-sdk/middleware-user-agent": "3.957.0", "@aws-sdk/region-config-resolver": "3.957.0", "@aws-sdk/signature-v4-multi-region": "3.957.0", "@aws-sdk/types": "3.957.0", "@aws-sdk/util-endpoints": "3.957.0", "@aws-sdk/util-user-agent-browser": "3.957.0", "@aws-sdk/util-user-agent-node": "3.957.0", "@smithy/config-resolver": "^4.4.5", "@smithy/core": "^3.20.0", "@smithy/eventstream-serde-browser": "^4.2.7", "@smithy/eventstream-serde-config-resolver": "^4.3.7", "@smithy/eventstream-serde-node": "^4.2.7", "@smithy/fetch-http-handler": "^5.3.8", "@smithy/hash-blob-browser": "^4.2.8", "@smithy/hash-node": "^4.2.7", "@smithy/hash-stream-node": "^4.2.7", "@smithy/invalid-dependency": "^4.2.7", "@smithy/md5-js": "^4.2.7", "@smithy/middleware-content-length": "^4.2.7", "@smithy/middleware-endpoint": "^4.4.1", "@smithy/middleware-retry": "^4.4.17", "@smithy/middleware-serde": "^4.2.8", "@smithy/middleware-stack": "^4.2.7", "@smithy/node-config-provider": "^4.3.7", "@smithy/node-http-handler": "^4.4.7", "@smithy/protocol-http": "^5.3.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "@smithy/url-parser": "^4.2.7", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.16", "@smithy/util-defaults-mode-node": "^4.2.19", "@smithy/util-endpoints": "^3.2.7", "@smithy/util-middleware": "^4.2.7", "@smithy/util-retry": "^4.2.7", "@smithy/util-stream": "^4.5.8", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.7", "tslib": "^2.6.2" } }, "sha512-ol8Sw37AToBWb6PjRuT/Wu40SrrZSA0N4F7U3yTkjUNX0lirfO1VFLZ0hZtZplVJv8GNPITbiczxQ8VjxESXxg=="],
|
||||||
|
|
||||||
|
"@aws-sdk/client-sesv2": ["@aws-sdk/client-sesv2@3.958.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.957.0", "@aws-sdk/credential-provider-node": "3.958.0", "@aws-sdk/middleware-host-header": "3.957.0", "@aws-sdk/middleware-logger": "3.957.0", "@aws-sdk/middleware-recursion-detection": "3.957.0", "@aws-sdk/middleware-user-agent": "3.957.0", "@aws-sdk/region-config-resolver": "3.957.0", "@aws-sdk/signature-v4-multi-region": "3.957.0", "@aws-sdk/types": "3.957.0", "@aws-sdk/util-endpoints": "3.957.0", "@aws-sdk/util-user-agent-browser": "3.957.0", "@aws-sdk/util-user-agent-node": "3.957.0", "@smithy/config-resolver": "^4.4.5", "@smithy/core": "^3.20.0", "@smithy/fetch-http-handler": "^5.3.8", "@smithy/hash-node": "^4.2.7", "@smithy/invalid-dependency": "^4.2.7", "@smithy/middleware-content-length": "^4.2.7", "@smithy/middleware-endpoint": "^4.4.1", "@smithy/middleware-retry": "^4.4.17", "@smithy/middleware-serde": "^4.2.8", "@smithy/middleware-stack": "^4.2.7", "@smithy/node-config-provider": "^4.3.7", "@smithy/node-http-handler": "^4.4.7", "@smithy/protocol-http": "^5.3.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "@smithy/url-parser": "^4.2.7", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.16", "@smithy/util-defaults-mode-node": "^4.2.19", "@smithy/util-endpoints": "^3.2.7", "@smithy/util-middleware": "^4.2.7", "@smithy/util-retry": "^4.2.7", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-3x3n8IIxIMAkdpt9wy9zS7MO2lqTcJwQTdHMn6BlD7YUohb+r5Q4KCOEQ2uHWd4WIJv2tlbXnfypHaXReO/WXA=="],
|
||||||
|
|
||||||
"@aws-sdk/client-sso": ["@aws-sdk/client-sso@3.958.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.957.0", "@aws-sdk/middleware-host-header": "3.957.0", "@aws-sdk/middleware-logger": "3.957.0", "@aws-sdk/middleware-recursion-detection": "3.957.0", "@aws-sdk/middleware-user-agent": "3.957.0", "@aws-sdk/region-config-resolver": "3.957.0", "@aws-sdk/types": "3.957.0", "@aws-sdk/util-endpoints": "3.957.0", "@aws-sdk/util-user-agent-browser": "3.957.0", "@aws-sdk/util-user-agent-node": "3.957.0", "@smithy/config-resolver": "^4.4.5", "@smithy/core": "^3.20.0", "@smithy/fetch-http-handler": "^5.3.8", "@smithy/hash-node": "^4.2.7", "@smithy/invalid-dependency": "^4.2.7", "@smithy/middleware-content-length": "^4.2.7", "@smithy/middleware-endpoint": "^4.4.1", "@smithy/middleware-retry": "^4.4.17", "@smithy/middleware-serde": "^4.2.8", "@smithy/middleware-stack": "^4.2.7", "@smithy/node-config-provider": "^4.3.7", "@smithy/node-http-handler": "^4.4.7", "@smithy/protocol-http": "^5.3.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "@smithy/url-parser": "^4.2.7", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.16", "@smithy/util-defaults-mode-node": "^4.2.19", "@smithy/util-endpoints": "^3.2.7", "@smithy/util-middleware": "^4.2.7", "@smithy/util-retry": "^4.2.7", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6qNCIeaMzKzfqasy2nNRuYnMuaMebCcCPP4J2CVGkA8QYMbIVKPlkn9bpB20Vxe6H/r3jtCCLQaOJjVTx/6dXg=="],
|
"@aws-sdk/client-sso": ["@aws-sdk/client-sso@3.958.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.957.0", "@aws-sdk/middleware-host-header": "3.957.0", "@aws-sdk/middleware-logger": "3.957.0", "@aws-sdk/middleware-recursion-detection": "3.957.0", "@aws-sdk/middleware-user-agent": "3.957.0", "@aws-sdk/region-config-resolver": "3.957.0", "@aws-sdk/types": "3.957.0", "@aws-sdk/util-endpoints": "3.957.0", "@aws-sdk/util-user-agent-browser": "3.957.0", "@aws-sdk/util-user-agent-node": "3.957.0", "@smithy/config-resolver": "^4.4.5", "@smithy/core": "^3.20.0", "@smithy/fetch-http-handler": "^5.3.8", "@smithy/hash-node": "^4.2.7", "@smithy/invalid-dependency": "^4.2.7", "@smithy/middleware-content-length": "^4.2.7", "@smithy/middleware-endpoint": "^4.4.1", "@smithy/middleware-retry": "^4.4.17", "@smithy/middleware-serde": "^4.2.8", "@smithy/middleware-stack": "^4.2.7", "@smithy/node-config-provider": "^4.3.7", "@smithy/node-http-handler": "^4.4.7", "@smithy/protocol-http": "^5.3.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "@smithy/url-parser": "^4.2.7", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.16", "@smithy/util-defaults-mode-node": "^4.2.19", "@smithy/util-endpoints": "^3.2.7", "@smithy/util-middleware": "^4.2.7", "@smithy/util-retry": "^4.2.7", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6qNCIeaMzKzfqasy2nNRuYnMuaMebCcCPP4J2CVGkA8QYMbIVKPlkn9bpB20Vxe6H/r3jtCCLQaOJjVTx/6dXg=="],
|
||||||
|
|
||||||
"@aws-sdk/core": ["@aws-sdk/core@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@aws-sdk/xml-builder": "3.957.0", "@smithy/core": "^3.20.0", "@smithy/node-config-provider": "^4.3.7", "@smithy/property-provider": "^4.2.7", "@smithy/protocol-http": "^5.3.7", "@smithy/signature-v4": "^5.3.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.7", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrZgDnF1lQZv75a52nFWs6MExihJF2GZB6ETZRqr6jMwhrk2kbJPUtvgbifwcL7AYmVqHQDJBrR/MqkwwFCpiw=="],
|
"@aws-sdk/core": ["@aws-sdk/core@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@aws-sdk/xml-builder": "3.957.0", "@smithy/core": "^3.20.0", "@smithy/node-config-provider": "^4.3.7", "@smithy/property-provider": "^4.2.7", "@smithy/protocol-http": "^5.3.7", "@smithy/signature-v4": "^5.3.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.7", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrZgDnF1lQZv75a52nFWs6MExihJF2GZB6ETZRqr6jMwhrk2kbJPUtvgbifwcL7AYmVqHQDJBrR/MqkwwFCpiw=="],
|
||||||
@ -640,6 +644,8 @@
|
|||||||
|
|
||||||
"@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="],
|
"@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="],
|
||||||
|
|
||||||
|
"@types/nodemailer": ["@types/nodemailer@7.0.4", "", { "dependencies": { "@aws-sdk/client-sesv2": "^3.839.0", "@types/node": "*" } }, "sha512-ee8fxWqOchH+Hv6MDDNNy028kwvVnLplrStm4Zf/3uHWw5zzo8FoYYeffpJtGs2wWysEumMH0ZIdMGMY1eMAow=="],
|
||||||
|
|
||||||
"@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="],
|
"@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="],
|
||||||
|
|
||||||
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
|
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
|
||||||
@ -1106,6 +1112,8 @@
|
|||||||
|
|
||||||
"node-vibrant": ["node-vibrant@4.0.3", "", { "dependencies": { "@types/node": "^18.15.3", "@vibrant/core": "^4.0.0", "@vibrant/generator-default": "^4.0.3", "@vibrant/image-browser": "^4.0.0", "@vibrant/image-node": "^4.0.0", "@vibrant/quantizer-mmcq": "^4.0.0" } }, "sha512-kzoIuJK90BH/k65Avt077JCX4Nhqz1LNc8cIOm2rnYEvFdJIYd8b3SQwU1MTpzcHtr8z8jxkl1qdaCfbP3olFg=="],
|
"node-vibrant": ["node-vibrant@4.0.3", "", { "dependencies": { "@types/node": "^18.15.3", "@vibrant/core": "^4.0.0", "@vibrant/generator-default": "^4.0.3", "@vibrant/image-browser": "^4.0.0", "@vibrant/image-node": "^4.0.0", "@vibrant/quantizer-mmcq": "^4.0.0" } }, "sha512-kzoIuJK90BH/k65Avt077JCX4Nhqz1LNc8cIOm2rnYEvFdJIYd8b3SQwU1MTpzcHtr8z8jxkl1qdaCfbP3olFg=="],
|
||||||
|
|
||||||
|
"nodemailer": ["nodemailer@7.0.12", "", {}, "sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA=="],
|
||||||
|
|
||||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||||
|
|
||||||
"nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="],
|
"nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="],
|
||||||
|
|||||||
@ -52,6 +52,7 @@
|
|||||||
"next": "^16.1.1",
|
"next": "^16.1.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-vibrant": "^4.0.3",
|
"node-vibrant": "^4.0.3",
|
||||||
|
"nodemailer": "^7.0.12",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"platejs": "^52.0.15",
|
"platejs": "^52.0.15",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
@ -73,6 +74,7 @@
|
|||||||
"@types/culori": "^4.0.1",
|
"@types/culori": "^4.0.1",
|
||||||
"@types/date-fns": "^2.6.3",
|
"@types/date-fns": "^2.6.3",
|
||||||
"@types/node": "^20.19.27",
|
"@types/node": "^20.19.27",
|
||||||
|
"@types/nodemailer": "^7.0.4",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
8
prisma/migrations/20260101122816_auth_3/migration.sql
Normal file
8
prisma/migrations/20260101122816_auth_3/migration.sql
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "session" ADD COLUMN "impersonatedBy" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "user" ADD COLUMN "banExpires" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "banReason" TEXT,
|
||||||
|
ADD COLUMN "banned" BOOLEAN DEFAULT false,
|
||||||
|
ADD COLUMN "role" TEXT NOT NULL DEFAULT 'user';
|
||||||
@ -422,6 +422,11 @@ model User {
|
|||||||
sessions Session[]
|
sessions Session[]
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
|
|
||||||
|
role String @default("user")
|
||||||
|
banned Boolean? @default(false)
|
||||||
|
banReason String?
|
||||||
|
banExpires DateTime?
|
||||||
|
|
||||||
@@unique([email])
|
@@unique([email])
|
||||||
@@map("user")
|
@@map("user")
|
||||||
}
|
}
|
||||||
@ -437,6 +442,8 @@ model Session {
|
|||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
impersonatedBy String?
|
||||||
|
|
||||||
@@unique([token])
|
@@unique([token])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@map("session")
|
@@map("session")
|
||||||
|
|||||||
39
src/actions/auth/registerFirstUser.ts
Normal file
39
src/actions/auth/registerFirstUser.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
email: z.string().email().max(320),
|
||||||
|
password: z.string().min(8).max(128),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function registerFirstUser(input: z.infer<typeof schema>) {
|
||||||
|
const count = await prisma.user.count();
|
||||||
|
if (count !== 0) throw new Error("Registration is disabled.");
|
||||||
|
|
||||||
|
const { name, email, password } = schema.parse(input);
|
||||||
|
|
||||||
|
const res = await auth.api.signUpEmail({
|
||||||
|
body: { name, email, password },
|
||||||
|
});
|
||||||
|
|
||||||
|
const userId =
|
||||||
|
(res as any)?.user?.id ??
|
||||||
|
(res as any)?.data?.user?.id ??
|
||||||
|
(res as any)?.data?.id;
|
||||||
|
|
||||||
|
if (!userId) throw new Error("Signup failed: no user id returned.");
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { role: "admin" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// IMPORTANT:
|
||||||
|
// Do NOT sign-in here when requireEmailVerification=true.
|
||||||
|
// User must verify first. Better Auth already sent the email (sendOnSignUp).
|
||||||
|
return { ok: true, requiresEmailVerification: true };
|
||||||
|
}
|
||||||
33
src/actions/users/createUser.ts
Normal file
33
src/actions/users/createUser.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
email: z.string().email().max(320),
|
||||||
|
password: z.string().min(8).max(128),
|
||||||
|
role: z.enum(["user", "admin"]).default("user"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createUser(input: z.infer<typeof schema>) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
const role = (session as any)?.user?.role;
|
||||||
|
|
||||||
|
if (!session || role !== "admin") {
|
||||||
|
throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = schema.parse(input);
|
||||||
|
|
||||||
|
return auth.api.createUser({
|
||||||
|
body: {
|
||||||
|
name: data.name,
|
||||||
|
email: data.email,
|
||||||
|
password: data.password,
|
||||||
|
role: data.role,
|
||||||
|
},
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
}
|
||||||
44
src/actions/users/deleteUser.ts
Normal file
44
src/actions/users/deleteUser.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
export async function deleteUser(id: string) {
|
||||||
|
const userId = z.string().min(1).parse(id);
|
||||||
|
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
const role = (session as any)?.user?.role as string | undefined;
|
||||||
|
const currentUserId = (session as any)?.user?.id as string | undefined;
|
||||||
|
|
||||||
|
if (!session || role !== "admin") throw new Error("Forbidden");
|
||||||
|
if (!currentUserId) throw new Error("Session missing user id");
|
||||||
|
|
||||||
|
if (userId === currentUserId) {
|
||||||
|
throw new Error("You cannot delete your own account.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await await_attachTarget(userId);
|
||||||
|
|
||||||
|
// Prevent deleting last admin
|
||||||
|
if (target.role === "admin") {
|
||||||
|
const adminCount = await prisma.user.count({ where: { role: "admin" } });
|
||||||
|
if (adminCount <= 1) {
|
||||||
|
throw new Error("Cannot delete the last admin user.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.delete({ where: { id: userId } });
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function await_attachTarget(userId: string) {
|
||||||
|
const target = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { id: true, role: true },
|
||||||
|
});
|
||||||
|
if (!target) throw new Error("User not found.");
|
||||||
|
return target as { id: string; role: "admin" | "user" };
|
||||||
|
}
|
||||||
39
src/actions/users/getUsers.ts
Normal file
39
src/actions/users/getUsers.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
export type UsersListRow = {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
role: "admin" | "user";
|
||||||
|
emailVerified: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getUsers(): Promise<UsersListRow[]> {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
const role = (session as any)?.user?.role as string | undefined;
|
||||||
|
|
||||||
|
if (!session || role !== "admin") {
|
||||||
|
throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await prisma.user.findMany({
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
emailVerified: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows as UsersListRow[];
|
||||||
|
}
|
||||||
40
src/actions/users/resendVerification.ts
Normal file
40
src/actions/users/resendVerification.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function resendVerification(input: z.infer<typeof schema>) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
const role = (session as any)?.user?.role as string | undefined;
|
||||||
|
if (!session || role !== "admin") throw new Error("Forbidden");
|
||||||
|
|
||||||
|
const { email } = schema.parse(input);
|
||||||
|
|
||||||
|
// Uses the public auth route (same origin)
|
||||||
|
const res = await fetch("http://localhost/api/auth/send-verification-email", {
|
||||||
|
// NOTE: In production, you should use an absolute URL from env, or use authClient.
|
||||||
|
// This is kept minimal; if you want, I'll refactor to authClient to avoid hostname concerns.
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
// forward cookies so Better Auth can authorize if needed
|
||||||
|
cookie: (await headers()).get("cookie") ?? "",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
callbackURL: "/",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
throw new Error(data?.message ?? "Failed to resend verification email.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
24
src/app/(admin)/users/new/page.tsx
Normal file
24
src/app/(admin)/users/new/page.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { CreateUserForm } from "@/components/users/CreateUserForm";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function NewUserPage() {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
const role = (session as any)?.user?.role;
|
||||||
|
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
if (role !== "admin") redirect("/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-md p-6">
|
||||||
|
<h1 className="text-xl font-semibold">Create user</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Create a new user account (registration is disabled publicly).
|
||||||
|
</p>
|
||||||
|
<div className="mt-6">
|
||||||
|
<CreateUserForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/app/(admin)/users/page.tsx
Normal file
27
src/app/(admin)/users/page.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { UsersTable } from "@/components/users/UsersTable";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function UsersPage() {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
const role = (session as any)?.user?.role as string | undefined;
|
||||||
|
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
if (role !== "admin") redirect("/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl p-6 space-y-6">
|
||||||
|
<div className="flex items-end justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Users</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Manage admin accounts and staff users.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UsersTable />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/app/(auth)/forgot-password/page.tsx
Normal file
15
src/app/(auth)/forgot-password/page.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { ForgotPasswordForm } from "@/components/auth/ForgotPasswordForm";
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-md p-6">
|
||||||
|
<h1 className="text-xl font-semibold">Forgot password</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Enter your email and we’ll send you a reset link.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6">
|
||||||
|
<ForgotPasswordForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/app/(auth)/register/page.tsx
Normal file
20
src/app/(auth)/register/page.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { RegisterForm } from "@/components/auth/RegisterForm";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function RegisterPage() {
|
||||||
|
const count = await prisma.user.count();
|
||||||
|
if (count !== 0) redirect("/login");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-md p-6">
|
||||||
|
<h1 className="text-xl font-semibold">Create admin account</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
This is only available until the first user is created.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6">
|
||||||
|
<RegisterForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/app/(auth)/reset-password/page.tsx
Normal file
19
src/app/(auth)/reset-password/page.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { ResetPasswordForm } from "@/components/auth/ResetPasswordForm";
|
||||||
|
|
||||||
|
export default function ResetPasswordPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: { token?: string };
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-md p-6">
|
||||||
|
<h1 className="text-xl font-semibold">Reset password</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Choose a new password.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6">
|
||||||
|
<ResetPasswordForm token={searchParams.token ?? ""} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/components/auth/ForgotPasswordForm.tsx
Normal file
40
src/components/auth/ForgotPasswordForm.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import * as React from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
export function ForgotPasswordForm() {
|
||||||
|
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||||
|
|
||||||
|
async function onSubmit(formData: FormData) {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const email = String(formData.get("email") ?? "").trim();
|
||||||
|
|
||||||
|
await authClient.requestPasswordReset({
|
||||||
|
email,
|
||||||
|
// after user clicks email link, they'll land here:
|
||||||
|
redirectTo: `${window.location.origin}/reset-password`,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("If the email exists, a reset link has been sent.");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Request failed");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={onSubmit} className="space-y-3">
|
||||||
|
<Input name="email" placeholder="Email" type="email" required />
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Sending…" : "Send reset link"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,11 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
type ApiErrorShape =
|
||||||
|
| { message?: string; error?: string; status?: string; code?: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
function isEmailNotVerified(data: ApiErrorShape) {
|
||||||
|
const msg = (data?.message ?? data?.error ?? "").toLowerCase();
|
||||||
|
const code = (data?.code ?? "").toLowerCase();
|
||||||
|
const status = (data?.status ?? "").toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
msg.includes("email not verified") ||
|
||||||
|
code.includes("email_not_verified") ||
|
||||||
|
(status.includes("forbidden") && msg.includes("verified"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function LoginForm() {
|
export default function LoginForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -15,30 +33,65 @@ export default function LoginForm() {
|
|||||||
const [email, setEmail] = React.useState("");
|
const [email, setEmail] = React.useState("");
|
||||||
const [password, setPassword] = React.useState("");
|
const [password, setPassword] = React.useState("");
|
||||||
const [pending, setPending] = React.useState(false);
|
const [pending, setPending] = React.useState(false);
|
||||||
|
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [needsVerification, setNeedsVerification] = React.useState(false);
|
||||||
|
const [resendPending, setResendPending] = React.useState(false);
|
||||||
|
|
||||||
|
async function resendVerification() {
|
||||||
|
setResendPending(true);
|
||||||
|
try {
|
||||||
|
// Endpoint name may differ slightly between versions,
|
||||||
|
// but this is the common Better Auth route.
|
||||||
|
const res = await fetch("/api/auth/send-verification-email", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
// Where user should land after verifying:
|
||||||
|
callbackURL: `${window.location.origin}/`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = (await res.json().catch(() => null)) as ApiErrorShape;
|
||||||
|
throw new Error(data?.message ?? "Failed to resend verification email.");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Verification email sent. Please check your inbox.");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Failed to resend verification email.");
|
||||||
|
} finally {
|
||||||
|
setResendPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onSubmit(e: React.FormEvent) {
|
async function onSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setPending(true);
|
setPending(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setNeedsVerification(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/auth/sign-in/email", {
|
const res = await fetch("/api/auth/sign-in/email", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ email, password }),
|
||||||
email,
|
|
||||||
password,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => null);
|
const data = (await res.json().catch(() => null)) as ApiErrorShape;
|
||||||
|
|
||||||
|
if (isEmailNotVerified(data)) {
|
||||||
|
setNeedsVerification(true);
|
||||||
|
setError("Email not verified. Please verify your email to sign in.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setError(data?.message ?? "Invalid email or password");
|
setError(data?.message ?? "Invalid email or password");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successful login → redirect back
|
|
||||||
router.replace(next);
|
router.replace(next);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch {
|
} catch {
|
||||||
@ -59,6 +112,7 @@ export default function LoginForm() {
|
|||||||
required
|
required
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
disabled={pending || resendPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -71,16 +125,38 @@ export default function LoginForm() {
|
|||||||
required
|
required
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={pending || resendPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
|
||||||
|
{needsVerification && (
|
||||||
|
<div className="space-y-2 rounded-lg border bg-muted/30 p-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Didn’t receive the verification email?
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={resendVerification}
|
||||||
|
disabled={!email || resendPending || pending}
|
||||||
|
>
|
||||||
|
{resendPending ? "Sending…" : "Resend verification email"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={pending}>
|
<Button type="submit" className="w-full" disabled={pending || resendPending}>
|
||||||
{pending ? "Signing in…" : "Sign in"}
|
{pending ? "Signing in…" : "Sign in"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
|
<Link href="/forgot-password" className="underline underline-offset-4">
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
51
src/components/auth/RegisterForm.tsx
Normal file
51
src/components/auth/RegisterForm.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import * as React from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { registerFirstUser } from "@/actions/auth/registerFirstUser";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
export function RegisterForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||||
|
|
||||||
|
async function onSubmit(formData: FormData) {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const email = String(formData.get("email") ?? "").trim();
|
||||||
|
|
||||||
|
const res = await registerFirstUser({
|
||||||
|
name: String(formData.get("name") ?? ""),
|
||||||
|
email,
|
||||||
|
password: String(formData.get("password") ?? ""),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.requiresEmailVerification) {
|
||||||
|
toast.success("Account created. Please verify your email, then log in.");
|
||||||
|
} else {
|
||||||
|
toast.success("Account created.");
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace("/login");
|
||||||
|
router.refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Registration failed");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={onSubmit} className="space-y-3">
|
||||||
|
<Input name="name" placeholder="Name" required />
|
||||||
|
<Input name="email" placeholder="Email" type="email" required />
|
||||||
|
<Input name="password" placeholder="Password" type="password" required />
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Creating…" : "Create account"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/components/auth/ResetPasswordForm.tsx
Normal file
49
src/components/auth/ResetPasswordForm.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import * as React from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
export function ResetPasswordForm({ token }: { token: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||||
|
|
||||||
|
async function onSubmit(formData: FormData) {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const password = String(formData.get("password") ?? "");
|
||||||
|
const password2 = String(formData.get("password2") ?? "");
|
||||||
|
|
||||||
|
if (!token) throw new Error("Missing token.");
|
||||||
|
if (password.length < 8) throw new Error("Password must be at least 8 characters.");
|
||||||
|
if (password !== password2) throw new Error("Passwords do not match.");
|
||||||
|
|
||||||
|
await authClient.resetPassword({
|
||||||
|
token,
|
||||||
|
newPassword: password,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Password updated. You can now log in.");
|
||||||
|
router.replace("/login");
|
||||||
|
router.refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Reset failed");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={onSubmit} className="space-y-3">
|
||||||
|
<Input name="password" placeholder="New password" type="password" required />
|
||||||
|
<Input name="password2" placeholder="Repeat new password" type="password" required />
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Updating…" : "Update password"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -40,28 +40,16 @@ const commissionItems = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
// const portfolioItems = [
|
const usersItems = [
|
||||||
// {
|
{
|
||||||
// title: "Images",
|
title: "Users",
|
||||||
// href: "/portfolio/images",
|
href: "/users",
|
||||||
// },
|
},
|
||||||
// {
|
{
|
||||||
// title: "Types",
|
title: "New User",
|
||||||
// href: "/portfolio/types",
|
href: "/users/new",
|
||||||
// },
|
}
|
||||||
// {
|
]
|
||||||
// title: "Albums",
|
|
||||||
// href: "/portfolio/albums",
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// title: "Categories",
|
|
||||||
// href: "/portfolio/categories",
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// title: "Tags",
|
|
||||||
// href: "/portfolio/tags",
|
|
||||||
// },
|
|
||||||
// ]
|
|
||||||
|
|
||||||
export default function TopNav() {
|
export default function TopNav() {
|
||||||
return (
|
return (
|
||||||
@ -143,6 +131,25 @@ export default function TopNav() {
|
|||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
|
|
||||||
|
<NavigationMenuItem>
|
||||||
|
<NavigationMenuTrigger>Users</NavigationMenuTrigger>
|
||||||
|
<NavigationMenuContent>
|
||||||
|
<ul className="grid w-50 gap-4">
|
||||||
|
{usersItems.map((item) => (
|
||||||
|
<li key={item.title}>
|
||||||
|
<NavigationMenuLink asChild>
|
||||||
|
<Link href={item.href}>
|
||||||
|
<div className="text-sm leading-none font-medium">{item.title}</div>
|
||||||
|
<p className="text-muted-foreground line-clamp-2 text-sm leading-snug">
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
</NavigationMenuLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</NavigationMenuContent>
|
||||||
|
</NavigationMenuItem>
|
||||||
|
|
||||||
{/* <NavigationMenuItem>
|
{/* <NavigationMenuItem>
|
||||||
<NavigationMenuTrigger>Portfolio</NavigationMenuTrigger>
|
<NavigationMenuTrigger>Portfolio</NavigationMenuTrigger>
|
||||||
<NavigationMenuContent>
|
<NavigationMenuContent>
|
||||||
|
|||||||
60
src/components/users/CreateUserForm.tsx
Normal file
60
src/components/users/CreateUserForm.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { createUser } from "@/actions/users/createUser";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
export function CreateUserForm() {
|
||||||
|
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||||
|
const [role, setRole] = React.useState<"user" | "admin">("user");
|
||||||
|
|
||||||
|
async function onSubmit(formData: FormData) {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await createUser({
|
||||||
|
name: String(formData.get("name") ?? ""),
|
||||||
|
email: String(formData.get("email") ?? ""),
|
||||||
|
password: String(formData.get("password") ?? ""),
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("User created");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Create user failed");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={onSubmit} className="space-y-3">
|
||||||
|
<Input name="name" placeholder="Name" required />
|
||||||
|
<Input name="email" placeholder="Email" type="email" required />
|
||||||
|
<Input name="password" placeholder="Password" type="password" required />
|
||||||
|
|
||||||
|
<Select value={role} onValueChange={(v) => setRole(v as any)}>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
<SelectValue placeholder="Role" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user">User</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Creating…" : "Create user"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
232
src/components/users/UsersTable.tsx
Normal file
232
src/components/users/UsersTable.tsx
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MoreHorizontal, Trash2, UserPlus } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import * as React from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { deleteUser } from "@/actions/users/deleteUser";
|
||||||
|
import { getUsers, type UsersListRow } from "@/actions/users/getUsers";
|
||||||
|
// import { resendVerification } from "@/actions/users/resendVerification";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
|
||||||
|
function RoleBadge({ role }: { role: UsersListRow["role"] }) {
|
||||||
|
return (
|
||||||
|
<Badge variant={role === "admin" ? "default" : "secondary"} className="px-2 py-0.5">
|
||||||
|
{role}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VerifiedBadge({ value }: { value: boolean }) {
|
||||||
|
return (
|
||||||
|
<Badge variant={value ? "default" : "secondary"} className="px-2 py-0.5">
|
||||||
|
{value ? "Verified" : "Unverified"}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsersTable() {
|
||||||
|
const [rows, setRows] = React.useState<UsersListRow[]>([]);
|
||||||
|
const [isPending, startTransition] = React.useTransition();
|
||||||
|
|
||||||
|
const [deleteOpen, setDeleteOpen] = React.useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = React.useState<{ id: string; label: string } | null>(null);
|
||||||
|
|
||||||
|
const refresh = React.useCallback(() => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const data = await getUsers();
|
||||||
|
setRows(data);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{isPending ? "Updating…" : null} Total: {rows.length}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/users/new">
|
||||||
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
|
Add user
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-border/60 bg-card shadow-sm ring-1 ring-border/40">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-muted/40">
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wide text-foreground/80">
|
||||||
|
Name
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wide text-foreground/80">
|
||||||
|
Email
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wide text-foreground/80">
|
||||||
|
Role
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wide text-foreground/80">
|
||||||
|
Status
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wide text-foreground/80">
|
||||||
|
Created
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="py-3" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="py-14 text-center">
|
||||||
|
<div className="text-sm font-medium">{isPending ? "Loading…" : "No users."}</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
rows.map((u, idx) => (
|
||||||
|
<TableRow
|
||||||
|
key={u.id}
|
||||||
|
className={[
|
||||||
|
"transition-colors",
|
||||||
|
"hover:bg-muted/50",
|
||||||
|
idx % 2 === 0 ? "bg-background" : "bg-muted/10",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<TableCell className="py-3">
|
||||||
|
<div className="text-sm font-medium">{u.name ?? "—"}</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground tabular-nums">{u.id}</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="py-3">
|
||||||
|
<div className="text-sm">{u.email}</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="py-3">
|
||||||
|
<RoleBadge role={u.role} />
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="py-3">
|
||||||
|
<VerifiedBadge value={u.emailVerified} />
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="py-3">
|
||||||
|
<span className="text-sm text-foreground/80">
|
||||||
|
{new Date(u.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="py-3">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Open row actions</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
{/* Optional resend verification */}
|
||||||
|
{/* {!u.emailVerified ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startTransition(async () => {
|
||||||
|
await resendVerification({ email: u.email });
|
||||||
|
toast.success("Verification email resent");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Resend verification email
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!u.emailVerified ? <DropdownMenuSeparator /> : null} */}
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer text-destructive focus:text-destructive"
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDeleteTarget({ id: u.id, label: `${u.email}` });
|
||||||
|
setDeleteOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete user?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will delete <span className="font-medium">{deleteTarget?.label}</span>. This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
disabled={isPending || !deleteTarget}
|
||||||
|
onClick={() => {
|
||||||
|
const target = deleteTarget;
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await deleteUser(target.id);
|
||||||
|
toast.success("User deleted");
|
||||||
|
setDeleteOpen(false);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Delete failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,3 +1,7 @@
|
|||||||
import { createAuthClient } from "better-auth/client";
|
import type { auth } from "@/lib/auth";
|
||||||
|
import { inferAdditionalFields } from "better-auth/client/plugins";
|
||||||
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
|
||||||
export const authClient = createAuthClient();
|
export const authClient = createAuthClient({
|
||||||
|
plugins: [inferAdditionalFields<typeof auth>()],
|
||||||
|
});
|
||||||
@ -1,12 +1,53 @@
|
|||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||||
|
import { nextCookies } from "better-auth/next-js";
|
||||||
|
import { admin } from "better-auth/plugins";
|
||||||
|
import { sendEmail } from "./email";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
database: prismaAdapter(prisma, {
|
database: prismaAdapter(prisma, {
|
||||||
provider: "postgresql",
|
provider: "postgresql",
|
||||||
}),
|
}),
|
||||||
emailAndPassword: {
|
|
||||||
enabled: true,
|
user: {
|
||||||
},
|
additionalFields: {
|
||||||
|
role: {
|
||||||
|
type: ["user", "admin"],
|
||||||
|
required: false,
|
||||||
|
defaultValue: "user",
|
||||||
|
input: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
emailVerification: {
|
||||||
|
sendOnSignUp: true,
|
||||||
|
sendOnSignIn: true,
|
||||||
|
autoSignInAfterVerification: true,
|
||||||
|
sendVerificationEmail: async ({ user, url }) => {
|
||||||
|
await sendEmail({
|
||||||
|
to: user.email,
|
||||||
|
subject: "Verify your email",
|
||||||
|
text: `Please verify your email by opening this link:\n\n${url}\n`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
requireEmailVerification: true,
|
||||||
|
sendResetPassword: async ({ user, url }) => {
|
||||||
|
await sendEmail({
|
||||||
|
to: user.email,
|
||||||
|
subject: "Reset your password",
|
||||||
|
text: `Reset your password using this link:\n\n${url}\n`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
plugins: [
|
||||||
|
admin(),
|
||||||
|
nextCookies(),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
48
src/lib/email.ts
Normal file
48
src/lib/email.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
type SendEmailArgs = {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
text: string;
|
||||||
|
html?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let cached: nodemailer.Transporter | null = null;
|
||||||
|
|
||||||
|
function getTransporter() {
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const host = process.env.SMTP_HOST;
|
||||||
|
const port = Number(process.env.SMTP_PORT ?? "587");
|
||||||
|
const secure = String(process.env.SMTP_SECURE ?? "false") === "true";
|
||||||
|
const user = process.env.SMTP_USER;
|
||||||
|
const pass = process.env.SMTP_PASS;
|
||||||
|
|
||||||
|
if (!host || !user || !pass) {
|
||||||
|
throw new Error("SMTP env vars missing (SMTP_HOST/SMTP_USER/SMTP_PASS).");
|
||||||
|
}
|
||||||
|
|
||||||
|
cached = nodemailer.createTransport({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure, // false for STARTTLS (587), true for 465
|
||||||
|
auth: { user, pass },
|
||||||
|
});
|
||||||
|
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendEmail(args: SendEmailArgs) {
|
||||||
|
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||||
|
if (!from) throw new Error("SMTP_FROM (or SMTP_USER) must be set.");
|
||||||
|
|
||||||
|
const transporter = getTransporter();
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from,
|
||||||
|
to: args.to,
|
||||||
|
subject: args.subject,
|
||||||
|
text: args.text,
|
||||||
|
html: args.html,
|
||||||
|
});
|
||||||
|
}
|
||||||
6
src/lib/registration.ts
Normal file
6
src/lib/registration.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function isPublicRegistrationOpen() {
|
||||||
|
const count = await prisma.user.count();
|
||||||
|
return count === 0;
|
||||||
|
}
|
||||||
92
src/proxy.ts
92
src/proxy.ts
@ -1,30 +1,82 @@
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
async function isFirstRun() {
|
||||||
|
const count = await prisma.user.count();
|
||||||
|
return count === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSignUpRequestPath(pathname: string) {
|
||||||
|
return pathname.startsWith("/api/auth") && pathname.includes("sign-up");
|
||||||
|
}
|
||||||
|
|
||||||
export async function proxy(request: NextRequest) {
|
export async function proxy(request: NextRequest) {
|
||||||
const { pathname, search } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
const session = await auth.api.getSession({
|
|
||||||
headers: await headers()
|
|
||||||
})
|
|
||||||
|
|
||||||
if (
|
|
||||||
pathname === "/login" ||
|
|
||||||
pathname.startsWith("/api/auth") ||
|
|
||||||
pathname.startsWith("/_next") ||
|
|
||||||
pathname === "/favicon.ico"
|
|
||||||
) {
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
if(!session) {
|
|
||||||
return NextResponse.redirect(new URL("/login", request.url));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Always allow Next internals
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
if (pathname.startsWith("/_next") || pathname === "/favicon.ico") {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Public APIs (explicitly allowed)
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
if (
|
||||||
|
pathname.startsWith("/api/v1") ||
|
||||||
|
pathname.startsWith("/api/image") ||
|
||||||
|
pathname.startsWith("/api/requests/image")
|
||||||
|
) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstRun = await isFirstRun();
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Auth APIs
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
if (pathname.startsWith("/api/auth")) {
|
||||||
|
// Block signup once first user exists
|
||||||
|
if (isSignUpRequestPath(pathname) && !firstRun) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Registration disabled" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// First run UX: force registration
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
if (firstRun) {
|
||||||
|
if (pathname !== "/register") {
|
||||||
|
return NextResponse.redirect(new URL("/register", request.url));
|
||||||
|
}
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Normal auth flow
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
if (pathname === "/login") {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.redirect(new URL("/login", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/((?!api/auth|api/image|api/v1|login|_next/static|_next/image|favicon.ico).*)"],
|
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user