diff --git a/docs/superpowers/plans/2026-08-12-user-management-ui-redesign.md b/docs/superpowers/plans/2026-08-12-user-management-ui-redesign.md new file mode 100644 index 0000000..cfa197c --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-user-management-ui-redesign.md @@ -0,0 +1,1074 @@ +# User Management UI Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Redesign the admin user-management section in `AccountDialog` — avatar rows, role badges, a `⋯` actions dropdown, and three proper dialogs (create / reset password / delete) replacing `window.prompt`/`window.confirm`, with full zh/en i18n. + +**Architecture:** All work is in `EvoScientist-WebUI` (Next.js 16 + React 19 + next-intl + shadcn/ui on Radix). No backend changes. Testable logic (username/password validation, role toggle) is extracted to `src/lib/userManagement.ts` with node-env vitest tests; presentational components follow the existing `ChangePasswordDialog.tsx` pattern. + +**Tech Stack:** TypeScript, React 19, next-intl 4, Radix dialog/dropdown-menu/select/scroll-area, Tailwind 4, vitest 3, lucide-react, sonner. + +**Spec:** `docs/superpowers/specs/2026-08-12-user-management-ui-redesign-design.md` + +## Global Constraints + +- Repo root for all paths/commands: `/Users/m4/Projects/EvoSci/OriginEvoScientist/EvoScientist-WebUI`. +- No new npm dependencies. Use only existing `src/components/ui/` primitives. +- All user-facing copy goes through `useTranslations("userManagement")` — no hardcoded English in the new components. +- Tests: repo runs vitest in **node** environment with `include: ["src/**/*.test.ts"]` (no Testing Library / jsdom). Only pure-logic modules get unit tests; component correctness is enforced by `npx tsc --noEmit` (next-intl key-checks translations at compile time) + `npm run lint` + manual dev-server check. This deviates from spec §Testing (which assumed Testing Library) to match repo conventions. +- Commit style: `feat(webui): ...` / `test(webui): ...` / `i18n(webui): ...`. +- Run commands from the repo root (`EvoScientist-WebUI/`). + +--- + +### Task 1: i18n catalogs (en + zh) + +**Files:** +- Create: `src/i18n/messages/en/userManagement.ts` +- Create: `src/i18n/messages/zh/userManagement.ts` +- Modify: `src/i18n/messages/en/index.ts` +- Modify: `src/i18n/messages/zh/index.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces: `userManagement` translation namespace with keys used by all later tasks: `title`, `userCount` (ICU plural), `addUser`, `you`, `roles.admin`, `roles.user`, `actions`, `makeAdmin`, `makeUser`, `resetPassword`, `deleteUser`, `empty`, `roleUpdated`, `create.*`, `reset.*`, `remove.*` (exact key list below). + +- [ ] **Step 1: Create the en catalog** + +`src/i18n/messages/en/userManagement.ts`: + +```ts +export default { + userManagement: { + title: "User management", + userCount: "{count, plural, one {# user} other {# users}}", + addUser: "Add user", + you: "(you)", + roles: { + admin: "Admin", + user: "User", + }, + actions: "Actions for {username}", + makeAdmin: "Make admin", + makeUser: "Make user", + resetPassword: "Reset password", + deleteUser: "Delete", + empty: "No users yet.", + roleUpdated: "Role updated for {username}.", + create: { + title: "Add user", + description: "Create a new account for this deployment.", + username: "Username", + usernameHint: + "Letters, digits, dot, underscore and hyphen; 1-64 characters.", + password: "Password", + passwordHint: "8-256 characters.", + role: "Role", + cancel: "Cancel", + submit: "Create user", + creating: "Creating...", + created: "User {username} created.", + }, + reset: { + title: "Reset password", + description: "Set a new password for {username}.", + password: "New password", + passwordHint: "8-256 characters.", + cancel: "Cancel", + submit: "Reset password", + resetting: "Resetting...", + done: "Password updated for {username}.", + }, + remove: { + title: "Delete user", + description: "Delete {username}? This cannot be undone.", + cancel: "Cancel", + submit: "Delete", + deleting: "Deleting...", + done: "User {username} deleted.", + }, + }, +}; +``` + +- [ ] **Step 2: Create the zh catalog** + +`src/i18n/messages/zh/userManagement.ts`: + +```ts +export default { + userManagement: { + title: "用户管理", + userCount: "{count} 位用户", + addUser: "添加用户", + you: "(你)", + roles: { + admin: "管理员", + user: "普通用户", + }, + actions: "{username} 的操作", + makeAdmin: "设为管理员", + makeUser: "设为普通用户", + resetPassword: "重置密码", + deleteUser: "删除", + empty: "暂无用户。", + roleUpdated: "{username} 的角色已更新。", + create: { + title: "添加用户", + description: "为此部署创建一个新账号。", + username: "用户名", + usernameHint: "字母、数字、点、下划线和连字符;1-64 个字符。", + password: "密码", + passwordHint: "8-256 个字符。", + role: "角色", + cancel: "取消", + submit: "创建用户", + creating: "创建中...", + created: "用户 {username} 已创建。", + }, + reset: { + title: "重置密码", + description: "为 {username} 设置新密码。", + password: "新密码", + passwordHint: "8-256 个字符。", + cancel: "取消", + submit: "重置密码", + resetting: "重置中...", + done: "{username} 的密码已更新。", + }, + remove: { + title: "删除用户", + description: "确定删除 {username} 吗?此操作无法撤销。", + cancel: "取消", + submit: "删除", + deleting: "删除中...", + done: "用户 {username} 已删除。", + }, + }, +}; +``` + +- [ ] **Step 3: Register both catalogs** + +`src/i18n/messages/en/index.ts` — add the import and spread (same for `zh/index.ts` with the same edit): + +```ts +import common from "./common"; +import header from "./header"; +import threadList from "./threadList"; +import chat from "./chat"; +import dialogs from "./dialogs"; +import panels from "./panels"; +import login from "./login"; +import errors from "./errors"; +import userManagement from "./userManagement"; + +export default { + ...common, + ...header, + ...threadList, + ...chat, + ...dialogs, + ...panels, + ...login, + ...errors, + ...userManagement, +}; +``` + +- [ ] **Step 4: Typecheck** + +Run: `npx tsc --noEmit` +Expected: no errors (en/zh catalogs structurally match, so `AppConfig.Messages` stays valid). + +- [ ] **Step 5: Commit** + +```bash +git add src/i18n/messages/en/userManagement.ts src/i18n/messages/zh/userManagement.ts src/i18n/messages/en/index.ts src/i18n/messages/zh/index.ts +git commit -m "i18n(webui): add userManagement message catalogs" +``` + +--- + +### Task 2: Validation/role logic module (TDD) + +**Files:** +- Create: `src/lib/userManagement.ts` +- Test: `src/lib/userManagement.test.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces (used by Tasks 3-5): + - `USERNAME_PATTERN: RegExp` — `/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/` (mirrors `userStore.ts`) + - `PASSWORD_MIN_LENGTH = 8`, `PASSWORD_MAX_LENGTH = 256` + - `type ManagedUserRole = "admin" | "user"` + - `interface ManagedUser { username: string; role: ManagedUserRole; createdAt: string; updatedAt: string }` + - `isValidUsername(username: string): boolean` + - `isValidPassword(password: string): boolean` + - `otherRole(role: ManagedUserRole): ManagedUserRole` + +- [ ] **Step 1: Write the failing test** + +`src/lib/userManagement.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { + isValidPassword, + isValidUsername, + otherRole, +} from "./userManagement"; + +describe("isValidUsername", () => { + it("accepts simple names", () => { + expect(isValidUsername("alice")).toBe(true); + expect(isValidUsername("A1_b-c.d")).toBe(true); + }); + + it("rejects empty and overlong names", () => { + expect(isValidUsername("")).toBe(false); + expect(isValidUsername("a".repeat(65))).toBe(false); + }); + + it("rejects names starting with a non-alphanumeric", () => { + expect(isValidUsername("-alice")).toBe(false); + expect(isValidUsername(".alice")).toBe(false); + }); + + it("rejects illegal characters", () => { + expect(isValidUsername("al ice")).toBe(false); + expect(isValidUsername("alice!")).toBe(false); + expect(isValidUsername("爱丽丝")).toBe(false); + }); +}); + +describe("isValidPassword", () => { + it("requires at least 8 characters", () => { + expect(isValidPassword("1234567")).toBe(false); + expect(isValidPassword("12345678")).toBe(true); + }); + + it("rejects passwords over 256 characters", () => { + expect(isValidPassword("x".repeat(256))).toBe(true); + expect(isValidPassword("x".repeat(257))).toBe(false); + }); +}); + +describe("otherRole", () => { + it("flips admin and user", () => { + expect(otherRole("admin")).toBe("user"); + expect(otherRole("user")).toBe("admin"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/userManagement.test.ts` +Expected: FAIL — `Cannot find module './userManagement'` + +- [ ] **Step 3: Write the implementation** + +`src/lib/userManagement.ts`: + +```ts +export const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; +export const PASSWORD_MIN_LENGTH = 8; +export const PASSWORD_MAX_LENGTH = 256; + +export type ManagedUserRole = "admin" | "user"; + +export interface ManagedUser { + username: string; + role: ManagedUserRole; + createdAt: string; + updatedAt: string; +} + +export function isValidUsername(username: string): boolean { + return USERNAME_PATTERN.test(username); +} + +export function isValidPassword(password: string): boolean { + return ( + password.length >= PASSWORD_MIN_LENGTH && + password.length <= PASSWORD_MAX_LENGTH + ); +} + +export function otherRole(role: ManagedUserRole): ManagedUserRole { + return role === "admin" ? "user" : "admin"; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/userManagement.test.ts` +Expected: PASS — 8 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/userManagement.ts src/lib/userManagement.test.ts +git commit -m "feat(webui): add user-management validation helpers" +``` + +--- + +### Task 3: `UserCreateDialog` component + +**Files:** +- Create: `src/app/components/UserCreateDialog.tsx` + +**Interfaces:** +- Consumes: `isValidUsername`, `isValidPassword`, `ManagedUserRole` from `@/lib/userManagement` (Task 2); `userManagement` i18n namespace (Task 1); ui primitives `Dialog*`, `Button`, `Input`, `Label`, `Select*`; `toast` from sonner. +- Produces: `UserCreateDialog({ open, onOpenChange, onChanged }: { open: boolean; onOpenChange: (open: boolean) => void; onChanged: () => Promise })` — POSTs `/api/auth/users`, toasts `create.created`, closes, then awaits `onChanged()`. Used by Task 5. + +- [ ] **Step 1: Write the component** + +`src/app/components/UserCreateDialog.tsx`: + +```tsx +"use client"; + +import { FormEvent, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + isValidPassword, + isValidUsername, + type ManagedUserRole, +} from "@/lib/userManagement"; + +interface UserCreateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onChanged: () => Promise; +} + +export function UserCreateDialog({ + open, + onOpenChange, + onChanged, +}: UserCreateDialogProps) { + const t = useTranslations("userManagement"); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [role, setRole] = useState("user"); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!open) { + setUsername(""); + setPassword(""); + setRole("user"); + setError(null); + setSaving(false); + } + }, [open]); + + const canSubmit = + isValidUsername(username.trim()) && isValidPassword(password) && !saving; + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!canSubmit) return; + setSaving(true); + setError(null); + try { + const response = await fetch("/api/auth/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ username: username.trim(), password, role }), + }); + const body = (await response.json().catch(() => ({}))) as { + message?: string; + error?: string; + }; + if (!response.ok) { + throw new Error(body.message || body.error || "Request failed."); + } + toast.success(t("create.created", { username: username.trim() })); + onOpenChange(false); + await onChanged(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Request failed."); + setSaving(false); + } + }; + + return ( + + + + {t("create.title")} + {t("create.description")} + +
+
+ + setUsername(event.target.value)} + disabled={saving} + autoFocus + /> +

