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

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