Skip to content

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

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