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

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