diff --git a/bun.lock b/bun.lock index 1dd767c..3c0df9f 100644 --- a/bun.lock +++ b/bun.lock @@ -47,6 +47,7 @@ "next": "^16.1.1", "next-themes": "^0.4.6", "node-vibrant": "^4.0.3", + "nodemailer": "^7.0.12", "pg": "^8.16.3", "platejs": "^52.0.15", "react": "19.2.1", @@ -68,6 +69,7 @@ "@types/culori": "^4.0.1", "@types/date-fns": "^2.6.3", "@types/node": "^20.19.27", + "@types/nodemailer": "^7.0.4", "@types/pg": "^8.16.0", "@types/react": "^19.2.7", "@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-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/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/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/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=="], + "nodemailer": ["nodemailer@7.0.12", "", {}, "sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA=="], + "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=="], diff --git a/package.json b/package.json index 9ec44ae..47cba49 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "next": "^16.1.1", "next-themes": "^0.4.6", "node-vibrant": "^4.0.3", + "nodemailer": "^7.0.12", "pg": "^8.16.3", "platejs": "^52.0.15", "react": "19.2.1", @@ -73,6 +74,7 @@ "@types/culori": "^4.0.1", "@types/date-fns": "^2.6.3", "@types/node": "^20.19.27", + "@types/nodemailer": "^7.0.4", "@types/pg": "^8.16.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", diff --git a/prisma/migrations/20260101122816_auth_3/migration.sql b/prisma/migrations/20260101122816_auth_3/migration.sql new file mode 100644 index 0000000..dc55e47 --- /dev/null +++ b/prisma/migrations/20260101122816_auth_3/migration.sql @@ -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'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 24fb1fd..ad56e78 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -422,6 +422,11 @@ model User { sessions Session[] accounts Account[] + role String @default("user") + banned Boolean? @default(false) + banReason String? + banExpires DateTime? + @@unique([email]) @@map("user") } @@ -437,6 +442,8 @@ model Session { userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) + impersonatedBy String? + @@unique([token]) @@index([userId]) @@map("session") diff --git a/src/actions/auth/registerFirstUser.ts b/src/actions/auth/registerFirstUser.ts new file mode 100644 index 0000000..d90b3f9 --- /dev/null +++ b/src/actions/auth/registerFirstUser.ts @@ -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) { + 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 }; +} diff --git a/src/actions/users/createUser.ts b/src/actions/users/createUser.ts new file mode 100644 index 0000000..af1584a --- /dev/null +++ b/src/actions/users/createUser.ts @@ -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) { + 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(), + }); +} diff --git a/src/actions/users/deleteUser.ts b/src/actions/users/deleteUser.ts new file mode 100644 index 0000000..7d348d1 --- /dev/null +++ b/src/actions/users/deleteUser.ts @@ -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" }; +} diff --git a/src/actions/users/getUsers.ts b/src/actions/users/getUsers.ts new file mode 100644 index 0000000..b99ac60 --- /dev/null +++ b/src/actions/users/getUsers.ts @@ -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 { + 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[]; +} diff --git a/src/actions/users/resendVerification.ts b/src/actions/users/resendVerification.ts new file mode 100644 index 0000000..fa5934d --- /dev/null +++ b/src/actions/users/resendVerification.ts @@ -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) { + 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 }; +} diff --git a/src/app/(admin)/users/new/page.tsx b/src/app/(admin)/users/new/page.tsx new file mode 100644 index 0000000..3dc05b1 --- /dev/null +++ b/src/app/(admin)/users/new/page.tsx @@ -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 ( +
+

Create user

+

+ Create a new user account (registration is disabled publicly). +

