flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

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

713 lines31,080 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 accounts, terms and enterprises; g1t is no longer free1import { ArrowLeft, Building2, Gift, LogOut, Plus, ScrollText, Trash2, UserRound } from "lucide-react";
2import type { ReactNode } from "react";
3import { data, Link, redirect, useLocation } from "react-router";
4
5import { type AccountDetail, type LedgerEntry, type Limit, type Terms, httpStatus } from "@g1t/contracts";
6
7import type { Route } from "./+types/account";
8import {
9 Avatar,
10 Badge,
11 Button,
12 EmptyState,
13 ExposureBar,
14 Field,
15 Input,
16 KindBadge,
17 Notice,
18 Section,
19 Select,
20 StateBadge,
21 TermsBadge,
22 Textarea,
23 TrustBadge,
24 When,
25} from "~/components/ui";
26import { fields, isAccountId, parseCredit, parseNote, parseSlug, parseTerms, text } from "~/lib/forms";
27import { dollarsField, usd } from "~/lib/money";
28import { admin } from "~/lib/services.server";
29import { requireStaff } from "~/lib/staff";
30
31export const meta: Route.MetaFunction = ({ loaderData }) => [
32 { title: `${loaderData?.detail.summary.account.name ?? "Account"} · sudo` },
33 { name: "robots", content: "noindex, nofollow" },
34];
35
36const DONE: Record<string, string> = {
37 terms: "Terms saved. They apply to charges from now on.",
38 attach: "Workspace moved onto the enterprise.",
39 detach: "Workspace moved back onto its own account.",
40 credit: "Credit issued.",
41 created: "Enterprise created.",
42};
43
44async function load(id: string): Promise<AccountDetail> {
45 if (!isAccountId(id)) throw data("That is not an account id or a workspace slug.", { status: 404 });
46 const result = await admin.account(id);
47 if (!result.ok) throw data(result.error.message, { status: httpStatus(result.error) });
48 return result.value;
49}
50
51export async function loader({ request, params, context }: Route.LoaderArgs) {
52 requireStaff(context);
53 const detail = await load(params.id);
54 const { account } = detail.summary;
55 // A workspace's page offers the enterprises it could move onto.
56 const enterprises =
57 account.kind === "workspace"
58 ? (await admin.accounts())
59 .filter((row) => row.account.kind === "enterprise")
60 .map((row) => ({ id: row.account.id, name: row.account.name }))
61 : [];
62 const done = new URL(request.url).searchParams.get("done");
63 return { detail, enterprises, done: done && DONE[done] ? DONE[done] : null };
64}
65
66type Review =
67 | { intent: "terms"; before: Terms; after: Terms; fields: Record<string, string> }
68 | { intent: "attach"; workspace: string; target: string; targetName: string; fields: Record<string, string> }
69 | { intent: "detach"; workspace: string; from: string; fields: Record<string, string> };
70
71type ActionData = { error: string; section: string; values?: Record<string, string> } | { review: Review };
72
73function failed(section: string, error: string, values?: Record<string, string>) {
74 return data<ActionData>({ error, section, values }, { status: 422 });
75}
76
77/** The workspace an account's page acts for when it is a workspace's own. */
78function ownWorkspace(detail: AccountDetail): string {
79 return detail.summary.limit.workspace;
80}
81
82export async function action({ request, params, context }: Route.ActionArgs) {
83 const staff = requireStaff(context);
84 // What is acted on comes from the billing service, not from the form.
85 const detail = await load(params.id);
86 const { account } = detail.summary;
87 const form = await request.formData();
88 const intent = text(form, "intent");
89 const confirmed = text(form, "confirm") === "yes";
90 const back = (done: string) => redirect(`/accounts/${encodeURIComponent(params.id)}?done=${done}#top`);
91
92 if (intent === "terms") {
93 const values = fields(form, "kind", "discount", "ceiling", "note", "until");
94 const terms = parseTerms(form, staff.email);
95 if (!terms.ok) return failed("terms", terms.error, values);
96 if (!confirmed) return { review: { intent, before: account.terms, after: terms.value, fields: values } } satisfies ActionData;
97 const result = await admin.setTerms(account.id, terms.value, staff.email);
98 if (!result.ok) return failed("terms", result.error.message, values);
99 return back("terms");
100 }
101
102 if (intent === "attach") {
103 const values = fields(form, "workspace", "target");
104 const slug = parseSlug(account.kind === "enterprise" ? values.workspace : ownWorkspace(detail));
105 if (!slug.ok) return failed("members", slug.error, values);
106 let target = account.id;
107 let targetName = account.name;
108 if (account.kind === "workspace") {
109 const enterprise = (await admin.accounts()).find((row) => row.account.kind === "enterprise" && row.account.id === values.target);
110 if (!enterprise) return failed("enterprise", "Choose an enterprise to move onto.", values);
111 target = enterprise.account.id;
112 targetName = enterprise.account.name;
113 } else if (account.workspaces.includes(slug.value)) {
114 return failed("members", `${slug.value} is already on this enterprise.`, values);
115 }
116 if (!confirmed) {
117 return { review: { intent, workspace: slug.value, target, targetName, fields: values } } satisfies ActionData;
118 }
119 const result = await admin.attach(slug.value, target, staff.email);
120 if (!result.ok) return failed(account.kind === "enterprise" ? "members" : "enterprise", result.error.message, values);
121 return back("attach");
122 }
123
124 if (intent === "detach") {
125 const values = fields(form, "workspace");
126 const workspace = account.kind === "enterprise" ? values.workspace : ownWorkspace(detail);
127 const onIt = account.kind === "enterprise" ? account.workspaces.includes(workspace) : detail.summary.limit.account !== account.id;
128 if (!onIt) return failed(account.kind === "enterprise" ? "members" : "enterprise", `${workspace} is not on an enterprise here.`);
129 const from = account.kind === "enterprise" ? account.name : detail.summary.limit.accountName;
130 if (!confirmed) return { review: { intent, workspace, from, fields: values } } satisfies ActionData;
131 const result = await admin.attach(workspace, null, staff.email);
132 if (!result.ok) return failed(account.kind === "enterprise" ? "members" : "enterprise", result.error.message);
133 return back("detach");
134 }
135
136 if (intent === "credit") {
137 const values = fields(form, "workspace", "amount", "note", "confirmation");
138 const workspace = account.kind === "enterprise" ? values.workspace : ownWorkspace(detail);
139 if (account.kind === "enterprise" && !account.workspaces.includes(workspace)) {
140 return failed("credit", "Choose one of this account's workspaces.", values);
141 }
142 const amount = parseCredit(values.amount);
143 if (!amount.ok) return failed("credit", amount.error, values);
144 const note = parseNote(values.note);
145 if (!note.ok) return failed("credit", note.error, values);
146 if (values.confirmation !== workspace) {
147 return failed("credit", `Type the workspace's slug, ${workspace}, exactly, to issue the credit.`, { ...values, confirmation: "" });
148 }
149 const result = await admin.credit(workspace, amount.value, note.value, staff.email);
150 if (!result.ok) return failed("credit", result.error.message, values);
151 return back("credit");
152 }
153
154 return failed("top", "Unknown action.");
155}
156
157export default function Account({ loaderData, actionData }: Route.ComponentProps) {
158 const { detail, enterprises, done } = loaderData;
159 const { summary } = detail;
160 const { account, limit } = summary;
161 const { pathname } = useLocation();
162 const result = actionData as ActionData | undefined;
163 const review = result && "review" in result ? result.review : null;
164 const error = (section: string) => (result && "error" in result && result.section === section ? result : null);
165 const isEnterprise = account.kind === "enterprise";
166 const billedElsewhere = !isEnterprise && limit.account !== account.id;
167
168 return (
169 <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10">
170 <Link to="/" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
171 <ArrowLeft size={14} />
172 Accounts
173 </Link>
174
175 {/* Header */}
176 <div className="mt-4 flex flex-wrap items-start justify-between gap-4">
177 <div className="flex min-w-0 items-start gap-3">
178 <Avatar name={account.name} size={40} />
179 <div className="min-w-0">
180 <h1 className="truncate text-2xl font-semibold tracking-tight">{account.name}</h1>
181 <p className="mt-0.5 font-mono text-xs break-all text-faint">{account.id}</p>
182 <div className="mt-2 flex flex-wrap gap-1.5">
183 <KindBadge kind={account.kind} />
184 <TermsBadge terms={account.terms} />
185 <TrustBadge trust={limit.trust} />
186 <StateBadge state={limit.state} />
187 {billedElsewhere && <Badge tone="lavender">Billed through {limit.accountName}</Badge>}
188 </div>
189 </div>
190 </div>
191 <p className="text-xs text-faint">
192 Account since <When at={account.createdAt} />
193 </p>
194 </div>
195
196 <div className="mt-6 space-y-3">
197 {done && <Notice tone="ok">{done}</Notice>}
198 {error("top") && <Notice tone="error">{error("top")?.error}</Notice>}
199 {limit.message && <Notice tone={limit.state === "stopped" ? "error" : limit.state === "warning" ? "warn" : "info"}>{limit.message}</Notice>}
200 {review && <ReviewPanel review={review} pathname={pathname} />}
201 </div>
202
203 {/* This month */}
204 <div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
205 <div className="rounded-lg border border-line bg-surface px-4 py-3 sm:col-span-2">
206 <p className="mb-2 text-xs text-muted">Unpaid exposure this month</p>
207 <ExposureBar limit={limit} wide />
208 <p className="mt-2 text-xs text-faint">
209 Trust ceiling {usd(limit.trustCeilingMicros)} · owner's spend limit {usd(limit.spendLimitMicros)}
210 {account.terms.ceilingMicros != null && <> · custom ceiling {usd(account.terms.ceilingMicros)}</>}
211 </p>
212 </div>
213 <Figure label="Charged this month" value={usd(summary.chargedMicros)} hint={`Cost to g1t ${usd(summary.costMicros)}`} />
214 <Figure label="Paid ever" value={usd(summary.paidMicros)} hint={`Margin this month ${usd(summary.chargedMicros - summary.costMicros)}`} />
215 </div>
216
217 <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
218 <div className="space-y-6">
219 <TermsForm terms={account.terms} pathname={pathname} error={error("terms")} />
220
221 {isEnterprise ? (
222 <MembersSection detail={detail} pathname={pathname} error={error("members")} />
223 ) : (
224 <EnterpriseSection
225 billedElsewhere={billedElsewhere}
226 limit={limit}
227 enterprises={enterprises}
228 pathname={pathname}
229 error={error("enterprise")}
230 />
231 )}
232
233 <LedgerSection ledger={detail.ledger} />
234 </div>
235
236 <div className="space-y-6">
237 <CreditForm
238 workspaces={isEnterprise ? account.workspaces : [limit.workspace]}
239 pathname={pathname}
240 error={error("credit")}
241 />
242 <AuditSection audit={detail.audit} />
243 </div>
244 </div>
245 </main>
246 );
247}
248
249function Figure({ label, value, hint }: { label: string; value: string; hint?: string }) {
250 return (
251 <div className="rounded-lg border border-line bg-surface px-4 py-3">
252 <p className="text-xs text-muted">{label}</p>
253 <p className="tabular mt-1 text-lg font-semibold tracking-tight">{value}</p>
254 {hint && <p className="mt-0.5 text-xs text-faint">{hint}</p>}
255 </div>
256 );
257}
258
259function Hidden({ values }: { values: Record<string, string> }) {
260 return (
261 <>
262 {Object.entries(values).map(([name, value]) => (
263 <input key={name} type="hidden" name={name} value={value} />
264 ))}
265 </>
266 );
267}
268
269type SectionError = { error: string; values?: Record<string, string> } | null;
270
271// --- Confirmation ------------------------------------------------------------
272
273function describeTerms(terms: Terms): [string, string][] {
274 return [
275 ["Terms", terms.kind === "custom" ? "Custom" : terms.kind === "comped" ? "Comped" : "Standard"],
276 ["Discount", terms.kind === "custom" ? `${terms.discountPercent}%` : "—"],
277 ["Ceiling", terms.ceilingMicros == null ? "By trust" : usd(terms.ceilingMicros)],
278 ["Until", terms.until ? terms.until.slice(0, 10) : "No end"],
279 ["Note", terms.note || "—"],
280 ];
281}
282
283function ReviewPanel({ review, pathname }: { review: Review; pathname: string }) {
284 let title: string;
285 let body: ReactNode;
286 let danger = false;
287 if (review.intent === "terms") {
288 const before = describeTerms(review.before);
289 const after = describeTerms(review.after);
290 title = "Confirm the new terms";
291 danger = review.after.kind === "comped";
292 body = (
293 <>
294 <div className="overflow-x-auto">
295 <table className="w-full text-sm">
296 <thead>
297 <tr className="text-left text-xs text-muted">
298 <th className="py-1.5 pr-4 font-medium" />
299 <th className="py-1.5 pr-4 font-medium">Now</th>
300 <th className="py-1.5 font-medium">After</th>
301 </tr>
302 </thead>
303 <tbody>
304 {after.map(([label, value], index) => (
305 <tr key={label} className="border-t border-line align-top">
306 <td className="py-1.5 pr-4 text-muted">{label}</td>
307 <td className="py-1.5 pr-4 break-words text-faint">{before[index][1]}</td>
308 <td className={`py-1.5 break-words ${value !== before[index][1] ? "font-medium text-fg" : "text-muted"}`}>{value}</td>
309 </tr>
310 ))}
311 </tbody>
312 </table>
313 </div>
314 {review.after.kind === "comped" && (
315 <p className="mt-3 text-sm text-warn">Comped: nothing this account uses will be charged{review.after.until ? ` until ${review.after.until.slice(0, 10)}` : ""}. Usage is still recorded at cost.</p>
316 )}
317 </>
318 );
319 } else if (review.intent === "attach") {
320 title = `Move ${review.workspace} onto ${review.targetName}?`;
321 body = (
322 <p className="text-sm text-muted">
323 From now on <span className="font-mono text-fg">{review.workspace}</span>'s usage is billed to{" "}
324 <span className="text-fg">{review.targetName}</span> and counts against its limit and terms, not its own.
325 </p>
326 );
327 } else {
328 title = `Move ${review.workspace} off ${review.from}?`;
329 danger = true;
330 body = (
331 <p className="text-sm text-muted">
332 <span className="font-mono text-fg">{review.workspace}</span> goes back to paying for itself, under its own terms and the
333 ceiling its trust gives it. It may stop at once if its own ceiling is lower than its exposure.
334 </p>
335 );
336 }
337 return (
338 <section id="review" className={`scroll-mt-20 rounded-lg border p-4 sm:p-5 ${danger ? "border-warn/40 bg-warn/5" : "border-merged/40 bg-merged/5"}`}>
339 <h2 className="font-semibold tracking-tight">{title}</h2>
340 <div className="mt-3">{body}</div>
341 <form method="post" action={`${pathname}#top`} className="mt-4 flex flex-wrap items-center gap-2">
342 <Hidden values={review.fields} />
343 <input type="hidden" name="intent" value={review.intent} />
344 <input type="hidden" name="confirm" value="yes" />
345 <Button type="submit" variant={danger ? "danger" : "lavender"}>
346 Confirm
347 </Button>
348 <Link to={pathname} className="px-2 text-sm text-muted hover:text-fg">
349 Cancel
350 </Link>
351 </form>
352 </section>
353 );
354}
355
356// --- Terms -------------------------------------------------------------------
357
358const KINDS: { value: Terms["kind"]; title: string; text: string }[] = [
359 { value: "standard", title: "Standard", text: "Published prices; the ceiling comes from trust." },
360 { value: "comped", title: "Comped", text: "Nothing charged. Usage still recorded at cost." },
361 { value: "custom", title: "Custom", text: "A discount, a custom ceiling, or both." },
362];
363
364function TermsForm({ terms, pathname, error }: { terms: Terms; pathname: string; error: SectionError }) {
365 const values = error?.values;
366 const kind = values?.kind ?? terms.kind;
367 return (
368 <Section
369 id="terms"
370 title="Terms"
371 description={
372 terms.setBy ? (
373 <>
374 Set by <span className="font-mono">{terms.setBy}</span> on <When at={terms.setAt} />
375 {terms.note && <> · “{terms.note}”</>}
376 </>
377 ) : (
378 "Standard terms, as every account starts."
379 )
380 }
381 >
382 <form method="post" action={`${pathname}#review`} className="space-y-4">
383 <input type="hidden" name="intent" value="terms" />
384 {error && <Notice tone="error">{error.error}</Notice>}
385 <fieldset>
386 <legend className="mb-1.5 text-sm font-medium text-muted">Kind</legend>
387 <div className="grid gap-2 sm:grid-cols-3">
388 {KINDS.map((option) => (
389 <label
390 key={option.value}
391 className="flex cursor-pointer gap-2.5 rounded-md border border-line bg-bg p-3 transition-colors hover:border-line-strong has-checked:border-merged/60 has-checked:bg-merged/8"
392 >
393 <input type="radio" name="kind" value={option.value} defaultChecked={kind === option.value} className="mt-0.5" required />
394 <span>
395 <span className="block text-sm font-medium">{option.title}</span>
396 <span className="mt-0.5 block text-xs text-muted">{option.text}</span>
397 </span>
398 </label>
399 ))}
400 </div>
401 </fieldset>
402 <div className="grid gap-4 sm:grid-cols-3">
403 <Field label="Discount %" hint="Custom only.">
404 <Input name="discount" inputMode="numeric" pattern="\d{1,3}" placeholder="0" defaultValue={values?.discount ?? (terms.discountPercent ? String(terms.discountPercent) : "")} />
405 </Field>
406 <Field label="Ceiling $" hint="Blank: trust decides.">
407 <Input name="ceiling" inputMode="decimal" placeholder="By trust" defaultValue={values?.ceiling ?? dollarsField(terms.ceilingMicros)} />
408 </Field>
409 <Field label="Until" hint="Blank: no end. UTC.">
410 <Input type="date" name="until" defaultValue={values?.until ?? (terms.until ? terms.until.slice(0, 10) : "")} />
411 </Field>
412 </div>
413 <Field label="Note" hint="Required. Why, for whoever looks next.">
414 <Textarea name="note" rows={2} required maxLength={500} defaultValue={values?.note ?? ""} placeholder="e.g. Design partner through launch" />
415 </Field>
416 <div className="flex justify-end">
417 <Button type="submit">Review terms</Button>
418 </div>
419 </form>
420 </Section>
421 );
422}
423
424// --- Enterprise membership ----------------------------------------------------
425
426function MembersSection({ detail, pathname, error }: { detail: AccountDetail; pathname: string; error: SectionError }) {
427 const members: Limit[] = detail.workspaces;
428 const listed = new Set(members.map((member) => member.workspace));
429 // Any workspace the account names but no limit came back for.
430 const missing = detail.summary.account.workspaces.filter((slug) => !listed.has(slug));
431 return (
432 <Section id="members" title="Workspaces" description="Billed together: one bill, one limit, one set of terms.">
433 {error && (
434 <div className="mb-4">
435 <Notice tone="error">{error.error}</Notice>
436 </div>
437 )}
438 {members.length + missing.length === 0 ? (
439 <EmptyState title="No workspaces yet">Add one below to bill it through this enterprise.</EmptyState>
440 ) : (
441 <ul className="divide-y divide-line rounded-md border border-line">
442 {members.map((member) => (
443 <li key={member.workspace} className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center">
444 <div className="flex min-w-0 grow items-center gap-2.5">
445 <Avatar name={member.workspace} size={22} />
446 <div className="min-w-0">
447 <Link to={`/accounts/${encodeURIComponent(member.workspace)}`} className="font-mono text-sm hover:underline hover:underline-offset-4">
448 {member.workspace}
449 </Link>
450 <div className="mt-1 flex flex-wrap gap-1.5">
451 <TrustBadge trust={member.trust} />
452 <StateBadge state={member.state} />
453 </div>
454 {member.message && <p className="mt-1 text-xs text-muted">{member.message}</p>}
455 </div>
456 </div>
457 <div className="flex items-center gap-3 sm:w-64">
458 <ExposureBar limit={member} wide />
459 <DetachButton workspace={member.workspace} pathname={pathname} />
460 </div>
461 </li>
462 ))}
463 {missing.map((slug) => (
464 <li key={slug} className="flex items-center gap-3 p-3">
465 <span className="grow font-mono text-sm">{slug}</span>
466 <DetachButton workspace={slug} pathname={pathname} />
467 </li>
468 ))}
469 </ul>
470 )}
471 <form method="post" action={`${pathname}#review`} className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-end">
472 <input type="hidden" name="intent" value="attach" />
473 <Field label="Add a workspace" className="grow">
474 <Input name="workspace" required placeholder="workspace-slug" defaultValue={error?.values?.workspace ?? ""} className="font-mono" />
475 </Field>
476 <Button type="submit" variant="quiet">
477 <Plus size={14} />
478 Add
479 </Button>
480 </form>
481 </Section>
482 );
483}
484
485function DetachButton({ workspace, pathname }: { workspace: string; pathname: string }) {
486 return (
487 <form method="post" action={`${pathname}#review`} className="shrink-0">
488 <input type="hidden" name="intent" value="detach" />
489 <input type="hidden" name="workspace" value={workspace} />
490 <button
491 type="submit"
492 aria-label={`Remove ${workspace}`}
493 title={`Remove ${workspace}`}
494 className="rounded-md border border-line p-2 text-muted transition-colors hover:border-danger/50 hover:text-danger"
495 >
496 <Trash2 size={14} />
497 </button>
498 </form>
499 );
500}
501
502function EnterpriseSection({
503 billedElsewhere,
504 limit,
505 enterprises,
506 pathname,
507 error,
508}: {
509 billedElsewhere: boolean;
510 limit: Limit;
511 enterprises: { id: string; name: string }[];
512 pathname: string;
513 error: SectionError;
514}) {
515 return (
516 <Section id="enterprise" title="Enterprise" description="Whether another account pays for this workspace.">
517 {error && (
518 <div className="mb-4">
519 <Notice tone="error">{error.error}</Notice>
520 </div>
521 )}
522 {billedElsewhere ? (
523 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
524 <p className="text-sm text-muted">
525 Billed through{" "}
526 <Link to={`/accounts/${encodeURIComponent(limit.account)}`} className="text-fg hover:underline hover:underline-offset-4">
527 {limit.accountName}
528 </Link>
529 , under its limit and terms.
530 </p>
531 <form method="post" action={`${pathname}#review`}>
532 <input type="hidden" name="intent" value="detach" />
533 <Button type="submit" variant="danger">
534 <LogOut size={14} />
535 Move off
536 </Button>
537 </form>
538 </div>
539 ) : enterprises.length === 0 ? (
540 <p className="text-sm text-muted">
541 Pays for itself. There are no enterprises to move it onto yet;{" "}
542 <Link to="/enterprises/new" className="text-merged hover:underline hover:underline-offset-4">
543 create one
544 </Link>
545 .
546 </p>
547 ) : (
548 <form method="post" action={`${pathname}#review`} className="flex flex-col gap-2 sm:flex-row sm:items-end">
549 <input type="hidden" name="intent" value="attach" />
550 <Field label="Pays for itself. Move onto" className="grow">
551 <Select name="target" required defaultValue={error?.values?.target ?? ""}>
552 <option value="" disabled>
553 Choose an enterprise
554 </option>
555 {enterprises.map((enterprise) => (
556 <option key={enterprise.id} value={enterprise.id}>
557 {enterprise.name} ({enterprise.id})
558 </option>
559 ))}
560 </Select>
561 </Field>
562 <Button type="submit" variant="quiet">
563 <Building2 size={14} />
564 Move
565 </Button>
566 </form>
567 )}
568 </Section>
569 );
570}
571
572// --- Credit -------------------------------------------------------------------
573
574function CreditForm({ workspaces, pathname, error }: { workspaces: string[]; pathname: string; error: SectionError }) {
575 const values = error?.values;
576 const single = workspaces.length === 1 ? workspaces[0] : null;
577 return (
578 <Section id="credit" title="Issue credit" description="A refund or goodwill. Added to the workspace's balance at once.">
579 {workspaces.length === 0 ? (
580 <p className="text-sm text-muted">Add a workspace first: credit goes to a workspace.</p>
581 ) : (
582 <form method="post" action={`${pathname}#credit`} className="space-y-4">
583 <input type="hidden" name="intent" value="credit" />
584 {error && <Notice tone="error">{error.error}</Notice>}
585 {single ? (
586 <input type="hidden" name="workspace" value={single} />
587 ) : (
588 <Field label="Workspace">
589 <Select name="workspace" required defaultValue={values?.workspace ?? ""}>
590 <option value="" disabled>
591 Choose a workspace
592 </option>
593 {workspaces.map((slug) => (
594 <option key={slug} value={slug}>
595 {slug}
596 </option>
597 ))}
598 </Select>
599 </Field>
600 )}
601 <Field label="Amount $" hint="Up to $10,000 at a time.">
602 <Input name="amount" inputMode="decimal" required placeholder="25.00" defaultValue={values?.amount ?? ""} />
603 </Field>
604 <Field label="Note" hint="Required. Shown on the workspace's statement.">
605 <Textarea name="note" rows={2} required maxLength={500} placeholder="e.g. Refund for the failed runs on Oct 2" defaultValue={values?.note ?? ""} />
606 </Field>
607 <Field
608 label="Confirm"
609 hint={
610 <>
611 Type the workspace's slug{single && <> (<span className="font-mono text-muted">{single}</span>)</>} to issue it.
612 </>
613 }
614 >
615 <Input name="confirmation" required placeholder={single ?? "workspace-slug"} className="font-mono" />
616 </Field>
617 <div className="flex justify-end">
618 <Button type="submit" variant="lavender">
619 <Gift size={14} />
620 Issue credit
621 </Button>
622 </div>
623 </form>
624 )}
625 </Section>
626 );
627}
628
629// --- Ledger and audit ---------------------------------------------------------
630
631const ENTRY_KIND: Record<string, string> = { top_up: "Top-up", usage: "Usage", credit: "Credit" };
632
633function LedgerSection({ ledger }: { ledger: LedgerEntry[] }) {
634 return (
635 <Section title="Ledger" description="Recent lines of the statement, newest first.">
636 {ledger.length === 0 ? (
637 <EmptyState title="Nothing yet" />
638 ) : (
639 <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
640 <table className="w-full min-w-[36rem] text-sm">
641 <thead>
642 <tr className="border-b border-line text-left text-xs text-muted">
643 <th className="px-4 py-2 font-medium sm:pl-5">When</th>
644 <th className="px-4 py-2 font-medium">What</th>
645 <th className="px-4 py-2 text-right font-medium sm:pr-5">Amount</th>
646 </tr>
647 </thead>
648 <tbody>
649 {ledger.map((entry) => (
650 <tr key={entry.id} className="border-b border-line align-top last:border-0">
651 <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted sm:pl-5">
652 <When at={entry.createdAt} time />
653 </td>
654 <td className="px-4 py-2.5">
655 <div className="flex flex-wrap items-center gap-1.5">
656 <Badge tone={entry.amountMicros > 0 ? "mint" : "plain"}>{ENTRY_KIND[entry.kind] ?? entry.kind}</Badge>
657 <span className="break-words">{entry.description}</span>
658 </div>
659 <p className="mt-0.5 font-mono text-xs text-faint">
660 {[
661 entry.repo && (entry.number != null ? `${entry.repo}#${entry.number}` : entry.repo),
662 entry.task,
663 entry.model,
664 entry.billedTo === "workspace" ? "own provider" : null,
665 entry.createdBy && `by ${entry.createdBy}`,
666 ]
667 .filter(Boolean)
668 .join(" · ")}
669 </p>
670 </td>
671 <td className={`tabular px-4 py-2.5 text-right whitespace-nowrap sm:pr-5 ${entry.amountMicros > 0 ? "text-accent" : "text-fg-soft"}`}>
672 {usd(entry.amountMicros, { signed: true })}
673 </td>
674 </tr>
675 ))}
676 </tbody>
677 </table>
678 </div>
679 )}
680 </Section>
681 );
682}
683
684function AuditSection({ audit }: { audit: AccountDetail["audit"] }) {
685 return (
686 <Section title="Audit log" description="Every change made in sudo, and by whom.">
687 {audit.length === 0 ? (
688 <p className="flex items-center gap-2 text-sm text-muted">
689 <ScrollText size={14} />
690 No changes yet.
691 </p>
692 ) : (
693 <ol className="space-y-3">
694 {audit.map((entry) => (
695 <li key={entry.id} className="border-l-2 border-merged/40 pl-3">
696 <p className="flex flex-wrap items-center gap-x-2 text-sm">
697 <span className="font-mono font-medium text-merged">{entry.action}</span>
698 <span className="text-xs text-faint">
699 <When at={entry.createdAt} time />
700 </span>
701 </p>
702 {entry.detail && <p className="mt-0.5 text-sm break-words text-fg-soft">{entry.detail}</p>}
703 <p className="mt-0.5 flex items-center gap-1 font-mono text-xs text-faint">
704 <UserRound size={11} />
705 {entry.by}
706 </p>
707 </li>
708 ))}
709 </ol>
710 )}
711 </Section>
712 );
713}