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.
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* @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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user