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

434 lines18,968 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";
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales2import type { ReactNode } from "react";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace3import { data, Link, useLocation } from "react-router";
4
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales5import { type AccountSummary, type AdminAction, type Limit, type Terms, httpStatus } from "@g1t/contracts";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace6
7import type { Route } from "./+types/workspace";
8import {
9 AuditSection,
10 BillingLinkSection,
11 CreditForm,
12 Figure,
13 LedgerSection,
14 Owners,
15 ReviewPanel,
16 TermsForm,
17} from "~/components/billing";
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales18import { MonthsChart } from "~/components/charts";
19import { SalesSection, StageBadge, WorkspaceInvoicesSection } from "~/components/sales";
20import { Avatar, Badge, Button, EmptyState, ExposureBar, Field, Notice, Section, Select, StateBadge, TermsBadge, TrustBadge, When, trustAbout } from "~/components/ui";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace21import { type Subject, billingAction } from "~/lib/billing-actions.server";
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales22import { parseSlug, text } from "~/lib/forms";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace23import { usd } from "~/lib/money";
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales24import { type ActionData, DONE, type SectionError, doneKey } from "~/lib/review";
25import { SALES_INTENTS, salesAction } from "~/lib/sales-actions.server";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace26import { admin, identity } from "~/lib/services.server";
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales27import { settle } from "~/lib/settle";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace28import { requireStaff } from "~/lib/staff";
29import type { Enterprise } from "~/lib/workspaces";
30
31export const meta: Route.MetaFunction = ({ loaderData }) => [
32 { title: `${loaderData?.name ?? "Workspace"} · sudo` },
33 { name: "robots", content: "noindex, nofollow" },
34];
35
36/** Whether an audit line is about `slug`, in an enterprise's log. */
37function mentions(action: AdminAction, slug: string): boolean {
38 // A slug is letters, digits and hyphens, none special in a pattern.
39 return new RegExp(`(^|[^a-z0-9-])${slug}($|[^a-z0-9-])`).test(action.detail.toLowerCase());
40}
41
42async function load(raw: string) {
43 const parsed = parseSlug(raw);
44 if (!parsed.ok) throw data("That is not a workspace slug.", { status: 404 });
45 const slug = parsed.value;
46 const [person, result] = await Promise.all([identity.workspace(slug), admin.account(slug)]);
47 if (!result.ok) throw data(result.error.message, { status: httpStatus(result.error) });
48 const detail = result.value;
49 const { account } = detail.summary;
50 const billedTo: Enterprise | null = account.kind === "enterprise" ? { id: account.id, name: account.name } : null;
51 const known = person != null || billedTo != null || account.createdAt !== "" || detail.ledger.length > 0 || detail.audit.length > 0;
52 if (!known) throw data(`There is no workspace called ${slug}.`, { status: 404 });
53 const subject: Subject = { kind: "workspace", slug, accountId: account.id, terms: account.terms, billedTo };
54 return { slug, person, detail, billedTo, subject };
55}
56
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales57/** Sales and invoices are newer billing methods; a page still opens without them. */
58const SALES_DONE = new Set(["sales", "note"]);
59
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace60export async function loader({ request, params, context }: Route.LoaderArgs) {
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales61 const staff = requireStaff(context);
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace62 const { slug, person, detail, billedTo, subject } = await load(params.slug);
63 const { summary } = detail;
64 // On an enterprise, the limit is the enterprise's and the figures are
65 // this workspace's share of its bill.
66 const limit = (billedTo && detail.workspaces.find((member) => member.workspace === slug)) || summary.limit;
67 const share = billedTo ? summary.byWorkspace?.find((row) => row.workspace === slug) : null;
68 const figures = billedTo
69 ? { charged: share?.chargedMicros ?? 0, cost: share?.costMicros ?? 0, paid: share?.paidMicros ?? 0 }
70 : { charged: summary.chargedMicros, cost: summary.costMicros, paid: summary.paidMicros };
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales71 const [enterprises, sales, invoices] = await Promise.all([
72 billedTo
73 ? Promise.resolve([])
74 : admin
75 .accounts()
76 .then((rows: AccountSummary[]) =>
77 rows
78 .filter((row) => row.account.kind === "enterprise")
79 .map((row) => ({ id: row.account.id, name: row.account.name, members: row.account.workspaces.length })),
80 ),
81 settle(admin.sales(slug)),
82 settle(admin.workspaceInvoices(slug)),
83 ]);
84 const done = doneKey(request.url);
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace85 return {
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales86 me: staff.email,
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace87 slug,
88 name: person?.name ?? slug,
89 person,
90 billedTo,
91 terms: subject.terms,
92 limit,
93 figures,
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales94 months: summary.months ?? [],
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace95 enterprises,
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales96 sales: sales.ok ? sales.value : null,
97 salesError: sales.ok ? null : sales.error,
98 invoices: invoices.ok ? invoices.value : [],
99 invoicesError: invoices.ok ? null : invoices.error,
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace100 ledger: billedTo ? detail.ledger.filter((entry) => !entry.workspace || entry.workspace === slug) : detail.ledger,
101 audit: billedTo ? detail.audit.filter((entry) => mentions(entry, slug)) : detail.audit,
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales102 done: done && !SALES_DONE.has(done) ? DONE[done] : null,
103 salesDone: done && SALES_DONE.has(done) ? DONE[done] : null,
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace104 };
105}
106
107export async function action({ request, params, context }: Route.ActionArgs) {
108 const staff = requireStaff(context);
109 // What is acted on comes from billing and identity, not from the form.
110 const { slug, subject } = await load(params.slug);
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales111 const path = `/workspaces/${encodeURIComponent(slug)}`;
112 const form = await request.clone().formData();
113 if (SALES_INTENTS.has(text(form, "intent"))) return salesAction(form, staff, slug, path);
114 return billingAction(request, staff, subject, path);
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace115}
116
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales117const SECTIONS = [
118 { id: "members", label: "Members" },
119 { id: "sales", label: "Sales" },
120 { id: "billing", label: "Billing" },
121 { id: "invoices", label: "Invoices" },
122 { id: "ledger", label: "Ledger" },
123 { id: "audit", label: "Audit log" },
124];
125
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace126export default function Workspace({ loaderData, actionData }: Route.ComponentProps) {
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales127 const { me, slug, name, person, billedTo, terms, limit, figures, months, enterprises, sales, salesError, invoices, invoicesError, ledger, audit, done, salesDone } =
128 loaderData;
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace129 const { pathname } = useLocation();
130 const result = actionData as ActionData | undefined;
131 const review = result && "review" in result ? result.review : null;
132 const link = result && "link" in result ? result.link : null;
133 const error = (section: string): SectionError => (result && "error" in result && result.section === section ? result : null);
134 const owners = person?.members.filter((member) => member.role === "owner") ?? [];
135
136 return (
137 <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10">
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales138 <Link to="/workspaces" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace139 <ArrowLeft size={14} />
140 Workspaces
141 </Link>
142
143 {/* Header */}
144 <div className="mt-4 flex flex-wrap items-start justify-between gap-4">
145 <div className="flex min-w-0 items-start gap-3">
146 <Avatar name={slug} size={40} />
147 <div className="min-w-0">
148 <h1 className="truncate text-2xl font-semibold tracking-tight">{name}</h1>
149 <p className="mt-0.5 text-xs text-faint">
150 <span className="font-mono">{slug}</span>
151 {person && (
152 <>
153 {" · "}created <When at={person.createdAt} />
154 </>
155 )}
156 </p>
157 {person?.description && <p className="mt-1 text-sm text-muted">{person.description}</p>}
158 <div className="mt-2 flex flex-wrap gap-1.5">
159 {billedTo && <Badge tone="lavender">Billed to {billedTo.name}</Badge>}
160 <TermsBadge terms={terms} />
161 {terms.kind !== "comped" && <TrustBadge trust={limit.trust} />}
162 <StateBadge state={limit.state} />
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales163 {sales && sales.stage !== "none" && <StageBadge stage={sales.stage} />}
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace164 </div>
165 </div>
166 </div>
167 {owners.length > 0 && (
168 <div className="text-xs">
169 <p className="mb-1.5 text-faint">Owners</p>
170 <Owners owners={owners} />
171 </div>
172 )}
173 </div>
174
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales175 <nav aria-label="On this page" className="mt-6 -mx-4 overflow-x-auto px-4">
176 <ul className="flex gap-1 border-b border-line text-sm">
177 {SECTIONS.map((section) => (
178 <li key={section.id}>
179 <a href={`#${section.id}`} className="block border-b-2 border-transparent px-2.5 py-2 whitespace-nowrap text-muted transition-colors hover:border-line-strong hover:text-fg">
180 {section.label}
181 </a>
182 </li>
183 ))}
184 </ul>
185 </nav>
186
187 <div className="mt-6 space-y-3 empty:hidden">
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace188 {done && <Notice tone="ok">{done}</Notice>}
189 {error("top") && <Notice tone="error">{error("top")?.error}</Notice>}
190 {limit.message && <Notice tone={limit.state === "stopped" ? "error" : limit.state === "warning" ? "warn" : "info"}>{limit.message}</Notice>}
191 {review && <ReviewPanel review={review} pathname={pathname} />}
192 </div>
193
194 {/* People */}
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales195 <Section id="members" title="Members" description="Everyone in the workspace. Owners manage its members and billing." className="mt-6">
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace196 {!person ? (
197 <Notice tone="warn">Identity has no record of {slug}; only billing knows it. It may have been deleted.</Notice>
198 ) : person.members.length === 0 ? (
199 <EmptyState title="No members" />
200 ) : (
201 <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
202 <table className="w-full min-w-120 text-sm">
203 <thead>
204 <tr className="border-b border-line text-left text-xs text-muted">
205 <th className="px-4 py-2 font-medium sm:pl-5">Member</th>
206 <th className="px-4 py-2 font-medium">Email</th>
207 <th className="px-4 py-2 font-medium">Role</th>
208 <th className="px-4 py-2 font-medium sm:pr-5">Joined</th>
209 </tr>
210 </thead>
211 <tbody>
212 {person.members.map((member) => (
213 <tr key={member.username} className="border-b border-line last:border-0">
214 <td className="px-4 py-2.5 sm:pl-5">
215 <span className="inline-flex items-center gap-2 font-mono">
216 <Avatar name={member.username} size={18} square={false} />
217 {member.username}
218 </span>
219 </td>
220 <td className="px-4 py-2.5 break-all text-muted">{member.email ?? <span className="text-faint">—</span>}</td>
221 <td className="px-4 py-2.5">{member.role === "owner" ? <Badge tone="lavender">Owner</Badge> : <Badge>Member</Badge>}</td>
222 <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted sm:pr-5">
223 <When at={member.joined} />
224 </td>
225 </tr>
226 ))}
227 </tbody>
228 </table>
229 </div>
230 )}
231 </Section>
232
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales233 {/* Sales */}
234 <div className="mt-6">
235 <SalesSection
236 sales={sales}
237 unavailable={salesError}
238 me={me}
239 pathname={pathname}
240 salesError={error("sales")}
241 noteError={error("note")}
242 done={salesDone}
243 />
244 </div>
245
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace246 {/* Billing */}
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales247 <h2 id="billing" className="mt-10 scroll-mt-20 text-lg font-semibold tracking-tight">
248 Billing
249 </h2>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace250 <div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales251 <LimitCard limit={limit} terms={terms} billedTo={billedTo} />
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace252 <Figure label="Charged this month" value={usd(figures.charged)} hint={`Cost to g1t ${usd(figures.cost)}`} />
253 <Figure label="Paid ever" value={usd(figures.paid)} hint={`Margin this month ${usd(figures.charged - figures.cost)}`} />
254 </div>
255
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales256 <Section
257 title="Last six months"
258 description={billedTo ? `${billedTo.name}'s figures: every workspace it pays for, together.` : "What it was charged, against what its usage cost g1t."}
259 className="mt-6"
260 >
261 <MonthsChart wide months={months} label={`${billedTo ? billedTo.name : name}: charged and cost by month`} />
262 </Section>
263
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace264 <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
265 <div className="space-y-6">
266 <BilledToSection billedTo={billedTo} enterprises={enterprises} pathname={pathname} error={error("billed-to")} />
267 {billedTo ? (
268 <Section id="terms" title="Terms" description={`Charged on ${billedTo.name}'s terms while it pays for this workspace.`}>
269 <div className="flex flex-wrap items-center justify-between gap-3">
270 <TermsBadge terms={terms} />
271 <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}#terms`} className="text-sm text-merged hover:underline hover:underline-offset-4">
272 Change them on {billedTo.name}
273 </Link>
274 </div>
275 </Section>
276 ) : (
277 <TermsForm terms={terms} pathname={pathname} error={error("terms")} />
278 )}
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales279 <WorkspaceInvoicesSection invoices={invoices} unavailable={invoicesError} />
280 <div id="ledger" className="scroll-mt-20">
281 <LedgerSection
282 ledger={ledger}
283 description={billedTo ? `This workspace's lines among ${billedTo.name}'s latest 100.` : "Recent lines of the statement, newest first."}
284 />
285 </div>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace286 </div>
287
288 <div className="space-y-6">
289 <BillingLinkSection link={link} pathname={pathname} error={error("billing-link")} />
290 <CreditForm workspaces={[slug]} pathname={pathname} error={error("credit")} />
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales291 <div id="audit" className="scroll-mt-20">
292 <AuditSection
293 audit={audit}
294 description={
295 billedTo ? (
296 <>
297 Changes about this workspace in {billedTo.name}'s log.{" "}
298 <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}`} className="text-merged hover:underline">
299 Full log
300 </Link>
301 </>
302 ) : undefined
303 }
304 />
305 </div>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace306 </div>
307 </div>
308 </main>
309 );
310}
311
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales312/**
313 * The limit, and in words how it is made: trust, the owners' own spend
314 * limit (or the default), the most they may set, and how it grows.
315 */
316function LimitCard({ limit, terms, billedTo }: { limit: Limit; terms: Terms; billedTo: Enterprise | null }) {
317 const lines: { label: string; value: ReactNode }[] = [];
318 if (billedTo) {
319 lines.push({ label: "Limit", value: <>{billedTo.name}'s, shared by every workspace it pays for</> });
320 } else if (terms.kind !== "comped") {
321 lines.push({ label: "From trust", value: <>{usd(limit.trustCeilingMicros)}. {trustAbout(limit.trust)}</> });
322 }
323 if (terms.ceilingMicros != null) lines.push({ label: "Custom limit", value: usd(terms.ceilingMicros) });
324 if (!billedTo && terms.kind !== "comped") {
325 lines.push({
326 label: "Owners' limit",
327 value: limit.defaultSpendLimit ? (
328 <>The default, {usd(limit.spendLimitMicros ?? 200_000_000)} a month: they have not chosen one</>
329 ) : limit.spendLimitMicros == null ? (
330 "None of their own: they use everything available"
331 ) : (
332 <>{usd(limit.spendLimitMicros)} a month, chosen by the owners</>
333 ),
334 });
335 if (limit.availableMicros !== undefined) {
336 lines.push({
337 label: "Available",
338 value:
339 limit.availableMicros == null ? (
340 "No ceiling"
341 ) : (
342 <>Up to {usd(limit.availableMicros)}: the most owners may set; past it, they contact g1t</>
343 ),
344 });
345 }
346 }
347 if (limit.growth) lines.push({ label: "Grows", value: limit.growth });
348
349 return (
350 <div className="rounded-lg border border-line bg-surface px-4 py-3 sm:col-span-2">
351 <p className="mb-2 text-xs text-muted">
352 Usage this month against the limit{billedTo && <> · shared with every workspace {billedTo.name} pays for</>}
353 </p>
354 <ExposureBar limit={limit} wide />
355 {lines.length > 0 && (
356 <dl className="mt-3 space-y-1 text-xs">
357 {lines.map((line) => (
358 <div key={line.label} className="grid grid-cols-[6.5rem_minmax(0,1fr)] gap-2">
359 <dt className="text-faint">{line.label}</dt>
360 <dd className="text-fg-soft">{line.value}</dd>
361 </div>
362 ))}
363 </dl>
364 )}
365 </div>
366 );
367}
368
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace369function BilledToSection({
370 billedTo,
371 enterprises,
372 pathname,
373 error,
374}: {
375 billedTo: Enterprise | null;
376 enterprises: { id: string; name: string; members: number }[];
377 pathname: string;
378 error: SectionError;
379}) {
380 return (
381 <Section id="billed-to" title="Billed to" description="Whether the workspace pays for itself, or an enterprise pays for it.">
382 {error && (
383 <div className="mb-4">
384 <Notice tone="error">{error.error}</Notice>
385 </div>
386 )}
387 {billedTo ? (
388 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
389 <p className="text-sm text-muted">
390 <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}`} className="font-medium text-fg hover:underline hover:underline-offset-4">
391 {billedTo.name}
392 </Link>{" "}
393 pays for it, under its limit and terms.
394 </p>
395 <form method="post" action={`${pathname}#review`}>
396 <input type="hidden" name="intent" value="detach" />
397 <Button type="submit" variant="danger">
398 <LogOut size={14} />
399 Move off
400 </Button>
401 </form>
402 </div>
403 ) : enterprises.length === 0 ? (
404 <p className="text-sm text-muted">
405 Itself. There are no enterprises to move it onto yet;{" "}
406 <Link to="/enterprises/new" className="text-merged hover:underline hover:underline-offset-4">
407 create one
408 </Link>
409 .
410 </p>
411 ) : (
412 <form method="post" action={`${pathname}#review`} className="flex flex-col gap-2 sm:flex-row sm:items-end">
413 <input type="hidden" name="intent" value="attach" />
414 <Field label="Itself. Move onto" className="grow">
415 <Select name="target" required defaultValue={error?.values?.target ?? ""}>
416 <option value="" disabled>
417 Choose an enterprise
418 </option>
419 {enterprises.map((enterprise) => (
420 <option key={enterprise.id} value={enterprise.id}>
421 {enterprise.name} ({enterprise.members} workspace{enterprise.members === 1 ? "" : "s"})
422 </option>
423 ))}
424 </Select>
425 </Field>
426 <Button type="submit" variant="quiet">
427 <Building2 size={14} />
428 Move
429 </Button>
430 </form>
431 )}
432 </Section>
433 );
434}