+
+ +
+
+ ); +} diff --git a/src/app/(admin)/users/page.tsx b/src/app/(admin)/users/page.tsx new file mode 100644 index 0000000..bd8ded1 --- /dev/null +++ b/src/app/(admin)/users/page.tsx @@ -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 ( +
+
+
+

Users

+

+ Manage admin accounts and staff users. +

+
+
+ + +
+ ); +} diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..5ec8397 --- /dev/null +++ b/src/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,15 @@ +import { ForgotPasswordForm } from "@/components/auth/ForgotPasswordForm"; + +export default function ForgotPasswordPage() { + return ( +
+

Forgot password

+

+ Enter your email and we’ll send you a reset link. +

+
+ +
+
+ ); +} diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx new file mode 100644 index 0000000..d088d19 --- /dev/null +++ b/src/app/(auth)/register/page.tsx @@ -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 ( +
+

Create admin account

+

+ This is only available until the first user is created. +

+
+ +
+
+ ); +} diff --git a/src/app/(auth)/reset-password/page.tsx b/src/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..1a84a05 --- /dev/null +++ b/src/app/(auth)/reset-password/page.tsx @@ -0,0 +1,19 @@ +import { ResetPasswordForm } from "@/components/auth/ResetPasswordForm"; + +export default function ResetPasswordPage({ + searchParams, +}: { + searchParams: { token?: string }; +}) { + return ( +
+

Reset password

+

+ Choose a new password. +

+
+ +
+
+ ); +} diff --git a/src/components/auth/ForgotPasswordForm.tsx b/src/components/auth/ForgotPasswordForm.tsx new file mode 100644 index 0000000..0954150 --- /dev/null +++ b/src/components/auth/ForgotPasswordForm.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx index 4f5d4cb..e1f4592 100644 --- a/src/components/auth/LoginForm.tsx +++ b/src/components/auth/LoginForm.tsx @@ -1,11 +1,29 @@ "use client"; +import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import * as React from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; 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() { const router = useRouter(); @@ -15,30 +33,65 @@ export default function LoginForm() { const [email, setEmail] = React.useState(""); const [password, setPassword] = React.useState(""); const [pending, setPending] = React.useState(false); + const [error, setError] = React.useState(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) { e.preventDefault(); setPending(true); setError(null); + setNeedsVerification(false); try { const res = await fetch("/api/auth/sign-in/email", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email, - password, - }), + body: JSON.stringify({ email, password }), }); 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"); return; } - // Successful login → redirect back router.replace(next); router.refresh(); } catch { @@ -59,6 +112,7 @@ export default function LoginForm() { required value={email} onChange={(e) => setEmail(e.target.value)} + disabled={pending || resendPending} /> @@ -71,16 +125,38 @@ export default function LoginForm() { required value={password} onChange={(e) => setPassword(e.target.value)} + disabled={pending || resendPending} /> - {error && ( -

{error}

+ {error &&

{error}

} + + {needsVerification && ( +
+

+ Didn’t receive the verification email? +

+ +
)} - + +
+ + Forgot password? + +
); } diff --git a/src/components/auth/RegisterForm.tsx b/src/components/auth/RegisterForm.tsx new file mode 100644 index 0000000..adc429e --- /dev/null +++ b/src/components/auth/RegisterForm.tsx @@ -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 ( +
+ + + + +
+ ); +} diff --git a/src/components/auth/ResetPasswordForm.tsx b/src/components/auth/ResetPasswordForm.tsx new file mode 100644 index 0000000..e5e9310 --- /dev/null +++ b/src/components/auth/ResetPasswordForm.tsx @@ -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 ( +
+ + + +
+ ); +} diff --git a/src/components/global/TopNav.tsx b/src/components/global/TopNav.tsx index c426f61..d3c599f 100644 --- a/src/components/global/TopNav.tsx +++ b/src/components/global/TopNav.tsx @@ -40,28 +40,16 @@ const commissionItems = [ } ] -// const portfolioItems = [ -// { -// title: "Images", -// href: "/portfolio/images", -// }, -// { -// title: "Types", -// href: "/portfolio/types", -// }, -// { -// title: "Albums", -// href: "/portfolio/albums", -// }, -// { -// title: "Categories", -// href: "/portfolio/categories", -// }, -// { -// title: "Tags", -// href: "/portfolio/tags", -// }, -// ] +const usersItems = [ + { + title: "Users", + href: "/users", + }, + { + title: "New User", + href: "/users/new", + } +] export default function TopNav() { return ( @@ -143,6 +131,25 @@ export default function TopNav() { + + Users + +
    + {usersItems.map((item) => ( +
  • + + +
    {item.title}
    +

    +

    + +
    +
  • + ))} +
