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