Files
AgentHub/client/src/pages/Users.tsx
T
nntrivi2001 fa27b299e7 feat(server-mode): spend quota per user — no new session, prompts refused once used up
An admin sets a default spend quota on the Users page and overrides it per
user (Edit user → Spend quota: default / no limit / their own), in dollars
per local day or per sliding 5 hours. Spend is the users page's own
hourlyTokenRows + calculateCost over the sessions a user owns.

Over the quota, until it opens again:
- spawnRun refuses every start/resume/home prompt with EQUOTA (429);
- a minute sweep writes each pane's AGENTHUB_QUOTA_FILE (host: data dir,
  member: handoff dir via write-as) and hook-handler.js exits 2 on
  UserPromptSubmit from that file alone (Claude Code, Codex with trusted
  hooks) — fail-open, never asks the dashboard, never for Antigravity;
- a member's omp on the shared login loses its gateway key (clientsFor);
- panes no hook can stop (Antigravity, Cursor, Codex without trusted
  hooks, omp on its own login) are ended.

GET /api/me/quota feeds a "Quota N%" chip in the status bar.
New: server/lib/user-quota.js, user_quotas table, PUT /api/users/quota-default,
PATCH /api/users/:id {quota}, docs in SERVER-MODE.md § Spend quotas.
2026-10-28 14:31:11 +07:00

787 lines
28 KiB
TypeScript

