diff --git a/apps/api/src/routes/admin-ui.test.ts b/apps/api/src/routes/admin-ui.test.ts index 1bce02ca..0ce31f5c 100644 --- a/apps/api/src/routes/admin-ui.test.ts +++ b/apps/api/src/routes/admin-ui.test.ts @@ -32,13 +32,23 @@ function stubEnv( return { AUTH: auth, REGISTRY: fakeKv([]) } as unknown as Env; } -function fakeKv(names: string[]): Pick { +function fakeKv( + names: string[], + records: Record> = {}, +): Pick { return { list: (async () => ({ keys: names.map((name) => ({ name: `ws:${name}` })), list_complete: true, cacheStatus: null, })) as unknown as KVNamespace["list"], + // The workspaces list now reads each record for its plan + BYOB flags + // (loadWorkspaceRecord). Return the seeded record (already parsed, since + // callers pass `{ type: "json" }`) or null for an unseeded workspace. + get: (async (key: string) => { + const name = key.startsWith("ws:") ? key.slice(3) : key; + return records[name] ?? null; + }) as unknown as KVNamespace["get"], }; } @@ -92,11 +102,45 @@ describe("GET /admin-ui/workspaces", () => { organization: { id: "org1", slug: "acme", name: "acme" }, memberCount: 2, pendingInviteCount: 1, + plan: "free", + byob: false, }, ], }); }); + it("reports plan + BYOB from each workspace record", async () => { + const auth = stubAuth((req) => { + const url = new URL(req.url); + if (url.pathname === "/api/auth/get-session") { + return new Response(JSON.stringify({ session: {}, user: ADMIN_USER }), { status: 200 }); + } + if (url.pathname === "/internal/orgs/summaries") { + return Response.json({ organizations: [] }); + } + return new Response(null, { status: 404 }); + }); + const env = { + AUTH: auth, + REGISTRY: fakeKv(["paid", "byo", "plain"], { + // Pro tier, still on the shared bucket. + paid: { plan: "pro" }, + // Free tier, but on their own bucket (customer S3 credentials, no + // binding) → isByoRecord true. + byo: { accountId: "acc", accessKeyId: "ak", secretAccessKey: "sk" }, + }), + } as unknown as Env; + const res = await app().request("/admin-ui/workspaces", {}, env); + expect(res.status).toBe(200); + const body = (await res.json()) as { + workspaces: { workspace: string; plan: string; byob: boolean }[]; + }; + const byName = Object.fromEntries(body.workspaces.map((w) => [w.workspace, w])); + expect(byName.paid).toMatchObject({ plan: "pro", byob: false }); + expect(byName.byo).toMatchObject({ plan: "free", byob: true }); + expect(byName.plain).toMatchObject({ plan: "free", byob: false }); + }); + it("leaves org null when a workspace has no matching summary", async () => { const auth = stubAuth((req) => { const url = new URL(req.url); @@ -118,6 +162,8 @@ describe("GET /admin-ui/workspaces", () => { organization: null, memberCount: 0, pendingInviteCount: 0, + plan: "free", + byob: false, }, ], }); diff --git a/apps/api/src/routes/admin-ui.ts b/apps/api/src/routes/admin-ui.ts index 68c0c379..dd78a66c 100644 --- a/apps/api/src/routes/admin-ui.ts +++ b/apps/api/src/routes/admin-ui.ts @@ -66,6 +66,7 @@ import { getWorkspaceUsage } from "../usage"; import { byoBucketAllowed, isPurgedTombstone, + loadWorkspaceRecord, loadWorkspaceRecordRaw, type WorkspaceRecord, } from "../workspace"; @@ -73,7 +74,7 @@ import { mutateWorkspaceRecord } from "../workspace-mutate"; import { LIMIT_FIELDS, validateLimitsPatch } from "../workspace-limits"; import { planResponse, planSourceFor, validatePlanPatch } from "../workspace-plan"; import { getPlan, resolveEffectiveLimits, type WorkspacePlanLimits } from "@uploads/billing"; -import { storageStatusResponse } from "./workspace-storage"; +import { isByoRecord, storageStatusResponse } from "./workspace-storage"; import { dbFor } from "../db-session"; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -200,13 +201,29 @@ async function allOrgSummaries(env: Env): Promise> { return map; } -/** One row of the `/admin-ui/workspaces` list: the KV workspace + its org counts. */ -function workspaceSummaryResponse(name: string, summary: OrgSummary | undefined) { +/** + * One row of the `/admin-ui/workspaces` list: the KV workspace + its org + * counts, plus the two at-a-glance signals an operator otherwise had to + * expand a row to see. Both derive from the workspace record alone (no extra + * AUTH round-trip): `plan` is the catalog id (`getPlan` fails open to "free" + * for a legacy/unapplied record, never "pro"), and `byob` is the storage + * mode's `"byo"` bit — the same `isByoRecord` gate `storageStatusResponse` + * uses for `mode`. `plan` deliberately does NOT distinguish Stripe-paid from + * admin-comped (that needs the per-workspace subscription lookup the drawer's + * plan endpoint does); the list only answers "free vs paid tier". + */ +function workspaceSummaryResponse( + name: string, + summary: OrgSummary | undefined, + record: WorkspaceRecord | null, +) { return { workspace: name, organization: summary?.organization ?? null, memberCount: summary?.memberCount ?? 0, pendingInviteCount: summary?.pendingInviteCount ?? 0, + plan: getPlan(record?.plan).id, + byob: record ? isByoRecord(record) : false, }; } @@ -588,7 +605,18 @@ export const adminUi = new Hono() } while (cursor); const summaries = await allOrgSummaries(c.env); - const workspaces = names.map((name) => workspaceSummaryResponse(name, summaries.get(name))); + // Plan + BYOB come off each workspace record. KV has no multi-get and the + // list enumeration returns keys only, so a per-workspace read is + // unavoidable; run them as one parallel fan-out rather than serially. This + // adds N subrequests (one KV get each) on top of the list pages — fine for + // the operator surface's current workspace count, but note it scales with N + // and would need chunking or a denormalized summary blob before N could + // approach the Workers subrequest ceiling. A null record (soft-deleted / + // purged tombstone) falls back to free / shared, same as an unknown workspace. + const records = await Promise.all(names.map((name) => loadWorkspaceRecord(c.env, name))); + const workspaces = names.map((name, i) => + workspaceSummaryResponse(name, summaries.get(name), records[i]), + ); return c.json({ workspaces }); }) diff --git a/apps/web/src/components/admin/AdminWorkspacesTable.tsx b/apps/web/src/components/admin/AdminWorkspacesTable.tsx new file mode 100644 index 00000000..6e4f7bf6 --- /dev/null +++ b/apps/web/src/components/admin/AdminWorkspacesTable.tsx @@ -0,0 +1,169 @@ +/** + * The admin operator "Workspaces" view: a shadcn `Table` of every registered + * workspace with at-a-glance Plan and BYOB columns (previously only visible by + * expanding a row), and a right-hand `Sheet` drawer for the full per-workspace + * detail (people, plan, limits, storage, GitHub links). Replaces the imperative + * innerHTML table the page shipped with. + * + * The single island mounted by `pages/admin/index.astro` (manual SSR + + * hydrateRoot, no `client:*` — same mechanism as WorkspaceFileTable). It + * composes `IslandErrorBoundary` itself so mounting it directly reconciles + * against the SSR'd tree without an extra wrapper. Data is fetched on mount + * against `/admin-ui/*`, which independently enforces admin access — the + * client-side gate in AdminLayout is a UX affordance only. + */ +import { useEffect, useMemo, useState } from "react"; +import { Badge } from "@uploads/ui/components/ui/badge"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@uploads/ui/components/ui/sheet"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@uploads/ui/components/ui/table"; +import "@uploads/ui/styles.css"; +import { IslandErrorBoundary } from "../IslandErrorBoundary"; +import { makeAdminApi, type AdminWorkspaceSummary } from "../../lib/admin-api"; +import { WorkspaceDetail } from "./WorkspaceDetail"; + +type LoadState = + | { status: "loading" } + | { status: "error" } + | { status: "ok"; workspaces: AdminWorkspaceSummary[] }; + +export interface AdminWorkspacesTableProps { + apiOrigin: string; +} + +function AdminWorkspacesTableInner({ apiOrigin }: AdminWorkspacesTableProps) { + const api = useMemo(() => makeAdminApi(apiOrigin), [apiOrigin]); + const [state, setState] = useState({ status: "loading" }); + const [selected, setSelected] = useState(null); + + useEffect(() => { + let alive = true; + api + .listWorkspaces() + .then((workspaces) => alive && setState({ status: "ok", workspaces })) + .catch(() => alive && setState({ status: "error" })); + return () => { + alive = false; + }; + }, [api]); + + if (state.status === "loading") { + return

Loading…

; + } + if (state.status === "error") { + return

Failed to load workspaces.

; + } + if (state.workspaces.length === 0) { + return

No workspaces yet.

; + } + + return ( + <> +
+ + + + Workspace + Organization + Plan + Storage + Members + Pending + + + + {state.workspaces.map((ws) => ( + setSelected(ws)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setSelected(ws); + } + }} + > + + {ws.workspace} + + + {ws.organization ? ws.organization.name : "no organization yet"} + + + {ws.plan === "free" ? ( + Free + ) : ( + {ws.plan === "pro" ? "Pro" : ws.plan} + )} + + + {ws.byob ? ( + BYO + ) : ( + Shared + )} + + {ws.memberCount} + {ws.pendingInviteCount} + + ))} + +
+
+ + { + if (!open) setSelected(null); + }} + > + + {selected && ( + <> + + {selected.workspace} + + {selected.organization + ? selected.organization.name + : "No organization provisioned yet"} + + +
+ +
+ + )} +
+
+ + ); +} + +export function AdminWorkspacesTable(props: AdminWorkspacesTableProps) { + return ( + + + + ); +} diff --git a/apps/web/src/components/admin/GithubLinksSection.tsx b/apps/web/src/components/admin/GithubLinksSection.tsx new file mode 100644 index 00000000..721571ef --- /dev/null +++ b/apps/web/src/components/admin/GithubLinksSection.tsx @@ -0,0 +1,35 @@ +/** + * GitHub repos claimed by the workspace — read-only list, the React port of + * the imperative `renderGithubLinks`. Renders nothing when there are no links + * (same as before), so the section collapses out of the drawer. + */ +import type { AdminApi } from "../../lib/admin-api"; +import { Muted, SectionHeading } from "./StatusLine"; +import { useAdminResource } from "./use-admin-resource"; + +export function GithubLinksSection({ api, workspace }: { api: AdminApi; workspace: string }) { + const { data: links, error } = useAdminResource( + () => api.getGithubLinks(workspace), + [api, workspace], + ); + + if (error) return Failed to load GitHub links.; + if (!links || links.length === 0) return null; + + return ( +
+ GitHub repos claimed +
    + {links.map((l) => ( +
  • + {l.repo} + {l.source} +
  • + ))} +
+
+ ); +} diff --git a/apps/web/src/components/admin/LimitsEditor.tsx b/apps/web/src/components/admin/LimitsEditor.tsx new file mode 100644 index 00000000..1845d615 --- /dev/null +++ b/apps/web/src/components/admin/LimitsEditor.tsx @@ -0,0 +1,237 @@ +/** + * Limits section of the workspace drawer — the React port of the imperative + * page's `renderLimitsForm` + `buildLimitsBody`. Same semantics, unchanged: + * + * - A field with no explicit override, on a plan-applied record, is pre-filled + * with the plan default and tagged "plan default" (issue #613). An untouched + * default row is NOT sent as an override — leaving it absent keeps the plan + * driving that cap across a later plan change (the `defaultValue` skip). + * - `Unlimited` sends `null`; a checked box disables the row's inputs. + * - Storage/uploads usage shows a compact percent-full bar (≥80% warns, ≥100% + * over), omitted for an unlimited cap. + */ +import { useEffect, useMemo, useState } from "react"; +import { Button } from "@uploads/ui/components/ui/button"; +import { errMessage, type AdminApi, type AdminLimitsResponse } from "../../lib/admin-api"; +import { + formatBytes, + LIMIT_FIELDS, + LIMIT_UNITS, + multForUnit, + splitBytes, +} from "../../lib/admin-limits"; +import { INPUT_NUM, SELECT_SM } from "./field-classes"; +import { Muted, SectionHeading, StatusLine } from "./StatusLine"; +import { useAdminResource } from "./use-admin-resource"; + +interface FieldState { + value: string; + unit: string; + unlimited: boolean; +} + +type LimitKey = (typeof LIMIT_FIELDS)[number]["key"]; + +/** Per-field editable state seeded from a limits response. */ +function seedFields(data: AdminLimitsResponse): Record { + const out = {} as Record; + for (const f of LIMIT_FIELDS) { + const raw = data.limits[f.key]; + if (raw === null) { + out[f.key] = { value: "", unit: "MB", unlimited: true }; + } else if (f.byte) { + const split = splitBytes(raw); + out[f.key] = { value: String(split.value), unit: split.unit, unlimited: false }; + } else { + out[f.key] = { value: String(raw), unit: "MB", unlimited: false }; + } + } + return out; +} + +/** + * Compact inline percent-full bar; renders nothing for an unlimited/zero cap. + * Self-contained (Tailwind + inline fill) so the drawer needs no page CSS: + * ≥80% is orange, ≥100% red, else accent — the same thresholds the imperative + * `.limit-storage-bar` used. + */ +function StorageBar({ used, cap }: { used: number; cap: number | null }) { + if (cap === null || cap <= 0) return null; + const pct = Math.round((used / cap) * 100); + const fill = Math.min(100, pct); + const color = pct >= 100 ? "var(--red)" : pct >= 80 ? "var(--orange, #d97706)" : "var(--accent)"; + return ( + <> + + + {" "} + {pct}% + + ); +} + +export function LimitsEditor({ api, workspace }: { api: AdminApi; workspace: string }) { + const { data, error, setData } = useAdminResource( + () => api.getLimits(workspace), + [api, workspace], + ); + const [fields, setFields] = useState | null>(null); + const [saving, setSaving] = useState(false); + const [status, setStatus] = useState<{ state: "error" | "ok"; message: string } | null>(null); + + // Reseed the editable rows whenever the load (or a save) returns fresh data. + useEffect(() => { + if (data) setFields(seedFields(data)); + }, [data]); + + // The inherited plan-default value per field (only when plan-applied and not + // an explicit override), used to skip an untouched default row on save. + const defaults = useMemo(() => { + const out: Partial> = {}; + if (!data) return out; + for (const f of LIMIT_FIELDS) { + const raw = data.limits[f.key]; + if (data.planApplied && !data.overrides.includes(f.key) && raw !== null) { + out[f.key] = raw; + } + } + return out; + }, [data]); + + if (error) return Failed to load limits.; + if (!data || !fields) return Loading limits…; + + function update(key: LimitKey, patch: Partial) { + setFields((prev) => (prev ? { ...prev, [key]: { ...prev[key], ...patch } } : prev)); + } + + /** Read the form into a PATCH body; throws on empty non-unlimited fields. */ + function buildBody(): Record { + const body: Record = {}; + for (const f of LIMIT_FIELDS) { + const state = fields![f.key]; + if (state.unlimited) { + body[f.key] = null; + continue; + } + const num = Number(state.value); + if (!state.value || !Number.isInteger(num) || num < 1) { + throw new Error(`Enter a whole number ≥ 1 for "${f.key}", or check Unlimited.`); + } + const value = f.byte ? Math.floor(num * multForUnit(state.unit)) : num; + // An untouched plan-default row is not an override — leave it absent. + if (defaults[f.key] !== undefined && defaults[f.key] === value) continue; + body[f.key] = value; + } + return body; + } + + async function save(event: React.FormEvent) { + event.preventDefault(); + setStatus(null); + let body: Record; + try { + body = buildBody(); + } catch (err) { + setStatus({ state: "error", message: err instanceof Error ? err.message : "Invalid input." }); + return; + } + setSaving(true); + try { + // The reseed effect above repopulates the rows from the returned data. + setData(await api.saveLimits(workspace, body)); + setStatus({ state: "ok", message: "Saved. Changes apply within ~60s." }); + } catch (err) { + setStatus({ state: "error", message: errMessage(err, "Couldn't save limits.") }); + } finally { + setSaving(false); + } + } + + const usage = data.usage; + + return ( +
+ Limits + {usage && ( +

+ {formatBytes(usage.bytes)} + {data.limits.maxStorageBytes !== null + ? ` of ${formatBytes(data.limits.maxStorageBytes)}` + : ""}{" "} + stored + · {usage.uploads} + {data.limits.maxUploadsPerPeriod !== null + ? ` of ${data.limits.maxUploadsPerPeriod}` + : ""}{" "} + uploads this month + {data.limits.maxUploadsPerPeriod !== null && ( + + )} +

+ )} +
+ {LIMIT_FIELDS.map((f) => { + const state = fields[f.key]; + const isDefault = defaults[f.key] !== undefined; + return ( +
+ + update(f.key, { value: e.target.value })} + /> + {f.byte && ( + + )} + + {isDefault && ( + + plan default + + )} +
+ ); + })} + + {status && {status.message}} +
+
+ ); +} diff --git a/apps/web/src/components/admin/PeopleSection.tsx b/apps/web/src/components/admin/PeopleSection.tsx new file mode 100644 index 00000000..bbc60639 --- /dev/null +++ b/apps/web/src/components/admin/PeopleSection.tsx @@ -0,0 +1,330 @@ +/** + * People section of the workspace drawer: members + pending invites (loaded + * together), the org-invite form, and the enrollment invite-link generator + * with per-link revoke. React port of the imperative page's `loadDetail` + * members/invites block, `renderInviteLinks`, and the two form handlers. + * + * Members/invites and the org-invite form only exist for a workspace that has + * an organization; the invite-link generator is workspace-level and always + * shown. After a mutation each affected list is refetched (the React + * equivalent of the imperative `loadOnce` reset), which sidesteps the + * in-flight-reload race the imperative version had to track by hand. + */ +import { useState } from "react"; +import { Button } from "@uploads/ui/components/ui/button"; +import { formatDate } from "../../lib/subscription-copy"; +import { errMessage, type AdminApi, type OpenEnrollment } from "../../lib/admin-api"; +import { FIELD_LABEL, INPUT_TEXT, SELECT_SM } from "./field-classes"; +import { Muted, SectionHeading, StatusLine } from "./StatusLine"; +import { useAdminResource } from "./use-admin-resource"; + +const SCOPES = ["files:read", "files:write"] as const; + +export function PeopleSection({ + api, + workspace, + hasOrg, +}: { + api: AdminApi; + workspace: string; + hasOrg: boolean; +}) { + // A successful invite bumps this nonce, which the members section takes as a + // load dep and refetches — a plain parent-owned signal, no global events. + const [membersReload, setMembersReload] = useState(0); + return ( +
+ {hasOrg ? : null} + {hasOrg ? ( + setMembersReload((n) => n + 1)} + /> + ) : ( + No organization provisioned for this workspace yet — run the org backfill. + )} + +
+ ); +} + +function MembersInvites({ + api, + workspace, + reloadKey, +}: { + api: AdminApi; + workspace: string; + reloadKey: number; +}) { + const { data, error } = useAdminResource( + () => + Promise.all([api.getMembers(workspace), api.getInvites(workspace)]).then( + ([members, invites]) => ({ members, invites }), + ), + [api, workspace, reloadKey], + ); + + if (error) return Failed to load members.; + if (!data) return Loading members…; + + const { members, invites } = data; + return ( +
+
+ Members + {members.length ? ( +
    + {members.map((m) => ( +
  • + {m.email} + {m.role} +
  • + ))} +
+ ) : ( + No members yet. + )} +
+ {invites.length > 0 && ( +
+ Pending invites +
    + {invites.map((i) => ( +
  • + {i.email} + pending +
  • + ))} +
+
+ )} +
+ ); +} + +function InviteForm({ + api, + workspace, + onInvited, +}: { + api: AdminApi; + workspace: string; + onInvited: () => void; +}) { + const [email, setEmail] = useState(""); + const [role, setRole] = useState("member"); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState<{ state: "error" | "ok"; message: string } | null>(null); + + async function submit(event: React.FormEvent) { + event.preventDefault(); + setStatus(null); + setBusy(true); + try { + await api.createInvite(workspace, { email: email.trim(), role }); + setStatus({ state: "ok", message: `Invited ${email.trim()}.` }); + setEmail(""); + onInvited(); + } catch (err) { + setStatus({ state: "error", message: errMessage(err, "Couldn't send the invite.") }); + } finally { + setBusy(false); + } + } + + return ( +
+
+ + + +
+ {status && {status.message}} +
+ ); +} + +function InviteLinks({ api, workspace }: { api: AdminApi; workspace: string }) { + const { + data: links, + error: loadError, + reload, + } = useAdminResource(() => api.getInviteLinks(workspace), [api, workspace]); + const [label, setLabel] = useState(""); + const [scopes, setScopes] = useState>({ + "files:read": true, + "files:write": true, + }); + const [generatedUrl, setGeneratedUrl] = useState(null); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState<{ state: "error" | "ok"; message: string } | null>(null); + + async function generate() { + const chosen = SCOPES.filter((s) => scopes[s]); + if (chosen.length === 0) { + setStatus({ state: "error", message: "Pick at least one scope." }); + return; + } + setStatus(null); + setGeneratedUrl(null); + setBusy(true); + try { + const url = await api.createInviteLink(workspace, { + label: label.trim() || undefined, + scopes: chosen, + }); + setGeneratedUrl(url); + setLabel(""); + reload(); + } catch (err) { + setStatus({ state: "error", message: errMessage(err, "Couldn't generate an invite link.") }); + } finally { + setBusy(false); + } + } + + async function copy() { + if (!generatedUrl) return; + try { + await navigator.clipboard.writeText(generatedUrl); + setStatus({ state: "ok", message: "Copied." }); + } catch { + setStatus({ state: "error", message: "Clipboard unavailable; copy the link manually." }); + } + } + + async function revoke(id: string) { + try { + await api.revokeInviteLink(workspace, id); + reload(); + } catch (err) { + setStatus({ state: "error", message: errMessage(err, "Couldn't revoke the link.") }); + } + } + + return ( +
+
+ +
+ Scopes + {SCOPES.map((s) => ( + + ))} +
+
+
+ + {generatedUrl && ( + <> + + + + )} +
+ {status && {status.message}} + {loadError ? ( + Failed to load invite links. + ) : links && links.length > 0 ? ( +
+ Invite links +
    + {links.map((link) => { + const expiry = link.expiresAt + ? `expires ${formatDate(link.expiresAt) ?? link.expiresAt}` + : "never expires"; + const uses = + link.kind === "member" + ? ` · ${link.useCount ?? 0}${link.maxUses ? `/${link.maxUses}` : ""} joins` + : ""; + const detail = link.kind === "member" ? "member" : link.scopes.join(", "); + return ( +
  • + + {link.label ? ( + link.label + ) : ( + unlabeled + )}{" "} + · {detail} · {expiry} + {uses} + + +
  • + ); + })} +
+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/admin/PlanEditor.tsx b/apps/web/src/components/admin/PlanEditor.tsx new file mode 100644 index 00000000..e147e661 --- /dev/null +++ b/apps/web/src/components/admin/PlanEditor.tsx @@ -0,0 +1,150 @@ +/** + * Plan section of the workspace drawer — the React port of the imperative + * page's `renderPlanSelector`. Same wire contract (`GET`/`PATCH + * /admin-ui/workspaces/:name/plan`, issue #445 subscription enrichment): the + * plan-source badge, self-serve availability, subscription status + Stripe + * deep link, customer tenure, and the legacy "no plan applied" caveat all + * carry over unchanged. `stripeCustomerId` is admin-ui-only (never sent to + * /me) and only present when a subscription row exists. + */ +import { useEffect, useState } from "react"; +import { Badge } from "@uploads/ui/components/ui/badge"; +import { Button } from "@uploads/ui/components/ui/button"; +import { formatDate } from "../../lib/subscription-copy"; +import { errMessage, type AdminApi, type AdminPlanResponse } from "../../lib/admin-api"; +import { SELECT_SM } from "./field-classes"; +import { Muted, SectionHeading, StatusLine } from "./StatusLine"; +import { useAdminResource } from "./use-admin-resource"; + +const PLAN_OPTIONS: { id: "free" | "pro"; label: string }[] = [ + { id: "free", label: "Free" }, + { id: "pro", label: "Pro (unavailable to self-serve)" }, +]; + +// Badge tone per plan source — the at-a-glance signal a paid workspace should +// be instantly distinguishable by, versus free and versus admin-comped. Free +// gets no badge; there's nothing "upgraded" to call out. +const PLAN_BADGE: Record< + AdminPlanResponse["planSource"], + { label: string; variant: "default" | "secondary" } | null +> = { + stripe: { label: "Pro · Stripe", variant: "default" }, + admin: { label: "Pro · comped", variant: "secondary" }, + none: null, +}; + +/** Months elapsed between an ISO date and now, floored, minimum 0. */ +function monthsSince(iso: string): number { + const then = new Date(iso); + if (Number.isNaN(then.getTime())) return 0; + const now = new Date(); + let months = (now.getFullYear() - then.getFullYear()) * 12 + (now.getMonth() - then.getMonth()); + if (now.getDate() < then.getDate()) months -= 1; + return Math.max(0, months); +} + +export function PlanEditor({ api, workspace }: { api: AdminApi; workspace: string }) { + const { data, error, setData } = useAdminResource(() => api.getPlan(workspace), [api, workspace]); + const [selected, setSelected] = useState("free"); + const [saving, setSaving] = useState(false); + const [status, setStatus] = useState<{ state: "error" | "ok"; message: string } | null>(null); + + // Sync the plan dropdown to whatever the load (or a save) most recently + // returned; the operator's in-progress choice is otherwise left alone. + useEffect(() => { + if (data) setSelected(data.plan); + }, [data]); + + if (error) return Failed to load plan.; + if (!data) return Loading plan…; + + const badge = PLAN_BADGE[data.planSource]; + const sub = data.subscription; + const periodEndText = sub ? formatDate(sub.periodEnd) : null; + + async function save(event: React.FormEvent) { + event.preventDefault(); + setStatus(null); + setSaving(true); + try { + setData(await api.savePlan(workspace, selected)); + setStatus({ state: "ok", message: "Saved." }); + } catch (err) { + setStatus({ state: "error", message: errMessage(err, "Couldn't save plan.") }); + } finally { + setSaving(false); + } + } + + return ( +
+ + Plan{" "} + {badge && ( + + {badge.label} + + )} + + {data.available ? "Available" : "Not available for self-serve upgrade"} + {sub && ( + + Subscription: {sub.status} + {sub.cancelAtPeriodEnd && periodEndText + ? ` · cancels on ${periodEndText}` + : periodEndText + ? ` · renews ${periodEndText}` + : ""} + {sub.stripeCustomerId ? ( + <> + {" · "} + + View in Stripe + + + ) : null} + + )} + {data.paidSince && ( + + Customer since {formatDate(data.paidSince) ?? data.paidSince} ( + {monthsSince(data.paidSince)} mo) + + )} + {!data.planApplied && ( + + No plan applied (legacy) — limits shown are the enforcement truth (explicit-or-unlimited), + not this plan's defaults. + + )} +
+ + + {status && ( +
+ {status.message} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/admin/StatusLine.tsx b/apps/web/src/components/admin/StatusLine.tsx new file mode 100644 index 00000000..2a9b898a --- /dev/null +++ b/apps/web/src/components/admin/StatusLine.tsx @@ -0,0 +1,34 @@ +/** + * Inline save/error status line for the drawer editors — the React equivalent + * of the imperative page's `data-state`-driven `.plan-status` / `.limit-status` + * spans. `error` uses the destructive token, `ok` the accent, and both are + * announced politely (aria-live) the way the original status nodes were. + */ +import type { ReactNode } from "react"; +import { ADMIN_DETAIL_HEADING } from "../../lib/admin-ui"; + +export type StatusState = "error" | "ok"; + +export function StatusLine({ state, children }: { state: StatusState; children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +/** Uppercase section heading inside the drawer — the shared admin heading style. */ +export function SectionHeading({ children }: { children: ReactNode }) { + return

{children}

; +} + +/** Muted secondary paragraph, matching the page's `.muted` copy. */ +export function Muted({ children }: { children: ReactNode }) { + return

{children}

; +} diff --git a/apps/web/src/components/admin/StorageEditor.tsx b/apps/web/src/components/admin/StorageEditor.tsx new file mode 100644 index 00000000..b0e2391b --- /dev/null +++ b/apps/web/src/components/admin/StorageEditor.tsx @@ -0,0 +1,126 @@ +/** + * Storage / BYO-bucket section of the workspace drawer — the React port of the + * imperative page's `renderStorageForm` (issue #583 Task 3.3). Read-out of the + * workspace's storage mode, the `byoBucketEnabled` gate, and configure/verify + * provenance (masked/presence fields only — never a credential value, matching + * the /me storage projection), plus `configuredBy` (admin-ui-only). The only + * write is the `byoBucketEnabled` kill-switch; lane activation/removal stays on + * the workspace's own settings page. + */ +import { useEffect, useState } from "react"; +import { Button } from "@uploads/ui/components/ui/button"; +import { formatDate } from "../../lib/subscription-copy"; +import { errMessage, type AdminApi } from "../../lib/admin-api"; +import { Muted, SectionHeading, StatusLine } from "./StatusLine"; +import { useAdminResource } from "./use-admin-resource"; + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
  • + {label} + {children} +
  • + ); +} + +export function StorageEditor({ api, workspace }: { api: AdminApi; workspace: string }) { + const { data, error, setData } = useAdminResource( + () => api.getStorage(workspace), + [api, workspace], + ); + const [enabled, setEnabled] = useState(false); + const [saving, setSaving] = useState(false); + const [status, setStatus] = useState<{ state: "error" | "ok"; message: string } | null>(null); + + // Mirror the gate checkbox to the latest loaded/saved value. + useEffect(() => { + if (data) setEnabled(data.byoBucketEnabled); + }, [data]); + + if (error) return Failed to load storage.; + if (!data) return Loading storage…; + + async function save(event: React.FormEvent) { + event.preventDefault(); + setStatus(null); + setSaving(true); + try { + setData(await api.saveStorage(workspace, enabled)); + setStatus({ state: "ok", message: "Saved." }); + } catch (err) { + setStatus({ state: "error", message: errMessage(err, "Couldn't save storage settings.") }); + } finally { + setSaving(false); + } + } + + const lanes = data.lanes ?? []; + + return ( +
    + Storage + Mode: {data.mode === "byo" ? "Bring your own bucket" : "Shared"} + {data.mode === "byo" ? ( +
      + {data.bucket && {data.bucket}} + {data.accountIdMasked && {data.accountIdMasked}} + {data.accessKeyIdLast4 && {data.accessKeyIdLast4}} + {data.publicBaseUrl && {data.publicBaseUrl}} + + {data.configuredAt ? ( + <> + {formatDate(data.configuredAt) ?? data.configuredAt} + {data.configuredBy ? ` by ${data.configuredBy}` : ""} + + ) : ( + Not configured + )} + + + {data.verifiedAt ? ( + (formatDate(data.verifiedAt) ?? data.verifiedAt) + ) : ( + Not verified + )} + +
    + ) : ( + Using the shared platform bucket. + )} + {lanes.length > 0 && ( +
      + {lanes.map((lane, i) => ( +
    • + + {lane.role === "fallback" ? "Previous" : "Saved"} + + + {lane.bucket} + {lane.lastActiveAt + ? ` — until ${formatDate(lane.lastActiveAt) ?? lane.lastActiveAt}` + : ""} + +
    • + ))} +
    + )} +
    + + + {status && ( +
    + {status.message} +
    + )} +
    +
    + ); +} diff --git a/apps/web/src/components/admin/WorkspaceDetail.tsx b/apps/web/src/components/admin/WorkspaceDetail.tsx new file mode 100644 index 00000000..95f70efd --- /dev/null +++ b/apps/web/src/components/admin/WorkspaceDetail.tsx @@ -0,0 +1,41 @@ +/** + * Body of the workspace side drawer: the same detail the imperative page put + * behind a row-expand (people, plan, limits, storage, GitHub links), now + * composed as React sections inside the shadcn `Sheet`. Each section owns its + * own fetch and error state, so they fill in independently as their requests + * land — matching the per-section lazy loads the expand-row used. + */ +import type { AdminApi } from "../../lib/admin-api"; +import { GithubLinksSection } from "./GithubLinksSection"; +import { LimitsEditor } from "./LimitsEditor"; +import { PeopleSection } from "./PeopleSection"; +import { PlanEditor } from "./PlanEditor"; +import { StorageEditor } from "./StorageEditor"; + +export function WorkspaceDetail({ + api, + workspace, + hasOrg, +}: { + api: AdminApi; + workspace: string; + hasOrg: boolean; +}) { + // `key={workspace}` on each section is set by the parent remounting the whole + // WorkspaceDetail per selection, so nothing here needs to reset on change. + return ( +
    + +
    + +
    +
    + +
    +
    + +
    + +
    + ); +} diff --git a/apps/web/src/components/admin/field-classes.ts b/apps/web/src/components/admin/field-classes.ts new file mode 100644 index 00000000..d1f38c14 --- /dev/null +++ b/apps/web/src/components/admin/field-classes.ts @@ -0,0 +1,20 @@ +/** + * Form-control class strings shared by the admin workspace drawer editors, + * lifted from the imperative admin page so the inputs keep the exact look they + * had before the React rewrite (the drawer sits next to the same shell, so its + * fields should not read as a different design language). Buttons and the + * plan/BYOB pills move to the shadcn `Button`/`Badge` primitives; only the + * native ``/`