diff --git a/apps/site/content/docs/studio.md b/apps/site/content/docs/studio.md index 13d0b4e..0472e13 100644 --- a/apps/site/content/docs/studio.md +++ b/apps/site/content/docs/studio.md @@ -120,6 +120,28 @@ Plans apply to the hosted Studio only. You pay per editor: a viewer is always fr - **The founding offer**: the first 100 paying workspaces pay half, for as long as they stay subscribed. - **You can always leave**: every design system, screen and Direction exports as JSON on every plan. +## Roles + +| Role | How many | What they can do | +|---|---|---| +| Owner | exactly one | Everything, plus billing, deleting the workspace and handing it to someone else | +| Admin | any number | Everything except those three: invite, remove, change roles, edit tokens, rules, screens and Directions | +| Design system | any number | Tokens, components, rules, releases | +| Designer | any number | Direction, reviews, exemplars | +| Product | any number | Capabilities, journeys, Insights | +| Engineer | any number | Components, capabilities, integrations | +| Viewer | any number | Everything, read only — and always free | + +Owner is never invited or set from the role list. It moves only by **handing the workspace over** (Team → Make owner), which makes the new person owner and the previous one an admin in the same step, so there is always exactly one. Everyone but a viewer counts as an editor for your plan's seats. + +## Support + +The people who run a hosted Studio can open **/admin**: find any workspace or person, set a plan by hand, hand a workspace to a new owner when the old one has gone, take someone out, and read what Stripe says about a customer. It never writes to Stripe. + +Who counts is the `SUPER_ADMINS` secret — email addresses separated by commas, checked against the signed-in person on every request. It is deliberately not a column, so editing the database grants nobody access. Every change is recorded with the address that made it, what it was before and after, and the reason given. + +A plan set by hand is separate from a Stripe subscription. **Enterprise** is the one a Stripe event never overwrites, so use it for comps, design partners and deals invoiced elsewhere. + ## Run it yourself ```sh diff --git a/apps/studio/migrations/0008_admin.sql b/apps/studio/migrations/0008_admin.sql new file mode 100644 index 0000000..a516187 --- /dev/null +++ b/apps/studio/migrations/0008_admin.sql @@ -0,0 +1,15 @@ +-- Every change a super admin makes to someone else's workspace or membership, kept for good. +-- Reading is not recorded; only what changed, who changed it, and why they said they did. +CREATE TABLE admin_actions ( + id TEXT PRIMARY KEY, + at TEXT NOT NULL, + admin_email TEXT NOT NULL, -- from SUPER_ADMINS, not from the database, so it can't be forged by editing a row + action TEXT NOT NULL, -- plan | owner | member-removed | verification-sent + workspace_id TEXT, -- no foreign key: the record outlives the workspace + user_id TEXT, + before TEXT, + after TEXT, + note TEXT +); +CREATE INDEX admin_actions_at ON admin_actions(at DESC); +CREATE INDEX admin_actions_workspace ON admin_actions(workspace_id); diff --git a/apps/studio/src/app/App.tsx b/apps/studio/src/app/App.tsx index b90e403..93bdb59 100644 --- a/apps/studio/src/app/App.tsx +++ b/apps/studio/src/app/App.tsx @@ -17,6 +17,7 @@ import { Invite } from "./pages/Invite.tsx"; import { Billing } from "./pages/Billing.tsx"; import { Mark, StudioLockup } from "./mark.tsx"; // The screen pages carry the renderer, the schema and the spec's examples; they load when opened. +const Admin = lazy(() => import("./pages/Admin.tsx").then((m) => ({ default: m.Admin }))); const Screens = lazy(() => import("./pages/Screens.tsx").then((m) => ({ default: m.Screens }))); const Screen = lazy(() => import("./pages/Screen.tsx").then((m) => ({ default: m.Screen }))); // The Direction editor draws exemplar screens with the renderer; it loads when opened. @@ -65,6 +66,7 @@ export function App() { } /> : } /> } /> + : } /> : } /> {toast && ( diff --git a/apps/studio/src/app/api.ts b/apps/studio/src/app/api.ts index 2a4bf7a..3262138 100644 --- a/apps/studio/src/app/api.ts +++ b/apps/studio/src/app/api.ts @@ -45,6 +45,8 @@ export interface Me { analytics?: { key: string; ui: string }; /** Whether this Studio has plans (the hosted one). A self-hosted Studio has no limits and no Billing page. */ billing?: boolean; + /** Present when this person's address is in SUPER_ADMINS: the Support page is theirs to open. */ + admin?: boolean; } type Limit = number | null; @@ -164,3 +166,65 @@ export interface DirectionVersionRow { author: string; version: string; } + + +// ---------------------------------------------------------------- support (super admins only) + +export interface AdminWorkspace { + id: string; + slug: string; + name: string; + plan: string; + plan_status: string | null; + seats: number | null; + stripe_customer_id: string | null; + created_at: string; + owner_email: string | null; + members: number; +} + +export interface AdminUser { + id: string; + email: string; + name: string; + emailVerified: number; + createdAt: string; + workspaces: number; +} + +export interface AdminAction { + id: string; + at: string; + admin_email: string; + action: string; + workspace_id: string | null; + workspace_slug?: string | null; + user_id: string | null; + before: string | null; + after: string | null; + note: string | null; +} + +export interface AdminOverview { + plans: Record; + workspaces: number; + users: number; + subscribed: number; + overQuota: number; + actions: AdminAction[]; +} + +export interface AdminStripe { + customer: string | null; + subscriptions: { id: string; status: string; cancelAtPeriodEnd: boolean; quantity: number | null; price: string | null; amount: number | null; currency: string | null }[]; + invoices: { id: string; number: string | null; status: string | null; total: number; currency: string; created: string; url: string | null }[]; + error: string | null; +} + +export interface AdminDetail { + workspace: Record; + members: { id: string; role: string; created_at: string; email: string; name: string }[]; + invites: { id: string; email: string; role: string; created_at: string; expires_at: string }[]; + usage: Omit; + stripe: AdminStripe; +} diff --git a/apps/studio/src/app/pages/Admin.tsx b/apps/studio/src/app/pages/Admin.tsx new file mode 100644 index 0000000..584ef1f --- /dev/null +++ b/apps/studio/src/app/pages/Admin.tsx @@ -0,0 +1,251 @@ +/** + * Support, for whoever runs this Studio: find a workspace or a person, set a plan by hand, hand a + * workspace to a new owner, take someone out, and read what Stripe says about a customer. Nothing + * here writes to Stripe. Every change is recorded and shown at the bottom of the page. + */ +import { useEffect, useState } from "react"; +import { api, type AdminDetail, type AdminOverview, type AdminUser, type AdminWorkspace } from "../api.ts"; +import { Page, useSession } from "../App.tsx"; + +const PLANS = ["free", "pro", "team", "enterprise"] as const; +const NAMES = { free: "Free", pro: "Pro", team: "Team", enterprise: "Enterprise" } as const; +const when = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }) : "—"); +const money = (cents: number, currency: string) => `${currency.toUpperCase()} ${(cents / 100).toFixed(2)}`; + +export function Admin() { + const { toast } = useSession(); + const [tab, setTab] = useState<"workspaces" | "people" | "log">("workspaces"); + const [q, setQ] = useState(""); + const [overview, setOverview] = useState(null); + const [workspaces, setWorkspaces] = useState([]); + const [users, setUsers] = useState([]); + const [log, setLog] = useState([]); + const [open, setOpen] = useState(null); + + const loadOverview = () => api("GET", "/api/admin/overview").then(setOverview).catch(() => undefined); + useEffect(() => { + loadOverview(); + }, []); + useEffect(() => { + const t = setTimeout(() => { + const search = encodeURIComponent(q); + if (tab === "workspaces") api<{ workspaces: AdminWorkspace[] }>("GET", `/api/admin/workspaces?q=${search}`).then((r) => setWorkspaces(r.workspaces)); + if (tab === "people") api<{ users: AdminUser[] }>("GET", `/api/admin/users?q=${search}`).then((r) => setUsers(r.users)); + if (tab === "log") api<{ actions: AdminOverview["actions"] }>("GET", "/api/admin/log").then((r) => setLog(r.actions)); + }, 200); + return () => clearTimeout(t); + }, [q, tab]); + + const show = async (id: string) => setOpen(await api("GET", `/api/admin/workspaces/${id}`)); + const setPlan = async (id: string, plan: string, note: string) => { + const r = await api<{ subscribed: boolean }>("POST", `/api/admin/workspaces/${id}/plan`, { plan, note }); + toast(r.subscribed ? `Set to ${NAMES[plan as keyof typeof NAMES]}. It still has a Stripe subscription, which keeps billing.` : `Set to ${NAMES[plan as keyof typeof NAMES]}.`); + await show(id); + setWorkspaces((list) => list.map((w) => (w.id === id ? { ...w, plan } : w))); + await loadOverview(); + }; + + return ( + + {overview.workspaces} workspace{overview.workspaces === 1 ? "" : "s"} · {overview.users} {overview.users === 1 ? "person" : "people"} · {overview.subscribed} subscribed + {PLANS.filter((p) => overview.plans[p]).map((p) => ` · ${overview.plans[p]} ${NAMES[p]}`)} + {overview.overQuota > 0 && · {overview.overQuota} over quota} + + )} + > +
+ {(["workspaces", "people", "log"] as const).map((t) => ( + + ))} +
+ + {tab !== "log" && ( +
+ + setQ(e.target.value)} placeholder={tab === "workspaces" ? "Workspace, slug or owner's email" : "Name or email"} /> +
+ )} + + {tab === "workspaces" && ( + + + + + + {workspaces.map((w) => ( + + + + + + + + + ))} + {!workspaces.length && } + +
WorkspaceOwnerPlanMembersMade
{w.name}
{w.slug}
{w.owner_email ?? no owner} + {NAMES[w.plan as keyof typeof NAMES] ?? w.plan} + {w.plan_status && w.plan_status !== "active" &&
{w.plan_status}
} +
{w.members}{when(w.created_at)}
Nothing matches.
+ )} + + {tab === "people" && ( + + + + {users.map((u) => ( + + + + + + + + ))} + {!users.length && } + +
PersonEmailVerifiedWorkspacesJoined
{u.name || "—"}{u.email}{u.emailVerified ? "yes" : no}{u.workspaces}{when(u.createdAt)}
Nothing matches.
+ )} + + {tab === "log" && } + + {open && setOpen(null)} onPlan={setPlan} onChange={() => show(String(open.workspace.id))} />} +
+ ); +} + +function ActionLog({ actions }: { actions: AdminOverview["actions"] }) { + return ( + + + + {actions.map((a) => ( + + + + + + + + + ))} + {!actions.length && } + +
WhenWhoWhatWhereChangeWhy
{new Date(String(a.at)).toLocaleString("en-GB")}{String(a.admin_email)}{String(a.action)}{a.workspace_slug ? String(a.workspace_slug) : "—"}{a.before ? JSON.parse(String(a.before)) : "—"} → {a.after ? JSON.parse(String(a.after)) : "—"}{a.note ? String(a.note) : ""}
Nothing has been changed yet.
+ ); +} + +function Detail({ detail, onClose, onPlan, onChange }: { detail: AdminDetail; onClose: () => void; onPlan: (id: string, plan: string, note: string) => void; onChange: () => void }) { + const { toast } = useSession(); + // Why, in the admin's own words: it goes into the record beside what changed. + const [note, setNote] = useState(""); + const w = detail.workspace as Record; + const id = String(w.id); + const act = async (run: () => Promise, done: string) => { + try { + await run(); + toast(done); + onChange(); + } catch (e) { + toast(e instanceof Error ? e.message : "That didn't work", "bad"); + } + }; + return ( + <> +
+
+
+
+

{String(w.name)}

+

{String(w.slug)} · made {when(String(w.created_at))}

+
+ +
+
+ +
+

Plan

+
+ {PLANS.map((p) => ( + + ))} +
+
+ + setNote(e.target.value)} placeholder="Design partner, enterprise deal, support fix…" /> +
+

