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

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