flagon-io/g1t

public

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

g1t/apps/web/app/routes/repo/settings-domains.tsx

341 lines15,001 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.

Agents and memory, checks and conflicts, profiles, slug renames, custom domains1import { CheckCircle2, Globe, Info, RotateCw, Trash2 } from "lucide-react";
2import { useEffect, useState } from "react";
3import { Form, Link, data, useNavigation, useRevalidator } from "react-router";
4
5import type { Domain, DomainRecord, DomainStatus } from "@g1t/contracts";
6
7import type { Route } from "./+types/settings-domains";
8import { page } from "../../lib/meta";
9import { RepoSettingsTabs } from "../../components/repo-settings-tabs";
10import { Button, CopyLine, EmptyState, ErrorText, Field, Input } from "../../components/ui";
11import {
12 AlertDialog,
13 AlertDialogAction,
14 AlertDialogCancel,
15 AlertDialogContent,
16 AlertDialogDescription,
17 AlertDialogFooter,
18 AlertDialogHeader,
19 AlertDialogTitle,
20 AlertDialogTrigger,
21} from "../../components/ui/alert-dialog";
22import { CheckboxOption } from "../../components/ui/checkbox";
23import { deployments } from "../../lib/services.server";
24import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
25
26export function meta({ params, ...args }: Route.MetaArgs) {
27 return page(args, { title: `Domains · ${params.owner}/${params.repo} · g1t` });
28}
29
30export async function loader({ params, context }: Route.LoaderArgs) {
31 const viewer = getViewer(context);
32 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
33 const ref = { workspace: params.owner, slug: params.repo };
34 const [settings, domains] = await Promise.all([deployments.settings(ref, viewer), deployments.domains(ref, viewer)]);
35 return { settings: unwrap(settings), ...unwrap(domains) };
36}
37
38export async function action({ request, params, context }: Route.ActionArgs) {
39 assertSameOrigin(request);
40 const user = requireUser(context, request);
41 const form = await request.formData();
42 const ref = { workspace: params.owner, slug: params.repo };
43 const intent = form.get("intent");
44 const id = String(form.get("id") ?? "");
45 if (intent === "add") {
46 const added = await deployments.addDomain(user, ref, String(form.get("hostname") ?? ""), { twin: form.get("twin") === "on" });
47 if (!added.ok) return { error: added.error.message };
48 const names = added.value.map((d) => d.hostname);
49 return { notice: names.length ? `Added ${names.join(" and ")}. Add the DNS records below.` : "Saved." };
50 }
51 if (intent === "refresh") {
52 const checked = await deployments.refreshDomain(user, ref, id);
53 if (!checked.ok) return { error: checked.error.message };
54 return { notice: checked.value.status === "active" ? `${checked.value.hostname} is active.` : `Checked ${checked.value.hostname} again.` };
55 }
56 if (intent === "remove") {
57 const removed = await deployments.removeDomain(user, ref, id);
58 return removed.ok ? { notice: "Domain removed." } : { error: removed.error.message };
59 }
60 return { error: "Unknown request." };
61}
62
63const STATUS: Record<DomainStatus, { label: string; tone: string; live?: boolean }> = {
64 pending: { label: "Waiting for DNS", tone: "text-warn border-warn/30 bg-warn/5", live: true },
65 verifying: { label: "Issuing certificate", tone: "text-warn border-warn/30 bg-warn/5", live: true },
66 active: { label: "Active", tone: "text-accent border-accent/30 bg-accent/5" },
67 failed: { label: "Failed", tone: "text-danger border-danger/30 bg-danger/5" },
68 removing: { label: "Removing", tone: "text-faint border-line" },
69};
70
71function StatusBadge({ status }: { status: DomainStatus }) {
72 const { label, tone, live } = STATUS[status];
73 return (
74 <span className={`inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${tone}`}>
75 <span className={`size-1.5 rounded-full bg-current ${live ? "animate-pulse motion-reduce:animate-none" : ""}`} />
76 {label}
77 </span>
78 );
79}
80
81/** The www or apex twin of what is typed, for the pairing option's label. */
82function twinOf(input: string): string | null {
83 const host = input.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/[/?#].*$/, "").replace(/\.$/, "");
84 if (!/^[a-z0-9.-]+\.[a-z0-9-]+$/.test(host)) return null;
85 if (host.startsWith("www.")) return host.slice(4).includes(".") ? host.slice(4) : null;
86 return host.split(".").length === 2 ? `www.${host}` : null;
87}
88
89export default function DomainSettings({ loaderData, actionData, params }: Route.ComponentProps) {
90 const { settings, domains, target, available, notice, included, used } = loaderData;
91 const busy = useNavigation().state === "submitting";
92 const base = `/${params.owner}/${params.repo}`;
93 const [hostname, setHostname] = useState("");
94 const twin = twinOf(hostname);
95 const added = actionData && "notice" in actionData && !!actionData.notice?.startsWith("Added");
96 useEffect(() => {
97 if (added) setHostname("");
98 }, [added, actionData]);
99
100 // While a domain is on its way, the page follows it.
101 const revalidator = useRevalidator();
102 const waiting = domains.some((d) => d.status === "pending" || d.status === "verifying");
103 useEffect(() => {
104 if (!waiting) return;
105 const timer = setInterval(() => {
106 if (revalidator.state === "idle" && document.visibilityState === "visible") revalidator.revalidate();
107 }, 10_000);
108 return () => clearInterval(timer);
109 }, [waiting, revalidator]);
110
111 // A redirecting domain is shown under the one it redirects to.
112 const primary = domains.filter((d) => !d.redirectTo || !domains.some((p) => p.hostname === d.redirectTo));
113
114 return (
115 <div className="max-w-4xl">
116 <RepoSettingsTabs base={base} />
117 <div className="min-h-6">
118 {actionData && "notice" in actionData && <p className="text-sm text-accent">{actionData.notice}</p>}
119 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
120 </div>
121
122 <header className="mb-6">
123 <h2 className="flex items-center gap-2 font-medium">
124 <Globe size={16} className="text-accent" />
125 Custom domains
126 </h2>
127 <p className="mt-1 max-w-2xl text-sm text-muted">
128 Serve production at a domain of your own, such as <span className="font-mono text-fg">example.com</span> or{" "}
129 <span className="font-mono text-fg">www.example.com</span>, beside{" "}
130 <span className="font-mono text-fg">{settings.productionUrl.replace("https://", "")}</span>. Point it at{" "}
131 <span className="font-mono text-fg">{target}</span>; g1t issues and renews its certificate. Redeploys never change
132 it.{" "}
133 <a href="https://docs.g1t.sh/guides/deployments/#custom-domains" className="text-fg hover:underline">
134 How custom domains work
135 </a>
136 </p>
137 </header>
138
139 {!available && notice && (
140 <p className="mb-6 flex items-start gap-2 rounded-lg border border-warn/30 bg-warn/5 px-4 py-3 text-sm text-warn">
141 <Info size={15} className="mt-0.5 shrink-0" />
142 {notice}
143 </p>
144 )}
145
146 {!settings.enabled && (
147 <p className="mb-6 rounded-lg border border-line bg-surface px-4 py-3 text-sm text-muted">
148 Deployments are off for this project, so a domain added now shows that nothing is deployed until they are on.{" "}
149 <Link to={`${base}/settings/deployments`} className="text-fg hover:underline">
150 Turn on deployments
151 </Link>
152 </p>
153 )}
154
155 <Form method="post" className="rounded-xl border border-line bg-surface p-5">
156 <input type="hidden" name="intent" value="add" />
157 <div className="flex flex-col gap-3 sm:flex-row sm:items-end">
158 <div className="grow">
159 <Field label="Domain" hint="A domain (the apex, such as example.com) or any subdomain, such as www.example.com or app.example.com.">
160 <Input
161 name="hostname"
162 value={hostname}
163 onChange={(event) => setHostname(event.target.value)}
164 placeholder="example.com"
165 autoComplete="off"
166 spellCheck={false}
167 required
168 />
169 </Field>
170 </div>
171 <div className="sm:mb-6">
172 <Button type="submit" variant="accent" disabled={busy || !hostname.trim()}>
173 Add domain
174 </Button>
175 </div>
176 </div>
177 <CheckboxOption
178 name="twin"
179 defaultChecked
180 disabled={!twin}
181 className="mt-2"
182 label={twin ? `Also add ${twin}, redirecting to ${hostname.trim().toLowerCase()}` : "Also add the www or apex twin, redirecting to it"}
183 description="Most sites answer at both example.com and www.example.com; one serves the app and the other sends visitors to it, path and query kept."
184 />
185 <p className="mt-4 text-xs text-faint">
186 The Deployments plan includes {included} custom domains across the workspace ({used} in use). Each one past that is
187 charged by the month at cost plus the margin.
188 </p>
189 </Form>
190
191 <div className="mt-8 space-y-4">
192 {domains.length === 0 ? (
193 <EmptyState title="No custom domains yet">Add one above, then the DNS records it asks for.</EmptyState>
194 ) : (
195 primary.map((domain) => (
196 <DomainCard
197 key={domain.id}
198 domain={domain}
199 redirects={domains.filter((d) => d.redirectTo === domain.hostname)}
200 busy={busy}
201 />
202 ))
203 )}
204 </div>
205 </div>
206 );
207}
208
209function DomainCard({ domain, redirects, busy }: { domain: Domain; redirects: Domain[]; busy: boolean }) {
210 return (
211 <section className="overflow-hidden rounded-xl border border-line bg-surface">
212 <DomainRow domain={domain} busy={busy} />
213 {redirects.map((other) => (
214 <div key={other.id} className="border-t border-line">
215 <DomainRow domain={other} busy={busy} />
216 </div>
217 ))}
218 </section>
219 );
220}
221
222function DomainRow({ domain, busy }: { domain: Domain; busy: boolean }) {
223 const active = domain.status === "active";
224 return (
225 <div className="p-5">
226 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
227 {active ? (
228 <a href={`https://${domain.hostname}`} className="min-w-0 truncate font-mono text-sm font-medium hover:text-accent">
229 {domain.hostname}
230 </a>
231 ) : (
232 <span className="min-w-0 truncate font-mono text-sm font-medium">{domain.hostname}</span>
233 )}
234 <StatusBadge status={domain.status} />
235 {domain.redirectTo && <span className="text-xs text-muted">redirects to {domain.redirectTo}</span>}
236 <span className="ml-auto flex items-center gap-2">
237 {domain.status !== "removing" && (
238 <Form method="post">
239 <input type="hidden" name="intent" value="refresh" />
240 <input type="hidden" name="id" value={domain.id} />
241 <Button type="submit" variant="quiet" disabled={busy} title="Ask Cloudflare to check the records again now">
242 <RotateCw size={14} />
243 Check now
244 </Button>
245 </Form>
246 )}
247 <RemoveDomain domain={domain} busy={busy} />
248 </span>
249 </div>
250 {domain.error && !active && <p className="mt-2 text-sm text-muted">{domain.error}</p>}
251 {active ? (
252 <p className="mt-2 flex items-center gap-1.5 text-sm text-muted">
253 <CheckCircle2 size={14} className="text-accent" />
254 {domain.redirectTo ? "Redirecting, with its certificate." : "Serving production, with its certificate."}
255 {domain.verifiedAt && <span className="text-faint">Since {new Date(domain.verifiedAt).toLocaleDateString()}.</span>}
256 </p>
257 ) : (
258 <Records domain={domain} />
259 )}
260 </div>
261 );
262}
263
264function Records({ domain }: { domain: Domain }) {
265 return (
266 <div className="mt-4 space-y-3">
267 <p className="text-sm text-muted">Add these records where the domain's DNS is managed:</p>
268 {domain.records.map((record) => (
269 <RecordRow key={`${record.type}-${record.name}-${record.value}`} record={record} />
270 ))}
271 {domain.apex && (
272 <p className="text-xs text-faint">
273 The apex (the bare domain) cannot have a plain CNAME. Use your provider's flattened CNAME or ALIAS record: a CNAME
274 at <span className="font-mono">@</span> on Cloudflare DNS, an ALIAS on DNSimple, NS1, Namecheap, Porkbun or Gandi,
275 or an ANAME on DNS Made Easy. Route 53's alias records cannot point outside AWS. If your provider has none of
276 these, add only the www domain here and forward the apex to it at your provider.
277 </p>
278 )}
279 <p className="text-xs text-faint">
280 DNS changes can take a few minutes to an hour to be seen. The certificate is issued once the domain points here.
281 </p>
282 </div>
283 );
284}
285
286function RecordRow({ record }: { record: DomainRecord }) {
287 const type = record.type === "ALIAS" ? "CNAME (flattened) or ALIAS" : record.type;
288 return (
289 <div className="grid gap-2 rounded-lg border border-line bg-bg p-3 sm:grid-cols-[9rem_minmax(0,1fr)]">
290 <div className="text-xs">
291 <p className="font-medium text-fg">{type}</p>
292 <p className="mt-0.5 text-faint">{record.purpose}</p>
293 </div>
294 <div className="grid min-w-0 gap-2">
295 <div>
296 <p className="mb-1 text-xs text-faint">Name</p>
297 <CopyLine text={record.name} />
298 </div>
299 <div>
300 <p className="mb-1 text-xs text-faint">{record.type === "TXT" ? "Content" : "Target"}</p>
301 <CopyLine text={record.value} />
302 </div>
303 </div>
304 </div>
305 );
306}
307
308function RemoveDomain({ domain, busy }: { domain: Domain; busy: boolean }) {
309 return (
310 <AlertDialog>
311 <AlertDialogTrigger asChild>
312 <Button type="button" variant="quiet" disabled={busy} aria-label={`Remove ${domain.hostname}`}>
313 <Trash2 size={14} />
314 Remove
315 </Button>
316 </AlertDialogTrigger>
317 <AlertDialogContent>
318 <Form method="post" className="grid gap-4">
319 <input type="hidden" name="intent" value="remove" />
320 <input type="hidden" name="id" value={domain.id} />
321 <AlertDialogHeader>
322 <AlertDialogTitle>Remove {domain.hostname}?</AlertDialogTitle>
323 <AlertDialogDescription>
324 It stops serving this project at once, and its certificate is given up. Any domain redirecting to it is
325 removed too. You can add it again later; its DNS records stay as you set them.
326 </AlertDialogDescription>
327 </AlertDialogHeader>
328 <AlertDialogFooter>
329 <AlertDialogCancel>Cancel</AlertDialogCancel>
330 <AlertDialogAction asChild>
331 <button type="submit">
332 <Trash2 size={14} />
333 Remove domain
334 </button>
335 </AlertDialogAction>
336 </AlertDialogFooter>
337 </Form>
338 </AlertDialogContent>
339 </AlertDialog>
340 );
341}