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

445 lines18,802 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents and memory, checks and conflicts, profiles, slug renames, custom domains1import { useEffect, useState } from "react";
2import { Form, data, redirect, useFetcher, useNavigation } from "react-router";
Agents as a team: lifecycle, merge queue, billing and a new shell3
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look4import {
5 RENAME_COOLDOWN_HOURS,
6 SLUG_HOLD_DAYS,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member7 WORKSPACE_RESTORE_DAYS,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look8 type Workspace,
9 type WorkspaceDeletion,
10 isValidNamespace,
11} from "@g1t/contracts";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains12
Agents as a team: lifecycle, merge queue, billing and a new shell13import type { Route } from "./+types/settings";
Social cards for every page: og.g1t.sh14import { page } from "../../lib/meta";
Self-hosting: the clone pack cache on S3 (MinIO, expiring), the API and MCP on their own port with PUBLIC_URL-derived addresses across the site, API and mail, scheduler --once, and smoke.sh covering pull requests from branches and forks and the merge queue15import { useAddresses } from "../../lib/addresses";
Workspace names and icons, and a component kit for every control16import { AvatarField } from "../../components/avatar-field";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look17import { DangerAction, DangerZone } from "../../components/danger-zone";
Agents as a team: lifecycle, merge queue, billing and a new shell18import { Button, ErrorText, Field, Input } from "../../components/ui";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains19import {
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";
Workspace names and icons, and a component kit for every control30import { readAvatarUpload } from "../../lib/avatar-upload";
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member31import { deployments, identity } from "../../lib/services.server";
Agents as a team: lifecycle, merge queue, billing and a new shell32import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look33import { forgetWorkspace } from "../../lib/workspace-choice";
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member34import { confirmsSlug, deletionRefusal, whatGoes } from "../../lib/workspace-deletion";
Agents as a team: lifecycle, merge queue, billing and a new shell35
Social cards for every page: og.g1t.sh36export function meta({ params, ...args }: Route.MetaArgs) {
37 return page(args, { title: `Settings · ${params.owner} · g1t` });
Agents as a team: lifecycle, merge queue, billing and a new shell38}
39
Agents and memory, checks and conflicts, profiles, slug renames, custom domains40/** What the address field shows about a new slug, asked for with `?check=`. */
41type Check = { slug: string; available: boolean; message: string | null };
42
43export async function loader({ params, context, request }: Route.LoaderArgs) {
Agents as a team: lifecycle, merge queue, billing and a new shell44 // Owners only; to anyone else the page does not exist.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains45 const viewer = getViewer(context);
46 if (roleIn(viewer, params.owner) !== "owner") {
Agents as a team: lifecycle, merge queue, billing and a new shell47 throw data(null, { status: 404 });
48 }
49 const workspace = await identity.getWorkspace(params.owner);
50 if (!workspace) throw data(null, { status: 404 });
Agents and memory, checks and conflicts, profiles, slug renames, custom domains51 // The address field asks whether a new slug is free as the owner types.
52 // It is a read, so a GET: nothing else on the page reloads for it.
53 const wanted = new URL(request.url).searchParams.get("check");
54 let check: Check | null = null;
55 if (wanted != null && viewer) {
56 const slug = wanted.trim().toLowerCase();
57 const result = await identity.checkWorkspaceRename(viewer, params.owner, slug);
58 check = result.ok
59 ? { slug, available: result.value, message: result.value ? null : "That address is not available." }
60 : { slug, available: false, message: result.error.message };
61 }
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member62 // What deleting it would take with it, and anything in the way, shown
63 // before anyone types. Its apps are counted by deployments.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look64 let deletion: WorkspaceDeletion | null = null;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member65 let apps: number | null = null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look66 if (viewer) {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member67 const [found, usage] = await Promise.all([
68 identity.checkWorkspaceDeletion(viewer, params.owner).catch(() => null),
69 deployments.usage(params.owner, viewer).catch(() => null),
70 ]);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look71 deletion = found?.ok ? found.value : null;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member72 apps = usage?.ok ? usage.value.apps : null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look73 }
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member74 return { workspace, check, deletion, apps };
Agents as a team: lifecycle, merge queue, billing and a new shell75}
76
77export async function action({ request, params, context }: Route.ActionArgs) {
78 assertSameOrigin(request);
79 const user = requireUser(context, request);
80 const form = await request.formData();
Workspace names and icons, and a component kit for every control81 const intent = form.get("intent");
82 // The icon: identity checks the owner and the image's bytes again.
83 if (intent === "avatar" || intent === "remove-avatar") {
84 let image: string | null = null;
85 if (intent === "avatar") {
86 const upload = await readAvatarUpload(form);
87 if ("error" in upload) return { avatarError: upload.error };
88 image = upload.image;
89 }
90 const result = await identity.setWorkspaceAvatar(user, params.owner, image);
91 if (!result.ok) return { avatarError: result.error.message };
92 return { saved: "avatar" as const };
93 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains94 // A new address: identity checks the owner, the name and the cooldown,
95 // and keeps the old one as a redirect.
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member96 // Deletion: identity checks the owner, the typed name, that it is not
97 // protected and that billing has settled it. Everything in it goes with
98 // it; the owner goes back to their own home, where it is no longer listed.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look99 if (intent === "delete") {
100 const result = await identity.deleteWorkspace(user, params.owner, String(form.get("confirm") ?? ""));
101 if (!result.ok) return { deleteError: result.error.message };
102 const secure = new URL(request.url).protocol === "https:";
103 throw redirect("/", { headers: { "Set-Cookie": forgetWorkspace(secure) } });
104 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains105 if (intent === "rename") {
106 const newSlug = String(form.get("newSlug") ?? "").trim().toLowerCase();
107 const result = await identity.renameWorkspace(user, params.owner, newSlug);
108 if (!result.ok) return { renameError: result.error.message };
109 throw redirect(`/${result.value.slug}/-/settings`);
110 }
Agents as a team: lifecycle, merge queue, billing and a new shell111 const result = await identity.updateWorkspace(user, params.owner, {
112 name: String(form.get("displayName") ?? ""),
113 description: String(form.get("description") ?? ""),
114 });
115 if (!result.ok) return { error: result.error.message };
116 throw redirect(`/${result.value.slug}`);
117}
118
119export default function WorkspaceSettings({ loaderData, actionData }: Route.ComponentProps) {
120 const { workspace } = loaderData;
121 return (
Workspace names and icons, and a component kit for every control122 <div className="max-w-lg space-y-10">
123 <section>
124 <h2 className="font-medium">Icon</h2>
125 <div className="mt-4">
126 <AvatarField
127 name={workspace.slug}
128 image={workspace.avatar}
129 square
130 error={actionData && "avatarError" in actionData ? actionData.avatarError : undefined}
131 about="Shown beside the workspace's name everywhere on g1t, and on its link previews. Without one, g1t draws its first letter."
Agents as a team: lifecycle, merge queue, billing and a new shell132 />
Workspace names and icons, and a component kit for every control133 </div>
134 </section>
135
136 <section>
137 <h2 className="font-medium">Workspace details</h2>
138 <Form method="post" className="mt-5 space-y-4">
139 <Field
140 label="Display name"
141 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."
142 >
143 <Input name="displayName" maxLength={80} defaultValue={workspace.name} placeholder={workspace.slug} />
144 </Field>
145 <Field label="Description" hint="One line, shown at the top of the workspace's page.">
146 <Input name="description" maxLength={160} defaultValue={workspace.description ?? ""} />
147 </Field>
148 <ErrorText>{actionData && "error" in actionData ? actionData.error : undefined}</ErrorText>
149 <Button type="submit">Save</Button>
150 </Form>
151 </section>
152
Agents and memory, checks and conflicts, profiles, slug renames, custom domains153 <AddressSection
154 workspace={workspace}
155 error={actionData && "renameError" in actionData ? actionData.renameError : undefined}
156 />
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look157
158 <DangerZone>
159 <DeleteAction
160 workspace={workspace}
161 deletion={loaderData.deletion}
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member162 apps={loaderData.apps}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look163 error={actionData && "deleteError" in actionData ? actionData.deleteError : undefined}
164 />
165 </DangerZone>
Agents as a team: lifecycle, merge queue, billing and a new shell166 </div>
167 );
168}
Agents and memory, checks and conflicts, profiles, slug renames, custom domains169
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look170/**
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member171 * Deleting the workspace, with everything in it, in one step. Owners only,
172 * as the whole page is; typed out to confirm. It is kept for
173 * `WORKSPACE_RESTORE_DAYS`, when support can restore it. A protected
174 * workspace says so instead of offering the button.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look175 */
176function DeleteAction({
177 workspace,
178 deletion,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member179 apps,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look180 error,
181}: {
182 workspace: Workspace;
183 deletion: WorkspaceDeletion | null;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member184 apps: number | null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look185 error?: string;
186}) {
187 const [open, setOpen] = useState(Boolean(error));
188 const [confirm, setConfirm] = useState("");
189 const navigation = useNavigation();
190 const deleting = navigation.state !== "idle" && navigation.formData?.get("intent") === "delete";
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member191 const refusal = deletionRefusal(workspace.slug, deletion);
192 const goes = deletion ? whatGoes(deletion, apps) : [];
193 if (deletion?.protected) {
194 return (
195 <DangerAction title="Delete this workspace" action={null}>
196 <span role="status">{refusal}</span>
197 </DangerAction>
198 );
199 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look200 return (
201 <DangerAction
202 title="Delete this workspace"
203 action={
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member204 <Button type="button" variant="danger" disabled={Boolean(refusal)} onClick={() => setOpen(true)}>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look205 Delete workspace
206 </Button>
207 }
208 >
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member209 {refusal ? (
210 <span role="status">{refusal}</span>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look211 ) : (
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member212 <>
213 Everything in it goes with it: repositories, projects, apps, members' access and tokens. It is kept for{" "}
214 {WORKSPACE_RESTORE_DAYS} days, and support can restore it until then.
215 </>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look216 )}
217 <AlertDialog
218 open={open}
219 onOpenChange={(next) => {
220 setOpen(next);
221 setConfirm("");
222 }}
223 >
224 <AlertDialogContent>
225 <Form method="post" className="grid gap-4">
226 <input type="hidden" name="intent" value="delete" />
227 <AlertDialogHeader>
228 <AlertDialogTitle>Delete {workspace.slug}?</AlertDialogTitle>
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member229 <AlertDialogDescription>
230 Everything in it goes now. For {WORKSPACE_RESTORE_DAYS} days, support can restore all of it; after
231 that it is gone for good.
232 </AlertDialogDescription>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look233 </AlertDialogHeader>
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member234 {goes.length > 0 ? (
235 <div className="grid gap-1.5">
236 <p className="text-sm font-medium">What goes with it</p>
237 <ul className="list-disc space-y-1 pl-5 text-sm text-muted">
238 {goes.map((line) => (
239 <li key={line}>{line}</li>
240 ))}
241 </ul>
242 </div>
243 ) : null}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look244 <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted">
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member245 <li>Everyone loses access to it at once, and its access tokens stop working.</li>
246 <li>Its pages, repositories and apps are no longer found, and nothing builds or runs for it.</li>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look247 <li>
248 Anything it owes is charged to its card now, and its plan ends today. Its statements, invoices and
249 audit log are kept.
250 </li>
251 <li>
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member252 To get it back within {WORKSPACE_RESTORE_DAYS} days, an owner writes to support. After that its
253 repositories, projects, apps, webhooks, integrations, secrets and memory are removed for good.
254 </li>
255 <li>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look256 The name <span className="font-mono text-fg">{workspace.slug}</span> is never given to another
257 workspace. Repositories that were transferred out keep redirecting from it.
258 </li>
259 <li>Members keep their own accounts.</li>
260 </ul>
261 <FormField>
262 <FieldLabel htmlFor="confirm-delete">
263 Type <span className="font-mono text-fg">{workspace.slug}</span> to confirm
264 </FieldLabel>
265 <TextInput
266 id="confirm-delete"
267 name="confirm"
268 value={confirm}
269 spellCheck={false}
270 autoCapitalize="off"
271 autoComplete="off"
272 onChange={(event) => setConfirm(event.target.value)}
273 className="font-mono"
274 />
275 </FormField>
276 <ErrorText>{error}</ErrorText>
277 <AlertDialogFooter>
278 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member279 <Button type="submit" variant="danger" disabled={!confirmsSlug(workspace.slug, confirm) || deleting}>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look280 {deleting ? "Deleting…" : "Delete workspace"}
281 </Button>
282 </AlertDialogFooter>
283 </Form>
284 </AlertDialogContent>
285 </AlertDialog>
286 </DangerAction>
287 );
288}
289
Agents and memory, checks and conflicts, profiles, slug renames, custom domains290/**
291 * The workspace's slug, the first part of every address under it. Owners
292 * change it here, apart from the display name; the old one redirects for
293 * `SLUG_HOLD_DAYS`.
294 */
295function AddressSection({ workspace, error }: { workspace: Workspace; error?: string }) {
296 const [slug, setSlug] = useState(workspace.slug);
297 const [open, setOpen] = useState(false);
298 const [confirm, setConfirm] = useState("");
299 const checker = useFetcher<typeof loader>();
Self-hosting: the clone pack cache on S3 (MinIO, expiring), the API and MCP on their own port with PUBLIC_URL-derived addresses across the site, API and mail, scheduler --once, and smoke.sh covering pull requests from branches and forks and the merge queue300 const { site } = useAddresses();
Agents and memory, checks and conflicts, profiles, slug renames, custom domains301 const navigation = useNavigation();
302 const renaming = navigation.state !== "idle" && navigation.formData?.get("intent") === "rename";
303
304 const wanted = slug.trim().toLowerCase();
305 const unchanged = wanted === workspace.slug;
306 const wellFormed = isValidNamespace(wanted);
307
308 // The shape is checked here; whether the name is free, once the owner
309 // stops typing.
310 const load = checker.load;
311 useEffect(() => {
312 if (unchanged || !wellFormed) return;
313 const timer = setTimeout(() => {
314 load(`/${workspace.slug}/-/settings?check=${encodeURIComponent(wanted)}`);
315 }, 400);
316 return () => clearTimeout(timer);
317 }, [load, wanted, unchanged, wellFormed, workspace.slug]);
318
319 // An answer only counts for what is in the field now.
320 const answer = checker.data?.check?.slug === wanted ? checker.data.check : null;
321 const available = !unchanged && wellFormed && answer?.available === true;
322
323 let status: { text: string; tone: "muted" | "ok" | "bad" } | null = null;
324 if (!wanted) {
325 status = { text: "Enter the new address.", tone: "muted" };
326 } else if (unchanged) {
327 status = null;
328 } else if (!wellFormed) {
329 status = {
330 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.",
331 tone: "bad",
332 };
333 } else if (!answer) {
334 status = { text: "Checking…", tone: "muted" };
335 } else if (answer.available) {
336 status = { text: "Available.", tone: "ok" };
337 } else {
338 status = { text: answer.message ?? "That address is not available.", tone: "bad" };
339 }
340
341 return (
342 <section>
343 <h2 className="font-medium">Address</h2>
344 <p className="mt-1.5 text-xs text-faint">
345 The workspace's slug: the first part of every page, clone URL and API path under it. Changing it does
346 not change the display name. The old address redirects for {SLUG_HOLD_DAYS} days.
347 </p>
348 <div className="mt-5 space-y-4">
349 <FormField>
350 <FieldLabel htmlFor="workspace-slug">Slug</FieldLabel>
351 <InputGroup>
352 <InputAddon className="border-r border-line bg-surface font-mono">g1t.sh/</InputAddon>
353 <TextInput
354 id="workspace-slug"
355 value={slug}
356 maxLength={39}
357 spellCheck={false}
358 autoCapitalize="off"
359 onChange={(event) => setSlug(event.target.value.toLowerCase())}
360 aria-invalid={status?.tone === "bad" || undefined}
361 className="pl-2 font-mono"
362 />
363 </InputGroup>
364 <FieldDescription>
365 <span className="font-mono text-muted">
366 g1t.sh/<span className={unchanged ? undefined : "text-fg"}>{wanted || workspace.slug}</span>
367 </span>
368 {status && (
369 <span
370 role="status"
371 className={`mt-1 block ${
372 status.tone === "ok" ? "text-accent" : status.tone === "bad" ? "text-danger" : ""
373 }`}
374 >
375 {status.text}
376 </span>
377 )}
378 </FieldDescription>
379 </FormField>
380 {!open && <ErrorText>{error}</ErrorText>}
381 <AlertDialog
382 open={open}
383 onOpenChange={(next) => {
384 setOpen(next);
385 setConfirm("");
386 }}
387 >
388 <Button type="button" variant="quiet" disabled={!available} onClick={() => setOpen(true)}>
389 Change address
390 </Button>
391 <AlertDialogContent>
392 {/* The dialog stays open while the form posts: success leaves
393 the page for the new address, and a failure shows here. */}
394 <Form method="post" className="grid gap-4">
395 <input type="hidden" name="intent" value="rename" />
396 <input type="hidden" name="newSlug" value={wanted} />
397 <AlertDialogHeader>
398 <AlertDialogTitle>Change the address to g1t.sh/{wanted}?</AlertDialogTitle>
399 <AlertDialogDescription>
400 Every page, repository and API path under g1t.sh/{workspace.slug} moves to g1t.sh/{wanted}.
401 </AlertDialogDescription>
402 </AlertDialogHeader>
403 <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted">
404 <li>Old links and git remotes redirect to the new address for {SLUG_HOLD_DAYS} days.</li>
405 <li>
406 Deployed apps move to <span className="font-mono">*.g1t.page</span> addresses with the new
407 name. The old addresses redirect for {SLUG_HOLD_DAYS} days.
408 </li>
409 <li>
410 Update your git remotes, for example{" "}
411 <code className="font-mono text-xs break-all text-fg">
Self-hosting: the clone pack cache on S3 (MinIO, expiring), the API and MCP on their own port with PUBLIC_URL-derived addresses across the site, API and mail, scheduler --once, and smoke.sh covering pull requests from branches and forks and the merge queue412 git remote set-url origin {site}/{wanted}/&lt;repo&gt;.git
Agents and memory, checks and conflicts, profiles, slug renames, custom domains413 </code>
414 , and any URLs written into code, API clients and MCP clients.
415 </li>
416 <li>After {SLUG_HOLD_DAYS} days, anyone can take the name {workspace.slug}.</li>
417 <li>You can change the address again after {RENAME_COOLDOWN_HOURS} hours.</li>
418 </ul>
419 <FormField>
420 <FieldLabel htmlFor="confirm-slug">
421 Type <span className="font-mono text-fg">{wanted}</span> to confirm
422 </FieldLabel>
423 <TextInput
424 id="confirm-slug"
425 value={confirm}
426 spellCheck={false}
427 autoCapitalize="off"
428 onChange={(event) => setConfirm(event.target.value)}
429 className="font-mono"
430 />
431 </FormField>
432 <ErrorText>{error}</ErrorText>
433 <AlertDialogFooter>
434 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
435 <Button type="submit" disabled={confirm.trim().toLowerCase() !== wanted || renaming}>
436 {renaming ? "Changing…" : "Change address"}
437 </Button>
438 </AlertDialogFooter>
439 </Form>
440 </AlertDialogContent>
441 </AlertDialog>
442 </div>
443 </section>
444 );
445}