/**
* @file Users.tsx
* @description The users section of the Administration page (`/admin`, which
* `/users` redirects to — `Administration.tsx`), server mode only and
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
* role, state, workspaces, sessions, the $ it used today (the browser's local
* day) with 7 and 30 days under it (`GET /api/users`),
* how much disk its home folder takes and its share of the homes disk (from
* `host`, the page's one `GET /api/host` read; each user's live CPU / RAM is on
* the Dashboard's System Health tab)
* (`GET /api/users/storage`, measured in the background — polled while the
* server says `measuring`; Refresh re-measures), and — one Linux user per
* account — its own tmux server (`MemberTmux.tsx`: version, outdated badge,
* sessions, agents; an admin's restart in the row's actions and "Restart idle"
* above the table): every per-user fact in ONE table. Also 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`). Each row's spend shows its spend quota (spent of the
* limit, red once used up) and the default quota sits in its own card
* (`UserQuota.tsx`); a user's own quota is part of Edit.
* 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, and "New password due" while
* the account still has a password an admin gave; right-clicking a row offers
* Edit, Open home (in /files), and copying the username or email. Clicking a
* row (or its name) expands that account in full right under it —
* `UserDetail`, one at a time, kept in `?user=<id>` so a reload or a shared
* link lands on it. `children` go between the account pool and the audit log.
* 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 {
Fragment,
useCallback,
useEffect,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
import { Navigate, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
AlertCircle,
ChevronRight,
DollarSign,
Eye,
FolderOpen,
MessagesSquare,
Pencil,
RefreshCw,
RotateCcw,
ScrollText,
UserPlus,
Users as UsersIcon,
} from "lucide-react";
import {
api,
type AuditEntry,
type AuthUser,
type MemberTmuxRow,
type ServerUserUsage,
type UserQuota,
type UserStorage,
} from "../lib/api";
import { fmt, fmtCost, formatBytes, 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 { UserDetail } from "../components/users/UserDetail";
import { UserAvatar } from "../components/users/UserAvatar";
import { QuotaDefaultCard, QuotaSummary } from "../components/users/UserQuota";
import {
MemberTmuxBar,
TmuxCell,
TmuxRestartDialog,
useMemberTmux,
} from "../components/users/MemberTmux";
import { Meter, fmtPct, pct, type HostState } from "../components/health/HostHealth";
import { TableRowSkeleton } from "../components/Skeleton";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
import { copyText } from "../lib/clipboard";
type Role = AuthUser["role"];
const ROLES: Role[] = ["admin", "analyst", "developer"];
export function Users({
embedded = false,
host,
children,
}: {
embedded?: boolean;
/** The Administration page's one `GET /api/host` read: each user's CPU/RAM now. */
host?: HostState;
children?: ReactNode;
} = {}) {
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);
// The default spend quota (`undefined` until loaded or from an older server).
const [quotaDefault, setQuotaDefault] = useState<UserQuota | null | undefined>(undefined);
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 rowMenu = useContextMenu<ServerUserUsage>();
const navigate = useNavigate();
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
const [measuring, setMeasuring] = useState(false);
const tmux = useMemberTmux(!!me && me.role !== "developer");
// A member's tmux restart: one member, or every idle outdated one.
const [restarting, setRestarting] = useState<MemberTmuxRow | "idle" | null>(null);
const [params, setParams] = useSearchParams();
const detailId = Number(params.get("user")) || null;
const openDetail = useCallback(
(id: number | null) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
if (id == null) next.delete("user");
else next.set("user", String(id));
return next;
},
{ replace: true }
),
[setParams]
);
const loadStorage = useCallback(
(remeasure = false) =>
api.users
.storage(remeasure)
.then((r) => {
setStorage(r.storage);
setMeasuring(r.measuring);
})
.catch(() => setMeasuring(false)),
[]
);
const load = useCallback(
(remeasure = false) =>
Promise.all([
loadStorage(remeasure),
api.users
.list()
.then((r) => {
setUsers(r.users);
setQuotaDefault(r.quotaDefault);
setError(null);
})
.catch((e: Error) => setError(e.message)),
api.users
.audit()
.then((r) => setAudit(r.entries))
.catch(() => setAudit([])),
]),
[loadStorage]
);
// The Refresh button also re-measures every home; a plain load reuses the
// server's cached numbers.
const reloadHost = host?.reload;
const reloadTmux = tmux.reload;
const [spinning, refresh] = useSpin(
useCallback(
() => Promise.all([load(true), reloadTmux(), reloadHost?.()]),
[load, reloadTmux, reloadHost]
)
);
useEffect(() => {
if (me && me.role !== "developer") void load();
}, [me, load]);
// Homes are measured in the background: ask again until every number is in.
useEffect(() => {
if (!measuring) return;
const id = setTimeout(() => void loadStorage(), 4000);
return () => clearTimeout(id);
}, [measuring, storage, loadStorage]);
// 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-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
const stats = host?.stats ?? null;
const homesDisk = stats?.disks.find((d) => d.mount === stats.homesMount) ?? null;
const showTmux = !!tmux.data?.available;
const tmuxById = new Map((tmux.data?.users ?? []).map((r) => [r.id, r]));
const columns = 5 + (showTmux ? 1 : 0) + (isAdmin ? 1 : 0);
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">
{!embedded && (
<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>
{/* On the Administration page this is one section of it: a section heading. */}
{embedded ? (
<h2 className="text-base font-semibold text-fg-primary">{t("title")}</h2>
) : (
<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>
)}
<MemberTmuxBar data={tmux.data} isAdmin={isAdmin} onRestart={setRestarting} />
<div className="card overflow-x-auto">
<table className="w-full min-w-[1040px]" 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}>{t("activity")}</th>
<th className={`${th} text-right`}>{t("spend")}</th>
<th className={`${th} w-[12%]`}>{t("storage")}</th>
{showTmux && <th className={th}>{t("tmuxCol")}</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={columns}
widths={["w-36", "w-20", "w-24", "w-16", "w-24", "w-24", "w-16", "w-10"].slice(
0,
columns
)}
/>
))}
{users?.map((u) => {
const self = u.id === me.id;
const open = detailId === u.id;
const toggle = () => openDetail(open ? null : u.id);
const tmuxRow = tmuxById.get(u.id);
const home = storage ? (storage[u.id] ?? null) : undefined;
return (
<Fragment key={u.id}>
<tr
onContextMenu={(e) => rowMenu.open(e, u)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("a,button")) return;
toggle();
}}
className={`cursor-pointer hover:bg-surface-4/40 transition-colors ${open ? "bg-surface-3/50" : ""} ${u.disabled ? "opacity-60" : ""}`}
>
<td className="px-4 py-3">
<div className="flex items-center gap-3 min-w-0">
<ChevronRight
aria-hidden
className={`w-4 h-4 flex-shrink-0 text-fg-muted transition-transform ${open ? "rotate-90" : ""}`}
/>
<UserAvatar
name={u.displayName || u.username}
userId={u.id}
avatarAt={u.avatarAt}
className="h-8 w-8 text-xs"
initialsClassName="bg-surface-4 text-fg-secondary"
/>
<div className="min-w-0">
<button
type="button"
onClick={toggle}
aria-expanded={open}
title={t("detail.open", { name: u.displayName || u.username })}
className="block max-w-full truncate text-left text-sm font-medium text-fg-primary hover:text-accent"
>
{u.displayName || u.username}
{u.displayName && (
<span className="ml-1.5 font-normal text-fg-muted">
@{u.username}
</span>
)}
</button>
{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>
)}
{!u.disabled && u.mustChangePassword && (
<span
className="badge py-0.5 text-[11px] bg-status-warning/10 text-status-warning border-status-warning/20"
title={t("mustChangeHint")}
data-testid="user-must-change"
>
{t("mustChange")}
</span>
)}
</div>
</td>
<td className="px-4 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-4 py-3 text-sm tabular-nums">
<div className="text-fg-primary">{t("sessionsN", { n: u.sessions })}</div>
<div className="text-[11px] text-fg-muted">
{t("workspacesN", { n: u.workspaces })}
</div>
</td>
<td
className="px-4 py-3 text-right tabular-nums"
title={t("tokens", { n: fmt((u.today ?? { tokens: 0 }).tokens) })}
>
<div className="text-sm text-fg-primary">
{fmtCost((u.today ?? { cost: 0 }).cost)}
</div>
<div className="whitespace-nowrap text-[11px] text-fg-muted">
{t("spendSub", {
week: fmtCost(u.last7d.cost),
month: fmtCost(u.last30d.cost),
})}
</div>
<QuotaSummary quota={u.quota} />
</td>
<StorageCell storage={home} measuring={measuring} disk={homesDisk} />
{showTmux && (
<td className="px-4 py-3">
<TmuxCell row={tmuxRow} />
</td>
)}
{isAdmin && (
<td className="px-4 py-2 text-right whitespace-nowrap">
{tmuxRow?.running && (
<RowAction
label={t("tmux.restart", { name: u.username })}
onClick={() => setRestarting(tmuxRow)}
>
<RotateCcw className="h-4 w-4" />
</RowAction>
)}
<RowAction label={t("edit.button")} onClick={() => setEditing(u)}>
<Pencil className="h-4 w-4" />
</RowAction>
</td>
)}
</tr>
{open && (
<tr className="bg-surface-2/40">
<td colSpan={columns} className="px-5 py-4 cursor-default">
<UserDetail userId={u.id} />
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
{rowMenu.menu &&
(() => {
const u = rowMenu.menu.item;
const pick = (fn: () => void) => () => {
rowMenu.close();
fn();
};
return (
<ContextMenu
x={rowMenu.menu.x}
y={rowMenu.menu.y}
onClose={rowMenu.close}
testId="users-row-menu"
>
<MenuItem onSelect={pick(() => openDetail(detailId === u.id ? null : u.id))}>
{t("detail.open", { name: u.displayName || u.username })}
</MenuItem>
{isAdmin && (
<MenuItem onSelect={pick(() => setEditing(u))}>{t("edit.button")}</MenuItem>
)}
{isAdmin && !u.disabled && (
<MenuItem
onSelect={pick(() =>
navigate(
`/files?home=${encodeURIComponent(u.id === me.id ? "me" : u.username)}`
)
)}
>
{t("rowMenu.openHome")}
</MenuItem>
)}
{isAdmin && <MenuSeparator />}
<MenuItem onSelect={pick(() => void copyText(u.username))}>
{t("rowMenu.copyUsername")}
</MenuItem>
{u.email && (
<MenuItem onSelect={pick(() => void copyText(u.email!))}>
{t("rowMenu.copyEmail")}
</MenuItem>
)}
</ContextMenu>
);
})()}
{quotaDefault !== undefined && (
<QuotaDefaultCard
quotaDefault={quotaDefault}
isAdmin={isAdmin}
onSaved={(message) => {
toast.success(message);
void load();
}}
/>
)}
<AccountPoolCard isAdmin={isAdmin} />
{children}
<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();
}}
/>
)}
{restarting && (
<TmuxRestartDialog
target={restarting}
data={tmux.data}
onClose={() => setRestarting(null)}
onDone={(message) => {
toast.success(message);
setRestarting(null);
void tmux.reload();
}}
/>
)}
{editing && (
<UserEditDialog
user={editing}
quotaDefault={quotaDefault ?? null}
self={editing.id === me.id}
users={users ?? []}
onClose={() => setEditing(null)}
onDone={(message) => {
toast.success(message);
setEditing(null);
void load();
}}
/>
)}
</div>
);
}
/**
* A user's home folder size (`undefined` = not loaded yet). `null` while the
* server is still measuring reads "Measuring…", otherwise "—" (no home of
* their own).
*/
function StorageCell({
storage,
measuring,
disk,
}: {
storage?: UserStorage;
measuring: boolean;
/** The disk holding the homes: the home is also shown as a share of it. */
disk: { total: number } | null;
}) {
const { t } = useTranslation("users");
if (!storage) {
const pending = storage === undefined || measuring;
return (
<td
className="px-4 py-3 text-sm tabular-nums text-fg-muted"
title={pending ? undefined : t("storageNone")}
>
{pending ? t("storageMeasuring") : "—"}
</td>
);
}
const share = disk ? pct(storage.bytes, disk.total) : null;
return (
<td
className="px-4 py-3 tabular-nums"
title={
storage.partial
? t("storagePartial")
: t("storageHint", { files: fmt(storage.files), at: formatDateTime(storage.measuredAt) })
}
>
<div className="text-sm text-fg-primary">
{storage.partial ? "≥ " : ""}
{formatBytes(storage.bytes)}
{share != null && <span className="text-[11px] text-fg-muted"> ({fmtPct(share)})</span>}
</div>
{share != null && <Meter value={share} label={t("storage")} />}
</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>
);
}