+ {t("create.usernameHint")} +

+
+
+ + setPassword(event.target.value)} + disabled={saving} + /> +

+ {t("create.passwordHint")} +

+
+
+ + +
+ {error && ( +

+ {error} +

+ )} + + + + +
+
+
+ ); +} +``` + +- [ ] **Step 2: Typecheck and lint** + +Run: `npx tsc --noEmit && npx eslint src/app/components/UserCreateDialog.tsx` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/components/UserCreateDialog.tsx +git commit -m "feat(webui): add create-user dialog" +``` + +--- + +### Task 4: `UserResetPasswordDialog` and `UserDeleteDialog` + +**Files:** +- Create: `src/app/components/UserResetPasswordDialog.tsx` +- Create: `src/app/components/UserDeleteDialog.tsx` + +**Interfaces:** +- Consumes: `isValidPassword`, `ManagedUser` from `@/lib/userManagement` (Task 2); i18n namespace (Task 1). +- Produces (both used by Task 5): + - `UserResetPasswordDialog({ user, open, onOpenChange, onChanged }: { user: ManagedUser; open: boolean; onOpenChange: (open: boolean) => void; onChanged: () => Promise })` — PATCHes `/api/auth/users/[username]` with `{ password }`. + - `UserDeleteDialog({ user, open, onOpenChange, onChanged }: same props)` — DELETEs `/api/auth/users/[username]`. + +- [ ] **Step 1: Write `UserResetPasswordDialog`** + +`src/app/components/UserResetPasswordDialog.tsx`: + +```tsx +"use client"; + +import { FormEvent, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { isValidPassword, type ManagedUser } from "@/lib/userManagement"; + +interface UserResetPasswordDialogProps { + user: ManagedUser; + open: boolean; + onOpenChange: (open: boolean) => void; + onChanged: () => Promise; +} + +export function UserResetPasswordDialog({ + user, + open, + onOpenChange, + onChanged, +}: UserResetPasswordDialogProps) { + const t = useTranslations("userManagement"); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!open) { + setPassword(""); + setError(null); + setSaving(false); + } + }, [open]); + + const canSubmit = isValidPassword(password) && !saving; + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!canSubmit) return; + setSaving(true); + setError(null); + try { + const response = await fetch( + `/api/auth/users/${encodeURIComponent(user.username)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ password }), + } + ); + const body = (await response.json().catch(() => ({}))) as { + message?: string; + error?: string; + }; + if (!response.ok) { + throw new Error(body.message || body.error || "Request failed."); + } + toast.success(t("reset.done", { username: user.username })); + onOpenChange(false); + await onChanged(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Request failed."); + setSaving(false); + } + }; + + return ( + + + + {t("reset.title")} + + {t("reset.description", { username: user.username })} + + +
+
+ + setPassword(event.target.value)} + disabled={saving} + autoFocus + /> +

