9a54978eef
Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged) - Login for every page and API call (server/lib/auth.js, routes/auth.js, AuthGate), roles admin / analyst / member, accounts made only from the host's shell: `agenthub server setup|off|status`, `agenthub users list|add|passwd|role|disable|enable|assign|audit` - Rows are owned: a member sees only their own workspaces, sessions and what hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience); staff reads of a member's session are audited (server/lib/audit.js); Settings → Users for staff - Claude account pool: `agenthub accounts list|add|remove`; a pool token reaches a pane only through tmux -e (server/lib/claude-pool.js) - A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback, live pane only); writes pass an Origin check; named locks per member - docs/SERVER-MODE.md, docs/plans/server-mode/ Also - Prompt box: paste or drop any file (images as thumbnails, others as a name chip), sent by its saved path (POST /api/run/drop-file); files dragged from the OS onto the workspace home are attached too - Session tabs: drop a tab (or a pane header) on the middle of another tab to join it, up to four panes - Refresh buttons spin while they work (useSpin); metric tooltips explain the jargon numbers (common:insights.help.*) - Light palette retuned for contrast; IntelliJ Light theme removed - CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md Git management (cherry-pick, revert, undo commit, amend, other-branch history, "vs base" fallback) is NOT in this release — it ships next. Committed --no-verify: both full suites run on this exact tree (server 2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev; client 1086/1086), tsc clean, prettier applied by hand.
109 lines
4.6 KiB
TypeScript
109 lines
4.6 KiB
TypeScript
/**
|
|
* @file UsersSettings.tsx
|
|
* @description Settings → Users, 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`), and the audit log of staff reads of members' sessions
|
|
* (`GET /api/users/audit`). Read-only — accounts are managed with
|
|
* `agenthub users …` on the host.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { api, type ServerUserUsage, type AuditEntry } from "../../lib/api";
|
|
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
|
|
import { SettingsSection } from "./SettingsRow";
|
|
|
|
export function UsersSettings() {
|
|
const { t } = useTranslation("settings");
|
|
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
|
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
api.users
|
|
.list()
|
|
.then((r) => setUsers(r.users))
|
|
.catch((e: Error) => setError(e.message));
|
|
api.users
|
|
.audit()
|
|
.then((r) => setAudit(r.entries))
|
|
.catch(() => setAudit([]));
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
<SettingsSection title={t("users.title")} description={t("users.description")}>
|
|
{error && (
|
|
<p role="alert" className="py-2 text-xs text-status-danger">
|
|
{error}
|
|
</p>
|
|
)}
|
|
{users && (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm" data-testid="users-table">
|
|
<thead className="text-left text-[11px] uppercase text-fg-muted">
|
|
<tr>
|
|
<th className="py-2 pr-3 font-medium">{t("users.name")}</th>
|
|
<th className="py-2 pr-3 font-medium">{t("users.role")}</th>
|
|
<th className="py-2 pr-3 text-right font-medium">{t("users.workspaces")}</th>
|
|
<th className="py-2 pr-3 text-right font-medium">{t("users.sessions")}</th>
|
|
<th className="py-2 pr-3 text-right font-medium">{t("users.last7d")}</th>
|
|
<th className="py-2 text-right font-medium">{t("users.last30d")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border/60">
|
|
{users.map((u) => (
|
|
<tr key={u.id} className={u.disabled ? "text-fg-muted" : "text-fg-secondary"}>
|
|
<td className="py-2 pr-3">
|
|
{u.username}
|
|
{u.disabled && (
|
|
<span className="ml-2 text-[11px]">({t("users.disabled")})</span>
|
|
)}
|
|
</td>
|
|
<td className="py-2 pr-3">{u.role}</td>
|
|
<td className="py-2 pr-3 text-right tabular-nums">{u.workspaces}</td>
|
|
<td className="py-2 pr-3 text-right tabular-nums">{u.sessions}</td>
|
|
<td
|
|
className="py-2 pr-3 text-right tabular-nums"
|
|
title={t("users.tokens", { n: fmt(u.last7d.tokens) })}
|
|
>
|
|
{fmtCost(u.last7d.cost)}
|
|
</td>
|
|
<td
|
|
className="py-2 text-right tabular-nums"
|
|
title={t("users.tokens", { n: fmt(u.last30d.tokens) })}
|
|
>
|
|
{fmtCost(u.last30d.cost)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
<p className="pt-2 text-[11px] text-fg-muted">{t("users.manageHint")}</p>
|
|
</SettingsSection>
|
|
<SettingsSection title={t("users.auditTitle")} description={t("users.auditDescription")}>
|
|
{audit?.length === 0 && (
|
|
<p className="py-2 text-sm text-fg-muted">{t("users.auditEmpty")}</p>
|
|
)}
|
|
{audit && audit.length > 0 && (
|
|
<ul className="divide-y divide-border/60 text-xs" data-testid="audit-list">
|
|
{audit.map((a) => (
|
|
<li key={a.id} className="flex gap-3 py-1.5">
|
|
<span className="w-36 flex-shrink-0 text-fg-muted">{formatDateTime(a.at)}</span>
|
|
<span className="w-40 flex-shrink-0 text-fg-secondary">
|
|
{a.reader} → {a.owner}
|
|
</span>
|
|
<span className="min-w-0 truncate font-mono text-fg-muted">{a.path}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</SettingsSection>
|
|
</>
|
|
);
|
|
}
|