g1t/apps/sudo/app/routes/workspace.tsx

309 lines14,127 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Billing on Stripe's pages, month-end charges, warnings; sudo by workspace1import { ArrowLeft, Building2, LogOut } from "lucide-react";
2import { data, Link, useLocation } from "react-router";
3
4import { type AccountSummary, type AdminAction, httpStatus } from "@g1t/contracts";
5
6import type { Route } from "./+types/workspace";
7import {
8 AuditSection,
9 BillingLinkSection,
10 CreditForm,
11 Figure,
12 LedgerSection,
13 Owners,
14 ReviewPanel,
15 TermsForm,
16} from "~/components/billing";
17import { Avatar, Badge, Button, EmptyState, ExposureBar, Field, Notice, Section, Select, StateBadge, TermsBadge, TrustBadge, When } from "~/components/ui";
18import { type Subject, billingAction } from "~/lib/billing-actions.server";
19import { parseSlug } from "~/lib/forms";
20import { usd } from "~/lib/money";
21import { type ActionData, type SectionError, doneMessage } from "~/lib/review";
22import { admin, identity } from "~/lib/services.server";
23import { requireStaff } from "~/lib/staff";
24import type { Enterprise } from "~/lib/workspaces";
25
26export const meta: Route.MetaFunction = ({ loaderData }) => [
27 { title: `${loaderData?.name ?? "Workspace"} · sudo` },
28 { name: "robots", content: "noindex, nofollow" },
29];
30
31/** Whether an audit line is about `slug`, in an enterprise's log. */
32function mentions(action: AdminAction, slug: string): boolean {
33 // A slug is letters, digits and hyphens, none special in a pattern.
34 return new RegExp(`(^|[^a-z0-9-])${slug}($|[^a-z0-9-])`).test(action.detail.toLowerCase());
35}
36
37async function load(raw: string) {
38 const parsed = parseSlug(raw);
39 if (!parsed.ok) throw data("That is not a workspace slug.", { status: 404 });
40 const slug = parsed.value;
41 const [person, result] = await Promise.all([identity.workspace(slug), admin.account(slug)]);
42 if (!result.ok) throw data(result.error.message, { status: httpStatus(result.error) });
43 const detail = result.value;
44 const { account } = detail.summary;
45 const billedTo: Enterprise | null = account.kind === "enterprise" ? { id: account.id, name: account.name } : null;
46 const known = person != null || billedTo != null || account.createdAt !== "" || detail.ledger.length > 0 || detail.audit.length > 0;
47 if (!known) throw data(`There is no workspace called ${slug}.`, { status: 404 });
48 const subject: Subject = { kind: "workspace", slug, accountId: account.id, terms: account.terms, billedTo };
49 return { slug, person, detail, billedTo, subject };
50}
51
52export async function loader({ request, params, context }: Route.LoaderArgs) {
53 requireStaff(context);
54 const { slug, person, detail, billedTo, subject } = await load(params.slug);
55 const { summary } = detail;
56 // On an enterprise, the limit is the enterprise's and the figures are
57 // this workspace's share of its bill.
58 const limit = (billedTo && detail.workspaces.find((member) => member.workspace === slug)) || summary.limit;
59 const share = billedTo ? summary.byWorkspace?.find((row) => row.workspace === slug) : null;
60 const figures = billedTo
61 ? { charged: share?.chargedMicros ?? 0, cost: share?.costMicros ?? 0, paid: share?.paidMicros ?? 0 }
62 : { charged: summary.chargedMicros, cost: summary.costMicros, paid: summary.paidMicros };
63 const enterprises = billedTo
64 ? []
65 : (await admin.accounts())
66 .filter((row: AccountSummary) => row.account.kind === "enterprise")
67 .map((row) => ({ id: row.account.id, name: row.account.name, members: row.account.workspaces.length }));
68 return {
69 slug,
70 name: person?.name ?? slug,
71 person,
72 billedTo,
73 terms: subject.terms,
74 limit,
75 figures,
76 enterprises,
77 ledger: billedTo ? detail.ledger.filter((entry) => !entry.workspace || entry.workspace === slug) : detail.ledger,
78 audit: billedTo ? detail.audit.filter((entry) => mentions(entry, slug)) : detail.audit,
79 done: doneMessage(request.url),
80 };
81}
82
83export async function action({ request, params, context }: Route.ActionArgs) {
84 const staff = requireStaff(context);
85 // What is acted on comes from billing and identity, not from the form.
86 const { slug, subject } = await load(params.slug);
87 return billingAction(request, staff, subject, `/workspaces/${encodeURIComponent(slug)}`);
88}
89
90export default function Workspace({ loaderData, actionData }: Route.ComponentProps) {
91 const { slug, name, person, billedTo, terms, limit, figures, enterprises, ledger, audit, done } = loaderData;
92 const { pathname } = useLocation();
93 const result = actionData as ActionData | undefined;
94 const review = result && "review" in result ? result.review : null;
95 const link = result && "link" in result ? result.link : null;
96 const error = (section: string): SectionError => (result && "error" in result && result.section === section ? result : null);
97 const owners = person?.members.filter((member) => member.role === "owner") ?? [];
98
99 return (
100 <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10">
101 <Link to="/" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
102 <ArrowLeft size={14} />
103 Workspaces
104 </Link>
105
106 {/* Header */}
107 <div className="mt-4 flex flex-wrap items-start justify-between gap-4">
108 <div className="flex min-w-0 items-start gap-3">
109 <Avatar name={slug} size={40} />
110 <div className="min-w-0">
111 <h1 className="truncate text-2xl font-semibold tracking-tight">{name}</h1>
112 <p className="mt-0.5 text-xs text-faint">
113 <span className="font-mono">{slug}</span>
114 {person && (
115 <>
116 {" · "}created <When at={person.createdAt} />
117 </>
118 )}
119 </p>
120 {person?.description && <p className="mt-1 text-sm text-muted">{person.description}</p>}
121 <div className="mt-2 flex flex-wrap gap-1.5">
122 {billedTo && <Badge tone="lavender">Billed to {billedTo.name}</Badge>}
123 <TermsBadge terms={terms} />
124 {terms.kind !== "comped" && <TrustBadge trust={limit.trust} />}
125 <StateBadge state={limit.state} />
126 </div>
127 </div>
128 </div>
129 {owners.length > 0 && (
130 <div className="text-xs">
131 <p className="mb-1.5 text-faint">Owners</p>
132 <Owners owners={owners} />
133 </div>
134 )}
135 </div>
136
137 <div className="mt-6 space-y-3">
138 {done && <Notice tone="ok">{done}</Notice>}
139 {error("top") && <Notice tone="error">{error("top")?.error}</Notice>}
140 {limit.message && <Notice tone={limit.state === "stopped" ? "error" : limit.state === "warning" ? "warn" : "info"}>{limit.message}</Notice>}
141 {review && <ReviewPanel review={review} pathname={pathname} />}
142 </div>
143
144 {/* People */}
145 <Section title="Members" description="Everyone in the workspace. Owners manage its members and billing." className="mt-6">
146 {!person ? (
147 <Notice tone="warn">Identity has no record of {slug}; only billing knows it. It may have been deleted.</Notice>
148 ) : person.members.length === 0 ? (
149 <EmptyState title="No members" />
150 ) : (
151 <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
152 <table className="w-full min-w-120 text-sm">
153 <thead>
154 <tr className="border-b border-line text-left text-xs text-muted">
155 <th className="px-4 py-2 font-medium sm:pl-5">Member</th>
156 <th className="px-4 py-2 font-medium">Email</th>
157 <th className="px-4 py-2 font-medium">Role</th>
158 <th className="px-4 py-2 font-medium sm:pr-5">Joined</th>
159 </tr>
160 </thead>
161 <tbody>
162 {person.members.map((member) => (
163 <tr key={member.username} className="border-b border-line last:border-0">
164 <td className="px-4 py-2.5 sm:pl-5">
165 <span className="inline-flex items-center gap-2 font-mono">
166 <Avatar name={member.username} size={18} square={false} />
167 {member.username}
168 </span>
169 </td>
170 <td className="px-4 py-2.5 break-all text-muted">{member.email ?? <span className="text-faint">—</span>}</td>
171 <td className="px-4 py-2.5">{member.role === "owner" ? <Badge tone="lavender">Owner</Badge> : <Badge>Member</Badge>}</td>
172 <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted sm:pr-5">
173 <When at={member.joined} />
174 </td>
175 </tr>
176 ))}
177 </tbody>
178 </table>
179 </div>
180 )}
181 </Section>
182
183 {/* Billing */}
184 <h2 className="mt-10 text-lg font-semibold tracking-tight">Billing</h2>
185 <div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
186 <div className="rounded-lg border border-line bg-surface px-4 py-3 sm:col-span-2">
187 <p className="mb-2 text-xs text-muted">
188 Usage this month against the limit{billedTo && <> · shared with every workspace {billedTo.name} pays for</>}
189 </p>
190 <ExposureBar limit={limit} wide />
191 <p className="mt-2 text-xs text-faint">
192 {billedTo ? <>Limit via {billedTo.name}</> : <>Limit from trust {usd(limit.trustCeilingMicros)}</>} · owner's spend limit{" "}
193 {usd(limit.spendLimitMicros)}
194 {terms.ceilingMicros != null && <> · custom limit {usd(terms.ceilingMicros)}</>}
195 </p>
196 </div>
197 <Figure label="Charged this month" value={usd(figures.charged)} hint={`Cost to g1t ${usd(figures.cost)}`} />
198 <Figure label="Paid ever" value={usd(figures.paid)} hint={`Margin this month ${usd(figures.charged - figures.cost)}`} />
199 </div>
200
201 <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
202 <div className="space-y-6">
203 <BilledToSection billedTo={billedTo} enterprises={enterprises} pathname={pathname} error={error("billed-to")} />
204 {billedTo ? (
205 <Section id="terms" title="Terms" description={`Charged on ${billedTo.name}'s terms while it pays for this workspace.`}>
206 <div className="flex flex-wrap items-center justify-between gap-3">
207 <TermsBadge terms={terms} />
208 <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}#terms`} className="text-sm text-merged hover:underline hover:underline-offset-4">
209 Change them on {billedTo.name}
210 </Link>
211 </div>
212 </Section>
213 ) : (
214 <TermsForm terms={terms} pathname={pathname} error={error("terms")} />
215 )}
216 <LedgerSection
217 ledger={ledger}
218 description={billedTo ? `This workspace's lines among ${billedTo.name}'s latest 100.` : "Recent lines of the statement, newest first."}
219 />
220 </div>
221
222 <div className="space-y-6">
223 <BillingLinkSection link={link} pathname={pathname} error={error("billing-link")} />
224 <CreditForm workspaces={[slug]} pathname={pathname} error={error("credit")} />
225 <AuditSection
226 audit={audit}
227 description={
228 billedTo ? (
229 <>
230 Changes about this workspace in {billedTo.name}'s log.{" "}
231 <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}`} className="text-merged hover:underline">
232 Full log
233 </Link>
234 </>
235 ) : undefined
236 }
237 />
238 </div>
239 </div>
240 </main>
241 );
242}
243
244function BilledToSection({
245 billedTo,
246 enterprises,
247 pathname,
248 error,
249}: {
250 billedTo: Enterprise | null;
251 enterprises: { id: string; name: string; members: number }[];
252 pathname: string;
253 error: SectionError;
254}) {
255 return (
256 <Section id="billed-to" title="Billed to" description="Whether the workspace pays for itself, or an enterprise pays for it.">
257 {error && (
258 <div className="mb-4">
259 <Notice tone="error">{error.error}</Notice>
260 </div>
261 )}
262 {billedTo ? (
263 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
264 <p className="text-sm text-muted">
265 <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}`} className="font-medium text-fg hover:underline hover:underline-offset-4">
266 {billedTo.name}
267 </Link>{" "}
268 pays for it, under its limit and terms.
269 </p>
270 <form method="post" action={`${pathname}#review`}>
271 <input type="hidden" name="intent" value="detach" />
272 <Button type="submit" variant="danger">
273 <LogOut size={14} />
274 Move off
275 </Button>
276 </form>
277 </div>
278 ) : enterprises.length === 0 ? (
279 <p className="text-sm text-muted">
280 Itself. There are no enterprises to move it onto yet;{" "}
281 <Link to="/enterprises/new" className="text-merged hover:underline hover:underline-offset-4">
282 create one
283 </Link>
284 .
285 </p>
286 ) : (
287 <form method="post" action={`${pathname}#review`} className="flex flex-col gap-2 sm:flex-row sm:items-end">
288 <input type="hidden" name="intent" value="attach" />
289 <Field label="Itself. Move onto" className="grow">
290 <Select name="target" required defaultValue={error?.values?.target ?? ""}>
291 <option value="" disabled>
292 Choose an enterprise
293 </option>
294 {enterprises.map((enterprise) => (
295 <option key={enterprise.id} value={enterprise.id}>
296 {enterprise.name} ({enterprise.members} workspace{enterprise.members === 1 ? "" : "s"})
297 </option>
298 ))}
299 </Select>
300 </Field>
301 <Button type="submit" variant="quiet">
302 <Building2 size={14} />
303 Move
304 </Button>
305 </form>
306 )}
307 </Section>
308 );
309}