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

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