+ Set by hand, with no Stripe subscription. Enterprise is the one a Stripe event never overwrites, so use it for anything that should stick. + {w.stripe_subscription_id ? " This workspace has a subscription, which keeps charging whatever you set here." : ""} +

+
+ +
+

People

+ + + {detail.members.map((m) => ( + + + + + + ))} + +
{m.name || m.email}
{m.email}
{m.role} + {m.role !== "owner" && ( + <> + + + + )} +
+ {!!detail.invites.length &&

{detail.invites.length} invite{detail.invites.length === 1 ? "" : "s"} waiting.

} +
+ +
+

Stripe

+ {detail.stripe.error &&

{detail.stripe.error}

} + {!detail.stripe.customer &&

No customer: this workspace has never paid.

} + {detail.stripe.subscriptions.map((s) => ( +

+ {s.status} {s.quantity ?? 1} × {s.amount !== null && s.currency ? money(s.amount, s.currency) : s.price} + {s.cancelAtPeriodEnd ? " · ends at the period's end" : ""} +

+ ))} + {!!detail.stripe.invoices.length && ( + + + {detail.stripe.invoices.map((i) => ( + + + + + + + + ))} + +
{when(i.created)}{i.number ?? i.id}{i.status}{money(i.total, i.currency)}{i.url && Open}
+ )} + {detail.stripe.customer && ( +

+ Open in Stripe +

+ )} +
+
+
+ + ); +} diff --git a/apps/studio/src/app/pages/Import.tsx b/apps/studio/src/app/pages/Import.tsx index 6cde85a..1c3d28b 100644 --- a/apps/studio/src/app/pages/Import.tsx +++ b/apps/studio/src/app/pages/Import.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type FormEvent } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, Link } from "react-router-dom"; import { api, type Scan } from "../api.ts"; import { track } from "../analytics.ts"; import { Page, type Ws } from "../App.tsx"; @@ -62,7 +62,12 @@ export function Import({ ws }: { ws: Ws }) { ]; return ( - + Not now} + >

1. Where are your tokens?

