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

@ -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";
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">
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"}
</Button>
<div className="text-center text-sm text-muted-foreground">
<Link href="/forgot-password" className="underline underline-offset-4">
Forgot password?
</Link>
</div>
</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 = [
// {
// 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>

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