Add user management

This commit is contained in:
2026-01-01 18:34:02 +01:00
parent 2fcf19c0df
commit 36fb2358dd
26 changed files with 1047 additions and 56 deletions

View File

@ -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=="],

View File

@ -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",

View 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';

View File

@ -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")

View 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 };
}

View 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(),
});
}

View 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" };
}

View 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[];
}

View 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 };
}

View 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>
);
}

View 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>
);
}

View 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 well send you a reset link.
</p>
<div className="mt-6">
<ForgotPasswordForm />
</div>
</div>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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">
Didnt 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>
); );
} }

View 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>
);
}

View 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>
);
}

View File

@ -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>

View 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>
);
}

View 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>
);
}

View File

@ -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>()],
});

View File

@ -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",
}), }),
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: { emailAndPassword: {
enabled: true, 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
View 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
View File

@ -0,0 +1,6 @@
import { prisma } from "@/lib/prisma";
export async function isPublicRegistrationOpen() {
const count = await prisma.user.count();
return count === 0;
}

View File

@ -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).*)"],
}; };