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.

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