flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

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