/** * @file UserEditDialog.tsx * @description The Users page's ONE account editor (admin only): profile * (display name, email), access (role, active/disabled — neither for your own * account), a new password (typed or generated, copyable), the workspaces the * account owns (remove, add from every workspace), and "sign out everywhere". * Every change is staged and sent as ONE `PATCH /api/users/:id` carrying only * what changed, confirmed with the admin's OWN password (`currentPassword`) — * the server validates the whole change before it applies any of it. A * refusal is shown inside the dialog, which stays open. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Copy, Eye, EyeOff, Plus, Sparkles, X } from "lucide-react"; import { confirmDialog } from "../../lib/confirm"; import { api, type AuthUser, type ServerUserUsage, type UserPatch } from "../../lib/api"; import type { Lane } from "../../lib/types"; import { fmtCost, formatDateTime } from "../../lib/format"; import { copyText } from "../../lib/clipboard"; import { isSubmitEnter } from "../../lib/keys"; import { useDialog } from "../../hooks/useDialog"; import { Switch } from "../Switch"; import { Checkbox } from "../Checkbox"; type Role = AuthUser["role"]; const ROLES: Role[] = ["admin", "analyst", "developer"]; export const ROLE_BADGE: Record = { admin: "bg-accent/15 text-accent border-accent/20", analyst: "bg-status-warning/10 text-status-warning border-status-warning/20", developer: "bg-surface-4 text-fg-secondary border-border", }; /** A random password of `n` characters, no look-alikes (0/O, 1/l/I). */ export function generatePassword(n = 16): string { const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789-_!@#%"; const bytes = new Uint32Array(n); crypto.getRandomValues(bytes); return Array.from(bytes, (b) => chars[b % chars.length]).join(""); } type OwnedWorkspace = { id: number; title: string; cwd: string }; export function UserEditDialog({ user, self, users, onClose, onDone, }: { user: ServerUserUsage; /** The account being edited is the signed-in admin's own. */ self: boolean; users: ServerUserUsage[]; onClose: () => void; onDone: (message: string) => void; }) { const { t } = useTranslation("users"); const panelRef = useRef(null); useDialog(true, panelRef, onClose); const [displayName, setDisplayName] = useState(user.displayName ?? ""); const [email, setEmail] = useState(user.email ?? ""); const [role, setRole] = useState(user.role); const [active, setActive] = useState(!user.disabled); const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); const [owned, setOwned] = useState(null); const [lanes, setLanes] = useState(null); const [removed, setRemoved] = useState>(new Set()); const [added, setAdded] = useState([]); const [pick, setPick] = useState(""); const [signOut, setSignOut] = useState(false); const [current, setCurrent] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); useEffect(() => { api.users .get(user.id) .then((r) => setOwned(r.workspaces)) .catch(() => setOwned([])); api.lanes .list() .then((r) => setLanes(r.lanes)) .catch(() => setLanes([])); }, [user.id]); const ownerName = (id: number) => { const u = users.find((x) => x.id === id); return u ? u.displayName || u.username : `#${id}`; }; const ownedIds = useMemo(() => new Set((owned ?? []).map((w) => w.id)), [owned]); const addable = (lanes ?? []).filter((l) => !ownedIds.has(l.id) && !added.includes(l.id)); const laneLabel = (id: number) => { const l = (lanes ?? []).find((x) => x.id === id); return l ? l.title || l.cwd : `#${id}`; }; const patch: UserPatch = {}; const norm = (v: string) => (v.trim() === "" ? null : v.trim()); if (norm(displayName) !== (user.displayName ?? null)) patch.displayName = norm(displayName); if (norm(email) !== (user.email ?? null)) patch.email = norm(email); if (!self && role !== user.role) patch.role = role; if (!self && active === user.disabled) patch.disabled = !active; if (password) patch.password = password; if (added.length) patch.addWorkspaces = added; if (removed.size) patch.removeWorkspaces = [...removed]; if (signOut) patch.signOut = true; const changed = Object.keys(patch).length > 0; // No minimum length (server/lib/auth.js) — a blank field keeps the password. const ready = changed && current.length > 0 && !busy; const submit = async () => { if (!ready) return; setBusy(true); setError(null); try { await api.users.update(user.id, patch, current); onDone(t("done.updated", { name: user.displayName || user.username })); } catch (e) { const err = e as Error & { code?: string }; setError( err.code && t(`errors.${err.code}`, { defaultValue: "" }) ? t(`errors.${err.code}`) : err.message ); } finally { setBusy(false); } }; /** Delete the user for good — asked first, with what goes, in red. */ const remove = async () => { if (self || busy) return; if (!current) { setError(t("delete.needsPassword")); return; } const name = user.displayName || user.username; const ok = await confirmDialog({ title: t("delete.title", { name }), message: t("delete.body", { name, count: owned?.length ?? 0, user: user.username }), confirmLabel: t("delete.confirm"), destructive: true, }); if (!ok) return; setBusy(true); setError(null); try { await api.users.remove(user.id, current); onDone(t("done.deleted", { name })); } catch (e) { const err = e as Error & { code?: string }; setError( err.code && t(`errors.${err.code}`, { defaultValue: "" }) ? t(`errors.${err.code}`) : err.message ); } finally { setBusy(false); } }; const onKeyDown = (e: KeyboardEvent) => { if (isSubmitEnter(e)) { e.preventDefault(); void submit(); } }; const shown = user.displayName || user.username; return (
{shown.slice(0, 2)}

{t("edit.title", { name: user.displayName || user.username })}

{t(`roles.${user.role}`)} {self && ( {t("you")} )} {user.disabled && ( {t("disabled")} )}
{user.createdAt ? formatDateTime(user.createdAt) : "—"} {user.lastLoginAt ? formatDateTime(user.lastLoginAt) : t("edit.never")} {user.logins ?? 0} {user.workspaces} {user.sessions} {fmtCost(user.last30d.cost)}
setDisplayName(e.target.value)} onKeyDown={onKeyDown} maxLength={100} aria-label={t("displayName")} /> setEmail(e.target.value)} onKeyDown={onKeyDown} maxLength={254} autoComplete="off" aria-label={t("email")} />
{t("edit.status")}
{active ? t("edit.active") : t("disabled")}
{self ? t("edit.selfDisable") : t("edit.statusHint")} {!active && !user.disabled && (

{t("dialog.disableBody", { name: user.displayName || user.username })}

)}
setPassword(e.target.value)} onKeyDown={onKeyDown} autoComplete="new-password" aria-label={t("newPassword")} />
{owned === null ? (

{t("edit.loading")}

) : (
    {owned.length === 0 && added.length === 0 && (
  • {t("edit.noOwned")}
  • )} {owned.map((w) => { const gone = removed.has(w.id); return (
  • {w.title || w.cwd} {gone ? ( ) : ( )}
  • ); })} {added.map((id) => (
  • {laneLabel(id)}
  • ))}
)} {addable.length > 0 && (
)} {(added.length > 0 || removed.size > 0) && (

{t("dialog.workspaceBody", { name: user.displayName || user.username })}

)}

{t("edit.signOutHint", { n: user.logins ?? 0 })}

setCurrent(e.target.value)} onKeyDown={onKeyDown} autoComplete="current-password" aria-label={t("yourPassword")} /> {error && (

{error}

)}
{!self && ( )} {!changed && ( {t("edit.noChanges")} )}
); } function Section({ title, children }: { title: string; children: ReactNode }) { return (

{title}

{children}
); } function Info({ label, children }: { label: string; children: ReactNode }) { return (
{label}
{children}
); } function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { return ( ); }