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.

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