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