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:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user