From 90f3f9adb347530295ba87be67395828c6a21ef3 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Fri, 28 Aug 2026 06:58:59 +0000 Subject: [PATCH 1/2] feat(control-plane): operator console routes and sponsored trials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two platform-operator routes. GET /admin/orgs lists every organization with its members, invites, live workspaces, and entitlement state — the one deliberate exception to org-scoped queries, gated on the same users.platform_operator flag as /operator-tokens. POST /admin/trial-orgs seeds a sponsored trial: a fresh org with platform_compute = 1, a trial clock, and an admin invite link, so a prospect tries the product on the deployment's cloud credential with no BYOK setup. The trial clock is org_entitlements.trial_expires_at (migration 0040). A janitor sweep flips platform_compute off when it runs out — one flag, no provider calls, same downgrade contract as a billing cancellation. A billing write clears the clock: the body states the org's whole entitlement, and a paid org is not a trial. Co-Authored-By: Claude Fable 5 --- packages/control-plane/core/admin.ts | 318 ++++++++++++++++++ packages/control-plane/core/app.ts | 2 + packages/control-plane/core/entitlements.ts | 9 +- .../control-plane/core/identity/invites.ts | 4 +- packages/control-plane/core/index.ts | 1 + packages/control-plane/core/janitors.ts | 26 ++ .../migrations/0040_trial_expiry.sql | 13 + .../scripts/lib/worker-source.mjs | 1 + packages/control-plane/src/worker.ts | 2 + .../control-plane/test/admin-console.test.ts | 205 +++++++++++ .../control-plane/test/core-imports.test.ts | 3 +- .../test/entitlements-fixtures.test.ts | 3 + packages/control-plane/wrangler.toml.example | 1 + 13 files changed, 583 insertions(+), 5 deletions(-) create mode 100644 packages/control-plane/core/admin.ts create mode 100644 packages/control-plane/migrations/0040_trial_expiry.sql create mode 100644 packages/control-plane/test/admin-console.test.ts diff --git a/packages/control-plane/core/admin.ts b/packages/control-plane/core/admin.ts new file mode 100644 index 00000000..206f2db5 --- /dev/null +++ b/packages/control-plane/core/admin.ts @@ -0,0 +1,318 @@ +import { randomToken } from "./crypto.js"; +import { rows, transaction } from "./db.js"; +import { + HttpError, + isRecord, + positiveInteger, + readJson, + requiredString, + type JsonValue, +} from "./http.js"; +import { + expireInvites, + INVITE_TTL_MS, + inviteCodeHash, + optionalEmail, +} from "./identity/invites.js"; +import { availableOrgSlug } from "./identity/orgs.js"; +import { runTrialExpirySweep } from "./janitors.js"; +import type { Principal } from "./principals.js"; +import type { CoreContext, CoreRouter, RuntimeFactory } from "./runtime.js"; +import { INVITE_TTL_DAYS } from "./wire.js"; + +/** + * The platform operator's console: every organization on the deployment, and + * the one write an operator makes — seeding a sponsored trial organization. + * + * These routes are the deployment owner's view, not a member's, so they are + * the one deliberate exception to "every query is scoped to the principal's + * organization". The gate is users.platform_operator, the same flag that + * gates /operator-tokens. A read-only operator token never reaches here: + * its scope check refuses everything outside GET /workspaces*. + */ + +/** A trial should end before its bill is interesting. Two weeks by default, + * a quarter at most; an operator who wants longer writes a new trial. */ +const DEFAULT_TRIAL_DAYS = 14; +const MAX_TRIAL_DAYS = 90; +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Small on purpose: a trial is one person kicking tires, not a rollout. + * The operator can pass larger numbers when the prospect is a team. */ +const DEFAULT_TRIAL_SEAT_LIMIT = 5; +const DEFAULT_TRIAL_VM_LIMIT = 2; + +type MemberRole = "admin" | "member"; +type MemberStatus = "invited" | "active" | "disabled"; +type InviteState = "ready" | "redeemed" | "revoked" | "expired"; + +interface AdminOrgRow { + id: string; + slug: string; + name: string; + created_at: number; + vm_limit: number; + created_by: string | null; + seat_limit: number | null; + platform_compute: number | null; + trial_expires_at: number | null; +} + +interface AdminMemberRow { + org_id: string; + email: string; + name: string; + role: MemberRole; + status: MemberStatus; +} + +interface AdminInviteRow { + id: string; + target_org_id: string; + email: string | null; + role: MemberRole; + state: InviteState; + created_at: number; + expires_at: number; + redeemed_at: number | null; +} + +interface AdminWorkspaceRow { + id: string; + org_id: string; + name: string | null; + phase: "creating" | "ready" | "destroying" | "destroyed" | "error"; + machine_type_id: string; + compute_credential_source: "org" | "deployment" | null; + created_at: number; +} + +export interface AdminOrgView { + id: string; + slug: string; + name: string; + createdAt: number; + /** Email of the user who created the organization; null for the bootstrap + * organization, whose creator predates the column. */ + createdBy: string | null; + vmLimit: number; + /** Null where no billing service has written a row: the free tier. */ + seatLimit: number | null; + platformCompute: boolean; + trialExpiresAt: number | null; + members: Array<{ email: string; name: string; role: MemberRole; status: MemberStatus }>; + invites: Array<{ + id: string; + email: string | null; + role: MemberRole; + state: InviteState; + createdAt: number; + expiresAt: number; + redeemedAt: number | null; + }>; + /** Everything but destroyed rows: the live estate plus its failures. */ + workspaces: Array<{ + id: string; + name: string | null; + phase: AdminWorkspaceRow["phase"]; + machineTypeId: string; + credentialSource: "org" | "deployment"; + createdAt: number; + }>; +} + +interface TrialOrgRequest { + name: string; + email: string | null; + seatLimit: number; + vmLimit: number; + trialDays: number; +} + +function trialOrgRequest(value: JsonValue): TrialOrgRequest { + if (!isRecord(value)) throw new HttpError(400, "request body must be an object"); + const name = requiredString(value.name, "name", 120).trim(); + if (name === "") throw new HttpError(400, "name must be a non-empty string"); + const trialDays = value.trialDays === undefined + ? DEFAULT_TRIAL_DAYS + : positiveInteger(value.trialDays, "trialDays"); + if (trialDays > MAX_TRIAL_DAYS) { + throw new HttpError(400, `trialDays must be at most ${MAX_TRIAL_DAYS}`); + } + return { + name, + email: optionalEmail(value.email), + seatLimit: value.seatLimit === undefined + ? DEFAULT_TRIAL_SEAT_LIMIT + : positiveInteger(value.seatLimit, "seatLimit"), + vmLimit: value.vmLimit === undefined + ? DEFAULT_TRIAL_VM_LIMIT + : positiveInteger(value.vmLimit, "vmLimit"), + trialDays, + }; +} + +export function addAdminRoutes( + router: CoreRouter, + runtimeFactory: RuntimeFactory, + requirePrincipal: (context: CoreContext) => Promise, +): void { + async function requirePlatformOperator(context: CoreContext): Promise { + const principal = await requirePrincipal(context); + if (!principal.platformOperator) throw new HttpError(403, "platform operator required"); + return principal; + } + + router.get("/admin/orgs", async (context) => { + await requirePlatformOperator(context); + const runtime = runtimeFactory(context); + const now = Date.now(); + // The console reports state, so it settles the two lazy clocks first: + // ready invites past their expiry, and trials past theirs. Without this a + // trial that ended overnight still reads as sponsored until some other + // request happens to sweep. + await expireInvites(runtime.db, now); + await runTrialExpirySweep(runtime, now); + const orgs = await rows(runtime.db, { + q: `SELECT o.id, o.slug, o.name, o.created_at, o.vm_limit, + creator.email AS created_by, + e.seat_limit, e.platform_compute, e.trial_expires_at + FROM orgs o + LEFT JOIN users creator ON creator.id = o.created_by_user_id + LEFT JOIN org_entitlements e ON e.org_id = o.id + ORDER BY o.created_at DESC, o.id`, + v: [], + }); + const members = await rows(runtime.db, { + q: `SELECT m.org_id, u.email, u.name, m.role, m.status + FROM memberships m JOIN users u ON u.id = m.user_id + ORDER BY m.role, u.email`, + v: [], + }); + const invites = await rows(runtime.db, { + q: `SELECT id, target_org_id, email, role, state, created_at, expires_at, redeemed_at + FROM invites ORDER BY created_at DESC, id`, + v: [], + }); + const workspaces = await rows(runtime.db, { + q: `SELECT id, org_id, name, phase, machine_type_id, compute_credential_source, created_at + FROM workspaces WHERE org_id IS NOT NULL AND phase != 'destroyed' + ORDER BY created_at DESC, id`, + v: [], + }); + const views = orgs.map((org): AdminOrgView => ({ + id: org.id, + slug: org.slug, + name: org.name, + createdAt: org.created_at, + createdBy: org.created_by, + vmLimit: org.vm_limit, + seatLimit: org.seat_limit, + platformCompute: org.platform_compute === 1, + trialExpiresAt: org.trial_expires_at, + members: members + .filter((member) => member.org_id === org.id) + .map(({ email, name, role, status }) => ({ email, name, role, status })), + invites: invites + .filter((invite) => invite.target_org_id === org.id) + .map((invite) => ({ + id: invite.id, + email: invite.email, + role: invite.role, + state: invite.state, + createdAt: invite.created_at, + expiresAt: invite.expires_at, + redeemedAt: invite.redeemed_at, + })), + workspaces: workspaces + .filter((workspace) => workspace.org_id === org.id) + .map((workspace) => ({ + id: workspace.id, + name: workspace.name, + phase: workspace.phase, + machineTypeId: workspace.machine_type_id, + credentialSource: workspace.compute_credential_source ?? "deployment", + createdAt: workspace.created_at, + })), + })); + return context.json({ orgs: views }); + }); + + // Seeds a sponsored trial: a fresh organization already entitled to the + // deployment's cloud credential, and an admin invite into it. The operator + // sends the returned link; the prospect lands with machines they can + // create, and never sees a BYOK form. + // + // This writes org_entitlements directly, which is otherwise the billing + // service's column. The invariant that matters survives: what lands in the + // row is integers and an instant, never a plan name — and a later billing + // write replaces the whole row and clears the trial clock. + router.post("/admin/trial-orgs", async (context) => { + const principal = await requirePlatformOperator(context); + // The invite row names its creator by membership, so an operator between + // organizations has nothing to sign it with — same rule as minting an + // operator token. + if (principal.membershipId === null) { + throw new HttpError(403, "active membership required"); + } + const runtime = runtimeFactory(context); + const request = trialOrgRequest(await readJson(context.req.raw)); + const orgId = crypto.randomUUID(); + const inviteId = crypto.randomUUID(); + const slug = await availableOrgSlug(runtime.db, request.name); + const code = randomToken(32); + const now = Date.now(); + const trialExpiresAt = now + request.trialDays * DAY_MS; + const inviteExpiresAt = now + INVITE_TTL_MS; + const result = await transaction(runtime.db, [ + { + q: `INSERT INTO orgs + (id, slug, name, vm_limit, created_by_user_id, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6) + RETURNING id`, + v: [orgId, slug, request.name, request.vmLimit, principal.id, now], + }, + { + q: `INSERT INTO org_entitlements + (org_id, seat_limit, platform_compute, trial_expires_at, updated_at) + VALUES (?1, ?2, 1, ?3, ?4) + RETURNING org_id`, + v: [orgId, request.seatLimit, trialExpiresAt, now], + }, + { + q: `INSERT INTO invites + (id, code_hash, email, target_org_id, role, state, + created_by_membership_id, redeemed_by_user_id, created_at, + expires_at, redeemed_at) + VALUES (?1, ?2, ?3, ?4, 'admin', 'ready', ?5, NULL, ?6, ?7, NULL) + RETURNING id`, + v: [ + inviteId, + await inviteCodeHash(code), + request.email, + orgId, + principal.membershipId, + now, + inviteExpiresAt, + ], + }, + ]); + if (result[0]?.length !== 1 || result[1]?.length !== 1 || result[2]?.length !== 1) { + throw new HttpError(409, "trial organization could not be created"); + } + return context.json({ + org: { id: orgId, slug, name: request.name, vmLimit: request.vmLimit }, + invite: { + id: inviteId, + email: request.email, + role: "admin", + state: "ready", + createdAt: now, + expiresAt: inviteExpiresAt, + }, + code, + ttlDays: INVITE_TTL_DAYS, + trialExpiresAt, + }, 201); + }); +} diff --git a/packages/control-plane/core/app.ts b/packages/control-plane/core/app.ts index 849bcd5c..bc88368d 100644 --- a/packages/control-plane/core/app.ts +++ b/packages/control-plane/core/app.ts @@ -1,3 +1,4 @@ +import { addAdminRoutes } from "./admin.js"; import { addAgentRuleLibraryRoutes, addAgentRulesRoutes } from "./agent-rules.js"; import { addBoxConfigRoutes } from "./box-config.js"; import { addBoxImageRoutes } from "./box-images.js"; @@ -75,6 +76,7 @@ export function installControlPlaneRoutes( addSessionRoutes(router, runtimeFactory, requirePrincipal); addOperatorTokenRoutes(router, runtimeFactory, requirePrincipal); + addAdminRoutes(router, runtimeFactory, requirePrincipal); addIdentityRoutes(router, runtimeFactory, requirePrincipal); addEntitlementsRoutes(router, runtimeFactory, requirePrincipal); addOrgComputeCredentialRoutes(router, runtimeFactory, requirePrincipal); diff --git a/packages/control-plane/core/entitlements.ts b/packages/control-plane/core/entitlements.ts index 04a1363e..3e9f4b87 100644 --- a/packages/control-plane/core/entitlements.ts +++ b/packages/control-plane/core/entitlements.ts @@ -293,14 +293,19 @@ export function addEntitlementsRoutes( // vmLimit lands in orgs.vm_limit, the column core/workspaces.ts has always // enforced. Storing it here as well would be a second source of truth for // one limit, and the enforcing statement would keep reading the other one. + // trial_expires_at is cleared on every billing write: the body states the + // organization's whole entitlement, and a paid (or cancelled) organization + // is not a trial. An operator-seeded trial converts by paying, and the + // trial clock must not end the subscription it converted into. const rowsWritten = await transaction<{ id: string }>(runtime.db, [ { q: `INSERT INTO org_entitlements - (org_id, seat_limit, platform_compute, updated_at) - SELECT ?1, ?2, ?3, ?4 WHERE EXISTS (SELECT 1 FROM orgs WHERE id = ?1) + (org_id, seat_limit, platform_compute, trial_expires_at, updated_at) + SELECT ?1, ?2, ?3, NULL, ?4 WHERE EXISTS (SELECT 1 FROM orgs WHERE id = ?1) ON CONFLICT(org_id) DO UPDATE SET seat_limit = excluded.seat_limit, platform_compute = excluded.platform_compute, + trial_expires_at = NULL, updated_at = excluded.updated_at RETURNING org_id AS id`, v: [orgId, written.seatLimit, written.platformCompute === true ? 1 : 0, now], diff --git a/packages/control-plane/core/identity/invites.ts b/packages/control-plane/core/identity/invites.ts index 1508bf12..569910d4 100644 --- a/packages/control-plane/core/identity/invites.ts +++ b/packages/control-plane/core/identity/invites.ts @@ -58,7 +58,7 @@ function inviteRole(value: JsonValue | undefined): InviteRole { return value; } -function optionalEmail(value: JsonValue | undefined): string | null { +export function optionalEmail(value: JsonValue | undefined): string | null { if (value === undefined || value === null || value === "") return null; const email = requiredString(value, "email", 320).trim().toLowerCase(); if (!/^[^\s@]+@[^\s@]+$/u.test(email)) throw new HttpError(400, "email must be valid"); @@ -86,7 +86,7 @@ function requireAdmin(principal: Principal): string { return principal.orgId; } -async function expireInvites(db: Db, now: number, orgId?: string): Promise { +export async function expireInvites(db: Db, now: number, orgId?: string): Promise { await rows(db, { q: `UPDATE invites SET state = 'expired' WHERE state = 'ready' AND expires_at <= ?1${orgId === undefined ? "" : " AND target_org_id = ?2"}`, diff --git a/packages/control-plane/core/index.ts b/packages/control-plane/core/index.ts index 5b06a61f..8dd0db84 100644 --- a/packages/control-plane/core/index.ts +++ b/packages/control-plane/core/index.ts @@ -25,6 +25,7 @@ export { maybeScheduleLazySweep, runInvariantSweep, runOrphanSweep, + runTrialExpirySweep, runVolumeRetentionSweep, runWorkspaceTunnelSweep, runSessionSweep, diff --git a/packages/control-plane/core/janitors.ts b/packages/control-plane/core/janitors.ts index 1ce52611..a2fabf50 100644 --- a/packages/control-plane/core/janitors.ts +++ b/packages/control-plane/core/janitors.ts @@ -212,6 +212,31 @@ export async function runInvariantSweep( }); } +/** + * Ends platform-sponsored trials whose clock has run out. + * + * One flag flip, no provider calls: the compute resolver reads + * platform_compute live on every create, so clearing it is the whole + * enforcement. Running workspaces stay alive by design — the same downgrade + * contract a billing cancellation follows — and vm_limit keeps bounding them + * until they are destroyed. + * + * trial_expires_at stays set: it is the record that this organization was a + * trial and when it ended, which is what the operator console reports. + */ +export async function runTrialExpirySweep( + runtime: CoreRuntime, + now = Date.now(), +): Promise { + return changed(runtime.db, { + q: `UPDATE org_entitlements SET platform_compute = 0, updated_at = ?1 + WHERE trial_expires_at IS NOT NULL AND trial_expires_at <= ?1 + AND platform_compute = 1 + RETURNING org_id`, + v: [now], + }); +} + export async function runSessionSweep( runtime: CoreRuntime, now = Date.now(), @@ -235,6 +260,7 @@ export function maybeScheduleLazySweep(runtime: CoreRuntime, path: string): void try { await runSessionSweep(runtime); await runLeaseSweep(runtime); + await runTrialExpirySweep(runtime); await runInvariantSweep(runtime); await runOrphanSweep(runtime); await runWorkspaceTunnelSweep(runtime); diff --git a/packages/control-plane/migrations/0040_trial_expiry.sql b/packages/control-plane/migrations/0040_trial_expiry.sql new file mode 100644 index 00000000..ae069954 --- /dev/null +++ b/packages/control-plane/migrations/0040_trial_expiry.sql @@ -0,0 +1,13 @@ +-- Trial expiry: when a platform-sponsored trial stops sponsoring. +-- +-- A platform operator can seed an organization with platform_compute = 1 so a +-- prospect tries the product on the deployment's own cloud credential, with no +-- BYOK setup. That sponsorship must end on its own: nothing else bounds the +-- deployment's bill for an organization that never converts. +-- +-- NULL is "not a trial". A billing write through PUT /orgs/:id/entitlements +-- states the organization's whole entitlement, so it clears this column: once +-- the customer pays (or cancels), the trial clock is meaningless either way. +-- Core still never learns a plan name; an expiry instant is a number, not a +-- plan. +ALTER TABLE org_entitlements ADD COLUMN trial_expires_at INTEGER; diff --git a/packages/control-plane/scripts/lib/worker-source.mjs b/packages/control-plane/scripts/lib/worker-source.mjs index 1e9635e5..8dd6a3a8 100644 --- a/packages/control-plane/scripts/lib/worker-source.mjs +++ b/packages/control-plane/scripts/lib/worker-source.mjs @@ -28,6 +28,7 @@ export const CORE_MANIFEST = Object.freeze([ "core/db.ts", "core/blobs.ts", "core/wire.ts", + "core/admin.ts", "core/agent-rules.ts", "core/bootstrap.ts", "core/box-config.ts", diff --git a/packages/control-plane/src/worker.ts b/packages/control-plane/src/worker.ts index 46c654ea..cd044654 100644 --- a/packages/control-plane/src/worker.ts +++ b/packages/control-plane/src/worker.ts @@ -19,6 +19,7 @@ import { runOrphanSweep, runProviderCanary, runSessionSweep, + runTrialExpirySweep, runVolumeRetentionSweep, runWorkspaceTunnelSweep, sessionTtlMsFromEnv, @@ -319,6 +320,7 @@ export default { await runtime.providers.microvm?.syncStaticHosts(); await runSessionSweep(runtime); await runLeaseSweep(runtime); + await runTrialExpirySweep(runtime); await runInvariantSweep(runtime); await runOrphanSweep(runtime); await runWorkspaceTunnelSweep(runtime); diff --git a/packages/control-plane/test/admin-console.test.ts b/packages/control-plane/test/admin-console.test.ts new file mode 100644 index 00000000..31d77052 --- /dev/null +++ b/packages/control-plane/test/admin-console.test.ts @@ -0,0 +1,205 @@ +import { env } from "cloudflare:workers"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { AdminOrgView } from "../core/admin.js"; +import { inviteCodeHash } from "../core/identity/invites.js"; +import { + appRequest, + harness, + operatorSession, + resetDatabase, + userSession, +} from "./helpers.js"; + +const KEY = "test-entitlements-key"; +const BILLING = { ENTITLEMENTS_API_KEY: KEY, PAYMENT_URL: "https://billing.test" }; + +type App = ReturnType["app"]; + +interface AdminOrgsBody { + orgs: AdminOrgView[]; +} + +interface TrialOrgBody { + org: { id: string; slug: string; name: string; vmLimit: number }; + invite: { id: string; email: string | null; role: string; state: string; expiresAt: number }; + code: string; + ttlDays: number; + trialExpiresAt: number; +} + +interface EntitlementsRow { + seat_limit: number; + platform_compute: number; + trial_expires_at: number | null; +} + +async function entitlementsRow(orgId: string): Promise { + return env.DB.prepare( + "SELECT seat_limit, platform_compute, trial_expires_at FROM org_entitlements WHERE org_id = ?1", + ).bind(orgId).first(); +} + +async function listOrgs(app: App, cookie: string): Promise { + const response = await appRequest(app, "/admin/orgs", { headers: { Cookie: cookie } }); + expect(response.status).toBe(200); + return await response.json() as AdminOrgsBody; +} + +async function createTrial( + app: App, + cookie: string, + body: Record, +): Promise { + return appRequest(app, "/admin/trial-orgs", { + method: "POST", + headers: { Cookie: cookie, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("operator console", () => { + beforeEach(resetDatabase); + + it("refuses anyone but a platform operator", async () => { + const { app } = harness(); + const anonymous = await appRequest(app, "/admin/orgs"); + expect(anonymous.status).toBe(401); + const member = await appRequest(app, "/admin/orgs", { + headers: { Cookie: await userSession("alice") }, + }); + expect(member.status).toBe(403); + const trial = await createTrial(app, await userSession("bob"), { name: "Nope" }); + expect(trial.status).toBe(403); + }); + + it("lists every organization with members, invites, and workspaces", async () => { + const { app } = harness(); + const operator = await operatorSession(); + const alice = await userSession("alice"); + // One invite in alice's org, minted through the product route. + const invited = await appRequest(app, "/invites", { + method: "POST", + headers: { Cookie: alice, "Content-Type": "application/json" }, + body: JSON.stringify({ role: "member", email: "friend@example.com" }), + }); + expect(invited.status).toBe(201); + // One live workspace and one destroyed one; the destroyed row is history, + // not estate, and must not be listed. + const now = Date.now(); + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO workspaces + (id, owner_id, org_id, name, phase, revision, machine_type_id, created_at, updated_at) + VALUES ('ws-live', 'alice', 'alice-org', 'dev box', 'ready', 1, 'fake-4c8g', ?1, ?1)`, + ).bind(now), + env.DB.prepare( + `INSERT INTO workspaces + (id, owner_id, org_id, name, phase, revision, machine_type_id, created_at, updated_at) + VALUES ('ws-gone', 'alice', 'alice-org', 'old box', 'destroyed', 1, 'fake-4c8g', ?1, ?1)`, + ).bind(now), + ]); + const body = await listOrgs(app, operator); + const names = body.orgs.map((org) => org.slug); + expect(names).toContain("personal"); + expect(names).toContain("alice-org"); + const aliceOrg = body.orgs.find((org) => org.slug === "alice-org"); + expect(aliceOrg?.createdBy).toBe(null); + expect(aliceOrg?.seatLimit).toBe(null); + expect(aliceOrg?.platformCompute).toBe(false); + expect(aliceOrg?.members).toEqual([ + { email: "alice@example.com", name: "alice", role: "admin", status: "active" }, + ]); + expect(aliceOrg?.invites).toHaveLength(1); + expect(aliceOrg?.invites[0]?.email).toBe("friend@example.com"); + expect(aliceOrg?.invites[0]?.state).toBe("ready"); + expect(aliceOrg?.workspaces).toEqual([{ + id: "ws-live", + name: "dev box", + phase: "ready", + machineTypeId: "fake-4c8g", + credentialSource: "deployment", + createdAt: now, + }]); + }); + + it("seeds a sponsored trial organization behind one invite link", async () => { + const { app } = harness(); + const operator = await operatorSession(); + const before = Date.now(); + const response = await createTrial(app, operator, { + name: "Prospect Co", + email: "ceo@prospect.example", + trialDays: 30, + }); + expect(response.status).toBe(201); + const body = await response.json() as TrialOrgBody; + expect(body.org.slug).toBe("prospect-co"); + expect(body.org.vmLimit).toBe(2); + expect(body.invite.role).toBe("admin"); + expect(body.invite.email).toBe("ceo@prospect.example"); + expect(body.trialExpiresAt).toBeGreaterThanOrEqual(before + 29 * 24 * 60 * 60 * 1_000); + // The entitlement row is written in the same transaction: the prospect + // lands already sponsored, with no billing write anywhere. + const entitlements = await entitlementsRow(body.org.id); + expect(entitlements?.platform_compute).toBe(1); + expect(entitlements?.seat_limit).toBe(5); + expect(entitlements?.trial_expires_at).toBe(body.trialExpiresAt); + // The returned code is the real invite: its hash is the stored row, and + // the public landing view resolves it to the new organization. + const hash = await inviteCodeHash(body.code); + const stored = await env.DB.prepare( + "SELECT id FROM invites WHERE code_hash = ?1", + ).bind(hash).first<{ id: string }>(); + expect(stored?.id).toBe(body.invite.id); + const landing = await appRequest(app, `/invite/${body.code}`); + expect(landing.status).toBe(200); + const landingBody = await landing.json() as { invite: { org: { name: string } } }; + expect(landingBody.invite.org.name).toBe("Prospect Co"); + // The console reports the trial as such. + const listed = await listOrgs(app, operator); + const trialOrg = listed.orgs.find((org) => org.id === body.org.id); + expect(trialOrg?.platformCompute).toBe(true); + expect(trialOrg?.trialExpiresAt).toBe(body.trialExpiresAt); + }); + + it("ends an expired trial when the console reads it", async () => { + const { app } = harness(); + const operator = await operatorSession(); + const created = await createTrial(app, operator, { name: "Sleepy" }); + const body = await (created.json() as Promise); + await env.DB.prepare( + "UPDATE org_entitlements SET trial_expires_at = ?2 WHERE org_id = ?1", + ).bind(body.org.id, Date.now() - 1_000).run(); + const listed = await listOrgs(app, operator); + const org = listed.orgs.find((item) => item.id === body.org.id); + // Sponsorship is gone; the clock stays as the record that it ran out. + expect(org?.platformCompute).toBe(false); + expect(org?.trialExpiresAt).not.toBe(null); + const entitlements = await entitlementsRow(body.org.id); + expect(entitlements?.platform_compute).toBe(0); + }); + + it("lets a billing write supersede the trial clock", async () => { + const { app } = harness(); + const operator = await operatorSession(); + const created = await createTrial(app, operator, { name: "Converted" }); + const body = await (created.json() as Promise); + const paid = await appRequest(app, `/orgs/${body.org.id}/entitlements`, { + method: "PUT", + headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" }, + body: JSON.stringify({ seatLimit: 10, vmLimit: 10, platformCompute: true }), + }, BILLING); + expect(paid.status).toBe(204); + const entitlements = await entitlementsRow(body.org.id); + expect(entitlements?.trial_expires_at).toBe(null); + expect(entitlements?.platform_compute).toBe(1); + expect(entitlements?.seat_limit).toBe(10); + }); + + it("caps the trial length", async () => { + const { app } = harness(); + const operator = await operatorSession(); + const response = await createTrial(app, operator, { name: "Forever", trialDays: 91 }); + expect(response.status).toBe(400); + }); +}); diff --git a/packages/control-plane/test/core-imports.test.ts b/packages/control-plane/test/core-imports.test.ts index bb093fe1..cff77598 100644 --- a/packages/control-plane/test/core-imports.test.ts +++ b/packages/control-plane/test/core-imports.test.ts @@ -7,6 +7,7 @@ const sources = import.meta.glob(["../core/**/*.ts", "../core/**/*.js"], }); const expected = [ + "admin.ts", "agent-rules.ts", "app.ts", "blobs.ts", @@ -124,6 +125,6 @@ describe("portable core imports", () => { (values: string[]) => values.every((value) => value.startsWith("./") || value.startsWith("../")), ); } - expect(expected).toHaveLength(99); + expect(expected).toHaveLength(100); }); }); diff --git a/packages/control-plane/test/entitlements-fixtures.test.ts b/packages/control-plane/test/entitlements-fixtures.test.ts index 025dcfb2..171a3330 100644 --- a/packages/control-plane/test/entitlements-fixtures.test.ts +++ b/packages/control-plane/test/entitlements-fixtures.test.ts @@ -112,6 +112,9 @@ describe("entitlements fixture conformance", () => { "updated_at", // Appended by migration 0037; ALTER TABLE puts a new column last. "platform_compute", + // Appended by migration 0040: the operator-sponsored trial clock. A + // billing write nulls it, so it never survives into a paid row. + "trial_expires_at", ]); // A body that omits the flag says the organization does not have it, which // is the same thing an absent row says. diff --git a/packages/control-plane/wrangler.toml.example b/packages/control-plane/wrangler.toml.example index 8e40db9c..7dc31ca8 100644 --- a/packages/control-plane/wrangler.toml.example +++ b/packages/control-plane/wrangler.toml.example @@ -120,6 +120,7 @@ not_found_handling = "single-page-application" # Workers pool. run_worker_first = [ "/", + "/admin/*", "/agent-rules", "/agent-rules/*", "/api/*", From 32a39f0a60f6fe97ad60b82cc7e83a129c2d5571 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Fri, 28 Aug 2026 07:04:36 +0000 Subject: [PATCH 2/2] feat(webapp): platform-operator console at /admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One page behind the operator flag: every organization with members, invites, live workspaces, seats and VM slots against their limits, and the plan state (free, paid, trial until, trial ended) derived from the entitlement integers. A "Start a trial" form seeds a sponsored org and shows its admin invite link once, the same way InvitesPanel does. The rail entry renders only for platform operators; direct navigation by anyone else renders the server's 403 in place — one refusal source. A hard refresh on /admin gets the SPA shell because run_worker_first derives /admin/* and never the bare path. Co-Authored-By: Claude Fable 5 --- packages/webapp/src/CloudApp.tsx | 27 ++ packages/webapp/src/SettingsPage.tsx | 4 +- packages/webapp/src/admin-api.ts | 264 ++++++++++++++++++ packages/webapp/src/admin/AdminPage.tsx | 250 +++++++++++++++++ packages/webapp/src/api.ts | 4 +- packages/webapp/src/files/DriveRail.tsx | 13 + packages/webapp/src/sessions-page-state.ts | 12 +- packages/webapp/src/settings.css | 79 ++++++ .../test/WorkspaceDetailsDialog.test.tsx | 2 + .../webapp/test/admin-connections.test.tsx | 2 + packages/webapp/test/admin-console.test.tsx | 257 +++++++++++++++++ packages/webapp/test/api-adapter.test.ts | 2 + .../test/credentials-surfaces-v2.test.tsx | 2 + packages/webapp/test/drive-rail.test.tsx | 1 + packages/webapp/test/recipes.test.tsx | 2 + packages/webapp/test/shell-smoke.test.tsx | 2 + 16 files changed, 920 insertions(+), 3 deletions(-) create mode 100644 packages/webapp/src/admin-api.ts create mode 100644 packages/webapp/src/admin/AdminPage.tsx create mode 100644 packages/webapp/test/admin-console.test.tsx diff --git a/packages/webapp/src/CloudApp.tsx b/packages/webapp/src/CloudApp.tsx index 95358260..bc9e5e4d 100644 --- a/packages/webapp/src/CloudApp.tsx +++ b/packages/webapp/src/CloudApp.tsx @@ -49,7 +49,9 @@ import { bindVisualViewportGeometry, useMobileWebApp, } from './mobile-webapp'; +import { AdminPage } from './admin/AdminPage'; import { + adminPath, drivePath, folderPagePath, parseAppRoute, @@ -694,6 +696,7 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { || !store.viewer || (!createWorkspaceRoute && store.workspaces.length > 0) || route.page === 'settings' + || route.page === 'admin' || firstWorkspacePrompted.current ) return; firstWorkspacePrompted.current = true; @@ -1470,6 +1473,7 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { }} onCreateOrg={() => setShowCreateOrg(true)} onOpenSettings={() => navigateToSettings('profile')} + onOpenAdmin={() => navigateTo(adminPath())} onOpenWorkspaceShare={(workspaceId) => setShareWorkspaceId(workspaceId)} onOpenWorkspaceDetails={(workspaceId) => { if (mobileWebApp) setDrawerOpen(false); @@ -1714,6 +1718,29 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { ); } + if (route.page === 'admin') { + // No client-side operator gate beyond the rail entry: the server's 403 is + // the one refusal, and AdminPage renders it in place. + return ( +
+ + {loaded && store.viewer ? ( + + ) : ( +
+ Loading admin console… +
+ )} + {error &&
{error}
} + {updateNotice} +
+ ); + } + if (isSettingsRoute) { return (
diff --git a/packages/webapp/src/SettingsPage.tsx b/packages/webapp/src/SettingsPage.tsx index fb9446ff..a4003b1d 100644 --- a/packages/webapp/src/SettingsPage.tsx +++ b/packages/webapp/src/SettingsPage.tsx @@ -118,9 +118,11 @@ function AppearanceControl() { export function SettingsHeader({ workspaceLabel, onBack, + title = 'Settings', }: { workspaceLabel?: string; onBack: () => void; + title?: string; }) { return (
@@ -134,7 +136,7 @@ export function SettingsHeader({ {workspaceLabel || 'WebApp'} Back - Settings + {title}
); } diff --git a/packages/webapp/src/admin-api.ts b/packages/webapp/src/admin-api.ts new file mode 100644 index 00000000..af447f9c --- /dev/null +++ b/packages/webapp/src/admin-api.ts @@ -0,0 +1,264 @@ +import type { WebAppApiRequest } from './compute-credentials-api'; +import { + asJsonObject, + isBoolean, + isNumber, + isString, + type JsonObject, + type JsonValue, +} from './type-guards'; + +/** The platform operator's console client. Session-authed like the rest of + * the control-plane surface; the server refuses anyone without + * users.platform_operator, so a 403 here is a person, not a bug. */ + +export type AdminRole = 'admin' | 'member'; +export type AdminMemberStatus = 'invited' | 'active' | 'disabled'; +export type AdminInviteState = 'ready' | 'redeemed' | 'revoked' | 'expired'; +export type AdminWorkspacePhase = 'creating' | 'ready' | 'destroying' | 'destroyed' | 'error'; + +export interface AdminOrgMember { + email: string; + name: string; + role: AdminRole; + status: AdminMemberStatus; +} + +export interface AdminOrgInvite { + id: string; + email: string | null; + role: AdminRole; + state: AdminInviteState; + createdAt: number; + expiresAt: number; + redeemedAt: number | null; +} + +export interface AdminOrgWorkspace { + id: string; + name: string | null; + phase: AdminWorkspacePhase; + machineTypeId: string; + credentialSource: 'org' | 'deployment'; + createdAt: number; +} + +export interface AdminOrgView { + id: string; + slug: string; + name: string; + createdAt: number; + /** Creator email; null for the bootstrap organization. */ + createdBy: string | null; + vmLimit: number; + /** Null where no billing row exists: the free tier. */ + seatLimit: number | null; + platformCompute: boolean; + /** Non-null marks an operator-sponsored trial. */ + trialExpiresAt: number | null; + members: AdminOrgMember[]; + invites: AdminOrgInvite[]; + /** Everything but destroyed rows: the live estate plus its failures. */ + workspaces: AdminOrgWorkspace[]; +} + +export interface CreateTrialOrgInput { + name: string; + email?: string; + seatLimit?: number; + vmLimit?: number; + trialDays?: number; +} + +export interface CreateTrialOrgResponse { + org: { id: string; slug: string; name: string; vmLimit: number }; + invite: { + id: string; + email: string | null; + role: AdminRole; + state: AdminInviteState; + createdAt: number; + expiresAt: number; + }; + /** The invite secret, answered exactly once: the link is + * `${origin}/invite/${code}` and the server stores only its hash. */ + code: string; + ttlDays: number; + trialExpiresAt: number; +} + +export interface AdminOrgsResponse { + orgs: AdminOrgView[]; +} + +export interface AdminClient { + adminOrgs(signal?: AbortSignal): Promise; + createTrialOrg(input: CreateTrialOrgInput): Promise; +} + +function parsedObject(json: string, label: string): JsonObject { + let value: JsonValue; + try { + value = JSON.parse(json); + } catch { + throw new Error(`${label} returned invalid JSON`); + } + const object = asJsonObject(value); + if (object === null) throw new Error(`${label} returned an invalid object`); + return object; +} + +function isRole(value: JsonValue | undefined): value is AdminRole { + return value === 'admin' || value === 'member'; +} + +function isInviteState(value: JsonValue | undefined): value is AdminInviteState { + return value === 'ready' || value === 'redeemed' || value === 'revoked' || value === 'expired'; +} + +function memberEntry(value: JsonValue): AdminOrgMember { + const member = asJsonObject(value); + if ( + member === null + || !isString(member.email) + || !isString(member.name) + || !isRole(member.role) + || (member.status !== 'invited' && member.status !== 'active' && member.status !== 'disabled') + ) throw new Error('admin orgs returned an invalid member'); + return { email: member.email, name: member.name, role: member.role, status: member.status }; +} + +function inviteEntry(value: JsonValue): AdminOrgInvite { + const invite = asJsonObject(value); + if ( + invite === null + || !isString(invite.id) + || !(invite.email === null || isString(invite.email)) + || !isRole(invite.role) + || !isInviteState(invite.state) + || !isNumber(invite.createdAt) + || !isNumber(invite.expiresAt) + || !(invite.redeemedAt === null || isNumber(invite.redeemedAt)) + ) throw new Error('admin orgs returned an invalid invite'); + return { + id: invite.id, + email: invite.email, + role: invite.role, + state: invite.state, + createdAt: invite.createdAt, + expiresAt: invite.expiresAt, + redeemedAt: invite.redeemedAt, + }; +} + +function workspaceEntry(value: JsonValue): AdminOrgWorkspace { + const workspace = asJsonObject(value); + if ( + workspace === null + || !isString(workspace.id) + || !(workspace.name === null || isString(workspace.name)) + || (workspace.phase !== 'creating' && workspace.phase !== 'ready' + && workspace.phase !== 'destroying' && workspace.phase !== 'destroyed' + && workspace.phase !== 'error') + || !isString(workspace.machineTypeId) + || (workspace.credentialSource !== 'org' && workspace.credentialSource !== 'deployment') + || !isNumber(workspace.createdAt) + ) throw new Error('admin orgs returned an invalid workspace'); + return { + id: workspace.id, + name: workspace.name, + phase: workspace.phase, + machineTypeId: workspace.machineTypeId, + credentialSource: workspace.credentialSource, + createdAt: workspace.createdAt, + }; +} + +function orgView(value: JsonValue): AdminOrgView { + const org = asJsonObject(value); + if ( + org === null + || !isString(org.id) + || !isString(org.slug) + || !isString(org.name) + || !isNumber(org.createdAt) + || !(org.createdBy === null || isString(org.createdBy)) + || !isNumber(org.vmLimit) + || !(org.seatLimit === null || isNumber(org.seatLimit)) + || !isBoolean(org.platformCompute) + || !(org.trialExpiresAt === null || isNumber(org.trialExpiresAt)) + || !Array.isArray(org.members) + || !Array.isArray(org.invites) + || !Array.isArray(org.workspaces) + ) throw new Error('admin orgs returned an invalid organization'); + return { + id: org.id, + slug: org.slug, + name: org.name, + createdAt: org.createdAt, + createdBy: org.createdBy, + vmLimit: org.vmLimit, + seatLimit: org.seatLimit, + platformCompute: org.platformCompute, + trialExpiresAt: org.trialExpiresAt, + members: org.members.map(memberEntry), + invites: org.invites.map(inviteEntry), + workspaces: org.workspaces.map(workspaceEntry), + }; +} + +function decodeAdminOrgs(json: string): AdminOrgsResponse { + const object = parsedObject(json, 'admin orgs'); + if (!Array.isArray(object.orgs)) throw new Error('admin orgs returned an invalid list'); + return { orgs: object.orgs.map(orgView) }; +} + +function decodeCreatedTrialOrg(json: string): CreateTrialOrgResponse { + const object = parsedObject(json, 'create trial org'); + const org = asJsonObject(object.org ?? null); + const invite = asJsonObject(object.invite ?? null); + if ( + org === null + || !isString(org.id) + || !isString(org.slug) + || !isString(org.name) + || !isNumber(org.vmLimit) + || invite === null + || !isString(invite.id) + || !(invite.email === null || isString(invite.email)) + || !isRole(invite.role) + || !isInviteState(invite.state) + || !isNumber(invite.createdAt) + || !isNumber(invite.expiresAt) + || !isString(object.code) + || !isNumber(object.ttlDays) + || !isNumber(object.trialExpiresAt) + ) throw new Error('create trial org returned invalid data'); + return { + org: { id: org.id, slug: org.slug, name: org.name, vmLimit: org.vmLimit }, + invite: { + id: invite.id, + email: invite.email, + role: invite.role, + state: invite.state, + createdAt: invite.createdAt, + expiresAt: invite.expiresAt, + }, + code: object.code, + ttlDays: object.ttlDays, + trialExpiresAt: object.trialExpiresAt, + }; +} + +export function createAdminClient(request: WebAppApiRequest): AdminClient { + return { + adminOrgs: (signal) => + request('/admin/orgs', { signal }, decodeAdminOrgs), + createTrialOrg: (input) => + request('/admin/trial-orgs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }, decodeCreatedTrialOrg), + }; +} diff --git a/packages/webapp/src/admin/AdminPage.tsx b/packages/webapp/src/admin/AdminPage.tsx new file mode 100644 index 00000000..635a3b70 --- /dev/null +++ b/packages/webapp/src/admin/AdminPage.tsx @@ -0,0 +1,250 @@ +import { useCallback, useEffect, useState } from 'react'; +import { ApiRequestError } from '../api'; +import type { + AdminClient, + AdminOrgView, + CreateTrialOrgResponse, +} from '../admin-api'; + +function shortDate(at: number): string { + return new Date(at).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +/** The operator's one-line read of an organization's standing. An expired + * trial clock with platformCompute still on reads as live on purpose: the + * expiry sweep, not this view, is what ends a trial. */ +export function planState( + org: Pick, + now: number, +): string { + if (org.trialExpiresAt !== null) { + return !org.platformCompute && org.trialExpiresAt <= now + ? `Trial ended ${shortDate(org.trialExpiresAt)}` + : `Trial until ${shortDate(org.trialExpiresAt)}`; + } + if (org.seatLimit === null) return 'Free'; + return `Paid · ${org.seatLimit} ${org.seatLimit === 1 ? 'seat' : 'seats'}`; +} + +/** Server-side defaults, mirrored so the form shows what an empty submit + * would do (control-plane core/admin.ts). */ +const TRIAL_DAYS_DEFAULT = 14; +const TRIAL_SEAT_LIMIT_DEFAULT = 5; +const TRIAL_VM_LIMIT_DEFAULT = 2; + +function positiveOr(value: string, fallback: number): number { + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function TrialForm({ client, onCreated }: { client: AdminClient; onCreated: () => void }) { + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [trialDays, setTrialDays] = useState(String(TRIAL_DAYS_DEFAULT)); + const [seatLimit, setSeatLimit] = useState(String(TRIAL_SEAT_LIMIT_DEFAULT)); + const [vmLimit, setVmLimit] = useState(String(TRIAL_VM_LIMIT_DEFAULT)); + const [minted, setMinted] = useState(null); + const [error, setError] = useState(null); + return ( + <> +
{ + event.preventDefault(); + if (name.trim() === '') return; + setError(null); + void client.createTrialOrg({ + name: name.trim(), + email: email.trim() === '' ? undefined : email.trim(), + trialDays: positiveOr(trialDays, TRIAL_DAYS_DEFAULT), + seatLimit: positiveOr(seatLimit, TRIAL_SEAT_LIMIT_DEFAULT), + vmLimit: positiveOr(vmLimit, TRIAL_VM_LIMIT_DEFAULT), + }).then((created) => { + setMinted(created); + setName(''); + setEmail(''); + onCreated(); + }).catch((caught: Error) => setError(caught.message)); + }}> + + + + + + +
+ {minted && (() => { + // The code is answered exactly once; this block is its only home. + const link = `${window.location.origin}/invite/${minted.code}`; + return ( +
+ Copy this link now — it is shown once. +
+ event.currentTarget.select()} /> + +
+ + {minted.org.name} · trial ends {shortDate(minted.trialExpiresAt)} · link expires after {minted.ttlDays} days. + +
+ ); + })()} + {error &&

{error}

} + + ); +} + +function OrgCard({ org, now }: { org: AdminOrgView; now: number }) { + const seatsUsed = org.members.filter((member) => member.status === 'active').length; + // Every phase but destroyed occupies a VM slot; the route already omits + // destroyed rows, so this states the rule rather than changing the count. + const vmsUsed = org.workspaces.filter((workspace) => workspace.phase !== 'destroyed').length; + return ( +
+
+

{org.name}

+ /{org.slug} + {planState(org, now)} + {org.platformCompute ? 'platform cloud' : 'BYOK'} +
+

+ Created {shortDate(org.createdAt)} + {org.createdBy !== null && <> by {org.createdBy}} + {' · '} + {org.seatLimit !== null + ? `${seatsUsed} / ${org.seatLimit} seats` + : `${seatsUsed} ${seatsUsed === 1 ? 'seat' : 'seats'}`} + {' · '}{vmsUsed} / {org.vmLimit} VMs +

+ {org.members.length > 0 && ( +
+ Members + {org.members.map((member) => ( +
+ {member.email} + {member.name} + {member.role} · {member.status} +
+ ))} +
+ )} + {org.invites.length > 0 && ( +
+ Invites + {org.invites.map((invite) => ( +
+ {invite.email ?? 'Anyone with the link'} + + {invite.role} · {invite.state} · expires {shortDate(invite.expiresAt)} + +
+ ))} +
+ )} + {org.workspaces.length > 0 && ( +
+ Workspaces + {org.workspaces.map((workspace) => ( +
+ {workspace.name ?? workspace.id} + {workspace.phase} + {workspace.machineTypeId} + + {workspace.credentialSource === 'deployment' ? 'platform cloud' : 'org key'} + +
+ ))} +
+ )} +
+ ); +} + +export function AdminPage({ client }: { client: AdminClient }) { + const [orgs, setOrgs] = useState([]); + const [loaded, setLoaded] = useState(false); + const [refused, setRefused] = useState(null); + const [error, setError] = useState(null); + const load = useCallback(async () => { + try { + setOrgs((await client.adminOrgs()).orgs); + setError(null); + setRefused(null); + } catch (caught) { + if (caught instanceof ApiRequestError && caught.status === 403) { + setRefused(caught.message); + } else { + setError(caught instanceof Error ? caught.message : 'Could not load organizations.'); + } + } + setLoaded(true); + }, [client]); + useEffect(() => { void load(); }, [load]); + + if (refused !== null) { + return ( +
+
+
+
+
+

Platform

+

Admin console

+ This console is for platform operators. The control plane said: {refused}. +
+
+
+
+
+ ); + } + + const now = Date.now(); + return ( +
+
+
+
+
+

Platform

+

Start a trial

+ Seeds a sponsored organization on the deployment's cloud key, with an admin invite link. +
+
+ { void load(); }} /> +
+
+
+
+

Platform

+

Organizations

+ {orgs.length === 1 ? '1 organization' : `${orgs.length} organizations`} on this deployment. +
+
+ {error &&

{error}

} + {orgs.map((org) => )} + {loaded && error === null && orgs.length === 0 && ( +

No organizations yet.

+ )} +
+
+
+ ); +} diff --git a/packages/webapp/src/api.ts b/packages/webapp/src/api.ts index 02e5997f..5cdd93e4 100644 --- a/packages/webapp/src/api.ts +++ b/packages/webapp/src/api.ts @@ -55,6 +55,7 @@ import { createComputeCredentialsClient, type ComputeCredentialsClient, } from "./compute-credentials-api.js"; +import { createAdminClient, type AdminClient } from "./admin-api.js"; export class ApiRequestError extends Error { public constructor( @@ -143,7 +144,7 @@ export interface CreateOrgResponse { membership: NonNullable; } -export interface ControlPlaneClient extends FileLibraryClient, ComputeCredentialsClient { +export interface ControlPlaneClient extends FileLibraryClient, ComputeCredentialsClient, AdminClient { googleLoginUrl(): string; inviteGoogleLoginUrl(code: string): string; inviteStatus(code: string): Promise<{ invite: InviteView; ttlDays: number }>; @@ -560,6 +561,7 @@ export function createControlPlaneClient(baseUrl = ""): ControlPlaneClient { return { ...createFileLibraryClient(rawRequest), ...createComputeCredentialsClient(request), + ...createAdminClient(request), googleLoginUrl: () => `${base}/auth/google/start`, inviteGoogleLoginUrl: (code) => `${base}/auth/google/start?invite=${encodeURIComponent(code)}`, inviteStatus: (code) => request<{ invite: InviteView; ttlDays: number }>(`/invite/${encodeURIComponent(code)}`, {}, decodeInviteStatus), diff --git a/packages/webapp/src/files/DriveRail.tsx b/packages/webapp/src/files/DriveRail.tsx index 52735773..dd260b08 100644 --- a/packages/webapp/src/files/DriveRail.tsx +++ b/packages/webapp/src/files/DriveRail.tsx @@ -65,6 +65,7 @@ export function DriveRail({ onSwitchOrg, onCreateOrg, onOpenSettings, + onOpenAdmin, onOpenWorkspaceShare, onOpenWorkspaceDetails, drawerOpen, @@ -87,6 +88,7 @@ export function DriveRail({ onSwitchOrg: (orgId: string) => void; onCreateOrg: () => void; onOpenSettings: () => void; + onOpenAdmin: () => void; onOpenWorkspaceShare: (workspaceId: string) => void; onOpenWorkspaceDetails: (workspaceId: string) => void; drawerOpen: boolean; @@ -372,6 +374,17 @@ export function DriveRail({ rel="noreferrer" >Having issues? Ask us on Discord + {/* Platform operators only: the flag rides /me, and the console + * routes refuse everyone else. */} + {identity?.platformOperator === true && ( + + )}