Skip to content

g1t/apps/web/app/routes/workspace/settings.tsx

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