0c2f49876d
- 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>
488 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|