Files
AgentHub/client/src/components/users/UserQuota.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

270 lines
8.6 KiB
TypeScript

/**
* @file UserQuota.tsx
* @description Spend quotas per user on the Users page (server mode;
* server/lib/user-quota.js): `QuotaSummary`, a row's "spent / limit per day"
* line with its bar — red once used up, with when it opens again;
* `QuotaFields`, a limit in dollars and its period (a day from local midnight,
* or a sliding 5 hours); and `QuotaDefaultCard`, the default every user
* without one of their own gets — an admin edits it with their own password
* again (`PUT /api/users/quota-default`), an analyst only reads. A user's own
* setting is part of the Edit user form (`UserEditDialog`). A user over their
* quota can start no session and their prompts are refused until it resets.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState, type KeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import { Gauge, Pencil } from "lucide-react";
import { api, type UserQuota, type UserQuotaStatus } from "../../lib/api";
import { fmtCost, formatDateTime } from "../../lib/format";
import { isSubmitEnter } from "../../lib/keys";
import { Checkbox } from "../Checkbox";
import { ConfirmModal } from "../ConfirmModal";
import { Meter } from "../health/HostHealth";
export type QuotaPeriod = UserQuota["period"];
export const QUOTA_PERIODS: QuotaPeriod[] = ["day", "5h"];
/** "$10 / day", "$2.50 / 5 h". */
export function useQuotaLabel() {
const { t } = useTranslation("users");
return (q: Pick<UserQuota, "limit_usd" | "period">) =>
t(`quota.per.${q.period}`, { limit: fmtCost(q.limit_usd) });
}
/** A row's quota: spent of the limit, a bar, and when a used-up one opens again. */
export function QuotaSummary({ quota }: { quota: UserQuotaStatus | null | undefined }) {
const { t } = useTranslation("users");
const label = useQuotaLabel();
if (quota === undefined) return null;
if (quota === null) {
return <div className="text-[11px] text-fg-muted">{t("quota.none")}</div>;
}
return (
<div
className="mt-1 space-y-0.5"
data-testid="user-quota"
title={
quota.exceeded && quota.resetsAt
? t("quota.blockedUntil", { at: formatDateTime(quota.resetsAt) })
: undefined
}
>
<div
className={`whitespace-nowrap text-[11px] ${quota.exceeded ? "text-status-danger" : "text-fg-muted"}`}
>
{t("quota.used", { spend: fmtCost(quota.spend), limit: label(quota) })}
{quota.source === "default" && <span className="ml-1">{t("quota.defaultTag")}</span>}
</div>
<Meter value={quota.pct} label={t("quota.title")} />
</div>
);
}
/** A dollar limit and its period. `limit` is the raw input text. */
export function QuotaFields({
limit,
period,
onLimit,
onPeriod,
onKeyDown,
disabled,
}: {
limit: string;
period: QuotaPeriod;
onLimit: (v: string) => void;
onPeriod: (v: QuotaPeriod) => void;
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
disabled?: boolean;
}) {
const { t } = useTranslation("users");
return (
<div className="flex gap-2">
<div className="relative w-36">
<span className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-sm text-fg-muted">
$
</span>
<input
type="number"
min={0}
step="0.01"
inputMode="decimal"
className="input w-full pl-6 text-sm tabular-nums"
value={limit}
disabled={disabled}
onChange={(e) => onLimit(e.target.value)}
onKeyDown={onKeyDown}
aria-label={t("quota.limit")}
/>
</div>
<select
className="input flex-1 text-sm"
value={period}
disabled={disabled}
onChange={(e) => onPeriod(e.target.value as QuotaPeriod)}
aria-label={t("quota.period")}
>
{QUOTA_PERIODS.map((p) => (
<option key={p} value={p}>
{t(`quota.periods.${p}`)}
</option>
))}
</select>
</div>
);
}
/** A positive dollar amount from the input text, or null. */
export function parseLimit(text: string): number | null {
const n = Number(text);
return text.trim() !== "" && Number.isFinite(n) && n > 0 ? Math.round(n * 100) / 100 : null;
}
/** The default quota: shown to staff, edited by an admin with their own password. */
export function QuotaDefaultCard({
quotaDefault,
isAdmin,
onSaved,
}: {
/** `undefined` while loading, null for none. */
quotaDefault: UserQuota | null | undefined;
isAdmin: boolean;
onSaved: (message: string) => void;
}) {
const { t } = useTranslation("users");
const label = useQuotaLabel();
const [editing, setEditing] = useState(false);
return (
<section className="card" data-testid="quota-default-card">
<div className="flex flex-wrap items-center gap-3 px-5 py-4">
<Gauge className="h-4 w-4 text-fg-muted" />
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-fg-primary">{t("quota.cardTitle")}</h2>
<p className="text-xs text-fg-muted">{t("quota.cardDescription")}</p>
</div>
<div className="text-sm tabular-nums text-fg-primary" data-testid="quota-default-value">
{quotaDefault === undefined
? "…"
: quotaDefault
? label(quotaDefault)
: t("quota.noDefault")}
</div>
{isAdmin && quotaDefault !== undefined && (
<button type="button" className="btn-ghost text-xs" onClick={() => setEditing(true)}>
<Pencil className="h-3.5 w-3.5" /> {t("quota.editDefault")}
</button>
)}
</div>
{editing && (
<QuotaDefaultDialog
current={quotaDefault ?? null}
onClose={() => setEditing(false)}
onDone={(message) => {
setEditing(false);
onSaved(message);
}}
/>
)}
</section>
);
}
function QuotaDefaultDialog({
current,
onClose,
onDone,
}: {
current: UserQuota | null;
onClose: () => void;
onDone: (message: string) => void;
}) {
const { t } = useTranslation("users");
const [on, setOn] = useState(current !== null);
const [limit, setLimit] = useState(current ? String(current.limit_usd) : "");
const [period, setPeriod] = useState<QuotaPeriod>(current?.period ?? "day");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const value = parseLimit(limit);
const ready = password.length > 0 && (!on || value !== null) && !busy;
const submit = async () => {
if (!ready) return;
setBusy(true);
setError(null);
try {
await api.users.setQuotaDefault(
on && value !== null
? { limit_usd: value, period, tz_offset: new Date().getTimezoneOffset() }
: null,
password
);
onDone(t("quota.defaultSaved"));
} 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 onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
};
return (
<ConfirmModal
open
title={t("quota.defaultDialog")}
confirmLabel={t("save")}
cancelLabel={t("common:cancel")}
destructive={false}
busy={busy}
disabled={!ready}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div className="mt-3 space-y-3">
<Checkbox
checked={on}
onChange={setOn}
label={t("quota.defaultOn")}
labelClassName="text-sm text-fg-secondary"
/>
<QuotaFields
limit={limit}
period={period}
onLimit={setLimit}
onPeriod={setPeriod}
onKeyDown={onKeyDown}
disabled={!on}
/>
<p className="text-[11px] text-fg-muted">{t("quota.enforceHint")}</p>
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
<input
type="password"
className="input mt-1 w-full text-sm"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={onKeyDown}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</label>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}