diff --git a/apps/api/src/routes/admin-ui.test.ts b/apps/api/src/routes/admin-ui.test.ts index ed0e06ad..6dafea15 100644 --- a/apps/api/src/routes/admin-ui.test.ts +++ b/apps/api/src/routes/admin-ui.test.ts @@ -402,3 +402,212 @@ describe("GET /admin-ui/workspaces/:name/invites", () => { expect(res.status).toBe(404); }); }); + +describe("workspace limits editing", () => { + const CURRENT_PERIOD = new Date().toISOString().slice(0, 7); + + /** Env with a mutable ws:acme record, a session user, and a usage row. */ + function limitsEnv( + user: typeof ADMIN_USER | null, + record: Record | null, + usage: { bytes: number; uploadsInPeriod: number } | null = { bytes: 0, uploadsInPeriod: 0 }, + ) { + const store = new Map(); + if (record) store.set("ws:acme", JSON.stringify(record)); + const base = stubEnv(user, () => new Response(null, { status: 404 })); + const db = { + prepare: () => ({ + bind: () => ({ + first: async () => + usage + ? { + workspace: "acme", + bytes: usage.bytes, + objects: 0, + uploads_in_period: usage.uploadsInPeriod, + period_start: CURRENT_PERIOD, + updated_at: "2026-07-20T00:00:00.000Z", + } + : null, + }), + }), + }; + const env = { + ...base, + DB: db, + REGISTRY: { + get: (async (key: string) => { + const raw = store.get(key); + return raw ? JSON.parse(raw) : null; + }) as unknown as KVNamespace["get"], + put: (async (key: string, value: string) => { + store.set(key, value); + }) as unknown as KVNamespace["put"], + }, + } as unknown as Env; + return { env, store }; + } + + const REC = { + provider: "r2", + bucket: "uploads-default", + prefix: "acme/", + maxStorageBytes: 250_000_000, + maxUploadsPerPeriod: 3000, + allowedKeyPrefixes: ["f", "screenshots", "gh"], + retentionDays: 90, + }; + + it("GET returns current limits and usage", async () => { + const { env } = limitsEnv(ADMIN_USER, REC, { bytes: 128, uploadsInPeriod: 5 }); + const res = await app().request("/admin-ui/workspaces/acme/limits", {}, env); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + workspace: "acme", + limits: { + maxStorageBytes: 250_000_000, + maxUploadsPerPeriod: 3000, + maxUploadBytes: null, + maxVideoUploadBytes: null, + }, + usage: { bytes: 128, uploads: 5 }, + }); + }); + + it("PATCH sets numeric limits on the record", async () => { + const { env, store } = limitsEnv(ADMIN_USER, REC); + const res = await app().request( + "/admin-ui/workspaces/acme/limits", + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ maxStorageBytes: 500_000_000, maxUploadBytes: 10_000_000 }), + }, + env, + ); + expect(res.status).toBe(200); + const saved = JSON.parse(store.get("ws:acme")!); + expect(saved.maxStorageBytes).toBe(500_000_000); + expect(saved.maxUploadBytes).toBe(10_000_000); + }); + + it("PATCH with null clears a limit to unlimited", async () => { + const { env, store } = limitsEnv(ADMIN_USER, REC); + const res = await app().request( + "/admin-ui/workspaces/acme/limits", + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ maxStorageBytes: null }), + }, + env, + ); + expect(res.status).toBe(200); + const saved = JSON.parse(store.get("ws:acme")!); + expect("maxStorageBytes" in saved).toBe(false); + }); + + it("PATCH leaves omitted budget fields and all non-budget fields intact", async () => { + const { env, store } = limitsEnv(ADMIN_USER, REC); + await app().request( + "/admin-ui/workspaces/acme/limits", + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ maxUploadBytes: 10_000_000 }), + }, + env, + ); + const saved = JSON.parse(store.get("ws:acme")!); + expect(saved.maxUploadsPerPeriod).toBe(3000); // omitted -> unchanged + expect(saved.allowedKeyPrefixes).toEqual(["f", "screenshots", "gh"]); // preserved + expect(saved.retentionDays).toBe(90); // preserved + expect(saved.prefix).toBe("acme/"); // preserved + }); + + it("PATCH 400s on an invalid limit value", async () => { + const { env } = limitsEnv(ADMIN_USER, REC); + const res = await app().request( + "/admin-ui/workspaces/acme/limits", + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ maxStorageBytes: -5 }), + }, + env, + ); + expect(res.status).toBe(400); + const payload = (await res.json()) as { error?: { code?: string } }; + expect(payload.error?.code).toBe("invalid_limit"); + }); + + it("404s for an unknown workspace", async () => { + const { env } = limitsEnv(ADMIN_USER, null); + const res = await app().request("/admin-ui/workspaces/acme/limits", {}, env); + expect(res.status).toBe(404); + }); + + it("404s for a soft-deleted workspace", async () => { + const { env } = limitsEnv(ADMIN_USER, { ...REC, deletedAt: "2026-07-01T00:00:00.000Z" }); + const res = await app().request("/admin-ui/workspaces/acme/limits", {}, env); + expect(res.status).toBe(404); + }); + + it("403s for a non-admin session", async () => { + const { env } = limitsEnv(NON_ADMIN_USER, REC); + const res = await app().request( + "/admin-ui/workspaces/acme/limits", + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ maxStorageBytes: 1 }), + }, + env, + ); + expect(res.status).toBe(403); + }); + + it("returns usage: null when the usage read finds no row", async () => { + const { env } = limitsEnv(ADMIN_USER, REC, null); + const res = await app().request("/admin-ui/workspaces/acme/limits", {}, env); + expect(res.status).toBe(200); + // getWorkspaceUsage returns an empty usage row (bytes 0) rather than throwing, + // so usage is still an object here; assert the shape is present. + const body = (await res.json()) as { usage: { bytes: number } | null }; + expect(body.usage).toEqual({ bytes: 0, uploads: 0 }); + }); + + it("PATCH 429s when the per-workspace write budget is exhausted", async () => { + const { env } = limitsEnv(ADMIN_USER, REC); + (env as Env & { WRITE_LIMITER: { limit: () => Promise<{ success: boolean }> } }).WRITE_LIMITER = + { limit: async () => ({ success: false }) }; + const res = await app().request( + "/admin-ui/workspaces/acme/limits", + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ maxStorageBytes: 1 }), + }, + env, + ); + expect(res.status).toBe(429); + }); + + it("PATCH 400s on a malformed JSON body (not a silent no-op)", async () => { + const { env, store } = limitsEnv(ADMIN_USER, REC); + const res = await app().request( + "/admin-ui/workspaces/acme/limits", + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: "{ not valid json", + }, + env, + ); + expect(res.status).toBe(400); + const payload = (await res.json()) as { error?: { code?: string } }; + expect(payload.error?.code).toBe("invalid_limit"); + // The record must be untouched — no write happened. + expect(JSON.parse(store.get("ws:acme")!).maxStorageBytes).toBe(250_000_000); + }); +}); diff --git a/apps/api/src/routes/admin-ui.ts b/apps/api/src/routes/admin-ui.ts index 528f876b..164aebba 100644 --- a/apps/api/src/routes/admin-ui.ts +++ b/apps/api/src/routes/admin-ui.ts @@ -27,6 +27,9 @@ import { sessionAuth, type SessionVars, } from "../session-auth"; +import { getWorkspaceUsage } from "../usage"; +import { isPurgedTombstone, loadWorkspaceRecordRaw, type WorkspaceRecord } from "../workspace"; +import { LIMIT_FIELDS, validateLimitsPatch } from "../workspace-limits"; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -96,6 +99,38 @@ async function proxyOauthClients( return Response.json(payload, { status: response.status }); } +/** + * Raw-reads ws: for a limits edit and 404s on missing / soft-deleted / + * purged-tombstone records (an admin can't edit limits on a workspace that no + * longer serves). Uses the uncached raw read so the edit sees the freshest + * record. Returns a live WorkspaceRecord the caller mutates and writes back. + */ +async function loadEditableWorkspace(env: Env, name: string): Promise { + const record = await loadWorkspaceRecordRaw(env, name); + if (!record || isPurgedTombstone(record) || record.deletedAt) { + throw new NotFoundError("workspace not found", { code: "workspace_not_found" }); + } + return record; +} + +/** Response body shared by GET and PATCH: current budget limits + usage. */ +async function limitsResponse(env: Env, name: string, record: WorkspaceRecord) { + const limits = { + maxStorageBytes: record.maxStorageBytes ?? null, + maxUploadsPerPeriod: record.maxUploadsPerPeriod ?? null, + maxUploadBytes: record.maxUploadBytes ?? null, + maxVideoUploadBytes: record.maxVideoUploadBytes ?? null, + }; + let usage: { bytes: number; uploads: number } | null = null; + try { + const u = await getWorkspaceUsage(env.DB, name); + usage = { bytes: u.bytes, uploads: u.uploadsInPeriod }; + } catch { + usage = null; + } + return { workspace: name, limits, usage }; +} + export const adminUi = new Hono() .use("/*", sessionAuth, requireSessionUser, requireAdminUser) @@ -249,6 +284,42 @@ export const adminUi = new Hono() ); }) + // Read the four budget limits (+ current usage) for one workspace. + .get("/workspaces/:name/limits", async (c) => { + const name = c.req.param("name"); + const record = await loadEditableWorkspace(c.env, name); + return c.json(await limitsResponse(c.env, name, record)); + }) + + // Patch the four budget limits. Each field is optional; a positive integer + // sets the cap, null clears it (-> unlimited), omitted leaves it unchanged. + // The whole record is written back so non-budget fields are preserved. + .patch("/workspaces/:name/limits", async (c) => { + const name = c.req.param("name"); + if (!(await allowWrite(c.env, name))) { + throw new RateLimitedError("rate limit exceeded"); + } + const record = await loadEditableWorkspace(c.env, name); + // Distinguish malformed JSON (400) from an intentionally empty object + // (a no-op patch): swallowing a parse failure into `{}` would silently + // 200 on a broken request and rewrite the record unchanged. + let body: unknown; + try { + body = await c.req.json(); + } catch { + throw new ValidationError("request body must be valid JSON", { code: "invalid_limit" }); + } + const patch = validateLimitsPatch(body); + for (const field of LIMIT_FIELDS) { + if (!(field in patch)) continue; + const value = patch[field]; + if (value === null) delete record[field]; + else record[field] = value; + } + await c.env.REGISTRY.put(`ws:${name}`, JSON.stringify(record)); + return c.json(await limitsResponse(c.env, name, record)); + }) + // OAuth client registrations — proxied 1:1 to Lane 1's internal routes // (see .context/2026-07-18-oauth-admin-panel-contract.md). Never re-validate // beyond parsing JSON; the auth worker owns validation. diff --git a/apps/api/src/self-serve-defaults.test.ts b/apps/api/src/self-serve-defaults.test.ts index 30824434..ba243e8f 100644 --- a/apps/api/src/self-serve-defaults.test.ts +++ b/apps/api/src/self-serve-defaults.test.ts @@ -17,7 +17,7 @@ describe("selfServeWorkspaceRecord", () => { selfServe: true, createdByUserId: "u1", createdAt: "2026-07-14T00:00:00.000Z", - maxStorageBytes: 1_000_000_000, + maxStorageBytes: 250_000_000, maxUploadsPerPeriod: 3000, maxUploadBytes: 25_000_000, maxVideoUploadBytes: 8_000_000, @@ -31,8 +31,8 @@ describe("selfServeWorkspaceRecord", () => { const b = selfServeWorkspaceRecord({ name: "b", userId: "u", now: new Date(0) }); expect(a.allowedKeyPrefixes).not.toBe(b.allowedKeyPrefixes); }); - it("limits are 1GB/3000-per-month", () => { - expect(SELF_SERVE_LIMITS.maxStorageBytes).toBe(1_000_000_000); + it("limits are 250MB/3000-per-month", () => { + expect(SELF_SERVE_LIMITS.maxStorageBytes).toBe(250_000_000); expect(SELF_SERVE_LIMITS.maxUploadsPerPeriod).toBe(3000); }); }); diff --git a/apps/api/src/self-serve-defaults.ts b/apps/api/src/self-serve-defaults.ts index 1eba04ce..d8aafc98 100644 --- a/apps/api/src/self-serve-defaults.ts +++ b/apps/api/src/self-serve-defaults.ts @@ -2,12 +2,14 @@ * Record template for self-serve workspaces (spec 2026-07-14, D3). * Deliberately tighter than the operator template in * scripts/workspace-limit-defaults.json (25 GB): self-serve tenants start at - * 1 GB / 3000 uploads per UTC month; raises are admin-only. + * 250 MB / 3000 uploads per UTC month; raises are admin-only. Uploads are + * WebP-converted by default, so 250 MB of stored assets holds far more + * screenshots than the raw byte figure suggests. */ import type { WorkspaceRecord } from "./workspace"; export const SELF_SERVE_LIMITS = { - maxStorageBytes: 1_000_000_000, + maxStorageBytes: 250_000_000, maxUploadsPerPeriod: 3000, maxUploadBytes: 25_000_000, maxVideoUploadBytes: 8_000_000, diff --git a/apps/api/src/workspace-limits.test.ts b/apps/api/src/workspace-limits.test.ts new file mode 100644 index 00000000..234ed5fb --- /dev/null +++ b/apps/api/src/workspace-limits.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { validateLimitsPatch } from "./workspace-limits"; + +describe("validateLimitsPatch", () => { + it("accepts positive integers for each field", () => { + expect( + validateLimitsPatch({ + maxStorageBytes: 250_000_000, + maxUploadsPerPeriod: 3000, + maxUploadBytes: 25_000_000, + maxVideoUploadBytes: 8_000_000, + }), + ).toEqual({ + maxStorageBytes: 250_000_000, + maxUploadsPerPeriod: 3000, + maxUploadBytes: 25_000_000, + maxVideoUploadBytes: 8_000_000, + }); + }); + + it("accepts null (clear to unlimited)", () => { + expect(validateLimitsPatch({ maxStorageBytes: null })).toEqual({ maxStorageBytes: null }); + }); + + it("only includes fields present in the body and ignores unknown keys", () => { + expect(validateLimitsPatch({ maxUploadBytes: 5, somethingElse: 9 })).toEqual({ + maxUploadBytes: 5, + }); + }); + + it("rejects zero, negatives, and non-integers", () => { + for (const bad of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) { + expect(() => validateLimitsPatch({ maxStorageBytes: bad })).toThrow( + /invalid_limit|positive/i, + ); + } + }); + + it("rejects non-number, non-null values", () => { + for (const bad of ["100", true, {}, []]) { + expect(() => validateLimitsPatch({ maxUploadsPerPeriod: bad })).toThrow(); + } + }); + + it("rejects a non-object body", () => { + expect(() => validateLimitsPatch(null)).toThrow(); + expect(() => validateLimitsPatch("nope")).toThrow(); + expect(() => validateLimitsPatch([1, 2])).toThrow(); + }); + + it("carries code invalid_limit on the thrown error", () => { + try { + validateLimitsPatch({ maxStorageBytes: -5 }); + throw new Error("should have thrown"); + } catch (err) { + expect((err as { code?: string }).code).toBe("invalid_limit"); + } + }); +}); diff --git a/apps/api/src/workspace-limits.ts b/apps/api/src/workspace-limits.ts new file mode 100644 index 00000000..beea0bc3 --- /dev/null +++ b/apps/api/src/workspace-limits.ts @@ -0,0 +1,45 @@ +/** + * Validates the budget-limit patch body accepted by the admin panel's + * PATCH /admin-ui/workspaces/:name/limits endpoint. Pure — no I/O — so it is + * unit-tested directly and could back a future token-gated /admin twin. + * + * Mirrors set-workspace-limits.mjs's field set and clear semantics, but only + * the four numeric budget fields (no retention / key-policy). A value is a + * finite integer >= 1 (set the cap) or null (clear the field -> unlimited). + */ +import { ValidationError } from "@uploads/errors"; + +export const LIMIT_FIELDS = [ + "maxStorageBytes", + "maxUploadsPerPeriod", + "maxUploadBytes", + "maxVideoUploadBytes", +] as const; + +export type LimitField = (typeof LIMIT_FIELDS)[number]; + +export type LimitsPatch = Partial>; + +export function validateLimitsPatch(body: unknown): LimitsPatch { + if (typeof body !== "object" || body === null || Array.isArray(body)) { + throw new ValidationError("limits body must be a JSON object", { code: "invalid_limit" }); + } + const record = body as Record; + const patch: LimitsPatch = {}; + for (const field of LIMIT_FIELDS) { + if (!(field in record)) continue; + const value = record[field]; + if (value === null) { + patch[field] = null; + continue; + } + if (typeof value !== "number" || !Number.isInteger(value) || value < 1) { + throw new ValidationError(`${field} must be a positive integer or null`, { + code: "invalid_limit", + details: { field }, + }); + } + patch[field] = value; + } + return patch; +} diff --git a/apps/web/src/pages/admin/index.astro b/apps/web/src/pages/admin/index.astro index 88e93a8c..d6a63ca7 100644 --- a/apps/web/src/pages/admin/index.astro +++ b/apps/web/src/pages/admin/index.astro @@ -59,6 +59,184 @@ export const prerender = false; return (await res.json()) as T; } + interface Limits { + maxStorageBytes: number | null; + maxUploadsPerPeriod: number | null; + maxUploadBytes: number | null; + maxVideoUploadBytes: number | null; + } + interface LimitsResponse { + workspace: string; + limits: Limits; + usage: { bytes: number; uploads: number } | null; + } + + const LIMIT_UNITS: { label: string; mult: number }[] = [ + { label: "MB", mult: 1_000_000 }, + { label: "GB", mult: 1_000_000_000 }, + { label: "GiB", mult: 1024 ** 3 }, + ]; + const LIMIT_FIELDS: { key: keyof Limits; label: string; byte: boolean }[] = [ + { key: "maxStorageBytes", label: "Storage", byte: true }, + { key: "maxUploadsPerPeriod", label: "Uploads / month", byte: false }, + { key: "maxUploadBytes", label: "Max file size", byte: true }, + { key: "maxVideoUploadBytes", label: "Max video size", byte: true }, + ]; + + /** Pick a friendly unit + value for a byte count (exact GiB > GB > MB). */ + function splitBytes(n: number): { value: number; unit: string } { + for (const u of [...LIMIT_UNITS].reverse()) { + if (n % u.mult === 0) return { value: n / u.mult, unit: u.label }; + } + return { value: n / 1_000_000, unit: "MB" }; + } + function formatBytes(n: number): string { + const { value, unit } = splitBytes(n); + return `${value} ${unit}`; + } + + function renderLimitsForm(host: HTMLElement, workspace: string, data: LimitsResponse): void { + const rows = LIMIT_FIELDS.map((f) => { + const raw = data.limits[f.key]; + const unlimited = raw === null; + if (f.byte) { + const split = raw === null ? { value: "", unit: "MB" } : splitBytes(raw); + const options = LIMIT_UNITS.map( + (u) => ``, + ).join(""); + return ` +
+ + + + +
`; + } + return ` +
+ + + ` + + `
`; + }).join(""); + + const usageLine = + data.usage && data.limits.maxStorageBytes !== null + ? `

