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) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 18:36:03 +08:00
parent 4ddf5c094e
commit b92ead3a97
3 changed files with 241 additions and 23 deletions

View File

@ -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<string | null>(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 (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="new-username">Username</Label>
<Input
id="new-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="off"
maxLength={256}
placeholder="alice"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-password">Password</Label>
<Input
id="new-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
maxLength={256}
placeholder="≥10 characters"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-role">Role</Label>
<select
id="new-role"
value={role}
onChange={(e) => setRole(e.target.value === "admin" ? "admin" : "user")}
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</div>
<div className="flex items-center justify-end gap-2">
{error && <p className="mr-auto text-xs text-destructive">{error}</p>}
{ok && (
<p className="mr-auto text-xs text-emerald-600 dark:text-emerald-400">
User created.
</p>
)}
<Button type="button" size="sm" disabled={pending} onClick={submit}>
{pending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<UserPlusIcon className="size-4" />
)}
Add user
</Button>
</div>
</div>
);
}

View File

@ -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 (
<PageShell title="Users">
<Card>
<CardContent className="space-y-3 py-4">
<CardHeader>
<CardTitle>Add user</CardTitle>
<CardDescription>
Create a sign-in account. Passwords must be at least 10
characters.
</CardDescription>
</CardHeader>
<CardContent>
<AddUserFormClient />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>All users</CardTitle>
<CardDescription>
Promote a user to admin, demote them back, reset their
password, or delete the account. The last admin cannot be
demoted or deleted.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{rows.map((u) => (
<UserRowClient
key={u.id}
user={{
id: u.id,
username: u.username,
role: (u.role === "admin" ? "admin" : "user"),
role: u.role === "admin" ? "admin" : "user",
}}
isSelf={u.id === me.id}
isLastAdmin={u.role === "admin" && adminCount === 1}
/>
))}
</CardContent>

View File

@ -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<string | null>(null);
const [resetVisible, setResetVisible] = useState(false);
const [resetPw, setResetPw] = useState("");
const [deleteOpen, setDeleteOpen] = useState(false);
function run<T extends { ok: boolean; error?: string }>(promise: Promise<T>) {
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 (
<div className="flex flex-col gap-2 rounded-lg border p-3">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{user.username}</p>
<p className="text-xs text-muted-foreground">{user.role}{isSelf && " · you"}</p>
<div className="flex items-center gap-1.5">
<Badge
variant="secondary"
className={
isAdmin
? "bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent"
: "bg-slate-200/60 text-slate-600 dark:bg-slate-700/40 dark:text-slate-300 border-transparent"
}
>
{user.role}
</Badge>
{isSelf && (
<span className="text-xs text-muted-foreground">you</span>
)}
{isAdmin && isLastAdmin && (
<span className="text-xs text-muted-foreground">
· last admin
</span>
)}
</div>
<div className="flex gap-1">
</div>
<div className="flex flex-wrap justify-end gap-1">
<Button
type="button"
size="sm"
variant="ghost"
disabled={pending || isSelf}
disabled={roleToggleDisabled}
onClick={() =>
run(
setUserRoleAction({
userId: user.id,
role: user.role === "admin" ? "user" : "admin",
role: isAdmin ? "user" : "admin",
}),
)
}
>
<ArrowUpDownIcon className="size-3.5" />
{user.role === "admin" ? "Demote" : "Promote"}
{isAdmin ? (
<ArrowDownIcon className="size-3.5" />
) : (
<ArrowUpIcon className="size-3.5" />
)}
{isAdmin ? "Demote" : "Promote"}
</Button>
<Button
type="button"
@ -64,16 +114,54 @@ export function UserRowClient({ user, isSelf }: UserRowClientProps) {
<KeyIcon className="size-3.5" />
Reset
</Button>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogTrigger asChild>
<Button
type="button"
size="sm"
variant="ghost"
className="text-destructive"
disabled={pending || isSelf}
onClick={() => run(deleteUserAction({ userId: user.id }))}
disabled={deleteDisabled}
>
{pending ? <Loader2Icon className="size-3.5 animate-spin" /> : <Trash2Icon className="size-3.5" />}
<Trash2Icon className="size-3.5" />
Delete
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete user @{user.username}?</DialogTitle>
<DialogDescription>
This permanently removes the account. They will be
signed out on their next request and cannot sign in
again. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>
<DialogClose asChild>
<Button type="button" variant="ghost" size="sm">
Cancel
</Button>
</DialogClose>
<Button
type="button"
variant="destructive"
size="sm"
disabled={pending}
onClick={() => {
setDeleteOpen(false);
run(deleteUserAction({ userId: user.id }));
}}
>
{pending ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<Trash2Icon className="size-3.5" />
)}
Delete user
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
{resetVisible && (
@ -88,9 +176,14 @@ export function UserRowClient({ user, isSelf }: UserRowClientProps) {
<Button
type="button"
size="sm"
disabled={pending}
disabled={pending || resetPw.length < 10}
onClick={() => {
run(resetUserPasswordAction({ userId: user.id, newPassword: resetPw }));
run(
resetUserPasswordAction({
userId: user.id,
newPassword: resetPw,
}),
);
setResetPw("");
setResetVisible(false);
}}