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:
2026-10-28 14:31:11 +07:00
parent 9e3d6f4ca1
commit fa27b299e7
29 changed files with 2044 additions and 23 deletions
+87 -3
View File
@@ -5,7 +5,9 @@
* account), a new password (typed or generated, copyable — someone else's
* may be anything and makes them choose their own at next sign-in; your own
* must pass every rule of `lib/passwordRules.ts`), the workspaces the
* account owns (remove, add from every workspace), and "sign out everywhere".
* account owns (remove, add from every workspace), its spend quota (the
* default, no limit, or its own dollars per day / 5 h — `UserQuota.tsx`), and
* "sign out everywhere".
* Every change is staged and sent as ONE `PATCH /api/users/:id` carrying only
* what changed, confirmed with the admin's OWN password (`currentPassword`) —
* the server validates the whole change before it applies any of it. A
@@ -17,7 +19,14 @@ import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNod
import { useTranslation } from "react-i18next";
import { Copy, Eye, EyeOff, Plus, Sparkles, X } from "lucide-react";
import { confirmDialog } from "../../lib/confirm";
import { api, type AuthUser, type ServerUserUsage, type UserPatch } from "../../lib/api";
import {
api,
type AuthUser,
type ServerUserUsage,
type UserPatch,
type UserQuota,
type UserQuotaSetting,
} from "../../lib/api";
import type { Lane } from "../../lib/types";
import { fmtCost, formatDateTime } from "../../lib/format";
import { copyText } from "../../lib/clipboard";
@@ -27,6 +36,7 @@ import { PasswordRules } from "./PasswordRules";
import { useDialog } from "../../hooks/useDialog";
import { Switch } from "../Switch";
import { Checkbox } from "../Checkbox";
import { QuotaFields, parseLimit, useQuotaLabel, type QuotaPeriod } from "./UserQuota";
type Role = AuthUser["role"];
const ROLES: Role[] = ["admin", "analyst", "developer"];
@@ -55,10 +65,13 @@ export function UserEditDialog({
user,
self,
users,
quotaDefault = null,
onClose,
onDone,
}: {
user: ServerUserUsage;
/** The default spend quota, named beside the "Default" choice. */
quotaDefault?: UserQuota | null;
/** The account being edited is the signed-in admin's own. */
self: boolean;
users: ServerUserUsage[];
@@ -81,6 +94,18 @@ export function UserEditDialog({
const [added, setAdded] = useState<number[]>([]);
const [pick, setPick] = useState("");
const [signOut, setSignOut] = useState(false);
// Spend quota: absent from an older server, which then gets no quota field.
const quotaSetting = user.quotaSetting;
const [quotaMode, setQuotaMode] = useState<UserQuotaSetting["mode"]>(
quotaSetting?.mode ?? "default"
);
const [quotaLimit, setQuotaLimit] = useState(
quotaSetting?.mode === "custom" ? String(quotaSetting.limit_usd) : ""
);
const [quotaPeriod, setQuotaPeriod] = useState<QuotaPeriod>(
quotaSetting?.mode === "custom" ? quotaSetting.period : (quotaDefault?.period ?? "day")
);
const quotaLabel = useQuotaLabel();
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -117,12 +142,33 @@ export function UserEditDialog({
if (added.length) patch.addWorkspaces = added;
if (removed.size) patch.removeWorkspaces = [...removed];
if (signOut) patch.signOut = true;
const quotaValue = parseLimit(quotaLimit);
const quotaBad = quotaMode === "custom" && quotaValue === null;
if (quotaSetting && !quotaBad) {
const same =
quotaMode === quotaSetting.mode &&
(quotaMode !== "custom" ||
(quotaSetting.mode === "custom" &&
quotaSetting.limit_usd === quotaValue &&
quotaSetting.period === quotaPeriod));
if (!same) {
patch.quota =
quotaMode === "custom"
? {
mode: "custom",
limit_usd: quotaValue as number,
period: quotaPeriod,
tz_offset: new Date().getTimezoneOffset(),
}
: { mode: quotaMode };
}
}
const changed = Object.keys(patch).length > 0;
// A blank field keeps the password. Someone else's may be anything (they
// choose their own at next sign-in); your own must pass every rule.
const weakOwn =
self && password.length > 0 && passwordProblems(password, user.username).length > 0;
const ready = changed && current.length > 0 && !weakOwn && !busy;
const ready = changed && current.length > 0 && !weakOwn && !quotaBad && !busy;
const submit = async () => {
if (!ready) return;
@@ -479,6 +525,44 @@ export function UserEditDialog({
)}
</Section>
{quotaSetting && (
<Section title={t("quota.title")}>
<div className="space-y-2" data-testid="user-edit-quota">
<select
className="input w-full text-sm"
value={quotaMode}
onChange={(e) => setQuotaMode(e.target.value as UserQuotaSetting["mode"])}
aria-label={t("quota.title")}
>
<option value="default">
{quotaDefault
? t("quota.modes.defaultOf", { quota: quotaLabel(quotaDefault) })
: t("quota.modes.defaultNone")}
</option>
<option value="unlimited">{t("quota.modes.unlimited")}</option>
<option value="custom">{t("quota.modes.custom")}</option>
</select>
{quotaMode === "custom" && (
<QuotaFields
limit={quotaLimit}
period={quotaPeriod}
onLimit={setQuotaLimit}
onPeriod={setQuotaPeriod}
onKeyDown={onKeyDown}
/>
)}
<span className="block text-[11px] text-fg-muted">
{user.quota
? t("quota.now", {
spend: fmtCost(user.quota.spend),
limit: quotaLabel(user.quota),
})
: t("quota.enforceHint")}
</span>
</div>
</Section>
)}
<Section title={t("edit.sessions")}>
<Checkbox
checked={signOut}
+269
View File
@@ -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>
);
}
@@ -0,0 +1,157 @@
/**
* @file UserQuota.test.tsx
* @description Spend quotas on the Users page: a row's "spent of limit" line
* (red once used up, "(default)" when it is the default), the default quota
* card (an admin edits it with their own password, an analyst only reads),
* and the Edit user form sending a user's own quota as part of its ONE PATCH.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ServerUserUsage } from "../../../lib/api";
const users = vi.hoisted(() => ({
setQuotaDefault: vi.fn(),
update: vi.fn(),
get: vi.fn(),
}));
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { users, lanes } };
});
import { QuotaDefaultCard, QuotaSummary } from "../UserQuota";
import { UserEditDialog } from "../UserEditDialog";
beforeEach(() => {
vi.clearAllMocks();
users.setQuotaDefault.mockResolvedValue({ user: {}, quotaDefault: null });
users.update.mockResolvedValue({ user: {} });
users.get.mockResolvedValue({ user: {}, workspaces: [] });
lanes.list.mockResolvedValue({ lanes: [] });
});
describe("QuotaSummary", () => {
it("shows spent of the limit, red and marked default once used up", () => {
render(
<QuotaSummary
quota={{
limit_usd: 10,
period: "day",
source: "default",
spend: 12.5,
pct: 125,
exceeded: true,
resetsAt: "2026-10-29T17:00:00.000Z",
since: "2026-10-28T17:00:00.000Z",
}}
/>
);
const line = screen.getByText(/of \$10\.00 \/ day/);
expect(line.textContent).toContain("$12.50");
expect(line.textContent).toContain("(default)");
expect(line.className).toContain("text-status-danger");
expect(screen.getByRole("meter")).toHaveAttribute("aria-valuenow", "100");
});
it("says No quota without one, and nothing for an older server", () => {
const { container, rerender } = render(<QuotaSummary quota={null} />);
expect(screen.getByText("No quota")).toBeInTheDocument();
rerender(<QuotaSummary quota={undefined} />);
expect(container.textContent).toBe("");
});
});
describe("QuotaDefaultCard", () => {
it("an admin sets the default with their own password", async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
render(<QuotaDefaultCard quotaDefault={null} isAdmin onSaved={onSaved} />);
expect(screen.getByTestId("quota-default-value").textContent).toBe("No default — no limit");
await user.click(screen.getByRole("button", { name: /Edit/ }));
await user.click(screen.getByRole("checkbox", { name: "Limit what each user may spend" }));
await user.type(screen.getByLabelText("Limit in dollars"), "25");
await user.selectOptions(screen.getByLabelText("Period"), "5h");
const save = screen.getByRole("button", { name: "Save" });
expect(save).toBeDisabled(); // no password yet
await user.type(screen.getByLabelText("Your password"), "pw");
await user.click(save);
await waitFor(() => expect(onSaved).toHaveBeenCalled());
expect(users.setQuotaDefault).toHaveBeenCalledWith(
expect.objectContaining({ limit_usd: 25, period: "5h" }),
"pw"
);
});
it("an analyst only reads it", () => {
render(
<QuotaDefaultCard
quotaDefault={{ limit_usd: 5, period: "day" }}
isAdmin={false}
onSaved={() => {}}
/>
);
expect(screen.getByTestId("quota-default-value").textContent).toBe("$5.00 / day");
expect(screen.queryByRole("button", { name: /Edit/ })).toBeNull();
});
});
describe("UserEditDialog quota", () => {
const mia: ServerUserUsage = {
id: 7,
username: "mia",
role: "developer",
disabled: false,
workspaces: 0,
sessions: 0,
last7d: { cost: 0, tokens: 0 },
last30d: { cost: 0, tokens: 0 },
quotaSetting: { mode: "default" },
quota: null,
} as ServerUserUsage;
it("sends the user's own limit in the one PATCH", async () => {
const user = userEvent.setup();
const onDone = vi.fn();
render(
<UserEditDialog
user={mia}
self={false}
users={[mia]}
quotaDefault={{ limit_usd: 10, period: "day" }}
onClose={() => {}}
onDone={onDone}
/>
);
const select = screen.getByLabelText("Spend quota");
expect(screen.getByRole("option", { name: "Default ($10.00 / day)" })).toBeInTheDocument();
await user.selectOptions(select, "custom");
await user.type(screen.getByLabelText("Limit in dollars"), "40");
await user.type(screen.getByLabelText("Your password"), "pw");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(onDone).toHaveBeenCalled());
expect(users.update).toHaveBeenCalledWith(
7,
{ quota: expect.objectContaining({ mode: "custom", limit_usd: 40, period: "day" }) },
"pw"
);
});
it("No limit is a change; an empty custom limit keeps Save off", async () => {
const user = userEvent.setup();
render(
<UserEditDialog user={mia} self={false} users={[mia]} onClose={() => {}} onDone={() => {}} />
);
await user.type(screen.getByLabelText("Your password"), "pw");
await user.selectOptions(screen.getByLabelText("Spend quota"), "custom");
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
await user.selectOptions(screen.getByLabelText("Spend quota"), "unlimited");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(users.update).toHaveBeenCalledWith(7, { quota: { mode: "unlimited" } }, "pw")
);
});
});