${formatBytes(data.usage.bytes)} of ${formatBytes(data.limits.maxStorageBytes)} stored · ${data.usage.uploads} uploads this month

` + : data.usage + ? `

${formatBytes(data.usage.bytes)} stored · ${data.usage.uploads} uploads this month

` + : ""; + + host.innerHTML = ` +

Limits

+ ${usageLine} +
+ ${rows} + + +
`; + + // Unlimited checkbox toggles its row's inputs. + host.querySelectorAll(".limit-row").forEach((row) => { + const check = row.querySelector(".limit-unlim"); + // Not querySelectorAll: + // worker-configuration.d.ts's HTMLRewriter ambient `Element` + // redeclares `remove()`, which makes HTMLSelectElement fail the + // `T extends Element` constraint. Fetch as HTMLElement and cast. + const inputs = row.querySelectorAll( + ".limit-value, .limit-unit", + ) as unknown as NodeListOf; + check?.addEventListener("change", () => { + inputs.forEach((el) => (el.disabled = check.checked)); + }); + }); + + const form = host.querySelector(".limits-form"); + const statusEl = host.querySelector(".limit-status"); + form?.addEventListener("submit", (event) => { + void (async () => { + event.preventDefault(); + if (statusEl) statusEl.hidden = true; + let body: Record; + try { + body = buildLimitsBody(host); + } catch (err) { + if (statusEl) { + statusEl.dataset.state = "error"; + statusEl.textContent = err instanceof Error ? err.message : "Invalid input."; + statusEl.hidden = false; + } + return; + } + const submitBtn = form.querySelector("button[type=submit]"); + if (submitBtn) submitBtn.disabled = true; + try { + const res = await fetch( + `${apiOrigin}/admin-ui/workspaces/${encodeURIComponent(workspace)}/limits`, + { + method: "PATCH", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + const payload = (await res.json().catch(() => null)) as + | { error?: { message?: string } } + | null; + throw new Error(payload?.error?.message || `save failed: ${res.status}`); + } + const updated = (await res.json()) as LimitsResponse; + renderLimitsForm(host, workspace, updated); + const freshStatus = host.querySelector(".limit-status"); + if (freshStatus) { + freshStatus.dataset.state = "ready"; + freshStatus.textContent = "Saved. Changes apply within ~60s."; + freshStatus.hidden = false; + } + } catch (err) { + if (statusEl) { + statusEl.dataset.state = "error"; + statusEl.textContent = err instanceof Error && err.message ? err.message : "Couldn't save limits."; + statusEl.hidden = false; + } + } finally { + if (submitBtn) submitBtn.disabled = false; + } + })(); + }); + } + + /** Read the form into a PATCH body; throws on empty non-unlimited fields. */ + function buildLimitsBody(host: HTMLElement): Record { + const body: Record = {}; + host.querySelectorAll(".limit-row").forEach((row) => { + const field = row.dataset.field; + if (!field) return; + const unlimited = row.querySelector(".limit-unlim")?.checked; + if (unlimited) { + body[field] = null; + return; + } + const valueInput = row.querySelector(".limit-value"); + const num = Number(valueInput?.value); + if (!valueInput?.value || !Number.isInteger(num) || num < 1) { + throw new Error(`Enter a whole number ≥ 1 for "${field}", or check Unlimited.`); + } + if (row.dataset.byte) { + // Not querySelector: same worker-configuration.d.ts + // `Element.remove()` drift noted above. Fetch as HTMLElement and cast. + const unitEl = row.querySelector(".limit-unit") as unknown as HTMLSelectElement | null; + const unit = unitEl?.value ?? "MB"; + const mult = LIMIT_UNITS.find((u) => u.label === unit)?.mult ?? 1_000_000; + body[field] = Math.floor(num * mult); + } else { + body[field] = num; + } + }); + return body; + } + function renderWorkspace(ws: WorkspaceSummary): HTMLElement { const details = document.createElement("details"); details.className = "workspace"; @@ -71,6 +249,7 @@ export const prerender = false;
Members load when expanded.
+
${ ws.organization ? `
@@ -96,6 +275,7 @@ export const prerender = false; `; let loaded = false; + let limitsLoaded = false; details.addEventListener("toggle", () => { void (async () => { if (!details.open || loaded) return; @@ -133,6 +313,23 @@ export const prerender = false; if (membersEl) membersEl.textContent = "Failed to load members."; } })(); + // Limits apply to every workspace regardless of org, and their load + // must not depend on the members/invites fetch — so run it in its own + // block with its own retry flag (only set on success). + void (async () => { + if (!details.open || limitsLoaded) return; + const limitsEl = details.querySelector(".limits"); + if (!limitsEl) return; + try { + const limitsData = await apiGet( + `/admin-ui/workspaces/${encodeURIComponent(ws.workspace)}/limits`, + ); + renderLimitsForm(limitsEl, ws.workspace, limitsData); + limitsLoaded = true; + } catch { + limitsEl.innerHTML = `

