feat: v2.5.12 — plan and quota for every AI tool, Grok Build and Kimi Code, Quit AgentHub (#65)

Usage panel (status bar):
- every account shows its plan (Claude subscriptionType, Codex
  chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier)
- new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity
  (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs
  agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's
  own file, sent only to its own host while valid, never refreshed
- in-flight quota reads are shared between tabs

AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added;
Gemini CLI, Hermes and OpenClaw removed.

Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat
on an editor selection, Settings in three groups, JetBrains-style Changes
panel (ticked files/hunks, ignore, blame, file history), terminal font.

--no-verify: :3000 is taken by another dev server (lane-detect tests);
both suites run by hand — server 2636/2639 (the 3 are :3000), client
1131/1131.
This commit is contained in:
2026-10-08 12:22:14 +07:00
parent 6ef9a5d4b7
commit a9c71cd905
133 changed files with 8007 additions and 1830 deletions
+456
View File
@@ -0,0 +1,456 @@
/**
* @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 — the account changes: add a user, change a role, disable/enable,
* reset a password, give a workspace. Every change asks for the admin's OWN
* password again (the server refuses without it); an analyst only reads.
* @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 { FolderInput, KeyRound, Lock, LockOpen, RefreshCw, Shield, UserPlus } from "lucide-react";
import { api, type AuditEntry, type AuthUser, type ServerUserUsage } from "../lib/api";
import type { Lane } from "../lib/types";
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 { SettingsSection } from "../components/settings/SettingsRow";
type Role = AuthUser["role"];
const ROLES: Role[] = ["admin", "analyst", "member"];
type DialogKind = "add" | "role" | "disable" | "enable" | "password" | "workspace";
type Dialog = { kind: DialogKind; user?: ServerUserUsage };
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 [dialog, setDialog] = useState<Dialog | 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 !== "member") void load();
}, [me, load]);
// Local mode, or a member: there is no Users page for them.
if (!me || me.role === "member") return <Navigate to="/" replace />;
return (
<div className="animate-fade-in space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<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 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={() => setDialog({ kind: "add" })}
className="btn-primary"
>
<UserPlus className="w-4 h-4" /> {t("add")}
</button>
)}
</div>
</div>
<SettingsSection title={t("title")}>
{error && (
<p role="alert" className="py-2 text-xs text-status-danger">
{error}
</p>
)}
{!isAdmin && <p className="pb-2 text-xs text-fg-muted">{t("readOnly")}</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("name")}</th>
<th className="py-2 pr-3 font-medium">{t("role")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("workspaces")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("sessions")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("last7d")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("last30d")}</th>
{isAdmin && <th className="py-2 text-right font-medium">{t("actions")}</th>}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{users.map((u) => {
const self = u.id === me.id;
return (
<tr key={u.id} className={u.disabled ? "text-fg-muted" : "text-fg-secondary"}>
<td className="py-2 pr-3">
{u.username}
{self && <span className="ml-2 text-[11px] text-accent">({t("you")})</span>}
{u.disabled && <span className="ml-2 text-[11px]">({t("disabled")})</span>}
</td>
<td className="py-2 pr-3" title={t(`roleHint.${u.role}`)}>
{t(`roles.${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("tokens", { n: fmt(u.last7d.tokens) })}
>
{fmtCost(u.last7d.cost)}
</td>
<td
className="py-2 pr-3 text-right tabular-nums"
title={t("tokens", { n: fmt(u.last30d.tokens) })}
>
{fmtCost(u.last30d.cost)}
</td>
{isAdmin && (
<td className="py-1 text-right whitespace-nowrap">
<RowAction
label={t("changeRole")}
disabled={self}
onClick={() => setDialog({ kind: "role", user: u })}
>
<Shield className="h-4 w-4" />
</RowAction>
<RowAction
label={t("resetPassword")}
onClick={() => setDialog({ kind: "password", user: u })}
>
<KeyRound className="h-4 w-4" />
</RowAction>
<RowAction
label={t("assignWorkspace")}
onClick={() => setDialog({ kind: "workspace", user: u })}
>
<FolderInput className="h-4 w-4" />
</RowAction>
<RowAction
label={u.disabled ? t("enable") : t("disable")}
disabled={self}
onClick={() =>
setDialog({ kind: u.disabled ? "enable" : "disable", user: u })
}
>
{u.disabled ? (
<LockOpen className="h-4 w-4" />
) : (
<Lock className="h-4 w-4" />
)}
</RowAction>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
)}
</SettingsSection>
<SettingsSection title={t("auditTitle")} description={t("auditDescription")}>
{audit?.length === 0 && <p className="py-2 text-sm text-fg-muted">{t("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>
{dialog && (
<AccountDialog
dialog={dialog}
users={users ?? []}
onClose={() => setDialog(null)}
onDone={(message) => {
toast.success(message);
setDialog(null);
void load();
}}
/>
)}
</div>
);
}
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>
);
}
/** One account change: its own fields, then the admin's password to confirm it. */
function AccountDialog({
dialog,
users,
onClose,
onDone,
}: {
dialog: Dialog;
users: ServerUserUsage[];
onClose: () => void;
onDone: (message: string) => void;
}) {
const { t } = useTranslation("users");
const { kind, user } = dialog;
const name = user?.username ?? "";
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>(user?.role ?? "member");
const [laneId, setLaneId] = useState<number | null>(null);
const [lanes, setLanes] = useState<Lane[] | null>(null);
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (kind !== "workspace") return;
api.lanes
.list()
.then((r) => setLanes(r.lanes))
.catch(() => setLanes([]));
}, [kind]);
const ready =
current.length > 0 &&
(kind === "add"
? username.trim().length > 0 && password.length > 0
: kind === "password"
? password.length > 0
: kind === "workspace"
? laneId !== null
: kind === "role"
? role !== user?.role
: true);
const submit = async () => {
if (!ready || busy) return;
setBusy(true);
setError(null);
try {
if (kind === "add") {
const r = await api.users.create({ username: username.trim(), password, role }, current);
onDone(t("done.created", { name: r.user.username }));
} else if (kind === "role" && user) {
await api.users.update(user.id, { role }, current);
onDone(t("done.role", { name, role: t(`roles.${role}`) }));
} else if ((kind === "disable" || kind === "enable") && user) {
await api.users.update(user.id, { disabled: kind === "disable" }, current);
onDone(t(kind === "disable" ? "done.disabled" : "done.enabled", { name }));
} else if (kind === "password" && user) {
await api.users.setPassword(user.id, password, current);
onDone(t("done.password", { name }));
} else if (kind === "workspace" && user && laneId !== null) {
await api.users.assignWorkspace(user.id, laneId, current);
onDone(t("done.workspace", { 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 titles: Record<DialogKind, string> = {
add: t("dialog.add"),
role: t("dialog.role", { name }),
disable: t("dialog.disable", { name }),
enable: t("dialog.enable", { name }),
password: t("dialog.password", { name }),
workspace: t("dialog.workspace", { name }),
};
const messages: Partial<Record<DialogKind, string>> = {
disable: t("dialog.disableBody", { name }),
password: t("dialog.passwordBody", { name }),
workspace: t("dialog.workspaceBody", { name }),
};
const ownerName = (id: number) => users.find((u) => u.id === id)?.username ?? `#${id}`;
// 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={titles[kind]}
message={messages[kind]}
confirmLabel={kind === "disable" ? t("disable") : kind === "add" ? t("add") : t("save")}
cancelLabel={t("common:cancel")}
destructive={kind === "disable"}
busy={busy}
disabled={!ready}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div className="mt-3 space-y-3" onKeyDown={onKeyDown}>
{kind === "add" && (
<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>
)}
{(kind === "add" || kind === "password") && (
<Field label={kind === "add" ? t("password") : t("newPassword")} 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={kind === "add" ? t("password") : t("newPassword")}
/>
</Field>
)}
{(kind === "add" || kind === "role") && (
<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>
)}
{kind === "workspace" && (
<Field label={t("workspace")}>
{lanes && lanes.length === 0 ? (
<p className="text-xs text-fg-muted">{t("noWorkspaces")}</p>
) : (
<select
className="input w-full text-sm"
value={laneId ?? ""}
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
aria-label={t("workspace")}
>
<option value="" />
{(lanes ?? []).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>
)}
</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>
);
}