+ {t("reset.passwordHint")} +

+
+ {error && ( +

+ {error} +

+ )} + + + + +
+
+
+ ); +} +``` + +- [ ] **Step 2: Write `UserDeleteDialog`** + +`src/app/components/UserDeleteDialog.tsx`: + +```tsx +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import type { ManagedUser } from "@/lib/userManagement"; + +interface UserDeleteDialogProps { + user: ManagedUser; + open: boolean; + onOpenChange: (open: boolean) => void; + onChanged: () => Promise; +} + +export function UserDeleteDialog({ + user, + open, + onOpenChange, + onChanged, +}: UserDeleteDialogProps) { + const t = useTranslations("userManagement"); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!open) { + setError(null); + setSaving(false); + } + }, [open]); + + const confirm = async () => { + if (saving) return; + setSaving(true); + setError(null); + try { + const response = await fetch( + `/api/auth/users/${encodeURIComponent(user.username)}`, + { method: "DELETE", credentials: "same-origin" } + ); + const body = (await response.json().catch(() => ({}))) as { + message?: string; + error?: string; + }; + if (!response.ok) { + throw new Error(body.message || body.error || "Request failed."); + } + toast.success(t("remove.done", { username: user.username })); + onOpenChange(false); + await onChanged(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Request failed."); + setSaving(false); + } + }; + + return ( + + + + {t("remove.title")} + + {t("remove.description", { username: user.username })} + + + {error && ( +

+ {error} +

+ )} + + + + +
+
+ ); +} +``` + +- [ ] **Step 3: Typecheck and lint** + +Run: `npx tsc --noEmit && npx eslint src/app/components/UserResetPasswordDialog.tsx src/app/components/UserDeleteDialog.tsx` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/components/UserResetPasswordDialog.tsx src/app/components/UserDeleteDialog.tsx +git commit -m "feat(webui): add reset-password and delete-user dialogs" +``` + +--- + +### Task 5: Rewrite `UserManagementSection` + +**Files:** +- Modify: `src/app/components/UserManagementSection.tsx` (full rewrite) + +**Interfaces:** +- Consumes: `ManagedUser`, `otherRole` from `@/lib/userManagement` (Task 2); `UserCreateDialog` (Task 3); `UserResetPasswordDialog`, `UserDeleteDialog` (Task 4); i18n namespace (Task 1); ui primitives `Button`, `DropdownMenu*`, `ScrollArea`, `Skeleton`; icons `MoreHorizontal`, `UserPlus` from lucide-react; existing `errorToast` from `@/lib/errorReporter`; existing `cn` from `@/lib/utils`. +- Produces: unchanged exported signature `UserManagementSection({ self }: { self: string })` — `AccountDialog.tsx` needs no changes. + +- [ ] **Step 1: Rewrite the component** + +Replace the entire contents of `src/app/components/UserManagementSection.tsx` with: + +```tsx +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { MoreHorizontal, UserPlus } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Skeleton } from "@/components/ui/skeleton"; +import { errorToast } from "@/lib/errorReporter"; +import { cn } from "@/lib/utils"; +import { otherRole, type ManagedUser } from "@/lib/userManagement"; +import { UserCreateDialog } from "@/app/components/UserCreateDialog"; +import { UserResetPasswordDialog } from "@/app/components/UserResetPasswordDialog"; +import { UserDeleteDialog } from "@/app/components/UserDeleteDialog"; + +async function readError(response: Response, fallback: string): Promise { + const body = (await response.json().catch(() => null)) as { + message?: unknown; + error?: unknown; + } | null; + const message = + typeof body?.message === "string" + ? body.message + : typeof body?.error === "string" + ? body.error + : fallback; + return new Error(message); +} + +function RoleBadge({ admin, label }: { admin: boolean; label: string }) { + return ( + + {label} + + ); +} + +/** Admin-only user management (design doc 7.2): list, create, change role, + * reset password, delete. The backend enforces the last-admin guard. */ +export function UserManagementSection({ self }: { self: string }) { + const t = useTranslations("userManagement"); + const [users, setUsers] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [resetTarget, setResetTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const refresh = useCallback(async () => { + try { + const response = await fetch("/api/auth/users", { cache: "no-store" }); + if (!response.ok) throw await readError(response, "Failed to load users."); + const body = (await response.json()) as { users?: ManagedUser[] }; + setUsers(Array.isArray(body.users) ? body.users : []); + setError(null); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Failed to load users."); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const toggleRole = useCallback( + async (user: ManagedUser) => { + setBusy(true); + try { + const response = await fetch( + `/api/auth/users/${encodeURIComponent(user.username)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ role: otherRole(user.role) }), + } + ); + if (!response.ok) throw await readError(response, "Request failed."); + toast.success(t("roleUpdated", { username: user.username })); + await refresh(); + } catch (reason) { + errorToast( + "users.manage", + reason instanceof Error ? reason.message : "Request failed." + ); + } finally { + setBusy(false); + } + }, + [refresh, t] + ); + + return ( +
+
+

