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.

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