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.

635 linesCodeBlameRaw
1import { useEffect, useState } from "react";
2import { Form, Link, data, redirect, useFetcher, useNavigation } from "react-router";
3
4import {
5 type DataResidency,
6 RENAME_COOLDOWN_HOURS,
7 SLUG_HOLD_DAYS,
8 WORKSPACE_RESTORE_DAYS,
9 type Workspace,
10 type WorkspaceDeletion,
11 isValidNamespace,
12} from "@g1t/contracts";
13
14import type { Route } from "./+types/settings";
15import { page } from "../../lib/meta";
16import { useAddresses } from "../../lib/addresses";
17import { AvatarField } from "../../components/avatar-field";
18import { GettingStartedButton } from "../../components/onboarding";
19import { ConfirmName } from "../../components/confirm-name";
20import { DangerAction, DangerZone } from "../../components/danger-zone";
21import { ErrorText, Field, Input, SubmitButton } from "../../components/ui";
22import {
23 AlertDialog,
24 AlertDialogCancel,
25 AlertDialogContent,
26 AlertDialogDescription,
27 AlertDialogFooter,
28 AlertDialogHeader,
29 AlertDialogTitle,
30} from "../../components/ui/alert-dialog";
31import { Button } from "../../components/ui/button";
32import { FieldDescription, FieldLabel, Field as FormField } from "../../components/ui/field";
33import { InputAddon, InputGroup, Input as TextInput } from "../../components/ui/input";
34import { RadioGroup, RadioOption } from "../../components/ui/radio-group";
35import { SwitchCard } from "../../components/ui/switch";
36import { readAvatarUpload } from "../../lib/avatar-upload";
37import { accounts, deployments, identity, repos } from "../../lib/services.server";
38import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
39import { forgetWorkspace } from "../../lib/workspace-choice";
40import { confirmsSlug, deletionRefusal, whatGoes } from "../../lib/workspace-deletion";
41import { Unavailable } from "../../components/boundary";
42import { isFailed, part, type Failed } from "../../lib/partial";
43
44export function meta({ params, ...args }: Route.MetaArgs) {
45 return page(args, { title: `Settings · ${params.owner} · g1t` });
46}
47
48/** What the address field shows about a new slug, asked for with `?check=`. */
49type Check = { slug: string; available: boolean; message: string | null };
50
51export async function loader({ params, context, request }: Route.LoaderArgs) {
52 // Owners only; to anyone else the page does not exist.
53 const viewer = getViewer(context);
54 if (roleIn(viewer, params.owner) !== "owner") {
55 throw data(null, { status: 404 });
56 }
57 const workspace = await identity.getWorkspace(params.owner);
58 if (!workspace) throw data(null, { status: 404 });
59 // The address field asks whether a new slug is free as the owner types.
60 // It is a read, so a GET: nothing else on the page reloads for it.
61 const wanted = new URL(request.url).searchParams.get("check");
62 let check: Check | null = null;
63 if (wanted != null && viewer) {
64 const slug = wanted.trim().toLowerCase();
65 const result = await identity.checkWorkspaceRename(viewer, params.owner, slug);
66 check = result.ok
67 ? { slug, available: result.value, message: result.value ? null : "That address is not available." }
68 : { slug, available: false, message: result.error.message };
69 }
70 // What deleting it would take with it, and anything in the way, shown
71 // before anyone types. Its apps are counted by deployments.
72 // When that cannot be read, Delete says so in its place rather than
73 // offering itself without saying what would go. How many apps go is only
74 // a count in that list, which reads without it.
75 let deletion: WorkspaceDeletion | Failed | null = null;
76 let apps: number | null = null;
77 if (viewer) {
78 const [found, usage] = await Promise.all([
79 part(identity.checkWorkspaceDeletion(viewer, params.owner), "What deleting it would take with it"),
80 deployments.usage(params.owner, viewer).catch(() => null),
81 ]);
82 deletion = isFailed(found) ? found : found.ok ? found.value : null;
83 apps = usage?.ok ? usage.value.apps : null;
84 }
85 // Where its repositories are kept: offered once g1t can keep them in the
86 // EU, and shown to a workspace that chose it whatever happens since.
87 // Each of these is a section's: one that cannot be read says so in its
88 // place, rather than show a choice that saving would put over the real
89 // one, or say no one would be held out when that is not known.
90 const [storage, residency, members, twoFactor] = await Promise.all([
91 // Whether the EU is offered: without it, only a workspace that chose it sees the choice.
92 repos.storageOptions().catch(() => ({ euAvailable: false })),
93 part(identity.workspaceResidency(params.owner), "Data residency"),
94 // Who would be held out by requiring two-factor authentication.
95 viewer ? part(identity.listMembers(params.owner, viewer), "Two-factor authentication") : null,
96 viewer ? part(accounts.twoFactorStatus(viewer), "Two-factor authentication") : null,
97 ]);
98 const without = isFailed(members)
99 ? members
100 : members?.ok
101 ? members.value.filter((member) => member.two_factor === false).map((member) => member.username)
102 : [];
103 return {
104 workspace,
105 check,
106 deletion,
107 apps,
108 euAvailable: storage.euAvailable,
109 residency: isFailed(residency) ? residency : (residency ?? "anywhere"),
110 withoutTwoFactor: without,
111 ownTwoFactor: isFailed(twoFactor) ? twoFactor : twoFactor?.ok ? twoFactor.value.enabled : false,
112 };
113}
114
115export async function action({ request, params, context }: Route.ActionArgs) {
116 assertSameOrigin(request);
117 const user = requireUser(context, request);
118 const form = await request.formData();
119 const intent = form.get("intent");
120 // The icon: identity checks the owner and the image's bytes again.
121 if (intent === "avatar" || intent === "remove-avatar") {
122 let image: string | null = null;
123 if (intent === "avatar") {
124 const upload = await readAvatarUpload(form);
125 if ("error" in upload) return { avatarError: upload.error };
126 image = upload.image;
127 }
128 const result = await identity.setWorkspaceAvatar(user, params.owner, image);
129 if (!result.ok) return { avatarError: result.error.message };
130 return { saved: "avatar" as const };
131 }
132 // A new address: identity checks the owner, the name and the cooldown,
133 // and keeps the old one as a redirect.
134 // Deletion: identity checks the owner, the typed name, that it is not
135 // protected and that billing has settled it. Everything in it goes with
136 // it; the owner goes back to their own home, where it is no longer listed.
137 if (intent === "delete") {
138 const result = await identity.deleteWorkspace(user, params.owner, String(form.get("confirm") ?? ""));
139 if (!result.ok) return { deleteError: result.error.message };
140 const secure = new URL(request.url).protocol === "https:";
141 throw redirect("/", { headers: { "Set-Cookie": forgetWorkspace(secure) } });
142 }
143 // Where new repositories keep their data: identity checks the owner.
144 if (intent === "residency") {
145 const wanted = form.get("residency") === "eu" ? "eu" : "anywhere";
146 const result = await identity.setWorkspaceResidency(user, params.owner, wanted);
147 if (!result.ok) return { residencyError: result.error.message };
148 return { saved: "residency" as const };
149 }
150 // Requiring two-factor authentication: identity checks the owner has it.
151 if (intent === "two-factor-requirement") {
152 const result = await identity.setTwoFactorRequirement(user, params.owner, form.get("required") === "on");
153 if (!result.ok) return { twoFactorError: result.error.message };
154 return { saved: "two-factor-requirement" as const };
155 }
156 if (intent === "rename") {
157 const newSlug = String(form.get("newSlug") ?? "")
158 .trim()
159 .toLowerCase();
160 const result = await identity.renameWorkspace(user, params.owner, newSlug);
161 if (!result.ok) return { renameError: result.error.message };
162 throw redirect(`/${result.value.slug}/-/settings`);
163 }
164 const result = await identity.updateWorkspace(user, params.owner, {
165 name: String(form.get("displayName") ?? ""),
166 description: String(form.get("description") ?? ""),
167 });
168 if (!result.ok) return { error: result.error.message };
169 throw redirect(`/${result.value.slug}`);
170}
171
172export default function WorkspaceSettings({ loaderData, actionData }: Route.ComponentProps) {
173 const { workspace } = loaderData;
174 return (
175 <div className="max-w-lg space-y-10">
176 <section>
177 <h2 className="font-medium">Getting started</h2>
178 <p className="mt-1.5 text-sm text-muted">
179 The setup guide that opened on Home: an AI provider, Ask&apos;s model, your code, your coding agent, your team and a runner, each ticked off as the
180 workspace has it. Open it again any time.
181 </p>
182 <div className="mt-3">
183 <GettingStartedButton slug={workspace.slug} />
184 </div>
185 </section>
186
187 <section>
188 <h2 className="font-medium">Icon</h2>
189 <div className="mt-4">
190 <AvatarField
191 name={workspace.slug}
192 image={workspace.avatar}
193 square
194 error={actionData && "avatarError" in actionData ? actionData.avatarError : undefined}
195 about="Shown beside the workspace's name everywhere on g1t, and on its link previews. Without one, g1t draws its first letter."
196 />
197 </div>
198 </section>
199
200 <section>
201 <h2 className="font-medium">Workspace details</h2>
202 <Form method="post" className="mt-5 space-y-4">
203 <input type="hidden" name="intent" value="details" />
204 <Field
205 label="Display name"
206 hint="How people see the workspace: in the sidebar, at the top of its page and in link previews. Up to 80 characters; spaces and capitals are fine."
207 >
208 <Input name="displayName" maxLength={80} defaultValue={workspace.name} placeholder={workspace.slug} />
209 </Field>
210 <Field label="Description" hint="One line, shown at the top of the workspace's page.">
211 <Input name="description" maxLength={160} defaultValue={workspace.description ?? ""} />
212 </Field>
213 <ErrorText>{actionData && "error" in actionData ? actionData.error : undefined}</ErrorText>
214 <SubmitButton match={{ intent: "details" }} pending="Saving…">
215 Save
216 </SubmitButton>
217 </Form>
218 </section>
219
220 <AddressSection
221 // A new address starts it again, so the dialog is closed on the renamed page.
222 key={workspace.slug}
223 workspace={workspace}
224 error={actionData && "renameError" in actionData ? actionData.renameError : undefined}
225 />
226
227 {isFailed(loaderData.withoutTwoFactor) || isFailed(loaderData.ownTwoFactor) ? (
228 <section id="two-factor" className="scroll-mt-20">
229 <h2 className="font-medium">Authentication security</h2>
230 <div className="mt-5">
231 <Unavailable what="Two-factor authentication" />
232 </div>
233 </section>
234 ) : (
235 <TwoFactorRequirementSection
236 key={String(workspace.twoFactorRequirementEnabled ?? false)}
237 slug={workspace.slug}
238 required={workspace.twoFactorRequirementEnabled ?? false}
239 without={loaderData.withoutTwoFactor}
240 ownTwoFactor={loaderData.ownTwoFactor}
241 saved={Boolean(actionData && "saved" in actionData && actionData.saved === "two-factor-requirement")}
242 error={actionData && "twoFactorError" in actionData ? actionData.twoFactorError : undefined}
243 />
244 )}
245
246 <section>
247 <h2 className="font-medium">Permissions</h2>
248 <p className="mt-1.5 text-xs text-faint">
249 Who may do what in {workspace.slug}: the base permission on its repositories, creating repositories and teams, channels and more are under{" "}
250 <Link to={`/${workspace.slug}/-/permissions`} className="text-muted underline underline-offset-4 hover:text-fg">
251 Permissions
252 </Link>
253 .
254 </p>
255 </section>
256
257 {isFailed(loaderData.residency) ? (
258 <section>
259 <h2 className="font-medium">Data residency</h2>
260 <div className="mt-5">
261 <Unavailable what={loaderData.residency.what} />
262 </div>
263 </section>
264 ) : (
265 (loaderData.euAvailable || loaderData.residency === "eu") && (
266 <ResidencySection
267 // Starts from the saved choice whenever it changes.
268 key={loaderData.residency}
269 residency={loaderData.residency}
270 euAvailable={loaderData.euAvailable}
271 saved={Boolean(actionData && "saved" in actionData && actionData.saved === "residency")}
272 error={actionData && "residencyError" in actionData ? actionData.residencyError : undefined}
273 />
274 )
275 )}
276
277 <DangerZone>
278 {isFailed(loaderData.deletion) ? (
279 <div className="p-4">
280 <Unavailable what={loaderData.deletion.what} compact />
281 </div>
282 ) : (
283 <DeleteAction
284 workspace={workspace}
285 deletion={loaderData.deletion}
286 apps={loaderData.apps}
287 error={actionData && "deleteError" in actionData ? actionData.deleteError : undefined}
288 />
289 )}
290 </DangerZone>
291 </div>
292 );
293}
294
295/** Requiring two-factor authentication of everyone with access. */
296function TwoFactorRequirementSection({
297 slug,
298 required,
299 without,
300 ownTwoFactor,
301 saved,
302 error,
303}: {
304 slug: string;
305 required: boolean;
306 without: string[];
307 ownTwoFactor: boolean;
308 saved: boolean;
309 error?: string;
310}) {
311 const [chosen, setChosen] = useState(required);
312 const navigation = useNavigation();
313 const saving = navigation.state !== "idle" && navigation.formData?.get("intent") === "two-factor-requirement";
314 return (
315 <section id="two-factor" className="scroll-mt-20">
316 <h2 className="font-medium">Authentication security</h2>
317 <p className="mt-1.5 text-xs text-faint">
318 With two-factor authentication required, members and outside collaborators without it keep their place but cannot use {slug} until they turn it on. They
319 see a notice saying so.
320 </p>
321 <Form method="post" className="mt-5 space-y-3">
322 <input type="hidden" name="intent" value="two-factor-requirement" />
323 <SwitchCard
324 name="required"
325 title="Require two-factor authentication"
326 checked={chosen}
327 disabled={!ownTwoFactor && !required}
328 onCheckedChange={setChosen}
329 >
330 {!ownTwoFactor && !required ? (
331 <>
332 Turn it on for your own account first, in{" "}
333 <Link to="/settings/two-factor" className="text-accent underline underline-offset-4">
334 your settings
335 </Link>
336 .
337 </>
338 ) : without.length === 0 ? (
339 "Every member has it on."
340 ) : (
341 `${without.length} ${without.length === 1 ? "member does" : "members do"} not have it on: ${without.slice(0, 5).join(", ")}${without.length > 5 ? ` and ${without.length - 5} more` : ""}.`
342 )}
343 </SwitchCard>
344 <ErrorText>{error}</ErrorText>
345 {saved && !error && chosen === required && (
346 <p role="status" className="text-xs text-muted">
347 Saved.
348 </p>
349 )}
350 <Button type="submit" disabled={saving || chosen === required}>
351 Save
352 </Button>
353 </Form>
354 </section>
355 );
356}
357
358/**
359 * Where the workspace's new repositories keep their git data. Shown only
360 * once g1t has EU storage (or to a workspace that already chose it), so
361 * nobody is offered a choice that does nothing.
362 */
363function ResidencySection({ residency, euAvailable, saved, error }: { residency: DataResidency; euAvailable: boolean; saved: boolean; error?: string }) {
364 const [choice, setChoice] = useState<DataResidency>(residency);
365 const navigation = useNavigation();
366 const saving = navigation.state !== "idle" && navigation.formData?.get("intent") === "residency";
367 return (
368 <section>
369 <h2 className="font-medium">Data residency</h2>
370 <p className="mt-1.5 text-xs text-faint">
371 Where the git data of repositories made from now on is stored. Repositories the workspace already has stay where they are; ask support to move them.
372 Work items, pull requests and settings are not affected.
373 </p>
374 <Form method="post" className="mt-5 space-y-4">
375 <input type="hidden" name="intent" value="residency" />
376 <RadioGroup name="residency" value={choice} onValueChange={(value) => setChoice(value as DataResidency)} aria-label="Where new repositories are stored">
377 <RadioOption value="anywhere" label="Anywhere" description="Wherever g1t stores repositories. The default." />
378 <RadioOption
379 value="eu"
380 label="EU only"
381 description={
382 euAvailable
383 ? "New repositories are stored in the EU, and are not made if EU storage cannot take them."
384 : "EU storage cannot take new repositories right now."
385 }
386 disabled={!euAvailable && residency !== "eu"}
387 />
388 </RadioGroup>
389 <ErrorText>{error}</ErrorText>
390 {saved && !error && (
391 <p role="status" className="text-xs text-muted">
392 Saved.
393 </p>
394 )}
395 <Button type="submit" disabled={saving || choice === residency}>
396 Save
397 </Button>
398 </Form>
399 </section>
400 );
401}
402
403/**
404 * Deleting the workspace, with everything in it, in one step. Owners only,
405 * as the whole page is; typed out to confirm. It is kept for
406 * `WORKSPACE_RESTORE_DAYS`, when support can restore it. A protected
407 * workspace says so instead of offering the button.
408 */
409function DeleteAction({ workspace, deletion, apps, error }: { workspace: Workspace; deletion: WorkspaceDeletion | null; apps: number | null; error?: string }) {
410 const [open, setOpen] = useState(Boolean(error));
411 const [confirm, setConfirm] = useState("");
412 const navigation = useNavigation();
413 const deleting = navigation.state !== "idle" && navigation.formData?.get("intent") === "delete";
414 const refusal = deletionRefusal(workspace.slug, deletion);
415 const goes = deletion ? whatGoes(deletion, apps) : [];
416 if (deletion?.protected) {
417 return (
418 <DangerAction title="Delete this workspace" action={null}>
419 <span role="status">{refusal}</span>
420 </DangerAction>
421 );
422 }
423 return (
424 <DangerAction
425 title="Delete this workspace"
426 action={
427 <Button type="button" variant="destructive" disabled={Boolean(refusal)} onClick={() => setOpen(true)}>
428 Delete workspace
429 </Button>
430 }
431 >
432 {refusal ? (
433 <span role="status">{refusal}</span>
434 ) : (
435 <>
436 Everything in it goes with it: repositories, projects, apps, members' access and tokens. It is kept for {WORKSPACE_RESTORE_DAYS} days, and support can
437 restore it until then.
438 </>
439 )}
440 <AlertDialog
441 open={open}
442 onOpenChange={(next) => {
443 setOpen(next);
444 setConfirm("");
445 }}
446 >
447 <AlertDialogContent>
448 <Form method="post" className="grid gap-4">
449 <input type="hidden" name="intent" value="delete" />
450 <AlertDialogHeader>
451 <AlertDialogTitle>Delete {workspace.slug}?</AlertDialogTitle>
452 <AlertDialogDescription>
453 Everything in it goes now. For {WORKSPACE_RESTORE_DAYS} days, support can restore all of it; after that it is gone for good.
454 </AlertDialogDescription>
455 </AlertDialogHeader>
456 {goes.length > 0 ? (
457 <div className="grid gap-1.5">
458 <p className="text-sm font-medium">What goes with it</p>
459 <ul className="list-disc space-y-1 pl-5 text-sm text-muted">
460 {goes.map((line) => (
461 <li key={line}>{line}</li>
462 ))}
463 </ul>
464 </div>
465 ) : null}
466 <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted">
467 <li>Everyone loses access to it at once, and its access tokens stop working.</li>
468 <li>Its pages, repositories and apps are no longer found, and nothing builds or runs for it.</li>
469 <li>Anything it owes is charged to its card now, and its plan ends today. Its statements, invoices and audit log are kept.</li>
470 <li>
471 To get it back within {WORKSPACE_RESTORE_DAYS} days, an owner writes to support. After that its repositories, projects, apps, webhooks,
472 integrations and secrets are removed for good.
473 </li>
474 <li>
475 The name <span className="font-mono text-fg">{workspace.slug}</span> is never given to another workspace. Repositories that were transferred out
476 keep redirecting from it.
477 </li>
478 <li>Members keep their own accounts.</li>
479 </ul>
480 <ConfirmName confirm={workspace.slug} id="confirm-delete" value={confirm} onChange={(event) => setConfirm(event.target.value)} />
481 <ErrorText>{error}</ErrorText>
482 <AlertDialogFooter>
483 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
484 <Button type="submit" variant="destructive" disabled={!confirmsSlug(workspace.slug, confirm) || deleting}>
485 {deleting ? "Deleting…" : "Delete workspace"}
486 </Button>
487 </AlertDialogFooter>
488 </Form>
489 </AlertDialogContent>
490 </AlertDialog>
491 </DangerAction>
492 );
493}
494
495/**
496 * The workspace's slug, the first part of every address under it. Owners
497 * change it here, apart from the display name; the old one redirects for
498 * `SLUG_HOLD_DAYS`.
499 */
500function AddressSection({ workspace, error }: { workspace: Workspace; error?: string }) {
501 const [slug, setSlug] = useState(workspace.slug);
502 const [open, setOpen] = useState(false);
503 const [confirm, setConfirm] = useState("");
504 const checker = useFetcher<typeof loader>();
505 const { site } = useAddresses();
506 const navigation = useNavigation();
507 const renaming = navigation.state !== "idle" && navigation.formData?.get("intent") === "rename";
508
509 const wanted = slug.trim().toLowerCase();
510 const unchanged = wanted === workspace.slug;
511 const wellFormed = isValidNamespace(wanted);
512
513 // The shape is checked here; whether the name is free, once the owner
514 // stops typing.
515 const load = checker.load;
516 useEffect(() => {
517 if (unchanged || !wellFormed) return;
518 const timer = setTimeout(() => {
519 load(`/${workspace.slug}/-/settings?check=${encodeURIComponent(wanted)}`);
520 }, 400);
521 return () => clearTimeout(timer);
522 }, [load, wanted, unchanged, wellFormed, workspace.slug]);
523
524 // An answer only counts for what is in the field now.
525 const answer = checker.data?.check?.slug === wanted ? checker.data.check : null;
526 const available = !unchanged && wellFormed && answer?.available === true;
527
528 let status: { text: string; tone: "muted" | "ok" | "bad" } | null = null;
529 if (!wanted) {
530 status = { text: "Enter the new address.", tone: "muted" };
531 } else if (unchanged) {
532 status = null;
533 } else if (!wellFormed) {
534 status = {
535 text: "Use lowercase letters, numbers and single hyphens, up to 39 characters, starting and ending with a letter or number. Some words, such as new and settings, are reserved.",
536 tone: "bad",
537 };
538 } else if (!answer) {
539 status = { text: "Checking…", tone: "muted" };
540 } else if (answer.available) {
541 status = { text: "Available.", tone: "ok" };
542 } else {
543 status = { text: answer.message ?? "That address is not available.", tone: "bad" };
544 }
545
546 return (
547 <section>
548 <h2 className="font-medium">Address</h2>
549 <p className="mt-1.5 text-xs text-faint">
550 The workspace's slug: the first part of every page, clone URL and API path under it. Changing it does not change the display name. The old address
551 redirects for {SLUG_HOLD_DAYS} days.
552 </p>
553 <div className="mt-5 space-y-4">
554 <FormField>
555 <FieldLabel htmlFor="workspace-slug">Slug</FieldLabel>
556 <InputGroup>
557 <InputAddon className="border-r border-line bg-surface font-mono">g1t.sh/</InputAddon>
558 <TextInput
559 id="workspace-slug"
560 value={slug}
561 maxLength={39}
562 spellCheck={false}
563 autoCapitalize="off"
564 onChange={(event) => setSlug(event.target.value.toLowerCase())}
565 aria-invalid={status?.tone === "bad" || undefined}
566 className="pl-2 font-mono"
567 />
568 </InputGroup>
569 <FieldDescription>
570 <span className="font-mono text-muted">
571 g1t.sh/<span className={unchanged ? undefined : "text-fg"}>{wanted || workspace.slug}</span>
572 </span>
573 {status && (
574 <span role="status" className={`mt-1 block ${status.tone === "ok" ? "text-success" : status.tone === "bad" ? "text-danger" : ""}`}>
575 {status.text}
576 </span>
577 )}
578 </FieldDescription>
579 </FormField>
580 {!open && <ErrorText>{error}</ErrorText>}
581 <AlertDialog
582 open={open}
583 onOpenChange={(next) => {
584 setOpen(next);
585 setConfirm("");
586 }}
587 >
588 <Button type="button" variant="outline" disabled={!available} onClick={() => setOpen(true)}>
589 Change address
590 </Button>
591 <AlertDialogContent>
592 {/* The dialog stays open while the form posts: success leaves
593 the page for the new address, and a failure shows here. */}
594 <Form method="post" className="grid gap-4">
595 <input type="hidden" name="intent" value="rename" />
596 <input type="hidden" name="newSlug" value={wanted} />
597 <AlertDialogHeader>
598 <AlertDialogTitle>Change the address to g1t.sh/{wanted}?</AlertDialogTitle>
599 <AlertDialogDescription>
600 Every page, repository and API path under g1t.sh/{workspace.slug} moves to g1t.sh/{wanted}.
601 </AlertDialogDescription>
602 </AlertDialogHeader>
603 <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted">
604 <li>Old links and git remotes redirect to the new address for {SLUG_HOLD_DAYS} days.</li>
605 <li>
606 Deployed apps move to <span className="font-mono">*.g1t.page</span> addresses with the new name. The old addresses redirect for{" "}
607 {SLUG_HOLD_DAYS} days.
608 </li>
609 <li>
610 Update your git remotes, for example{" "}
611 <code className="font-mono text-xs break-all text-fg">
612 git remote set-url origin {site}/{wanted}/&lt;repo&gt;.git
613 </code>
614 , and any URLs written into code, API clients and MCP clients.
615 </li>
616 <li>
617 After {SLUG_HOLD_DAYS} days, anyone can take the name {workspace.slug}.
618 </li>
619 <li>You can change the address again after {RENAME_COOLDOWN_HOURS} hours.</li>
620 </ul>
621 <ConfirmName confirm={wanted} id="confirm-slug" name={undefined} value={confirm} onChange={(event) => setConfirm(event.target.value)} />
622 <ErrorText>{error}</ErrorText>
623 <AlertDialogFooter>
624 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
625 <Button type="submit" disabled={confirm.trim().toLowerCase() !== wanted || renaming}>
626 {renaming ? "Changing…" : "Change address"}
627 </Button>
628 </AlertDialogFooter>
629 </Form>
630 </AlertDialogContent>
631 </AlertDialog>
632 </div>
633 </section>
634 );
635}