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