Files
AgentHub/client/src/components/users/UserEditDialog.tsx
T
nntrivi2001 f1a90e120e fix(users): every user row shows its avatar, not only its initials
System Health's per-user CPU/RAM card asked UserAvatar for the viewer's own
picture with no user id, and the host stats never carried avatarAt, so every
card fell back to letters. The groups now carry avatarAt and the card passes
userId. The user editor's header, the pool board's member chips and the pool
member picker drew hand-made initials; they use UserAvatar too.
2026-10-29 13:40:28 +07:00

663 lines
25 KiB
TypeScript

/**
* @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 — someone else's
* may be anything and makes them choose their own at next sign-in; your own
* must pass every rule of `lib/passwordRules.ts`), the workspaces the
* account owns (remove, add from every workspace), its spend quota (the
* default, no limit, or its own dollars per day / 5 h — `UserQuota.tsx`), 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ĩ <vinnt@smartgift.vn>
*/
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,
type UserQuota,
type UserQuotaSetting,
} 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 { passwordProblems } from "../../lib/passwordRules";
import { PasswordRules } from "./PasswordRules";
import { UserAvatar } from "./UserAvatar";
import { useDialog } from "../../hooks/useDialog";
import { Switch } from "../Switch";
import { Checkbox } from "../Checkbox";
import { QuotaFields, parseLimit, useQuotaLabel, type QuotaPeriod } from "./UserQuota";
type Role = AuthUser["role"];
const ROLES: Role[] = ["admin", "analyst", "developer"];
export const ROLE_BADGE: Record<Role, string> = {
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, username?: string): string {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789-_!@#%";
const bytes = new Uint32Array(n);
// Drawn again until it passes every rule, so it also fits an own password.
for (;;) {
crypto.getRandomValues(bytes);
const out = Array.from(bytes, (b) => chars[b % chars.length]).join("");
if (passwordProblems(out, username).length === 0) return out;
}
}
type OwnedWorkspace = { id: number; title: string; cwd: string };
export function UserEditDialog({
user,
self,
users,
quotaDefault = null,
onClose,
onDone,
}: {
user: ServerUserUsage;
/** The default spend quota, named beside the "Default" choice. */
quotaDefault?: UserQuota | null;
/** 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<HTMLDivElement>(null);
useDialog(true, panelRef, onClose);
const [displayName, setDisplayName] = useState(user.displayName ?? "");
const [email, setEmail] = useState(user.email ?? "");
const [role, setRole] = useState<Role>(user.role);
const [active, setActive] = useState(!user.disabled);
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [owned, setOwned] = useState<OwnedWorkspace[] | null>(null);
const [lanes, setLanes] = useState<Lane[] | null>(null);
const [removed, setRemoved] = useState<Set<number>>(new Set());
const [added, setAdded] = useState<number[]>([]);
const [pick, setPick] = useState("");
const [signOut, setSignOut] = useState(false);
// Spend quota: absent from an older server, which then gets no quota field.
const quotaSetting = user.quotaSetting;
const [quotaMode, setQuotaMode] = useState<UserQuotaSetting["mode"]>(
quotaSetting?.mode ?? "default"
);
const [quotaLimit, setQuotaLimit] = useState(
quotaSetting?.mode === "custom" ? String(quotaSetting.limit_usd) : ""
);
const [quotaPeriod, setQuotaPeriod] = useState<QuotaPeriod>(
quotaSetting?.mode === "custom" ? quotaSetting.period : (quotaDefault?.period ?? "day")
);
const quotaLabel = useQuotaLabel();
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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 quotaValue = parseLimit(quotaLimit);
const quotaBad = quotaMode === "custom" && quotaValue === null;
if (quotaSetting && !quotaBad) {
const same =
quotaMode === quotaSetting.mode &&
(quotaMode !== "custom" ||
(quotaSetting.mode === "custom" &&
quotaSetting.limit_usd === quotaValue &&
quotaSetting.period === quotaPeriod));
if (!same) {
patch.quota =
quotaMode === "custom"
? {
mode: "custom",
limit_usd: quotaValue as number,
period: quotaPeriod,
tz_offset: new Date().getTimezoneOffset(),
}
: { mode: quotaMode };
}
}
const changed = Object.keys(patch).length > 0;
// A blank field keeps the password. Someone else's may be anything (they
// choose their own at next sign-in); your own must pass every rule.
const weakOwn =
self && password.length > 0 && passwordProblems(password, user.username).length > 0;
const ready = changed && current.length > 0 && !weakOwn && !quotaBad && !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<HTMLInputElement>) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
};
const shown = user.displayName || user.username;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
role="presentation"
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="user-edit-title"
className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-xl"
>
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
<UserAvatar
name={shown}
userId={user.id}
avatarAt={user.avatarAt}
className="h-10 w-10 text-sm"
/>
<div className="min-w-0 flex-1">
<h2 id="user-edit-title" className="text-base font-semibold text-fg-primary">
{t("edit.title", { name: user.displayName || user.username })}
</h2>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<span className={`badge py-0.5 text-[11px] ${ROLE_BADGE[user.role]}`}>
{t(`roles.${user.role}`)}
</span>
{self && (
<span className="badge py-0.5 text-[11px] bg-accent/15 text-accent border-accent/20">
{t("you")}
</span>
)}
{user.disabled && (
<span className="badge py-0.5 text-[11px] bg-status-danger/10 text-status-danger border-status-danger/20">
{t("disabled")}
</span>
)}
</div>
</div>
<button
type="button"
onClick={onClose}
aria-label={t("common:close")}
className="btn-icon"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 space-y-5 overflow-y-auto px-5 py-4">
<dl
className="grid grid-cols-2 gap-3 rounded-lg border border-border bg-surface-2 p-3 text-xs sm:grid-cols-3"
data-testid="user-edit-info"
>
<Info label={t("edit.created")}>
{user.createdAt ? formatDateTime(user.createdAt) : "—"}
</Info>
<Info label={t("edit.lastLogin")}>
{user.lastLoginAt ? formatDateTime(user.lastLoginAt) : t("edit.never")}
</Info>
<Info label={t("edit.logins")}>{user.logins ?? 0}</Info>
<Info label={t("workspaces")}>{user.workspaces}</Info>
<Info label={t("sessions")}>{user.sessions}</Info>
<Info label={t("cost30d")}>{fmtCost(user.last30d.cost)}</Info>
</dl>
<Section title={t("edit.profile")}>
<div className="grid gap-3 sm:grid-cols-2">
<Field label={t("displayName")} hint={t("displayNameHint")}>
<input
className="input w-full text-sm"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
onKeyDown={onKeyDown}
maxLength={100}
aria-label={t("displayName")}
/>
</Field>
<Field label={t("email")}>
<input
type="email"
className="input w-full text-sm"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={onKeyDown}
maxLength={254}
autoComplete="off"
aria-label={t("email")}
/>
</Field>
</div>
</Section>
<Section title={t("edit.access")}>
<div className="grid gap-3 sm:grid-cols-2">
<Field label={t("role")} hint={self ? t("edit.selfRole") : t(`roleHint.${role}`)}>
<select
className="input w-full text-sm"
value={role}
disabled={self}
onChange={(e) => setRole(e.target.value as Role)}
aria-label={t("role")}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{t(`roles.${r}`)}
</option>
))}
</select>
</Field>
<div>
<span className="text-xs font-medium text-fg-secondary">{t("edit.status")}</span>
<div className="mt-2 flex items-center gap-2">
<Switch
checked={active}
onChange={setActive}
disabled={self}
label={t("edit.active")}
/>
<span className="text-sm text-fg-secondary">
{active ? t("edit.active") : t("disabled")}
</span>
</div>
<span className="mt-0.5 block text-[11px] text-fg-muted">
{self ? t("edit.selfDisable") : t("edit.statusHint")}
</span>
{!active && !user.disabled && (
<p className="mt-1 text-[11px] text-status-danger">
{t("dialog.disableBody", { name: user.displayName || user.username })}
</p>
)}
</div>
</div>
</Section>
<Section title={t("edit.password")}>
<Field
label={t("newPassword")}
hint={self ? t("edit.ownPasswordHint") : t("edit.passwordHint")}
>
<div className="flex gap-2">
<input
type={showPassword ? "text" : "password"}
className="input w-full font-mono text-sm"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={onKeyDown}
autoComplete="new-password"
aria-label={t("newPassword")}
/>
<button
type="button"
className="btn-icon"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? t("edit.hide") : t("edit.show")}
title={showPassword ? t("edit.hide") : t("edit.show")}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
<button
type="button"
className="btn-icon"
disabled={!password}
onClick={() => void copyText(password)}
aria-label={t("edit.copy")}
title={t("edit.copy")}
>
<Copy className="h-4 w-4" />
</button>
<button
type="button"
className="btn-ghost whitespace-nowrap text-xs"
onClick={() => {
setPassword(generatePassword(16, user.username));
setShowPassword(true);
}}
>
<Sparkles className="h-3.5 w-3.5" /> {t("edit.generate")}
</button>
</div>
</Field>
{self && password.length > 0 && (
<div className="mt-2">
<PasswordRules password={password} username={user.username} />
</div>
)}
</Section>
<Section title={t("workspaces")}>
{owned === null ? (
<p className="text-xs text-fg-muted">{t("edit.loading")}</p>
) : (
<ul className="space-y-1.5" data-testid="user-edit-workspaces">
{owned.length === 0 && added.length === 0 && (
<li className="text-xs text-fg-muted">{t("edit.noOwned")}</li>
)}
{owned.map((w) => {
const gone = removed.has(w.id);
return (
<li
key={w.id}
className="flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm"
>
<span
className={`min-w-0 flex-1 truncate ${gone ? "text-fg-muted line-through" : "text-fg-primary"}`}
title={w.cwd}
>
{w.title || w.cwd}
</span>
{gone ? (
<button
type="button"
className="text-xs text-accent hover:underline"
onClick={() =>
setRemoved((s) => {
const n = new Set(s);
n.delete(w.id);
return n;
})
}
>
{t("edit.undo")}
</button>
) : (
<button
type="button"
className="btn-icon"
aria-label={t("edit.removeWorkspace", { name: w.title || w.cwd })}
title={t("edit.removeWorkspaceHint")}
onClick={() => setRemoved((s) => new Set(s).add(w.id))}
>
<X className="h-4 w-4" />
</button>
)}
</li>
);
})}
{added.map((id) => (
<li
key={`add-${id}`}
className="flex items-center gap-2 rounded-md border border-accent/30 bg-accent/5 px-3 py-1.5 text-sm"
>
<Plus className="h-3.5 w-3.5 text-accent" />
<span className="min-w-0 flex-1 truncate text-fg-primary">{laneLabel(id)}</span>
<button
type="button"
className="btn-icon"
aria-label={t("edit.undo")}
onClick={() => setAdded((a) => a.filter((x) => x !== id))}
>
<X className="h-4 w-4" />
</button>
</li>
))}
</ul>
)}
{addable.length > 0 && (
<div className="mt-2 flex gap-2">
<select
className="input w-full text-sm"
value={pick}
onChange={(e) => setPick(e.target.value)}
aria-label={t("edit.addWorkspace")}
>
<option value="">{t("edit.chooseWorkspace")}</option>
{addable.map((l) => (
<option key={l.id} value={l.id}>
{(l.title || l.cwd) +
" — " +
(l.owner_id == null
? t("noOwner")
: t("ownedBy", { name: ownerName(l.owner_id) }))}
</option>
))}
</select>
<button
type="button"
className="btn-ghost whitespace-nowrap text-xs"
disabled={!pick}
onClick={() => {
setAdded((a) => [...a, Number(pick)]);
setPick("");
}}
>
<Plus className="h-3.5 w-3.5" /> {t("edit.addWorkspace")}
</button>
</div>
)}
{(added.length > 0 || removed.size > 0) && (
<p className="mt-1 text-[11px] text-fg-muted">
{t("dialog.workspaceBody", { name: user.displayName || user.username })}
</p>
)}
</Section>
{quotaSetting && (
<Section title={t("quota.title")}>
<div className="space-y-2" data-testid="user-edit-quota">
<select
className="input w-full text-sm"
value={quotaMode}
onChange={(e) => setQuotaMode(e.target.value as UserQuotaSetting["mode"])}
aria-label={t("quota.title")}
>
<option value="default">
{quotaDefault
? t("quota.modes.defaultOf", { quota: quotaLabel(quotaDefault) })
: t("quota.modes.defaultNone")}
</option>
<option value="unlimited">{t("quota.modes.unlimited")}</option>
<option value="custom">{t("quota.modes.custom")}</option>
</select>
{quotaMode === "custom" && (
<QuotaFields
limit={quotaLimit}
period={quotaPeriod}
onLimit={setQuotaLimit}
onPeriod={setQuotaPeriod}
onKeyDown={onKeyDown}
/>
)}
<span className="block text-[11px] text-fg-muted">
{user.quota
? t("quota.now", {
spend: fmtCost(user.quota.spend),
limit: quotaLabel(user.quota),
})
: t("quota.enforceHint")}
</span>
</div>
</Section>
)}
<Section title={t("edit.sessions")}>
<Checkbox
checked={signOut}
onChange={setSignOut}
tone="warning"
label={t("edit.signOut")}
labelClassName="text-sm text-fg-secondary"
/>
<p className="ml-6 mt-0.5 text-[11px] text-fg-muted">
{t("edit.signOutHint", { n: user.logins ?? 0 })}
</p>
</Section>
</div>
<div className="space-y-3 border-t border-border bg-surface-2/40 px-5 py-4">
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"
className="input w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
onKeyDown={onKeyDown}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</Field>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
<div className="flex items-center justify-end gap-2">
{!self && (
<button
type="button"
onClick={() => void remove()}
disabled={busy}
data-testid="user-delete"
className="mr-auto rounded-md border border-status-danger/50 px-3 py-1.5 text-sm text-status-danger hover:bg-status-danger/10 disabled:opacity-50"
>
{t("delete.button")}
</button>
)}
{!changed && (
<span className={`text-xs text-fg-muted ${self ? "mr-auto" : ""}`}>
{t("edit.noChanges")}
</span>
)}
<button type="button" onClick={onClose} className="btn-ghost">
{t("common:cancel")}
</button>
<button
type="button"
onClick={() => void submit()}
disabled={!ready}
aria-busy={busy}
className="btn-primary disabled:opacity-50"
>
{t("save")}
</button>
</div>
</div>
</div>
</div>
);
}
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section>
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-fg-muted">
{title}
</h3>
{children}
</section>
);
}
function Info({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="min-w-0">
<dt className="text-[11px] text-fg-muted">{label}</dt>
<dd className="truncate text-sm tabular-nums text-fg-primary">{children}</dd>
</div>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{label}</span>
<div className="mt-1">{children}</div>
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
</label>
);
}