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

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