Failed to load limits.

`; + } + })(); }); const form = details.querySelector(".invite-form"); diff --git a/apps/web/src/styles/admin-workspaces.css b/apps/web/src/styles/admin-workspaces.css index 16e318ae..960ba9da 100644 --- a/apps/web/src/styles/admin-workspaces.css +++ b/apps/web/src/styles/admin-workspaces.css @@ -128,3 +128,61 @@ padding: 7px 9px; font: 11px var(--mono); } +.workspace .limits { + margin-top: 16px; + border-top: 1px solid var(--border, #e5e7eb); + padding-top: 12px; +} +.workspace .limits-heading { + margin: 0 0 8px; + font-size: 14px; +} +.workspace .limit-usage { + margin: 0 0 10px; + font-size: 12px; +} +.workspace .limits-form { + display: flex; + flex-direction: column; + gap: 8px; +} +.workspace .limit-row { + display: grid; + grid-template-columns: 130px 1fr auto auto; + align-items: center; + gap: 8px; +} +.workspace .limit-row[data-byte] { + grid-template-columns: 130px 1fr 70px auto; +} +.workspace .limit-row > label:first-child { + font-size: 13px; +} +.workspace .limit-value, +.workspace .limit-unit { + padding: 4px 6px; + font: inherit; +} +.workspace .limit-value:disabled, +.workspace .limit-unit:disabled { + opacity: 0.5; +} +.workspace .limit-unlimited { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + white-space: nowrap; +} +.workspace .limits-form button { + align-self: flex-start; + margin-top: 4px; +} +.workspace .limit-status[data-state="error"] { + color: var(--danger, #b91c1c); + font-size: 12px; +} +.workspace .limit-status[data-state="ready"] { + color: var(--muted, #6b7280); + font-size: 12px; +} diff --git a/docs/superpowers/plans/2026-07-20-admin-workspace-limits-editing.md b/docs/superpowers/plans/2026-07-20-admin-workspace-limits-editing.md new file mode 100644 index 00000000..da1455d7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-admin-workspace-limits-editing.md @@ -0,0 +1,843 @@ +# Admin Workspace Limits Editing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a global admin edit a single workspace's four numeric budget limits (storage, monthly uploads, per-file, per-video) from the admin panel's Workspaces view, including clearing any of them to unlimited. + +**Architecture:** A pure validation helper (`workspace-limits.ts`) parses/validates a PATCH body. Two new session-cookie-gated endpoints on the existing `/admin-ui/*` router read and write the four fields on the KV `WorkspaceRecord` — reading the raw record, mutating only the four budget fields (set number / delete for unlimited), and writing the whole record back so all other fields survive. The admin Workspaces page gains a lazily-loaded Limits form inside each expanded workspace row. + +**Tech Stack:** TypeScript, Hono (Cloudflare Workers), Vitest, Astro (vanilla TS in a `