Add user management
This commit is contained in:
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";
|
||||
|
||||
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<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) {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -71,16 +125,38 @@ export default function LoginForm() {
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={pending || resendPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
{error && <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"}
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<Link href="/forgot-password" className="underline underline-offset-4">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
</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 = [
|
||||
// {
|
||||
// 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() {
|
||||
</NavigationMenuLink>
|
||||
</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>
|
||||
<NavigationMenuTrigger>Portfolio</NavigationMenuTrigger>
|
||||
<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 { 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(),
|
||||
],
|
||||
});
|
||||
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 { 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).*)"],
|
||||
};
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user