diff --git a/apps/studio/src/app/pages/Team.tsx b/apps/studio/src/app/pages/Team.tsx index dc9f3fc..ddcaf05 100644 --- a/apps/studio/src/app/pages/Team.tsx +++ b/apps/studio/src/app/pages/Team.tsx @@ -10,10 +10,10 @@ interface Key { id: string; name: string; created_at: string; last_used_at: stri interface IngestKey extends Key { key: string } const CAN_MANAGE_INGEST = new Set(["owner", "engineer", "design-system", "product"]); -const ROLES: [string, string][] = [["design-system", "Design system: tokens, components, rules, releases"], ["designer", "Designer: direction, reviews, exemplars"], ["product", "Product: capabilities, journeys, insights"], ["engineer", "Engineer: components, capabilities, integrations"], ["viewer", "Viewer: everything, read only"]]; +const ROLES: [string, string][] = [["admin", "Admin: everything, including people and billing"], ["design-system", "Design system: tokens, components, rules, releases"], ["designer", "Designer: direction, reviews, exemplars"], ["product", "Product: capabilities, journeys, insights"], ["engineer", "Engineer: components, capabilities, integrations"], ["viewer", "Viewer: everything, read only"]]; export function Team({ ws }: { ws: Ws }) { - const { toast, me } = useSession(); + const { toast, me, refresh } = useSession(); const [d, setD] = useState(null); const [keys, setKeys] = useState([]); const [inviting, setInviting] = useState(false); @@ -68,7 +68,29 @@ export function Team({ ws }: { ws: Ws }) { toast((e as Error).message, "bad"); } }; - const canInvite = ws.role === "owner" || ws.role === "design-system"; + const canInvite = ws.role === "owner" || ws.role === "admin" || ws.role === "design-system"; + // The owner and the admins run the workspace; only the owner can hand it on. + const runs = ws.role === "owner" || ws.role === "admin"; + const changeRole = async (id: string, to: string, who: string) => { + try { + await api("PATCH", `/api/w/${ws.slug}/members/${id}`, { role: to }); + toast(`${who} is now ${to}`); + load(); + } catch (e) { + toast((e as Error).message, "bad"); + } + }; + const handOver = async (id: string, who: string) => { + if (!window.confirm(`Hand ${ws.name} to ${who}? You stay as an admin, and only they can hand it on after that.`)) return; + try { + await api("POST", `/api/w/${ws.slug}/owner`, { user: id }); + toast(`${who} owns ${ws.name}`); + load(); + await refresh(); + } catch (e) { + toast((e as Error).message, "bad"); + } + }; if (!d) return null; return ( setInviting(true)}>Invite people}> @@ -77,7 +99,25 @@ export function Team({ ws }: { ws: Ws }) { - {d.members.map((m) => )} + {d.members.map((m) => ( + + + + + + + ))} {d.invites.map((i) => )}
PersonRoleJoined
{m.name || m.email}
{m.email}
{m.role}{new Date(m.created_at).toLocaleDateString("en-GB")}{ws.role === "owner" && m.id !== me.user?.id && }
{m.name || m.email}
{m.email}
+ {m.role === "owner" || !runs || m.id === me.user?.id ? ( + m.role + ) : ( + + )} + {new Date(m.created_at).toLocaleDateString("en-GB")} + {ws.role === "owner" && m.role !== "owner" && m.id !== me.user?.id && } + {runs && m.role !== "owner" && m.id !== me.user?.id && } +
{i.email}
invited
{i.role}expires {new Date(i.expires_at).toLocaleDateString("en-GB")}{canInvite && }
diff --git a/apps/studio/src/app/pages/Workspaces.tsx b/apps/studio/src/app/pages/Workspaces.tsx index 42af04d..e96f31f 100644 --- a/apps/studio/src/app/pages/Workspaces.tsx +++ b/apps/studio/src/app/pages/Workspaces.tsx @@ -19,7 +19,7 @@ export function Workspaces() { const w = await api<{ id: string; slug: string }>("POST", "/api/workspaces", { name, slug: slug || name }); track("workspace_created", {}, w.id); await refresh(); - navigate(`/w/${w.slug}/design-systems/import`); + navigate(`/w/${w.slug}`); } catch (err) { setError((err as Error).message); } @@ -29,7 +29,14 @@ export function Workspaces() {
- {me.user?.email} + + {me.admin && ( + <> + Support ·{" "} + + )} + {me.user?.email} +
{creating ? ( diff --git a/apps/studio/src/templates/packs.ts b/apps/studio/src/templates/packs.ts index 8a38631..aa984b8 100644 --- a/apps/studio/src/templates/packs.ts +++ b/apps/studio/src/templates/packs.ts @@ -69,6 +69,68 @@ import monoSystemDark from "../../../../packages/ds-mono/tokens/system.dark.json import monoSystem from "../../../../packages/ds-mono/tokens/system.json" with { type: "json" }; import monoSystemLight from "../../../../packages/ds-mono/tokens/system.light.json" with { type: "json" }; +// The thirteen packs modelled on published design systems. Starting from one copies its tokens +// into the workspace; from then on the copy is the team's own, and the pack is not read again. +import material3Manifest from "../../../../packages/ds-material3/manifest.json" with { type: "json" }; +import material3Primitive from "../../../../packages/ds-material3/tokens/primitive.json" with { type: "json" }; +import material3Semantic from "../../../../packages/ds-material3/tokens/semantic.json" with { type: "json" }; +import material3SystemDark from "../../../../packages/ds-material3/tokens/system.dark.json" with { type: "json" }; +import material3System from "../../../../packages/ds-material3/tokens/system.json" with { type: "json" }; +import material3SystemLight from "../../../../packages/ds-material3/tokens/system.light.json" with { type: "json" }; +import carbonManifest from "../../../../packages/ds-carbon/manifest.json" with { type: "json" }; +import carbonPrimitive from "../../../../packages/ds-carbon/tokens/primitive.json" with { type: "json" }; +import carbonSemantic from "../../../../packages/ds-carbon/tokens/semantic.json" with { type: "json" }; +import carbonSystemDark from "../../../../packages/ds-carbon/tokens/system.dark.json" with { type: "json" }; +import carbonSystem from "../../../../packages/ds-carbon/tokens/system.json" with { type: "json" }; +import carbonSystemLight from "../../../../packages/ds-carbon/tokens/system.light.json" with { type: "json" }; +import antdManifest from "../../../../packages/ds-antd/manifest.json" with { type: "json" }; +import antdPrimitive from "../../../../packages/ds-antd/tokens/primitive.json" with { type: "json" }; +import antdSemantic from "../../../../packages/ds-antd/tokens/semantic.json" with { type: "json" }; +import antdSystemDark from "../../../../packages/ds-antd/tokens/system.dark.json" with { type: "json" }; +import antdSystem from "../../../../packages/ds-antd/tokens/system.json" with { type: "json" }; +import antdSystemLight from "../../../../packages/ds-antd/tokens/system.light.json" with { type: "json" }; +import fluentManifest from "../../../../packages/ds-fluent/manifest.json" with { type: "json" }; +import fluentSemantic from "../../../../packages/ds-fluent/tokens/semantic.json" with { type: "json" }; +import fluentSystemDark from "../../../../packages/ds-fluent/tokens/system.dark.json" with { type: "json" }; +import fluentSystemLight from "../../../../packages/ds-fluent/tokens/system.light.json" with { type: "json" }; +import shadcnManifest from "../../../../packages/ds-shadcn/manifest.json" with { type: "json" }; +import shadcnSemantic from "../../../../packages/ds-shadcn/tokens/semantic.json" with { type: "json" }; +import shadcnSystemDark from "../../../../packages/ds-shadcn/tokens/system.dark.json" with { type: "json" }; +import shadcnSystem from "../../../../packages/ds-shadcn/tokens/system.json" with { type: "json" }; +import shadcnSystemLight from "../../../../packages/ds-shadcn/tokens/system.light.json" with { type: "json" }; +import bootstrapManifest from "../../../../packages/ds-bootstrap/manifest.json" with { type: "json" }; +import bootstrapSemantic from "../../../../packages/ds-bootstrap/tokens/semantic.json" with { type: "json" }; +import bootstrapSystemDark from "../../../../packages/ds-bootstrap/tokens/system.dark.json" with { type: "json" }; +import bootstrapSystemLight from "../../../../packages/ds-bootstrap/tokens/system.light.json" with { type: "json" }; +import mantineManifest from "../../../../packages/ds-mantine/manifest.json" with { type: "json" }; +import mantineSemantic from "../../../../packages/ds-mantine/tokens/semantic.json" with { type: "json" }; +import mantineSystemDark from "../../../../packages/ds-mantine/tokens/system.dark.json" with { type: "json" }; +import mantineSystemLight from "../../../../packages/ds-mantine/tokens/system.light.json" with { type: "json" }; +import radixManifest from "../../../../packages/ds-radix/manifest.json" with { type: "json" }; +import radixSemantic from "../../../../packages/ds-radix/tokens/semantic.json" with { type: "json" }; +import radixSystemDark from "../../../../packages/ds-radix/tokens/system.dark.json" with { type: "json" }; +import radixSystemLight from "../../../../packages/ds-radix/tokens/system.light.json" with { type: "json" }; +import polarisManifest from "../../../../packages/ds-polaris/manifest.json" with { type: "json" }; +import polarisSemantic from "../../../../packages/ds-polaris/tokens/semantic.json" with { type: "json" }; +import polarisSystemDark from "../../../../packages/ds-polaris/tokens/system.dark.json" with { type: "json" }; +import polarisSystemLight from "../../../../packages/ds-polaris/tokens/system.light.json" with { type: "json" }; +import primerManifest from "../../../../packages/ds-primer/manifest.json" with { type: "json" }; +import primerSemantic from "../../../../packages/ds-primer/tokens/semantic.json" with { type: "json" }; +import primerSystemDark from "../../../../packages/ds-primer/tokens/system.dark.json" with { type: "json" }; +import primerSystem from "../../../../packages/ds-primer/tokens/system.json" with { type: "json" }; +import primerSystemLight from "../../../../packages/ds-primer/tokens/system.light.json" with { type: "json" }; +import spectrumManifest from "../../../../packages/ds-spectrum/manifest.json" with { type: "json" }; +import spectrumSemantic from "../../../../packages/ds-spectrum/tokens/semantic.json" with { type: "json" }; +import spectrumSystemDark from "../../../../packages/ds-spectrum/tokens/system.dark.json" with { type: "json" }; +import spectrumSystemLight from "../../../../packages/ds-spectrum/tokens/system.light.json" with { type: "json" }; +import govukManifest from "../../../../packages/ds-govuk/manifest.json" with { type: "json" }; +import govukSemantic from "../../../../packages/ds-govuk/tokens/semantic.json" with { type: "json" }; +import govukSystemLight from "../../../../packages/ds-govuk/tokens/system.light.json" with { type: "json" }; +import chakraManifest from "../../../../packages/ds-chakra/manifest.json" with { type: "json" }; +import chakraSemantic from "../../../../packages/ds-chakra/tokens/semantic.json" with { type: "json" }; +import chakraSystemDark from "../../../../packages/ds-chakra/tokens/system.dark.json" with { type: "json" }; +import chakraSystemLight from "../../../../packages/ds-chakra/tokens/system.light.json" with { type: "json" }; + export interface Manifest { name: string; displayName?: string; @@ -100,14 +162,56 @@ export const TEMPLATE_PACKS: Record = { health: { manifest: healthManifest as Manifest, files: { "tokens/semantic.json": healthSemantic, "tokens/system.dark.json": healthSystemDark, "tokens/system.json": healthSystem, "tokens/system.light.json": healthSystemLight } }, neon: { manifest: neonManifest as Manifest, files: { "tokens/semantic.json": neonSemantic, "tokens/system.dark.json": neonSystemDark, "tokens/system.json": neonSystem, "tokens/system.light.json": neonSystemLight } }, mono: { manifest: monoManifest as Manifest, files: { "tokens/semantic.json": monoSemantic, "tokens/system.dark.json": monoSystemDark, "tokens/system.json": monoSystem, "tokens/system.light.json": monoSystemLight } }, + material3: { manifest: material3Manifest as Manifest, files: { "tokens/primitive.json": material3Primitive, "tokens/semantic.json": material3Semantic, "tokens/system.dark.json": material3SystemDark, "tokens/system.json": material3System, "tokens/system.light.json": material3SystemLight } }, + carbon: { manifest: carbonManifest as Manifest, files: { "tokens/primitive.json": carbonPrimitive, "tokens/semantic.json": carbonSemantic, "tokens/system.dark.json": carbonSystemDark, "tokens/system.json": carbonSystem, "tokens/system.light.json": carbonSystemLight } }, + antd: { manifest: antdManifest as Manifest, files: { "tokens/primitive.json": antdPrimitive, "tokens/semantic.json": antdSemantic, "tokens/system.dark.json": antdSystemDark, "tokens/system.json": antdSystem, "tokens/system.light.json": antdSystemLight } }, + fluent: { manifest: fluentManifest as Manifest, files: { "tokens/semantic.json": fluentSemantic, "tokens/system.dark.json": fluentSystemDark, "tokens/system.light.json": fluentSystemLight } }, + shadcn: { manifest: shadcnManifest as Manifest, files: { "tokens/semantic.json": shadcnSemantic, "tokens/system.dark.json": shadcnSystemDark, "tokens/system.json": shadcnSystem, "tokens/system.light.json": shadcnSystemLight } }, + bootstrap: { manifest: bootstrapManifest as Manifest, files: { "tokens/semantic.json": bootstrapSemantic, "tokens/system.dark.json": bootstrapSystemDark, "tokens/system.light.json": bootstrapSystemLight } }, + mantine: { manifest: mantineManifest as Manifest, files: { "tokens/semantic.json": mantineSemantic, "tokens/system.dark.json": mantineSystemDark, "tokens/system.light.json": mantineSystemLight } }, + radix: { manifest: radixManifest as Manifest, files: { "tokens/semantic.json": radixSemantic, "tokens/system.dark.json": radixSystemDark, "tokens/system.light.json": radixSystemLight } }, + polaris: { manifest: polarisManifest as Manifest, files: { "tokens/semantic.json": polarisSemantic, "tokens/system.dark.json": polarisSystemDark, "tokens/system.light.json": polarisSystemLight } }, + primer: { manifest: primerManifest as Manifest, files: { "tokens/semantic.json": primerSemantic, "tokens/system.dark.json": primerSystemDark, "tokens/system.json": primerSystem, "tokens/system.light.json": primerSystemLight } }, + spectrum: { manifest: spectrumManifest as Manifest, files: { "tokens/semantic.json": spectrumSemantic, "tokens/system.dark.json": spectrumSystemDark, "tokens/system.light.json": spectrumSystemLight } }, + govuk: { manifest: govukManifest as Manifest, files: { "tokens/semantic.json": govukSemantic, "tokens/system.light.json": govukSystemLight } }, + chakra: { manifest: chakraManifest as Manifest, files: { "tokens/semantic.json": chakraSemantic, "tokens/system.dark.json": chakraSystemDark, "tokens/system.light.json": chakraSystemLight } }, }; /** In the order the chooser shows them: the plainest starts first, the strongest characters after. */ -export const TEMPLATE_NAMES = ["mono", "civic", "sketch", "wireframe", "editorial", "pastel", "health", "finance", "glass", "terminal", "brutalist", "neon"] as const; +export const TEMPLATE_NAMES = [ + // Polyxd's own, plainest first. + "mono", "civic", "sketch", "wireframe", "editorial", "pastel", "health", "finance", "glass", "terminal", "brutalist", "neon", + // Modelled on published design systems, for a team whose product already uses one. shadcn is + // not among them yet: five of its tokens carry shadcn's own `var(--surface)` CSS instead of an + // alias, so a copy would land in a workspace with broken aliases showing. + "material3", "carbon", "fluent", "antd", "bootstrap", "mantine", "radix", "polaris", "primer", "spectrum", "chakra", "govuk", +] as const; export type TemplateName = (typeof TEMPLATE_NAMES)[number]; -/** The one line the pack's provenance gives it: "Hand-drawn: paper, ink, …". */ +/** + * The one line under a template's name. Polyxd's own packs carry it in their provenance + * ("Hand-drawn: paper, ink, …"); the packs modelled on a published design system say whose tokens + * they are and where they came from, because that is the thing a team picking one needs to know. + */ +const MODELLED: Record = { + material3: "Google's Material 3: its palette and type scale, as published", + carbon: "IBM Carbon: square corners, IBM Plex Sans, full-width actions", + fluent: "Microsoft Fluent 2: Segoe UI, soft depth", + shadcn: "shadcn/ui on Tailwind CSS: the default zinc theme", + antd: "Ant Design: dense, businesslike, Ant's blue", + bootstrap: "Bootstrap 5: the framework most products already have", + mantine: "Mantine 8: rounded, roomy, Inter", + radix: "Radix Themes: WorkOS's indigo scale", + polaris: "Shopify Polaris: Inter, admin-scale density", + primer: "GitHub Primer: Mona Sans, tight controls", + spectrum: "Adobe Spectrum 2: Source Sans, precise spacing", + chakra: "Chakra UI 3: Inter, generous radii", + govuk: "GOV.UK Frontend: one theme, no dark mode, plain and accessible", +}; + export function characterOf(manifest: Manifest): string { + const modelled = MODELLED[manifest.name]; + if (modelled) return modelled; const notes = manifest.provenance?.map((p) => p.notes ?? "").find((n) => /^Original template/.test(n)) ?? ""; const m = /^Original template by Polyxd:\s*(.+?)(?:\s+Not derived|$)/s.exec(notes); return (m?.[1] ?? notes).trim().replace(/\.$/, ""); diff --git a/apps/studio/src/worker/admin.ts b/apps/studio/src/worker/admin.ts new file mode 100644 index 0000000..c210d3f --- /dev/null +++ b/apps/studio/src/worker/admin.ts @@ -0,0 +1,176 @@ +/** + * Support, for the people who run this Studio. A super admin can find any workspace or person, + * set a workspace's plan by hand (a comp, a design partner, an enterprise deal invoiced outside + * Stripe), hand a workspace to a new owner when the old one has gone, take someone out of a + * workspace, and read what Stripe says about a customer. + * + * Who counts as one is the SUPER_ADMINS secret, a list of email addresses, checked against the + * signed-in user on every request. It is never a column, so no amount of editing the database + * grants it. Nothing here writes to Stripe: a plan set by hand and a subscription are separate + * things, and the webhook leaves `enterprise` alone (billing.ts). + */ +import type { Env, User } from "./auth.ts"; +import { stripe, stripeOn } from "./billing.ts"; +import { PLANS, planSummary, type Plan } from "./plans.ts"; + +export const superAdmins = (env: Env): Set => + new Set( + (env.SUPER_ADMINS ?? "") + .split(",") + .map((e) => e.trim().toLowerCase()) + .filter(Boolean), + ); + +export const isSuperAdmin = (env: Env, user: User | null): boolean => !!user && superAdmins(env).has(user.email.toLowerCase()); + +export const isPlan = (p: unknown): p is Plan => typeof p === "string" && (PLANS as readonly string[]).includes(p); + +/** What changed, who changed it and why. Written before the answer, so a failure is visible. */ +export async function audit( + env: Env, + adminEmail: string, + action: string, + o: { workspaceId?: string | null; userId?: string | null; before?: unknown; after?: unknown; note?: string | null }, +): Promise { + await env.DB.prepare("INSERT INTO admin_actions (id, at, admin_email, action, workspace_id, user_id, before, after, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") + .bind( + crypto.randomUUID(), + new Date().toISOString(), + adminEmail.toLowerCase(), + action, + o.workspaceId ?? null, + o.userId ?? null, + o.before === undefined ? null : JSON.stringify(o.before), + o.after === undefined ? null : JSON.stringify(o.after), + o.note?.trim() || null, + ) + .run(); +} + +export interface AdminWorkspace { + id: string; + slug: string; + name: string; + plan: string; + plan_status: string | null; + seats: number | null; + stripe_customer_id: string | null; + created_at: string; + owner_email: string | null; + members: number; +} + +/** Workspaces by slug, name or the owner's email; the newest first when nothing is searched for. */ +export async function searchWorkspaces(env: Env, q: string, limit = 50): Promise { + const like = `%${q.trim().toLowerCase()}%`; + const where = q.trim() ? "WHERE lower(w.slug) LIKE ?1 OR lower(w.name) LIKE ?1 OR lower(o.email) LIKE ?1" : ""; + const sql = `SELECT w.id, w.slug, w.name, w.plan, w.plan_status, w.seats, w.stripe_customer_id, w.created_at, + o.email AS owner_email, + (SELECT COUNT(*) FROM memberships m WHERE m.workspace_id = w.id) AS members + FROM workspaces w + LEFT JOIN memberships mo ON mo.workspace_id = w.id AND mo.role = 'owner' + LEFT JOIN user o ON o.id = mo.user_id + ${where} + ORDER BY w.created_at DESC LIMIT ${Math.min(Math.max(limit, 1), 200)}`; + const stmt = q.trim() ? env.DB.prepare(sql).bind(like) : env.DB.prepare(sql); + return (await stmt.all()).results; +} + +export async function searchUsers(env: Env, q: string, limit = 50) { + const like = `%${q.trim().toLowerCase()}%`; + const where = q.trim() ? "WHERE lower(u.email) LIKE ?1 OR lower(u.name) LIKE ?1" : ""; + const sql = `SELECT u.id, u.email, u.name, u.emailVerified, u.createdAt, + (SELECT COUNT(*) FROM memberships m WHERE m.user_id = u.id) AS workspaces + FROM user u ${where} ORDER BY u.createdAt DESC LIMIT ${Math.min(Math.max(limit, 1), 200)}`; + const stmt = q.trim() ? env.DB.prepare(sql).bind(like) : env.DB.prepare(sql); + return (await stmt.all<{ id: string; email: string; name: string; emailVerified: number; createdAt: string; workspaces: number }>()).results; +} + +/** One workspace as support needs it: who is in it, what it uses, and what Stripe says. */ +export async function workspaceDetail(env: Env, id: string) { + const w = await env.DB.prepare("SELECT id, slug, name, plan, plan_status, billing_interval, seats, stripe_customer_id, stripe_subscription_id, period_end, created_at FROM workspaces WHERE id = ?") + .bind(id) + .first>(); + if (!w) return null; + const members = ( + await env.DB.prepare( + "SELECT m.user_id AS id, m.role, m.created_at, u.email, u.name FROM memberships m JOIN user u ON u.id = m.user_id WHERE m.workspace_id = ? ORDER BY CASE m.role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END, u.email", + ) + .bind(id) + .all<{ id: string; role: string; created_at: string; email: string; name: string }>() + ).results; + const invites = ( + await env.DB.prepare("SELECT id, email, role, created_at, expires_at FROM invites WHERE workspace_id = ? AND accepted_at IS NULL ORDER BY created_at DESC") + .bind(id) + .all<{ id: string; email: string; role: string; created_at: string; expires_at: string }>() + ).results; + return { + workspace: w, + members, + invites, + usage: await planSummary(env, id), + stripe: await stripeSnapshot(env, w.stripe_customer_id as string | null), + }; +} + +/** + * What Stripe holds for this customer, read only: the subscription and the last few invoices, so a + * question about a charge can be answered without leaving Studio. Any failure is reported as text + * rather than thrown: support should still see the rest of the page. + */ +export async function stripeSnapshot(env: Env, customerId: string | null) { + if (!customerId) return { customer: null as string | null, subscriptions: [], invoices: [], error: null as string | null }; + if (!stripeOn(env)) return { customer: customerId, subscriptions: [], invoices: [], error: "Stripe isn't set up on this Studio" }; + try { + const subs = await stripe<{ data: { id: string; status: string; cancel_at_period_end?: boolean; items: { data: { quantity?: number; price: { id: string; nickname?: string | null; unit_amount?: number | null; currency?: string } }[] } }[] }>( + env, + "GET", + "/subscriptions", + { customer: customerId, status: "all", limit: 5 }, + ); + const invoices = await stripe<{ data: { id: string; number?: string | null; status?: string | null; total: number; currency: string; created: number; hosted_invoice_url?: string | null }[] }>( + env, + "GET", + "/invoices", + { customer: customerId, limit: 10 }, + ); + return { + customer: customerId, + subscriptions: subs.data.map((s) => ({ + id: s.id, + status: s.status, + cancelAtPeriodEnd: !!s.cancel_at_period_end, + quantity: s.items.data[0]?.quantity ?? null, + price: s.items.data[0]?.price.id ?? null, + amount: s.items.data[0]?.price.unit_amount ?? null, + currency: s.items.data[0]?.price.currency ?? null, + })), + invoices: invoices.data.map((i) => ({ id: i.id, number: i.number ?? null, status: i.status ?? null, total: i.total, currency: i.currency, created: new Date(i.created * 1000).toISOString(), url: i.hosted_invoice_url ?? null })), + error: null, + }; + } catch (e) { + return { customer: customerId, subscriptions: [], invoices: [], error: e instanceof Error ? e.message : "Stripe didn't answer" }; + } +} + +/** How many workspaces sit on each plan, and how many people there are: the numbers on the front page. */ +export async function overview(env: Env) { + const plans = (await env.DB.prepare("SELECT plan, COUNT(*) AS n FROM workspaces GROUP BY plan").all<{ plan: string; n: number }>()).results; + const one = async (sql: string) => (await env.DB.prepare(sql).first<{ n: number }>())?.n ?? 0; + return { + plans: Object.fromEntries(plans.map((p) => [p.plan, p.n])), + workspaces: await one("SELECT COUNT(*) AS n FROM workspaces"), + users: await one("SELECT COUNT(*) AS n FROM user"), + subscribed: await one("SELECT COUNT(*) AS n FROM workspaces WHERE stripe_subscription_id IS NOT NULL"), + overQuota: await one("SELECT COUNT(*) AS n FROM workspaces WHERE over_quota_since IS NOT NULL"), + }; +} + +export async function recentActions(env: Env, limit = 50) { + return ( + await env.DB.prepare( + `SELECT a.*, w.slug AS workspace_slug FROM admin_actions a LEFT JOIN workspaces w ON w.id = a.workspace_id + ORDER BY a.at DESC LIMIT ${Math.min(Math.max(limit, 1), 200)}`, + ).all>() + ).results; +} diff --git a/apps/studio/src/worker/auth.ts b/apps/studio/src/worker/auth.ts index c2f4c78..98a9495 100644 --- a/apps/studio/src/worker/auth.ts +++ b/apps/studio/src/worker/auth.ts @@ -41,6 +41,8 @@ export interface Env extends AnalyticsEnv { /** For the rollup to read Analytics Engine back: the account, and a token with Account Analytics Read. */ CF_ACCOUNT_ID?: string; CF_ANALYTICS_TOKEN?: string; + /** Who may use /admin: email addresses, separated by commas. Never a column, so no row grants it. */ + SUPER_ADMINS?: string; } export interface User { @@ -69,8 +71,51 @@ export async function sendEmail(env: Env, to: string, subject: string, html: str return r.ok; } -const page = (title: string, body: string, cta?: { text: string; url: string }) => - `

${title}

${body}

${cta ? `

${cta.text}

Or paste this into your browser: ${cta.url}

` : ""}
`; +/** + * One email, laid out the way email actually works: tables, inline styles, no web fonts and no + * image that has to load before it makes sense. The mark is two squares drawn with table cells, + * so it survives a blocked-images inbox. The site's paper, ink and signal orange. + */ +const EMAIL = { ground: "#F4F1EA", paper: "#FFFFFF", ink: "#141413", muted: "#5E5A52", line: "#E3DED2", signal: "#FF5A1F" }; + +/** "Hi Neel," reads better than "Hi," and better than the whole name; nothing at all is fine too. */ +export const firstName = (name?: string | null): string => (name ?? "").trim().split(/\s+/)[0] ?? ""; + +const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +export function page(o: { title: string; body: string; name?: string | null; cta?: { text: string; url: string }; note?: string; preview?: string }): string { + const hello = firstName(o.name); + const font = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif"; + return `${escapeHtml(o.title)} + +
${escapeHtml(o.preview ?? o.body)}
+ +
+ + + + +
+ + + +
polyxd Studio
+
+

${escapeHtml(o.title)}

+ ${hello ? `

Hi ${escapeHtml(hello)},

` : ""} +

${o.body}

+ ${o.cta ? `
+ ${escapeHtml(o.cta.text)} +
+

Or paste this into your browser:
${o.cta.url}

` : ""} + ${o.note ? `

${o.note}

` : ""} +
+ Polyxd Studio · polyxd.com
+ Interfaces that show up when you need them. +
+
+`; +} /** * One auth instance per request: the D1 binding is per request on Workers. `onSignUp` hears of @@ -104,14 +149,38 @@ export function makeAuth(env: Env, hooks: { onSignUp?: (userId: string, method: requireEmailVerification: !local, revokeSessionsOnPasswordReset: true, sendResetPassword: async ({ user, url }) => { - await sendEmail(env, user.email, "Reset your Studio password", page("Choose a new password", "Someone asked to reset the password for this email on Polyxd Studio. If it wasn't you, ignore this; nothing changes.", { text: "Reset password", url })); + await sendEmail( + env, + user.email, + "Reset your Studio password", + page({ + title: "Choose a new password", + name: user.name, + body: "Someone asked to reset the password for this email on Polyxd Studio.", + cta: { text: "Reset password", url }, + note: "If it wasn't you, ignore this and nothing changes. The link works for an hour.", + preview: "Reset the password for your Polyxd Studio account.", + }), + ); }, }, emailVerification: { sendOnSignUp: !local, autoSignInAfterVerification: true, sendVerificationEmail: async ({ user, url }) => { - await sendEmail(env, user.email, "Verify your email for Studio", page("Verify your email", "One click and you're in. The link works for an hour.", { text: "Verify email", url })); + await sendEmail( + env, + user.email, + "Verify your email for Studio", + page({ + title: "Verify your email", + name: user.name, + body: "One click and your Studio account is ready. Then you can make a workspace, bring in your design system and start publishing screens.", + cta: { text: "Verify email", url }, + note: "The link works for an hour.", + preview: "One click and your Polyxd Studio account is ready.", + }), + ); }, }, socialProviders: env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET ? { google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET } } : {}, diff --git a/apps/studio/src/worker/index.ts b/apps/studio/src/worker/index.ts index bcef5f4..e8f0650 100644 --- a/apps/studio/src/worker/index.ts +++ b/apps/studio/src/worker/index.ts @@ -10,7 +10,7 @@ import { scan } from "../import/scan.ts"; import { mapRoles, candidatesFor, scalar, type Contract, type Override } from "../import/map.ts"; import { checkRegistryUrl, fetchPackage, findTokenFiles, untar } from "../import/package.ts"; import { decrypt, encrypt, newApiKey, sha256 } from "./crypto.ts"; -import { isLocal, makeAuth, now, sendEmail, userFromRequest, type Ctx, type Env, type User } from "./auth.ts"; +import { isLocal, makeAuth, now, page, sendEmail, userFromRequest, type Ctx, type Env, type User } from "./auth.ts"; import { checkDocument, type Rule } from "../screens/validate.ts"; import { blankDocument } from "../screens/tree.ts"; import type { Doc } from "../screens/schema.ts"; @@ -23,16 +23,23 @@ import { checkDirection } from "../direction/schema.ts"; import { capture, ingest, posthogConfig, type Properties } from "./analytics.ts"; import { CORS, ingestEvents, newIngestKey, preflight } from "./insights.ts"; import { RANGES, RETENTION_DAYS, detail, range, summarise, type StoredRow } from "../insights/report.ts"; -import { PlanLimit, assertCanCreate, assertCanEdit, billingOn, countFetch, isEditor, planSummary, pruneHistory, rollUp } from "./plans.ts"; +import { PLANS, PlanLimit, assertCanCreate, assertCanEdit, billingOn, countFetch, isEditor, planSummary, pruneHistory, rollUp } from "./plans.ts"; import { StripeError, applySubscription, checkoutParams, isPaidPlan, priceFor, stripe, stripeOn, syncSeats, verifySignature, type Subscription } from "./billing.ts"; +import { audit, isPlan, isSuperAdmin, overview, recentActions, searchUsers, searchWorkspaces, workspaceDetail } from "./admin.ts"; type Vars = { user: User | null; apiWorkspace: string | null }; const app = new Hono<{ Bindings: Env; Variables: Vars }>(); const CONTRACT = contract as unknown as Contract; -const ROLES = ["owner", "design-system", "designer", "product", "engineer", "viewer"] as const; -const CAN_EDIT_TOKENS = new Set(["owner", "design-system", "engineer"]); -const CAN_EDIT_RULES = new Set(["owner", "design-system", "designer"]); -const CAN_EDIT_SCREENS = new Set(["owner", "design-system", "designer", "product"]); +// owner and admin both run a workspace; the difference is that there is exactly one owner, and +// only the owner can transfer it away or delete the workspace. owner is never invited or set by a +// role change: it moves only through a transfer, which demotes the previous owner to admin. +const ROLES = ["owner", "admin", "design-system", "designer", "product", "engineer", "viewer"] as const; +const GRANTABLE = ROLES.filter((r) => r !== "owner"); +/** Runs the workspace: people, roles and billing. */ +const RUNS = new Set(["owner", "admin"]); +const CAN_EDIT_TOKENS = new Set(["owner", "admin", "design-system", "engineer"]); +const CAN_EDIT_RULES = new Set(["owner", "admin", "design-system", "designer"]); +const CAN_EDIT_SCREENS = new Set(["owner", "admin", "design-system", "designer", "product"]); const secretsKey = (env: Env) => env.SECRETS_KEY ?? (isLocal(env) ? "dev-only-not-a-secret" : ""); class Fail extends Error { @@ -184,7 +191,7 @@ app.get("/api/me", async (c) => { .bind(user.id).all(); // The app's analytics are on only for signed-in people, and only when the Worker has a key (the public one). const config = posthogConfig(c.env); - return c.json({ user, workspaces: workspaces.results, signIn, billing: billingOn(c.env), ...(config ? { analytics: { key: config.key, ui: config.ui } } : {}) }); + return c.json({ user, workspaces: workspaces.results, signIn, billing: billingOn(c.env), ...(isSuperAdmin(c.env, user) ? { admin: true } : {}), ...(config ? { analytics: { key: config.key, ui: config.ui } } : {}) }); }); // ---------------------------------------------------------------- workspaces, members, invites @@ -215,7 +222,7 @@ app.get("/api/w/:slug", async (c) => { }); app.post("/api/w/:slug/invites", async (c) => { - const w = await ws(c as Ctx, new Set(["owner", "design-system"])); + const w = await ws(c as Ctx, new Set(["owner", "admin", "design-system"])); const user = need(c as Ctx); const { emails, role, message } = await body<{ emails?: string[]; role?: string; message?: string }>(c as Ctx); if (!role || !ROLES.includes(role as (typeof ROLES)[number]) || role === "owner") throw new Fail(400, "Pick a role other than owner"); @@ -228,13 +235,29 @@ app.post("/api/w/:slug/invites", async (c) => { if (already.length) throw new Fail(409, `${already.join(", ")} ${already.length === 1 ? "is" : "are"} already in this workspace`); // An editor invite holds a seat until it is used or expires; viewers are always free. if (isEditor(role)) await assertCanCreate(c.env, w, { kind: "editors", adding: list.length, pending: true }); + // Someone invited who already has an account is greeted by name; a stranger simply isn't greeted. + const known = new Map( + (await c.env.DB.prepare(`SELECT email, name FROM user WHERE email IN (${list.map(() => "?").join(", ")})`).bind(...list).all<{ email: string; name: string }>()).results.map((u) => [u.email.toLowerCase(), u.name]), + ); const made = []; for (const email of list) { const id = crypto.randomUUID(); await c.env.DB.prepare("INSERT INTO invites (id, workspace_id, email, role, message, invited_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") .bind(id, w.id, email, role, message ?? "", user.id, now(), new Date(Date.now() + 7 * 86400e3).toISOString()).run(); const link = `${c.env.APP_URL}/invite/${id}`; - const sent = await sendEmail(c.env, email, `${user.name || user.email} invited you to ${w.name} on Polyxd Studio`, `

Join ${w.name} on Studio

${user.name || user.email} invited you as ${role}.${message ? ` “${String(message).replace(/[<>]/g, "")}”` : ""}

Accept the invite

It expires in 7 days. Or paste this into your browser: ${link}

`); + const sent = await sendEmail( + c.env, + email, + `${user.name || user.email} invited you to ${w.name} on Polyxd Studio`, + page({ + title: `Join ${w.name} on Studio`, + name: known.get(email) ?? null, + body: `${user.name || user.email} invited you to ${w.name} as ${role}.${message ? ` “${String(message).replace(/[<>]/g, "")}”` : ""}`, + cta: { text: "Accept the invite", url: link }, + note: "The invite expires in 7 days.", + preview: `${user.name || user.email} invited you to ${w.name} on Polyxd Studio.`, + }), + ); // Without an email sender, the link comes back so the inviter can pass it on. made.push({ id, email, link: sent ? undefined : link, sent }); } @@ -272,20 +295,55 @@ app.post("/api/invites/:id/accept", async (c) => { /** An open invite withdrawn, which frees the seat it held. */ app.delete("/api/w/:slug/invites/:id", async (c) => { - const w = await ws(c as Ctx, new Set(["owner", "design-system"])); + const w = await ws(c as Ctx, new Set(["owner", "admin", "design-system"])); await c.env.DB.prepare("DELETE FROM invites WHERE id = ? AND workspace_id = ? AND accepted_at IS NULL").bind(c.req.param("id"), w.id).run(); return c.json({ ok: true }); }); -/** Someone taken out of the workspace by an owner. The last owner stays. */ -app.delete("/api/w/:slug/members/:user", async (c) => { +/** Someone's role changed by whoever runs the workspace. The owner's role changes only by transfer. */ +app.patch("/api/w/:slug/members/:user", async (c) => { + const w = await ws(c as Ctx, RUNS); + const { role } = await body<{ role?: string }>(c as Ctx); + if (!role || !GRANTABLE.includes(role as (typeof GRANTABLE)[number])) throw new Fail(400, `Pick one of ${GRANTABLE.join(", ")}`); + const target = c.req.param("user"); + const m = await c.env.DB.prepare("SELECT role FROM memberships WHERE workspace_id = ? AND user_id = ?").bind(w.id, target).first<{ role: string }>(); + if (!m) throw new Fail(404, "They're not in this workspace"); + if (m.role === "owner") throw new Fail(409, "The owner's role changes by transferring the workspace"); + if (m.role === role) return c.json({ ok: true, role }); + // A viewer becoming an editor takes a seat, so the plan has to have one. + if (!isEditor(m.role) && isEditor(role)) await assertCanCreate(c.env, w, { kind: "editors", adding: 1, pending: false }); + await c.env.DB.prepare("UPDATE memberships SET role = ? WHERE workspace_id = ? AND user_id = ?").bind(role, w.id, target).run(); + if (isEditor(m.role) !== isEditor(role)) await later(c as Ctx, syncSeats(c.env, w.id)); + return c.json({ ok: true, role }); +}); + +/** + * The workspace handed to someone else: exactly one owner, always. The previous owner stays as an + * admin, so nobody loses their place, and the two changes are one batch so there is never a moment + * with two owners or none. + */ +app.post("/api/w/:slug/owner", async (c) => { const w = await ws(c as Ctx, new Set(["owner"])); + const me = need(c as Ctx); + const { user: target } = await body<{ user?: string }>(c as Ctx); + if (!target || target === me.id) throw new Fail(400, "Name someone else in this workspace"); + const m = await c.env.DB.prepare("SELECT role FROM memberships WHERE workspace_id = ? AND user_id = ?").bind(w.id, target).first<{ role: string }>(); + if (!m) throw new Fail(404, "They're not in this workspace"); + await c.env.DB.batch([ + c.env.DB.prepare("UPDATE memberships SET role = 'admin' WHERE workspace_id = ? AND user_id = ?").bind(w.id, me.id), + c.env.DB.prepare("UPDATE memberships SET role = 'owner' WHERE workspace_id = ? AND user_id = ?").bind(w.id, target), + ]); + // A viewer becoming the owner is a new editor; the seats follow. + if (!isEditor(m.role)) await later(c as Ctx, syncSeats(c.env, w.id)); + return c.json({ ok: true }); +}); + +/** Someone taken out of the workspace. The owner can only leave by transferring it first. */ +app.delete("/api/w/:slug/members/:user", async (c) => { + const w = await ws(c as Ctx, RUNS); const m = await c.env.DB.prepare("SELECT role FROM memberships WHERE workspace_id = ? AND user_id = ?").bind(w.id, c.req.param("user")).first<{ role: string }>(); if (!m) throw new Fail(404, "They're not in this workspace"); - if (m.role === "owner") { - const owners = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM memberships WHERE workspace_id = ? AND role = 'owner'").bind(w.id).first<{ n: number }>(); - if ((owners?.n ?? 0) <= 1) throw new Fail(409, "A workspace needs an owner; make someone else owner first"); - } + if (m.role === "owner") throw new Fail(409, "A workspace needs an owner; transfer it first"); await c.env.DB.prepare("DELETE FROM memberships WHERE workspace_id = ? AND user_id = ?").bind(w.id, c.req.param("user")).run(); if (isEditor(m.role)) await later(c as Ctx, syncSeats(c.env, w.id)); return c.json({ ok: true }); @@ -332,7 +390,7 @@ app.get("/api/w/:slug/api-keys", async (c) => { }); app.post("/api/w/:slug/api-keys", async (c) => { - const w = await ws(c as Ctx, new Set(["owner", "engineer", "design-system"])); + const w = await ws(c as Ctx, new Set(["owner", "admin", "engineer", "design-system"])); const user = need(c as Ctx); const { name } = await body<{ name?: string }>(c as Ctx); if (name !== undefined) text(name, 80, "Name"); @@ -345,7 +403,7 @@ app.post("/api/w/:slug/api-keys", async (c) => { }); app.delete("/api/w/:slug/api-keys/:id", async (c) => { - const w = await ws(c as Ctx, new Set(["owner", "engineer", "design-system"])); + const w = await ws(c as Ctx, new Set(["owner", "admin", "engineer", "design-system"])); await c.env.DB.prepare("DELETE FROM api_keys WHERE id = ? AND workspace_id = ?").bind(c.req.param("id"), w.id).run(); return c.json({ ok: true }); }); @@ -763,7 +821,7 @@ app.post("/api/w/:slug/design-systems/:id/default", async (c) => { }); app.delete("/api/w/:slug/design-systems/:id", async (c) => { - const w = await ws(c as Ctx, new Set(["owner", "design-system"])); + const w = await ws(c as Ctx, new Set(["owner", "admin", "design-system"])); const { ds } = await version(c as Ctx, w); const { confirm } = await body<{ confirm?: string }>(c as Ctx).catch(() => ({ confirm: undefined })); if (confirm !== ds.name) throw new Fail(400, `Type the design system's name, ${ds.name}, to confirm`); @@ -1317,16 +1375,16 @@ app.get("/api/w/:slug/insights/:intent", async (c) => { return c.json({ days, from, to, retentionDays: RETENTION_DAYS, ...detail(intent, rows, from, to), screens: screens[intent] ?? [] }); }); -/** Every count the workspace holds, gone. The owner's call: the counts are theirs. */ +/** Every count the workspace holds, gone. The workspace's call: the counts are theirs. */ app.delete("/api/w/:slug/insights", async (c) => { - const w = await ws(c as Ctx, new Set(["owner"])); + const w = await ws(c as Ctx, RUNS); const r = await c.env.DB.prepare("DELETE FROM insight_counts WHERE workspace_id = ?").bind(w.id).run(); return c.json({ ok: true, deleted: r.meta.changes }); }); // ---------------------------------------------------------------- plans and billing (hosted Studio only) -const OWNERS = new Set(["owner"]); +const OWNERS = RUNS; // billing: the owner and any admin /** The workspace's plan and what it uses, for the Billing page; { enabled: false } on a self-hosted Studio. */ app.get("/api/w/:slug/billing", async (c) => { @@ -1405,6 +1463,110 @@ app.post("/api/billing/webhook", async (c) => { return c.json({ received: true }); }); +// ---------------------------------------------------------------- support (super admins only) + +/** + * Every /api/admin route passes through here. Someone signed in who isn't a super admin is told + * the endpoint doesn't exist rather than that they aren't allowed, so the surface isn't something + * to probe; a reader of the list is, by definition, on it. + */ +const needAdmin = (c: Ctx): User => { + const user = need(c); + if (!isSuperAdmin(c.env, user)) throw new Fail(404, "No such endpoint"); + return user; +}; + +app.get("/api/admin/overview", async (c) => { + needAdmin(c as Ctx); + return c.json({ ...(await overview(c.env)), actions: await recentActions(c.env, 20) }); +}); + +app.get("/api/admin/workspaces", async (c) => { + needAdmin(c as Ctx); + return c.json({ workspaces: await searchWorkspaces(c.env, c.req.query("q") ?? "") }); +}); + +app.get("/api/admin/workspaces/:id", async (c) => { + needAdmin(c as Ctx); + const detail = await workspaceDetail(c.env, c.req.param("id")); + if (!detail) throw new Fail(404, "No such workspace"); + return c.json(detail); +}); + +/** + * A plan set by hand, for a comp, a design partner or an enterprise deal invoiced elsewhere. It + * does not touch Stripe: `enterprise` is the one the webhook leaves alone, so it is the one to use + * for anything that should not be undone by a subscription event. + */ +app.post("/api/admin/workspaces/:id/plan", async (c) => { + const admin = needAdmin(c as Ctx); + const { plan, note } = await body<{ plan?: string; note?: string }>(c as Ctx); + if (!isPlan(plan)) throw new Fail(400, `plan: one of ${PLANS.join(", ")}`); + const id = c.req.param("id"); + const w = await c.env.DB.prepare("SELECT plan, stripe_subscription_id FROM workspaces WHERE id = ?").bind(id).first<{ plan: string; stripe_subscription_id: string | null }>(); + if (!w) throw new Fail(404, "No such workspace"); + await audit(c.env, admin.email, "plan", { workspaceId: id, before: w.plan, after: plan, note }); + await c.env.DB.prepare("UPDATE workspaces SET plan = ? WHERE id = ?").bind(plan, id).run(); + // A workspace that pays Stripe and is given a plan by hand keeps its subscription: say so, so + // nobody is surprised by the next invoice. + return c.json({ ok: true, plan, subscribed: !!w.stripe_subscription_id }); +}); + +/** The workspace handed to someone already in it, when the owner has gone and support must step in. */ +app.post("/api/admin/workspaces/:id/owner", async (c) => { + const admin = needAdmin(c as Ctx); + const { user: target, note } = await body<{ user?: string; note?: string }>(c as Ctx); + const id = c.req.param("id"); + if (!target) throw new Fail(400, "user: who to hand it to"); + const m = await c.env.DB.prepare("SELECT role FROM memberships WHERE workspace_id = ? AND user_id = ?").bind(id, target).first<{ role: string }>(); + if (!m) throw new Fail(404, "They're not in this workspace"); + const current = await c.env.DB.prepare("SELECT user_id FROM memberships WHERE workspace_id = ? AND role = 'owner'").bind(id).first<{ user_id: string }>(); + if (current?.user_id === target) return c.json({ ok: true }); + await audit(c.env, admin.email, "owner", { workspaceId: id, userId: target, before: current?.user_id ?? null, after: target, note }); + const statements = [c.env.DB.prepare("UPDATE memberships SET role = 'owner' WHERE workspace_id = ? AND user_id = ?").bind(id, target)]; + if (current) statements.unshift(c.env.DB.prepare("UPDATE memberships SET role = 'admin' WHERE workspace_id = ? AND user_id = ?").bind(id, current.user_id)); + await c.env.DB.batch(statements); + await later(c as Ctx, syncSeats(c.env, id)); + return c.json({ ok: true }); +}); + +/** Someone taken out of a workspace by support. The owner goes only after the workspace is handed on. */ +app.delete("/api/admin/workspaces/:id/members/:user", async (c) => { + const admin = needAdmin(c as Ctx); + const id = c.req.param("id"); + const target = c.req.param("user"); + const m = await c.env.DB.prepare("SELECT role FROM memberships WHERE workspace_id = ? AND user_id = ?").bind(id, target).first<{ role: string }>(); + if (!m) throw new Fail(404, "They're not in this workspace"); + if (m.role === "owner") throw new Fail(409, "Hand the workspace to someone else first"); + await audit(c.env, admin.email, "member-removed", { workspaceId: id, userId: target, before: m.role, after: null }); + await c.env.DB.prepare("DELETE FROM memberships WHERE workspace_id = ? AND user_id = ?").bind(id, target).run(); + if (isEditor(m.role)) await later(c as Ctx, syncSeats(c.env, id)); + return c.json({ ok: true }); +}); + +app.get("/api/admin/users", async (c) => { + needAdmin(c as Ctx); + return c.json({ users: await searchUsers(c.env, c.req.query("q") ?? "") }); +}); + +/** The workspaces one person is in, for "I can't see my workspace" questions. */ +app.get("/api/admin/users/:id", async (c) => { + needAdmin(c as Ctx); + const u = await c.env.DB.prepare("SELECT id, email, name, emailVerified, createdAt FROM user WHERE id = ?").bind(c.req.param("id")).first>(); + if (!u) throw new Fail(404, "No such person"); + const workspaces = ( + await c.env.DB.prepare("SELECT w.id, w.slug, w.name, w.plan, m.role FROM memberships m JOIN workspaces w ON w.id = m.workspace_id WHERE m.user_id = ? ORDER BY w.name") + .bind(c.req.param("id")) + .all>() + ).results; + return c.json({ user: u, workspaces }); +}); + +app.get("/api/admin/log", async (c) => { + needAdmin(c as Ctx); + return c.json({ actions: await recentActions(c.env, 100) }); +}); + app.get("/api/contract", (c) => c.json({ roles: Object.keys(CONTRACT.tokens).length, contrastPairs: CONTRACT.contrast.length })); app.all("/api/*", (c) => c.json({ error: "No such endpoint" }, 404)); diff --git a/apps/studio/test/admin.worker.test.ts b/apps/studio/test/admin.worker.test.ts new file mode 100644 index 0000000..2ed9a67 --- /dev/null +++ b/apps/studio/test/admin.worker.test.ts @@ -0,0 +1,204 @@ +/** + * Support: who may use it, what it changes, and what it records. Stripe is never called: the one + * place that would reach it (a customer's subscriptions and invoices) has global fetch replaced. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { startWorker } from "./support/worker.ts"; + +type Worker = Awaited>; +const ADMIN = { SUPER_ADMINS: "boss@polyxd.com, Other@Polyxd.com" }; + +async function person(worker: Worker, email: string) { + const p = worker.client(); + const r = await p.call("POST", "/api/auth/sign-up/email", { name: email.split("@")[0], email, password: `test-${crypto.randomUUID()}` }); + assert.equal(r.status, 200); + return p; +} + +/** An invite made and accepted, so there are two people in one workspace. */ +async function join(worker: Worker, owner: Awaited>, slug: string, email: string, role: string) { + const inv = await owner.call("POST", `/api/w/${slug}/invites`, { emails: [email], role }); + assert.equal(inv.status, 201, JSON.stringify(inv.body)); + const them = await person(worker, email); + await them.call("POST", `/api/invites/${inv.body.invites[0].id}/accept`); + return them; +} + +const idOf = async (worker: Worker, slug: string) => (await worker.env.DB.prepare("SELECT id FROM workspaces WHERE slug = ?").bind(slug).first<{ id: string }>())!.id; +const one = async (worker: Worker, sql: string, ...params: unknown[]) => (await worker.env.DB.prepare(sql).bind(...params).first())!; +const all = async (worker: Worker, sql: string, ...params: unknown[]) => (await worker.env.DB.prepare(sql).bind(...params).all()).results; + +test("support is only for the addresses in SUPER_ADMINS, and says nothing to anyone else", async () => { + const worker = await startWorker(ADMIN); + const boss = await person(worker, "boss@polyxd.com"); + const nosy = await person(worker, "nosy@northwind.io"); + await nosy.call("POST", "/api/workspaces", { name: "Northwind", slug: "northwind" }); + + // Someone signed in who isn't on the list is told the endpoint isn't there, not that they can't. + for (const path of ["/api/admin/overview", "/api/admin/workspaces", "/api/admin/users", "/api/admin/log"]) { + assert.equal((await nosy.call("GET", path)).status, 404, path); + } + assert.equal((await worker.client().call("GET", "/api/admin/overview")).status, 401); + + const overview = await boss.call("GET", "/api/admin/overview"); + assert.equal(overview.status, 200); + assert.equal(overview.body.workspaces, 1); + assert.equal(overview.body.users, 2); + assert.equal(overview.body.plans.free, 1); + + // The list is matched case-insensitively, and the spaces around an address don't count. + const other = await person(worker, "other@polyxd.com"); + assert.equal((await other.call("GET", "/api/admin/overview")).status, 200); +}); + +test("a plan set by hand: the workspace changes, and what changed is recorded with a reason", async () => { + const worker = await startWorker(ADMIN); + const boss = await person(worker, "boss@polyxd.com"); + const ana = await person(worker, "ana@northwind.io"); + await ana.call("POST", "/api/workspaces", { name: "Northwind", slug: "northwind" }); + const id = await idOf(worker, "northwind"); + + assert.equal((await boss.call("POST", `/api/admin/workspaces/${id}/plan`, { plan: "gold" })).status, 400); + + const set = await boss.call("POST", `/api/admin/workspaces/${id}/plan`, { plan: "enterprise", note: "Design partner" }); + assert.equal(set.status, 200); + assert.equal(set.body.plan, "enterprise"); + assert.equal(set.body.subscribed, false); + assert.equal((await one<{ plan: string }>(worker, "SELECT plan FROM workspaces WHERE id = ?", id)).plan, "enterprise"); + + const log = await boss.call("GET", "/api/admin/log"); + assert.equal(log.body.actions.length, 1); + const a = log.body.actions[0]; + assert.equal(a.admin_email, "boss@polyxd.com"); + assert.equal(a.action, "plan"); + assert.equal(JSON.parse(a.before), "free"); + assert.equal(JSON.parse(a.after), "enterprise"); + assert.equal(a.note, "Design partner"); + assert.equal(a.workspace_slug, "northwind"); + + // The workspace now has an enterprise workspace's limits, not Free's. + const detail = await boss.call("GET", `/api/admin/workspaces/${id}`); + assert.equal(detail.status, 200); + assert.equal(detail.body.usage.plan, "enterprise"); + assert.equal(detail.body.usage.limits.designSystems, null); + assert.equal(detail.body.stripe.customer, null); +}); + +test("a workspace handed to someone else keeps exactly one owner, and the old owner stays as an admin", async () => { + const worker = await startWorker(ADMIN); + const boss = await person(worker, "boss@polyxd.com"); + const ana = await person(worker, "ana@northwind.io"); + await ana.call("POST", "/api/workspaces", { name: "Northwind", slug: "northwind" }); + const leo = await join(worker, ana, "northwind", "leo@northwind.io", "designer"); + const id = await idOf(worker, "northwind"); + const users = await all<{ id: string; email: string }>(worker, "SELECT id, email FROM user"); + const leoId = users.find((u) => u.email === "leo@northwind.io")!.id; + const anaId = users.find((u) => u.email === "ana@northwind.io")!.id; + + // Ana hands it over herself, from the Team page. + assert.equal((await ana.call("POST", "/api/w/northwind/owner", { user: leoId })).status, 200); + const roles = async () => Object.fromEntries((await all<{ user_id: string; role: string }>(worker, "SELECT user_id, role FROM memberships WHERE workspace_id = ?", id)).map((m) => [m.user_id, m.role])); + assert.deepEqual(await roles(), { [anaId]: "admin", [leoId]: "owner" }); + + // She can't take it back: she is an admin now, and only the owner may hand it on. + assert.equal((await ana.call("POST", "/api/w/northwind/owner", { user: anaId })).status, 403); + // Nor can anyone remove the owner; the workspace would have none. + assert.equal((await ana.call("DELETE", `/api/w/northwind/members/${leoId}`)).status, 409); + + // Support can step in when the owner has gone, and it is recorded. + assert.equal((await boss.call("POST", `/api/admin/workspaces/${id}/owner`, { user: anaId, note: "Leo left the company" })).status, 200); + assert.deepEqual(await roles(), { [anaId]: "owner", [leoId]: "admin" }); + const log = await boss.call("GET", "/api/admin/log"); + assert.equal(log.body.actions[0].action, "owner"); + assert.equal(log.body.actions[0].note, "Leo left the company"); + void leo; +}); + +test("an admin runs the workspace: people, roles and billing, but never the handover", async () => { + const worker = await startWorker({ ...ADMIN, BILLING: "on" }); + const ana = await person(worker, "ana@northwind.io"); + await ana.call("POST", "/api/workspaces", { name: "Northwind", slug: "northwind" }); + const mia = await join(worker, ana, "northwind", "mia@northwind.io", "admin"); + await join(worker, ana, "northwind", "leo@northwind.io", "viewer"); + // On Team, so seats aren't what this test is about: Free stops at two editors. + await worker.env.DB.prepare("UPDATE workspaces SET plan = 'team' WHERE slug = 'northwind'").run(); + const leoId = (await one<{ id: string }>(worker, "SELECT id FROM user WHERE email = ?", "leo@northwind.io")).id; + + // An admin may invite, change a role and see billing. + assert.equal((await mia.call("POST", "/api/w/northwind/invites", { emails: ["sam@northwind.io"], role: "designer" })).status, 201); + assert.equal((await mia.call("GET", "/api/w/northwind/billing")).status, 200); + assert.equal((await mia.call("PATCH", `/api/w/northwind/members/${leoId}`, { role: "engineer" })).status, 200); + assert.equal((await one<{ role: string }>(worker, "SELECT role FROM memberships WHERE user_id = ?", leoId)).role, "engineer"); + + // Nobody is made an owner by a role change; that is what the handover is for. + const bad = await mia.call("PATCH", `/api/w/northwind/members/${leoId}`, { role: "owner" }); + assert.equal(bad.status, 400); + // And an admin cannot hand the workspace on. + assert.equal((await mia.call("POST", "/api/w/northwind/owner", { user: leoId })).status, 403); +}); + +test("what Stripe says about a customer is read, never written, and a failure doesn't hide the rest", async () => { + const worker = await startWorker({ ...ADMIN, BILLING: "on", STRIPE_SECRET_KEY: "sk_test_fake" }); + const boss = await person(worker, "boss@polyxd.com"); + const ana = await person(worker, "ana@northwind.io"); + await ana.call("POST", "/api/workspaces", { name: "Northwind", slug: "northwind" }); + const id = await idOf(worker, "northwind"); + await worker.env.DB.prepare("UPDATE workspaces SET stripe_customer_id = ?, plan = 'team' WHERE id = ?").bind("cus_1", id).run(); + + const calls: string[] = []; + const real = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (!url.startsWith("https://api.stripe.com")) return real(input as RequestInfo, init); + calls.push(`${init?.method ?? "GET"} ${url.split("?")[0].replace("https://api.stripe.com/v1", "")}`); + const body = url.includes("/subscriptions") + ? { data: [{ id: "sub_1", status: "active", items: { data: [{ quantity: 3, price: { id: "price_team_month", unit_amount: 1200, currency: "usd" } }] } }] } + : { data: [{ id: "in_1", number: "A-1", status: "paid", total: 3600, currency: "usd", created: 1_790_000_000, hosted_invoice_url: "https://pay.stripe.com/x" }] }; + return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } }); + }) as typeof fetch; + try { + const detail = await boss.call("GET", `/api/admin/workspaces/${id}`); + assert.equal(detail.status, 200); + assert.equal(detail.body.stripe.subscriptions[0].quantity, 3); + assert.equal(detail.body.stripe.subscriptions[0].amount, 1200); + assert.equal(detail.body.stripe.invoices[0].number, "A-1"); + assert.equal(detail.body.stripe.error, null); + // Only reads: support can look at a subscription, never change or cancel it. + assert.ok(calls.every((c) => c.startsWith("GET ")), calls.join(", ")); + + // Stripe down: the page still answers, with the rest of the workspace and the reason. + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (!String(input).startsWith("https://api.stripe.com")) return real(input as RequestInfo, init); + return new Response(JSON.stringify({ error: { message: "Stripe is having a moment" } }), { status: 500, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const broken = await boss.call("GET", `/api/admin/workspaces/${id}`); + assert.equal(broken.status, 200); + assert.match(broken.body.stripe.error, /moment|500/); + assert.equal(broken.body.members.length, 1); + } finally { + globalThis.fetch = real; + } +}); + +test("support finds a workspace by slug, name or the owner's address, and a person by either", async () => { + const worker = await startWorker(ADMIN); + const boss = await person(worker, "boss@polyxd.com"); + const ana = await person(worker, "ana@northwind.io"); + await ana.call("POST", "/api/workspaces", { name: "Northwind Bank", slug: "northwind" }); + const harbour = await person(worker, "sam@harbourline.com"); + await harbour.call("POST", "/api/workspaces", { name: "Harbourline", slug: "harbourline" }); + + const by = async (q: string) => (await boss.call("GET", `/api/admin/workspaces?q=${encodeURIComponent(q)}`)).body.workspaces.map((w: { slug: string }) => w.slug); + assert.deepEqual(await by("harbour"), ["harbourline"]); + assert.deepEqual(await by("Northwind Bank"), ["northwind"]); + assert.deepEqual(await by("ana@northwind.io"), ["northwind"]); + assert.equal((await by("")).length, 2); + + const all = (await boss.call("GET", "/api/admin/workspaces")).body.workspaces; + assert.equal(all.find((w: { slug: string }) => w.slug === "northwind").owner_email, "ana@northwind.io"); + + const people = (await boss.call("GET", "/api/admin/users?q=harbourline")).body.users; + assert.deepEqual(people.map((u: { email: string }) => u.email), ["sam@harbourline.com"]); + assert.equal(people[0].workspaces, 1); +}); diff --git a/apps/studio/test/templates.test.ts b/apps/studio/test/templates.test.ts index edf124f..f3dd3cd 100644 --- a/apps/studio/test/templates.test.ts +++ b/apps/studio/test/templates.test.ts @@ -29,7 +29,8 @@ test("every template pack becomes a graph with two modes, a clean scan and all 8 for (const name of TEMPLATE_NAMES) { const g = templateGraph(name); const s = scan(g); - assert.deepEqual([...s.modes.map((m) => m.name)].sort(), ["dark", "light"], name); + // Two modes, unless the pack itself publishes one: GOV.UK has no dark theme. + assert.deepEqual([...s.modes.map((m) => m.name)].sort(), Object.keys(TEMPLATE_PACKS[name].manifest.modes).sort(), name); assert.equal(s.modes[0].name, TEMPLATE_PACKS[name].manifest.defaultMode, `${name}: the pack's default mode leads (Terminal is dark-first)`); assert.ok(s.total > 150, `${name}: ${s.total} tokens`); assert.equal(s.byTier.semantic, ROLES.length, `${name}: the semantic file is the 87 roles`); @@ -41,7 +42,7 @@ test("every template pack becomes a graph with two modes, a clean scan and all 8 assert.equal(rows.filter((r) => r.status === "fails").length, 0, `${name}: contrast passes in both modes`); // Values differ by mode where the pack says so. const surface = rows.find((r) => r.role === "color.surface.default")!; - assert.notEqual(surface.values.light, surface.values.dark, `${name}: the page colour changes with the mode`); + if (s.modes.length > 1) assert.notEqual(surface.values.light, surface.values.dark, `${name}: the page colour changes with the mode`); } }); @@ -54,9 +55,15 @@ test("a template's summary has a character line, swatches from its own tokens, a assert.ok(sketch.font, "a display face"); assert.match(sketch.radius, /px$/); const all = allTemplates(); - assert.equal(all.length, 13); + // Blank, Polyxd's twelve, and the twelve modelled on published design systems. + assert.equal(all.length, 25); assert.equal(all[0].name, BLANK); - assert.ok(all.every((t) => t.character && t.swatches.length >= 8)); + assert.ok(all.every((t) => t.character && t.swatches.length >= 8), all.filter((t) => !t.character || t.swatches.length < 8).map((t) => t.name).join(", ")); + // A pack modelled on someone else's system says so, and still carries a full set of swatches. + const m3 = all.find((t) => t.name === "material3")!; + assert.equal(m3.displayName, "Material 3"); + assert.match(m3.character, /Material 3/); + assert.deepEqual(all.find((t) => t.name === "govuk")!.modes, ["light"], "GOV.UK publishes one theme"); }); test("blank is Mono with a grey brand ramp: same structure, chroma 0, contrast still passing", () => {