Skip to content

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.sh
250 linesCodeBlameRaw
1/**
2 * Moving workspaces off Stripe's test mode before the live key, history
3 * kept: one workspace on its page, every workspace on the Stripe page. A
4 * preview (dry run) lists what will change; the change itself needs the
5 * slug, or "detach", typed. sudo ships no JavaScript: the apply buttons say
6 * they are running with app.css's run-button rules.
7 */
8import { Eye, Unplug } from "lucide-react";
9
10import type { TestStripeDetach, WorkspaceDetach } from "@g1t/contracts";
11
12import { Badge, Button, EmptyState, Field, Input, Notice, Section } from "~/components/ui";
13import { usd } from "~/lib/money";
14import type { SectionError } from "~/lib/review";
15import { CONFIRM_ALL, DEFAULT_EXCLUDED, detachChanges } from "~/lib/test-detach";
16
17const WHAT =
18 "Plans canceled; the Stripe customer, cached card and card checks forgotten (the trial grant stays); open checkouts removed; AI auto-reload and a declined autopay cleared; open invoices void; past months closed as skipped; AI credit closed; test payments reversed and what is owed written off as goodwill. The ledger, statements and audit log keep every line.";
19
20/** The busy-aware submit for a detach: "Detaching…" while the page waits. */
21function ApplyButton({ label }: { label: string }) {
22 return (
23 <Button type="submit" variant="danger" className="run-button">
24 <span className="run-idle inline-flex items-center gap-2">
25 <Unplug size={14} />
26 {label}
27 </span>
28 <span className="run-busy">
29 <span className="run-spinner" aria-hidden="true" />
30 Detaching…
31 </span>
32 </Button>
33 );
34}
35
36function CancelAtStripe({ defaultChecked = false }: { defaultChecked?: boolean }) {
37 return (
38 <label className="flex items-start gap-2 text-sm">
39 <input type="checkbox" name="cancelAtStripe" defaultChecked={defaultChecked} className="mt-0.5 accent-(--g1t-accent)" />
40 <span>
41 <span className="block font-medium">Also cancel the subscriptions at Stripe</span>
42 <span className="block text-xs text-muted">Test key only. Without it, they are canceled here and left in Stripe's test mode.</span>
43 </span>
44 </label>
45 );
46}
47
48/** One workspace's changes, with its balance before and after. */
49function Changes({ row }: { row: WorkspaceDetach }) {
50 return (
51 <ul className="list-disc space-y-0.5 pl-4 text-sm text-fg-soft">
52 {detachChanges(row).map((line) => (
53 <li key={line}>{line}</li>
54 ))}
55 </ul>
56 );
57}
58
59/** Every workspace a detach reaches, as a table. */
60export function DetachTable({ rows }: { rows: WorkspaceDetach[] }) {
61 return (
62 <div className="-mx-4 overflow-x-auto sm:-mx-5">
63 <table className="w-full min-w-xl text-sm">
64 <thead>
65 <tr className="border-b border-line text-left text-xs text-muted">
66 <th className="px-4 py-2 font-medium sm:pl-5">Workspace</th>
67 <th className="px-4 py-2 font-medium">What changes</th>
68 <th className="px-4 py-2 text-right font-medium sm:pr-5">Balance</th>
69 </tr>
70 </thead>
71 <tbody>
72 {rows.map((row) => (
73 <tr key={row.workspace} className="border-b border-line align-top last:border-0">
74 <td className="px-4 py-2.5 sm:pl-5">
75 <a href={`/workspaces/${encodeURIComponent(row.workspace)}`} className="font-mono text-xs hover:underline">
76 {row.workspace}
77 </a>
78 {row.done && (
79 <span className="mt-1 block">
80 <Badge tone="mint">Detached</Badge>
81 </span>
82 )}
83 </td>
84 <td className="px-4 py-2.5">
85 <Changes row={row} />
86 </td>
87 <td className="tabular px-4 py-2.5 text-right whitespace-nowrap sm:pr-5">
88 <span className="text-faint">{usd(row.balance_before_micros, { cents: true })}</span>
89 {" → "}
90 <span className="font-medium">{usd(row.balance_after_micros, { cents: true })}</span>
91 </td>
92 </tr>
93 ))}
94 </tbody>
95 </table>
96 </div>
97 );
98}
99
100/** The workspace page's section: preview, then confirm with the slug. */
101export function WorkspaceTestDetachSection({
102 workspace,
103 pathname,
104 preview,
105 error,
106}: {
107 workspace: string;
108 pathname: string;
109 preview: TestStripeDetach | null;
110 error: SectionError;
111}) {
112 const row = preview?.workspaces.find((w) => w.workspace === workspace) ?? null;
113 return (
114 <Section
115 id="test-detach"
116 title="Detach from Stripe test mode"
117 description={`Before billing takes a live Stripe key. ${WHAT} Refused once the key is live.`}
118 >
119 <div className="space-y-4">
120 {error && <Notice tone="error">{error.error}</Notice>}
121 {!preview ? (
122 <form method="post" action={`${pathname}#test-detach`}>
123 <input type="hidden" name="intent" value="test-detach" />
124 <Button type="submit" variant="quiet">
125 <Eye size={14} />
126 Preview what changes
127 </Button>
128 </form>
129 ) : !row ? (
130 <Notice tone="ok">Nothing from Stripe's test mode is left on {workspace}: a detach would change nothing.</Notice>
131 ) : (
132 <>
133 <Changes row={row} />
134 <p className="tabular text-sm text-muted">
135 Balance {usd(row.balance_before_micros, { cents: true })} → {usd(row.balance_after_micros, { cents: true })}.
136 </p>
137 <form method="post" action={`${pathname}#test-detach`} className="space-y-4">
138 <input type="hidden" name="intent" value="test-detach" />
139 <input type="hidden" name="confirm" value="yes" />
140 {row.subscriptions > 0 && <CancelAtStripe defaultChecked={error?.values?.cancelAtStripe === "on"} />}
141 <Field
142 label="Confirm"
143 hint={
144 <>
145 Type the workspace's slug (<span className="font-mono text-muted">{workspace}</span>) to detach it. Kept in the audit log.
146 </>
147 }
148 >
149 <Input name="confirmation" required placeholder={workspace} className="font-mono" />
150 </Field>
151 <div className="flex justify-end">
152 <ApplyButton label="Detach from test mode" />
153 </div>
154 </form>
155 </>
156 )}
157 </div>
158 </Section>
159 );
160}
161
162/** The Stripe page's section: every workspace, previewed, then confirmed. */
163export function EveryWorkspaceDetachSection({
164 live,
165 result,
166 error,
167 exclude,
168}: {
169 live: boolean;
170 result: TestStripeDetach | null;
171 error: string | null;
172 exclude: string | null;
173}) {
174 const excludeValue = exclude ?? DEFAULT_EXCLUDED.join(", ");
175 const preview = result?.dry_run ? result : null;
176 const applied = result && !result.dry_run ? result : null;
177 return (
178 <Section
179 id="test-detach"
180 title="Detach every workspace from test mode"
181 description={`Run before switching billing to the live key. ${WHAT} Comped and enterprise workspaces are skipped, and so is anything excluded below.`}
182 className="mt-6"
183 >
184 {live ? (
185 <Notice tone="info">Billing is on the live key: test-mode data can no longer be detached.</Notice>
186 ) : (
187 <div className="space-y-4">
188 {error && <Notice tone="error">{error}</Notice>}
189 <form method="post" action="/stripe#test-detach" className="flex flex-col gap-2 sm:flex-row sm:items-end">
190 <input type="hidden" name="intent" value="test-detach-preview" />
191 <Field label="Leave alone" hint="Slugs, separated by commas. Blank leaves nothing out." className="grow">
192 <Input name="exclude" defaultValue={excludeValue} className="font-mono" />
193 </Field>
194 <Button type="submit" variant="quiet" className="shrink-0">
195 <Eye size={14} />
196 Preview
197 </Button>
198 </form>
199
200 {applied && (
201 <>
202 <Notice tone={applied.remaining > 0 ? "warn" : "ok"}>
203 Detached {applied.workspaces.length} workspace{applied.workspaces.length === 1 ? "" : "s"}.
204 {applied.remaining > 0 ? ` ${applied.remaining} more to go: preview and detach again.` : " Each is in its audit log."}
205 </Notice>
206 {applied.workspaces.length > 0 && <DetachTable rows={applied.workspaces} />}
207 </>
208 )}
209
210 {preview &&
211 (preview.workspaces.length === 0 ? (
212 <EmptyState title="Nothing to detach">No workspace outside the exclusions holds anything from Stripe's test mode.</EmptyState>
213 ) : (
214 <>
215 <p className="text-sm text-muted">
216 {preview.workspaces.length} workspace{preview.workspaces.length === 1 ? "" : "s"} will change
217 {preview.workspaces.length > 25 ? ", 25 a run" : ""}:
218 </p>
219 <DetachTable rows={preview.workspaces} />
220 <form method="post" action="/stripe#test-detach" className="space-y-4 border-t border-line pt-4">
221 <input type="hidden" name="intent" value="test-detach-all" />
222 <input type="hidden" name="exclude" value={excludeValue} />
223 {preview.workspaces.some((w) => w.subscriptions > 0) && <CancelAtStripe />}
224 <Field label="Confirm" hint={<>Type <span className="font-mono text-muted">{CONFIRM_ALL}</span> to detach every workspace listed. Each is kept in its audit log.</>}>
225 <Input name="confirmation" required placeholder={CONFIRM_ALL} className="font-mono" />
226 </Field>
227 <div className="flex justify-end">
228 <ApplyButton label={`Detach ${Math.min(preview.workspaces.length, 25)} workspace${preview.workspaces.length === 1 ? "" : "s"}`} />
229 </div>
230 </form>
231 </>
232 ))}
233
234 {result && result.skipped.length > 0 && (
235 <div>
236 <p className="mb-1.5 text-xs text-faint">Left alone, though they hold test-mode data</p>
237 <ul className="space-y-0.5 text-sm">
238 {result.skipped.map((s) => (
239 <li key={s.workspace}>
240 <span className="font-mono text-xs">{s.workspace}</span> <span className="text-muted">— {s.reason}</span>
241 </li>
242 ))}
243 </ul>
244 </div>
245 )}
246 </div>
247 )}
248 </Section>
249 );
250}