From b92ead3a9737f3f201b6146f4160767ca0e41188 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 18:36:03 +0800 Subject: [PATCH] feat(web): add-user form + delete confirmation in user management - New AddUserFormClient on /settings/users (admin-only): username + password + role select. Wraps createUserAction. - UserRowClient gains an isLastAdmin prop and a confirm-dialog before delete. Demote and Delete are both disabled on the last remaining admin so an admin can't lock everyone out via the UI (server-side guards in users.ts already cover the API). - Page passes isLastAdmin per row and computes adminCount once. - Role badge uses emerald for admin / slate for user; explicit Promote / Demote arrows replace the bidirectional icon. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../settings/users/add-user-form-client.tsx | 95 +++++++++++++ apps/web/src/app/settings/users/page.tsx | 36 ++++- .../app/settings/users/user-row-client.tsx | 133 +++++++++++++++--- 3 files changed, 241 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/app/settings/users/add-user-form-client.tsx diff --git a/apps/web/src/app/settings/users/add-user-form-client.tsx b/apps/web/src/app/settings/users/add-user-form-client.tsx new file mode 100644 index 0000000..c032bb4 --- /dev/null +++ b/apps/web/src/app/settings/users/add-user-form-client.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Loader2Icon, UserPlusIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { createUserAction } from "@/actions/users"; + +export function AddUserFormClient() { + const [pending, start] = useTransition(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [role, setRole] = useState<"admin" | "user">("user"); + const [error, setError] = useState(null); + const [ok, setOk] = useState(false); + + function submit() { + start(async () => { + setError(null); + setOk(false); + const r = await createUserAction({ + username: username.trim(), + password, + role, + }); + if (!r.ok) { + setError(r.error); + return; + } + setUsername(""); + setPassword(""); + setRole("user"); + setOk(true); + }); + } + + return ( +
+
+
+ + setUsername(e.target.value)} + autoComplete="off" + maxLength={256} + placeholder="alice" + /> +
+
+ + setPassword(e.target.value)} + autoComplete="new-password" + maxLength={256} + placeholder="≥10 characters" + /> +
+
+
+ + +
+
+ {error &&

{error}

} + {ok && ( +

+ User created. +

+ )} + +
+
+ ); +} diff --git a/apps/web/src/app/settings/users/page.tsx b/apps/web/src/app/settings/users/page.tsx index dff2c2b..da85bf4 100644 --- a/apps/web/src/app/settings/users/page.tsx +++ b/apps/web/src/app/settings/users/page.tsx @@ -1,28 +1,58 @@ import { requireAdmin } from "@/lib/auth"; import { db } from "@/lib/db"; import { PageShell } from "@/components/page-shell"; -import { Card, CardContent } from "@/components/ui/card"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { UserRowClient } from "./user-row-client"; +import { AddUserFormClient } from "./add-user-form-client"; export default async function UsersPage() { const me = await requireAdmin(); const rows = await db.query.operators.findMany({ orderBy: (o, { asc }) => [asc(o.username)], }); + const adminCount = rows.filter((r) => r.role === "admin").length; return ( - + + Add user + + Create a sign-in account. Passwords must be at least 10 + characters. + + + + + + + + + + All users + + Promote a user to admin, demote them back, reset their + password, or delete the account. The last admin cannot be + demoted or deleted. + + + {rows.map((u) => ( ))} diff --git a/apps/web/src/app/settings/users/user-row-client.tsx b/apps/web/src/app/settings/users/user-row-client.tsx index ada13d7..fe52d3a 100644 --- a/apps/web/src/app/settings/users/user-row-client.tsx +++ b/apps/web/src/app/settings/users/user-row-client.tsx @@ -1,9 +1,26 @@ "use client"; import { useState, useTransition } from "react"; -import { Loader2Icon, Trash2Icon, KeyIcon, ArrowUpDownIcon } from "lucide-react"; +import { + Loader2Icon, + Trash2Icon, + KeyIcon, + ArrowUpIcon, + ArrowDownIcon, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogClose, +} from "@/components/ui/dialog"; import { setUserRoleAction, resetUserPasswordAction, @@ -13,13 +30,16 @@ import { interface UserRowClientProps { user: { id: string; username: string; role: "admin" | "user" }; isSelf: boolean; + /** True when this row is the only remaining admin. Disables demote+delete. */ + isLastAdmin: boolean; } -export function UserRowClient({ user, isSelf }: UserRowClientProps) { +export function UserRowClient({ user, isSelf, isLastAdmin }: UserRowClientProps) { const [pending, start] = useTransition(); const [error, setError] = useState(null); const [resetVisible, setResetVisible] = useState(false); const [resetPw, setResetPw] = useState(""); + const [deleteOpen, setDeleteOpen] = useState(false); function run(promise: Promise) { start(async () => { @@ -29,30 +49,60 @@ export function UserRowClient({ user, isSelf }: UserRowClientProps) { }); } + const isAdmin = user.role === "admin"; + // The role-toggle button is disabled if: + // - flipping yourself (admin self-demotion is rejected server-side too) + // - this row is the last remaining admin and would become a user + const roleToggleDisabled = pending || isSelf || (isAdmin && isLastAdmin); + const deleteDisabled = pending || isSelf || isLastAdmin; + return (

{user.username}

-

{user.role}{isSelf && " · you"}

+
+ + {user.role} + + {isSelf && ( + you + )} + {isAdmin && isLastAdmin && ( + + · last admin + + )} +
-
+
- + + + + + + + Delete user @{user.username}? + + This permanently removes the account. They will be + signed out on their next request and cannot sign in + again. This cannot be undone. + + + + + + + + + +
{resetVisible && ( @@ -88,9 +176,14 @@ export function UserRowClient({ user, isSelf }: UserRowClientProps) {