Skip to content

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

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