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