+ {t("title")} + {users !== null && ( + + {t("userCount", { count: users.length })} + + )} +

+ +
+ {error && ( +

+ {error} +

+ )} + +
    + {(users ?? []).map((user) => ( +
  • + + {user.username.charAt(0).toUpperCase()} + + + {user.username} + {user.username === self && ( + + {t("you")} + + )} + + + + + + + + void toggleRole(user)}> + {user.role === "admin" ? t("makeUser") : t("makeAdmin")} + + setResetTarget(user)}> + {t("resetPassword")} + + {user.username !== self && ( + <> + + setDeleteTarget(user)} + > + {t("deleteUser")} + + + )} + + +
  • + ))} +
+ {users !== null && users.length === 0 && ( +

+ {t("empty")} +

+ )} + {users === null && !error && ( +
+ {[0, 1, 2].map((index) => ( +
+ + +
+ ))} +
+ )} +
+ + {resetTarget && ( + { + if (!open) setResetTarget(null); + }} + onChanged={refresh} + /> + )} + {deleteTarget && ( + { + if (!open) setDeleteTarget(null); + }} + onChanged={refresh} + /> + )} +
+ ); +} +``` + +- [ ] **Step 2: Typecheck and lint** + +Run: `npx tsc --noEmit && npx eslint src/app/components/UserManagementSection.tsx` +Expected: no errors. + +- [ ] **Step 3: Run the full test suite** + +Run: `npm test` +Expected: all existing tests pass (no regressions). + +- [ ] **Step 4: Commit** + +```bash +git add src/app/components/UserManagementSection.tsx +git commit -m "feat(webui): redesign user management with avatar rows and action dialogs" +``` + +--- + +### Task 6: Final verification + +**Files:** none (verification only). + +- [ ] **Step 1: Full static checks** + +Run: `npx tsc --noEmit && npm run lint && npm test` +Expected: all clean. + +- [ ] **Step 2: Manual UI check in the dev server** + +The backend must be running first (see project memory: backend + WebUI service tokens must match). Then: + +Run: `npm run dev` (port 4716), sign in as an admin, open the account dialog ("User information"), and verify: + +1. User rows show avatar circle, username, localized role badge, `(you)` on self. +2. `⋯` menu: make admin/user flips the role with a toast; reset password opens the dialog and rejects <8-char passwords (submit disabled); delete asks for confirmation and the destructive button is red; self row has no Delete item. +3. "Add user" dialog: submit disabled until a valid username and ≥8-char password; creating a user refreshes the list and toasts. +4. Switch locale (zh ↔ en) and confirm all new strings translate. +5. Toggle dark mode and confirm badges/rows/skeletons look right. + +- [ ] **Step 3: Fix anything found and commit** + +Commit any polish fixes as `fix(webui): ...`.