+
+
+ {/* Portfolio diff --git a/src/components/users/CreateUserForm.tsx b/src/components/users/CreateUserForm.tsx new file mode 100644 index 0000000..b1b9ae8 --- /dev/null +++ b/src/components/users/CreateUserForm.tsx @@ -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 ( +
+ + + + + + + +
+ ); +} diff --git a/src/components/users/UsersTable.tsx b/src/components/users/UsersTable.tsx new file mode 100644 index 0000000..0873c7e --- /dev/null +++ b/src/components/users/UsersTable.tsx @@ -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 ( + + {role} + + ); +} + +function VerifiedBadge({ value }: { value: boolean }) { + return ( + + {value ? "Verified" : "Unverified"} + + ); +} + +export function UsersTable() { + const [rows, setRows] = React.useState([]); + 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 ( +
+
+
+ {isPending ? "Updating…" : null} Total: {rows.length} +
+ + +
+ +
+ + + + + Name + + + Email + + + Role + + + Status + + + Created + + + + + + + {rows.length === 0 ? ( + + +
{isPending ? "Loading…" : "No users."}
+
+
+ ) : ( + rows.map((u, idx) => ( + + +
{u.name ?? "—"}
+
{u.id}
+
+ + +
{u.email}
+
+ + + + + + + + + + + + {new Date(u.createdAt).toLocaleDateString()} + + + + +
+ + + + + + + {/* Optional resend verification */} + {/* {!u.emailVerified ? ( + { + e.preventDefault(); + startTransition(async () => { + await resendVerification({ email: u.email }); + toast.success("Verification email resent"); + }); + }} + > + Resend verification email + + ) : null} + + {!u.emailVerified ? : null} */} + + { + e.preventDefault(); + setDeleteTarget({ id: u.id, label: `${u.email}` }); + setDeleteOpen(true); + }} + > + + Delete + + + +
+
+
+ )) + )} +
+
+
+ + + + + Delete user? + + This will delete {deleteTarget?.label}. This action cannot be undone. + + + + Cancel + { + 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 + + + + +
+ ); +} diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index b313a12..a548405 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -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(); \ No newline at end of file +export const authClient = createAuthClient({ + plugins: [inferAdditionalFields()], +}); \ No newline at end of file diff --git a/src/lib/auth.ts b/src/lib/auth.ts index ac763be..a38dfbe 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,12 +1,53 @@ import { betterAuth } from "better-auth"; 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"; export const auth = betterAuth({ database: prismaAdapter(prisma, { 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(), + ], }); \ No newline at end of file diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..06cfc84 --- /dev/null +++ b/src/lib/email.ts @@ -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, + }); +} diff --git a/src/lib/registration.ts b/src/lib/registration.ts new file mode 100644 index 0000000..8627378 --- /dev/null +++ b/src/lib/registration.ts @@ -0,0 +1,6 @@ +import { prisma } from "@/lib/prisma"; + +export async function isPublicRegistrationOpen() { + const count = await prisma.user.count(); + return count === 0; +} diff --git a/src/proxy.ts b/src/proxy.ts index cfab274..619ec14 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,30 +1,82 @@ import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; import { headers } from "next/headers"; 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) { - const { pathname, search } = 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)); - } + const { pathname } = request.nextUrl; + // ───────────────────────────────────────────── + // Always allow Next internals + // ───────────────────────────────────────────── + if (pathname.startsWith("/_next") || pathname === "/favicon.ico") { 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 = { - matcher: ["/((?!api/auth|api/image|api/v1|login|_next/static|_next/image|favicon.ico).*)"], -}; \ No newline at end of file + matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], +};