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
+31 -2
View File
@@ -22,6 +22,9 @@
* its fullest window, live account only — and, on hover or focus, a panel
* above grouping every account per tool (saved Claude profiles too): name,
* sign-in expiry, and a row per window with its bar, percent and reset time.
* Server mode: the user's OWN spend quota (`GET /api/me/quota`, read with the
* costs) as "Quota N%" — amber from 80 %, red once used up, when the user can
* start nothing and their prompts are refused until it opens again.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -30,9 +33,9 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router-dom";
import { RefreshCw, Wifi, WifiOff } from "lucide-react";
import { api, type RunHandle } from "../lib/api";
import { api, type RunHandle, type UserQuotaStatus } from "../lib/api";
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
import { fmt, fmtCostFull, formatDateTime, formatModelName, timeAgo } from "../lib/format";
import { AiToolIcon } from "./AiToolIcon";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
@@ -369,6 +372,8 @@ export function StatusBar({
const [runs, setRuns] = useState<RunHandle[]>([]);
// The budget furthest past one of its thresholds, if any.
const [budget, setBudget] = useState<BudgetStatus | null>(null);
// Server mode: this user's own spend quota (null: none, or local mode).
const [myQuota, setMyQuota] = useState<UserQuotaStatus | null>(null);
const location = useLocation();
const shown = selectedFrom(location.pathname, location.search);
const lane = shown.lane;
@@ -414,6 +419,10 @@ export function StatusBar({
if (live()) setBudget(hit[0] ?? null);
})
.catch(() => undefined);
api.me
.quota()
.then((r) => live() && setMyQuota(r?.quota ?? null))
.catch(() => undefined);
if (lane !== null) {
api.pricing
.totalCost({ lane })
@@ -524,6 +533,26 @@ export function StatusBar({
{t("statusBar.budget", { pct: Math.round(budget.pct) })}
</Link>
)}
{myQuota && (
<span
data-testid="status-bar-user-quota"
title={t(myQuota.exceeded ? "statusBar.userQuotaBlocked" : "statusBar.userQuotaTitle", {
spend: usd(myQuota.spend),
limit: usd(myQuota.limit_usd),
period: t(`statusBar.userQuotaPeriod.${myQuota.period}`),
at: myQuota.resetsAt ? formatDateTime(myQuota.resetsAt) : "",
})}
className={`tabular-nums ${
myQuota.exceeded
? "text-status-danger"
: myQuota.pct >= 80
? "text-status-warning"
: "text-fg-muted"
}`}
>
{t("statusBar.userQuota", { pct: Math.min(999, Math.round(myQuota.pct)) })}
</span>
)}
<QuotaChips />
</footer>
);
@@ -129,6 +129,20 @@ vi.mock("../../lib/api", () => ({
daily_costs: [],
})),
},
me: {
// Server mode: this user's own spend quota, used up.
quota: vi.fn().mockResolvedValue({
quota: {
limit_usd: 10,
period: "day",
spend: 12,
pct: 120,
exceeded: true,
resetsAt: "2026-10-29T17:00:00.000Z",
since: "2026-10-28T17:00:00.000Z",
},
}),
},
budgets: {
status: vi.fn().mockResolvedValue({
budgets: [
@@ -183,6 +197,16 @@ describe("StatusBar", () => {
expect(chip.getAttribute("title")).toBe("Monthly: $85.00 of $100.00 this period");
});
it("shows the user's own spend quota, red once used up", async () => {
at("/", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
const chip = await screen.findByTestId("status-bar-user-quota");
expect(chip.textContent).toBe("Quota 120%");
expect(chip.className).toContain("text-status-danger");
expect(chip.getAttribute("title")).toMatch(
/^Your spend quota is used up \(\$12\.00 of \$10\.00 today\)/
);
});
it("says Disconnected when the socket is down", () => {
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument();
+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")
);
});
});
+7
View File
@@ -78,6 +78,13 @@
"quotaWindow": {
"wk": "Week",
"mo": "Month"
},
"userQuota": "Quota {{pct}}%",
"userQuotaTitle": "Your spend quota: {{spend}} of {{limit}} {{period}}",
"userQuotaBlocked": "Your spend quota is used up ({{spend}} of {{limit}} {{period}}): no new session and no prompt until {{at}}",
"userQuotaPeriod": {
"day": "today",
"5h": "in the last 5 hours"
}
},
"version": "Version",
+35 -2
View File
@@ -87,7 +87,8 @@
"ELOCALMODE": "Accounts are managed in server mode only.",
"ELASTADMIN": "The last admin cannot be deleted, demoted or disabled.",
"EWEAKPASSWORD": "The new password does not meet every rule (or is the current one).",
"EPWCHANGE": "Choose your own password first."
"EPWCHANGE": "Choose your own password first.",
"EBADQUOTA": "Enter a positive dollar limit and a period of a day or 5 hours."
},
"readOnly": "Analysts can view accounts but not change them.",
"auditTitle": "Audit log",
@@ -412,5 +413,37 @@
"spendSub": "7 d {{week}} · 30 d {{month}}",
"sessionsN": "{{n}} sessions",
"workspacesN": "{{n}} workspaces",
"tmuxCol": "tmux"
"tmuxCol": "tmux",
"quota": {
"title": "Spend quota",
"limit": "Limit in dollars",
"period": "Period",
"periods": {
"day": "per day (from local midnight)",
"5h": "per 5 hours (sliding)"
},
"per": {
"day": "{{limit}} / day",
"5h": "{{limit}} / 5 h"
},
"used": "{{spend}} of {{limit}}",
"defaultTag": "(default)",
"none": "No quota",
"blockedUntil": "Used up — no new session and no prompt until {{at}}",
"cardTitle": "Default spend quota",
"cardDescription": "Every user without a quota of their own gets this one. Once it is used up they can start no session and their prompts are refused until it resets; a pane no hook can stop (Antigravity, Cursor, Codex without trusted hooks, omp on its own login) is ended.",
"noDefault": "No default — no limit",
"editDefault": "Edit",
"defaultDialog": "Default spend quota",
"defaultOn": "Limit what each user may spend",
"defaultSaved": "Default spend quota saved",
"enforceHint": "Over the quota: no new session, Claude Code and Codex prompts refused, other AI tools' panes ended — until it resets.",
"modes": {
"defaultOf": "Default ({{quota}})",
"defaultNone": "Default (no limit)",
"unlimited": "No limit",
"custom": "Their own limit"
},
"now": "Now: {{spend}} of {{limit}}"
}
}
+7
View File
@@ -78,6 +78,13 @@
"quotaWindow": {
"wk": "Tuần",
"mo": "Tháng"
},
"userQuota": "Quota {{pct}}%",
"userQuotaTitle": "Quota chi phí của bạn: {{spend}} trên {{limit}} {{period}}",
"userQuotaBlocked": "Bạn đã dùng hết quota chi phí ({{spend}} trên {{limit}} {{period}}): không mở phiên mới, không gửi prompt đến {{at}}",
"userQuotaPeriod": {
"day": "hôm nay",
"5h": "trong 5 giờ qua"
}
},
"version": "Phiên bản",
+35 -2
View File
@@ -87,7 +87,8 @@
"ELOCALMODE": "Chỉ quản lý tài khoản được ở chế độ server.",
"ELASTADMIN": "Không thể xoá, hạ quyền hay khoá admin cuối cùng.",
"EWEAKPASSWORD": "Mật khẩu mới chưa đạt đủ các yêu cầu (hoặc trùng mật khẩu hiện tại).",
"EPWCHANGE": "Hãy đặt mật khẩu riêng của bạn trước."
"EPWCHANGE": "Hãy đặt mật khẩu riêng của bạn trước.",
"EBADQUOTA": "Nhập hạn mức USD lớn hơn 0 và chu kỳ ngày hoặc 5 giờ."
},
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
"auditTitle": "Nhật ký audit",
@@ -412,5 +413,37 @@
"spendSub": "7 ngày {{week}} · 30 ngày {{month}}",
"sessionsN": "{{n}} session",
"workspacesN": "{{n}} workspace",
"tmuxCol": "tmux"
"tmuxCol": "tmux",
"quota": {
"title": "Quota chi phí",
"limit": "Hạn mức (USD)",
"period": "Chu kỳ",
"periods": {
"day": "mỗi ngày (từ nửa đêm giờ địa phương)",
"5h": "mỗi 5 giờ (trượt)"
},
"per": {
"day": "{{limit}} / ngày",
"5h": "{{limit}} / 5 giờ"
},
"used": "{{spend}} trên {{limit}}",
"defaultTag": "(mặc định)",
"none": "Không giới hạn",
"blockedUntil": "Đã hết — không mở phiên mới, không gửi prompt đến {{at}}",
"cardTitle": "Quota chi phí mặc định",
"cardDescription": "Áp cho mọi user không có quota riêng. Khi dùng hết, user không mở được phiên mới và prompt bị từ chối đến khi quota mở lại; pane không hook nào chặn được (Antigravity, Cursor, Codex chưa tin hook, omp dùng login riêng) sẽ bị dừng.",
"noDefault": "Chưa có mặc định — không giới hạn",
"editDefault": "Sửa",
"defaultDialog": "Quota chi phí mặc định",
"defaultOn": "Giới hạn chi phí của mỗi user",
"defaultSaved": "Đã lưu quota chi phí mặc định",
"enforceHint": "Vượt quota: không mở phiên mới, prompt Claude Code và Codex bị từ chối, pane của các AI tool khác bị dừng — đến khi quota mở lại.",
"modes": {
"defaultOf": "Mặc định ({{quota}})",
"defaultNone": "Mặc định (không giới hạn)",
"unlimited": "Không giới hạn",
"custom": "Hạn mức riêng"
},
"now": "Hiện tại: {{spend}} trên {{limit}}"
}
}
+38 -1
View File
@@ -545,6 +545,31 @@ export type ServerUserUsage = AuthUser & {
/** Active login sessions (cookies not expired). */
logins?: number;
lastLoginAt?: string | null;
/** This user's own spend quota setting (absent from an older server). */
quotaSetting?: UserQuotaSetting;
/** The quota that binds them now with its spend; null = no limit. */
quota?: UserQuotaStatus | null;
};
/** A spend quota: `limit_usd` per day (from local midnight of `tz_offset`) or per sliding 5 h. */
export type UserQuota = {
limit_usd: number;
period: "day" | "5h";
/** getTimezoneOffset() minutes of the zone a `day` starts in. */
tz_offset?: number;
/** Where it comes from: the default, or the user's own. */
source?: "default" | "user";
};
/** A user's own setting: the default applies, no limit, or a limit of their own. */
export type UserQuotaSetting =
{ mode: "default" } | { mode: "unlimited" } | ({ mode: "custom" } & UserQuota);
/** A quota now (`GET /api/users`, `GET /api/me/quota`). */
export type UserQuotaStatus = UserQuota & {
spend: number;
pct: number;
/** Used up: no new session starts and prompts are refused until `resetsAt`. */
exceeded: boolean;
resetsAt: string | null;
since: string;
};
/** A priced slice of a user's spend (`GET /api/users/:id/details`). */
export type UsageSlice = { key: string; cost: number; tokens: number; sessions: number };
@@ -773,6 +798,8 @@ export type UserPatch = {
addWorkspaces?: number[];
removeWorkspaces?: number[];
signOut?: true;
/** Their own spend quota (`default` = the default applies). */
quota?: UserQuotaSetting;
};
/** GET /api/me/account — the signed-in account's own information. */
export type MyAccount =
@@ -1210,6 +1237,8 @@ export const api = {
/** PUT /api/me/prefs — merge; `null` deletes a key. */
savePrefs: (prefs: Record<string, string | null>) =>
request<UserPrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
/** GET /api/me/quota — this account's own spend quota now (null: no limit, or local mode). */
quota: () => request<{ quota: UserQuotaStatus | null }>("/me/quota"),
/** GET /api/me/instructions — this account's own agent instructions (issue #67). */
instructions: () => request<AgentInstructionsView>("/me/instructions"),
/** PUT /api/me/instructions — blank clears; put before every workspace's own. */
@@ -1271,7 +1300,15 @@ export const api = {
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
/** GET /api/users — every account with its recent usage (admin/analyst). */
list: () =>
request<{ users: ServerUserUsage[] }>(`/users?tz_offset=${new Date().getTimezoneOffset()}`),
request<{ users: ServerUserUsage[]; quotaDefault?: UserQuota | null }>(
`/users?tz_offset=${new Date().getTimezoneOffset()}`
),
/** PUT /api/users/quota-default — the default spend quota (null = none). */
setQuotaDefault: (quota: UserQuota | null, currentPassword: string) =>
request<{ user: AuthUser; quotaDefault: UserQuota | null }>("/users/quota-default", {
method: "PUT",
body: JSON.stringify({ quota, currentPassword }),
}),
/** GET /api/users/:id/details — one account in full (admin/analyst). */
details: (id: number) =>
request<UserDetails>(`/users/${id}/details?tz_offset=${new Date().getTimezoneOffset()}`),
+21 -1
View File
@@ -17,7 +17,9 @@
* 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`).
* 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
@@ -61,6 +63,7 @@ import {
type AuthUser,
type MemberTmuxRow,
type ServerUserUsage,
type UserQuota,
type UserStorage,
} from "../lib/api";
import { fmt, fmtCost, formatBytes, formatDateTime } from "../lib/format";
@@ -74,6 +77,7 @@ 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,
@@ -104,6 +108,8 @@ export function Users({
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);
@@ -151,6 +157,7 @@ export function Users({
.list()
.then((r) => {
setUsers(r.users);
setQuotaDefault(r.quotaDefault);
setError(null);
})
.catch((e: Error) => setError(e.message)),
@@ -406,6 +413,7 @@ export function Users({
month: fmtCost(u.last30d.cost),
})}
</div>
<QuotaSummary quota={u.quota} />
</td>
<StorageCell storage={home} measuring={measuring} disk={homesDisk} />
{showTmux && (
@@ -487,6 +495,17 @@ export function Users({
);
})()}
{quotaDefault !== undefined && (
<QuotaDefaultCard
quotaDefault={quotaDefault}
isAdmin={isAdmin}
onSaved={(message) => {
toast.success(message);
void load();
}}
/>
)}
<AccountPoolCard isAdmin={isAdmin} />
{children}
@@ -559,6 +578,7 @@ export function Users({
{editing && (
<UserEditDialog
user={editing}
quotaDefault={quotaDefault ?? null}
self={editing.id === me.id}
users={users ?? []}
onClose={() => setEditing(null)}