Files
AgentHub/client/src/pages/Users.tsx
T
nntrivi2001 0c2f49876d feat: v2.7.6 — edit users in one form, Settings → Account, display names, copy over plain HTTP
- Users page: one Edit user form (display name, email, role, active, a new or
  generated password, workspaces given or taken back, sign out everywhere),
  validated as a whole before anything is written (PATCH /api/users/:id,
  GET /api/users/:id); users.display_name/email added migration-safe.
- Settings → Account for every signed-in user: profile, own password (the
  current one required, this login kept), sign out other devices
  (/api/me/account, /api/me/password, /api/me/sign-out-others; login cookie
  only, never a pane token).
- Display names wherever a person is named (sidebar, workspace owner groups,
  Users page and audit log); owner_display_name / reader_display additive.
- Users page: admins add and remove Claude/Codex pool accounts.
- Folder browser: an admin with their own Linux account opens in their home;
  the "Server folder" tick opens the host's (never remembered).
- AI Proxy: "Config 9router" beside Add profile when 9router is installed,
  opened on the dashboard's own host (GET /api/ai-proxies/9router).
- Terminal: a tmux selection reaches the clipboard over plain HTTP too
  (writeClipboard's textarea fallback, focus handed back).
- Chat: a file the session touched outside the workspace previews
  (GET /api/sessions/:id/file).
- install-system: `!requiretty` only when visudo accepts it (sudo-rs on
  Ubuntu 25.10+ rejects the setting).
- openapi.yaml regenerated; docs (API, SERVER-MODE, LANES) updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:10:29 +07:00

488 lines
17 KiB
TypeScript

/**
* @file Users.tsx
* @description The Users page (`/users`, a sidebar item), server mode only and
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
* role, state, workspaces, sessions and the tokens/$ it used in the last 7 and
* 30 days (`GET /api/users`), the audit log (`GET /api/users/audit`), and — for
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
* role, active/disabled, password, owned workspaces, sign out everywhere, sent
* as one change), and the Claude/Codex account pool every user's sessions run
* on (`AccountPoolCard`). Every change asks for the admin's OWN password again (the
* server refuses without it); an analyst only reads. A row shows the display
* name and email under the username when set.
* Laid out like the other list pages: icon header, a row of totals
* (`StatCard`), the accounts in a `card` table, the audit log in a card.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
AlertCircle,
DollarSign,
Eye,
FolderOpen,
MessagesSquare,
Pencil,
RefreshCw,
ScrollText,
UserPlus,
Users as UsersIcon,
} from "lucide-react";
import { api, type AuditEntry, type AuthUser, type ServerUserUsage } from "../lib/api";
import { fmt, fmtCost, formatDateTime } from "../lib/format";
import { toast } from "../lib/toast";
import { isSubmitEnter } from "../lib/keys";
import { useSpin } from "../hooks/useSpin";
import { useAuth } from "../components/AuthGate";
import { ConfirmModal } from "../components/ConfirmModal";
import { StatCard } from "../components/StatCard";
import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog";
import { AccountPoolCard } from "../components/users/AccountPoolCard";
import { TableRowSkeleton } from "../components/Skeleton";
type Role = AuthUser["role"];
const ROLES: Role[] = ["admin", "analyst", "developer"];
export function Users() {
const { t } = useTranslation("users");
const auth = useAuth();
const me = auth.state.mode === "server" ? auth.state.user : null;
const isAdmin = me?.role === "admin";
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [editing, setEditing] = useState<ServerUserUsage | null>(null);
const load = useCallback(
() =>
Promise.all([
api.users
.list()
.then((r) => {
setUsers(r.users);
setError(null);
})
.catch((e: Error) => setError(e.message)),
api.users
.audit()
.then((r) => setAudit(r.entries))
.catch(() => setAudit([])),
]),
[]
);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
if (me && me.role !== "developer") void load();
}, [me, load]);
// Local mode, or a developer: there is no Users page for them.
if (!me || me.role === "developer") return <Navigate to="/" replace />;
const sum = (pick: (u: ServerUserUsage) => number) =>
(users ?? []).reduce((n, u) => n + pick(u), 0);
const active = (users ?? []).filter((u) => !u.disabled).length;
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
return (
<div className="animate-fade-in space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
<UsersIcon className="w-4.5 h-4.5 text-accent" />
</div>
<div>
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
<p className="text-xs text-fg-muted">{t("description")}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost"
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
{isAdmin && (
<button type="button" onClick={() => setAdding(true)} className="btn-primary">
<UserPlus className="w-4 h-4" /> {t("add")}
</button>
)}
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label={t("title")}
value={users?.length ?? 0}
icon={UsersIcon}
trend={users ? t("activeCount", { n: active }) : undefined}
loading={!users && !error}
/>
<StatCard
label={t("workspaces")}
value={sum((u) => u.workspaces)}
icon={FolderOpen}
loading={!users && !error}
/>
<StatCard
label={t("sessions")}
value={sum((u) => u.sessions)}
icon={MessagesSquare}
loading={!users && !error}
/>
<StatCard
label={t("cost30d")}
value={fmtCost(sum((u) => u.last30d.cost))}
icon={DollarSign}
trend={users ? `${t("last7d")}: ${fmtCost(sum((u) => u.last7d.cost))}` : undefined}
raw={t("tokens", { n: fmt(sum((u) => u.last30d.tokens)) })}
loading={!users && !error}
/>
</div>
{error && (
<div
role="alert"
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 min-w-0">{error}</span>
<button type="button" onClick={refresh} className="btn-ghost text-xs">
{t("common:retry")}
</button>
</div>
)}
{!isAdmin && (
<p className="flex items-center gap-1.5 text-xs text-fg-muted">
<Eye className="w-3.5 h-3.5" /> {t("readOnly")}
</p>
)}
<div className="card overflow-x-auto">
<table className="w-full min-w-[760px]" data-testid="users-table">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("name")}</th>
<th className={th}>{t("role")}</th>
<th className={`${th} text-right`}>{t("workspaces")}</th>
<th className={`${th} text-right`}>{t("sessions")}</th>
<th className={`${th} text-right`}>{t("last7d")}</th>
<th className={`${th} text-right`}>{t("last30d")}</th>
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
</tr>
</thead>
<tbody className="divide-y divide-border">
{!users &&
!error &&
Array.from({ length: 4 }).map((_, i) => (
<TableRowSkeleton
key={i}
columns={isAdmin ? 7 : 6}
widths={["w-36", "w-20", "w-8", "w-8", "w-14", "w-14", "w-28"]}
/>
))}
{users?.map((u) => {
const self = u.id === me.id;
return (
<tr
key={u.id}
className={`hover:bg-surface-4/40 transition-colors ${u.disabled ? "opacity-60" : ""}`}
>
<td className="px-5 py-3">
<div className="flex items-center gap-3 min-w-0">
<span
aria-hidden
className="w-8 h-8 flex-shrink-0 rounded-full bg-surface-4 flex items-center justify-center text-xs font-semibold uppercase text-fg-secondary"
>
{(u.displayName || u.username).slice(0, 2)}
</span>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-fg-primary">
{u.displayName || u.username}
{u.displayName && (
<span className="ml-1.5 font-normal text-fg-muted">@{u.username}</span>
)}
</div>
{u.email && (
<div className="truncate text-[11px] text-fg-muted">{u.email}</div>
)}
</div>
{self && (
<span className="badge py-0.5 text-[11px] bg-accent/15 text-accent border-accent/20">
{t("you")}
</span>
)}
{u.disabled && (
<span className="badge py-0.5 text-[11px] bg-status-danger/10 text-status-danger border-status-danger/20">
{t("disabled")}
</span>
)}
</div>
</td>
<td className="px-5 py-3">
<span
className={`badge py-0.5 text-[11px] ${ROLE_BADGE[u.role]}`}
title={t(`roleHint.${u.role}`)}
>
{t(`roles.${u.role}`)}
</span>
</td>
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
{u.workspaces}
</td>
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
{u.sessions}
</td>
<UsageCell usage={u.last7d} />
<UsageCell usage={u.last30d} />
{isAdmin && (
<td className="px-5 py-2 text-right whitespace-nowrap">
<RowAction label={t("edit.button")} onClick={() => setEditing(u)}>
<Pencil className="h-4 w-4" />
</RowAction>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
<AccountPoolCard isAdmin={isAdmin} />
<section className="card">
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
<ScrollText className="w-4 h-4 text-fg-muted" />
<div>
<h2 className="text-sm font-semibold text-fg-primary">{t("auditTitle")}</h2>
<p className="text-xs text-fg-muted">{t("auditDescription")}</p>
</div>
</div>
{audit?.length === 0 && (
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("auditEmpty")}</p>
)}
{audit && audit.length > 0 && (
<ul
className="divide-y divide-border text-xs max-h-[28rem] overflow-y-auto"
data-testid="audit-list"
>
{audit.map((a) => (
<li
key={a.id}
className="flex items-center gap-3 px-5 py-2 hover:bg-surface-4/40 transition-colors"
>
<span className="w-36 flex-shrink-0 tabular-nums text-fg-muted">
{formatDateTime(a.at)}
</span>
<span className="w-44 flex-shrink-0 truncate text-fg-secondary">
<span className="font-medium text-fg-primary">
{a.reader_display || a.reader}
</span>
<span className="mx-1.5 text-fg-muted">→</span>
{a.owner_display || a.owner}
</span>
<span className="badge py-0 px-1.5 text-[10px] font-mono bg-surface-4 text-fg-muted border-border">
{a.method}
</span>
<span className="min-w-0 truncate font-mono text-fg-muted" title={a.path}>
{a.path}
</span>
</li>
))}
</ul>
)}
</section>
{adding && (
<AddUserDialog
onClose={() => setAdding(false)}
onDone={(message) => {
toast.success(message);
setAdding(false);
void load();
}}
/>
)}
{editing && (
<UserEditDialog
user={editing}
self={editing.id === me.id}
users={users ?? []}
onClose={() => setEditing(null)}
onDone={(message) => {
toast.success(message);
setEditing(null);
void load();
}}
/>
)}
</div>
);
}
/** A period's cost, with its tokens underneath. */
function UsageCell({ usage }: { usage: { cost: number; tokens: number } }) {
const { t } = useTranslation("users");
return (
<td className="px-5 py-3 text-right tabular-nums">
<div className="text-sm text-fg-primary">{fmtCost(usage.cost)}</div>
<div className="text-[11px] text-fg-muted">{t("tokens", { n: fmt(usage.tokens) })}</div>
</td>
);
}
function RowAction({
label,
disabled,
onClick,
children,
}: {
label: string;
disabled?: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={label}
aria-label={label}
className="btn-icon disabled:opacity-30"
>
{children}
</button>
);
}
/** A new account: username, password, role, then the admin's own password. */
function AddUserDialog({
onClose,
onDone,
}: {
onClose: () => void;
onDone: (message: string) => void;
}) {
const { t } = useTranslation("users");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>("developer");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const ready = current.length > 0 && username.trim().length > 0 && password.length > 0;
const submit = async () => {
if (!ready || busy) return;
setBusy(true);
setError(null);
try {
const r = await api.users.create({ username: username.trim(), password, role }, current);
onDone(t("done.created", { name: r.user.displayName || r.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);
}
};
// Enter in any field submits — never mid-IME (Vietnamese typing commits with Enter).
const onKeyDown = (e: KeyboardEvent) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
};
return (
<ConfirmModal
open
title={t("dialog.add")}
confirmLabel={t("add")}
cancelLabel={t("common:cancel")}
busy={busy}
disabled={!ready}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div className="mt-3 space-y-3" onKeyDown={onKeyDown}>
<Field label={t("username")} hint={t("usernameHint")}>
<input
className="input w-full text-sm"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="off"
aria-label={t("username")}
/>
</Field>
<Field label={t("password")} hint={t("passwordHint")}>
<input
type="password"
className="input w-full text-sm"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
aria-label={t("password")}
/>
</Field>
<Field label={t("role")} hint={t(`roleHint.${role}`)}>
<select
className="input w-full text-sm"
value={role}
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>
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"
className="input w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</Field>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}
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>
);
}