Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

218 linesCodeBlameRaw
1import { DOC_ROLES, DOC_ROLE_LABELS, DOC_ROLE_SUMMARIES, type DocRole, type DocSpace, type DocSpaceMember } from "@g1t/contracts";
2import { Archive, Share2, Trash2 } from "lucide-react";
3import { useState } from "react";
4import { Link, data, useNavigate } from "react-router";
5
6import type { Route } from "./+types/space-settings";
7import { memberKey } from "../../../components/folios/share-dialog";
8import { useFoliosAction, useFoliosData } from "../../../components/folios/actions";
9import { Face, SectionTitle } from "../../../components/folios/parts";
10import { SpaceForm, initialSpace, spaceInput } from "../../../components/folios/space-form";
11import { ErrorText } from "../../../components/ui";
12import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "../../../components/ui/alert-dialog";
13import { Button } from "../../../components/ui/button";
14import { Card } from "../../../components/ui/card";
15import { SelectField } from "../../../components/ui/select";
16import { Switch } from "../../../components/ui/switch";
17import { spacePath } from "../../../lib/folios";
18import { page } from "../../../lib/meta";
19import { docs, identity } from "../../../lib/services.server";
20import { requireUser, roleIn } from "../../../lib/session.server";
21
22export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) {
23 return page(args, { title: `Settings · ${loaded?.space.name ?? "Space"} · Artifacts · ${params.owner} · g1t` });
24}
25
26export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ space: DocSpace; members: DocSpaceMember[]; teams: { slug: string; name: string }[] }> {
27 const viewer = requireUser(context, request);
28 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
29 const slug = params.owner.toLowerCase();
30 const [found, teams] = await Promise.all([docs.space(slug, params.space, viewer), identity.listTeams(viewer, slug).catch(() => null)]);
31 if (!found.ok) throw data(null, { status: 404 });
32 if (found.value.space.viewer_role !== "manage") throw data(null, { status: 403 });
33 return { space: found.value.space, members: found.value.members, teams: teams?.ok ? teams.value.map((t) => ({ slug: t.slug, name: t.name })) : [] };
34}
35
36const ROLE_OPTIONS = DOC_ROLES.map((r) => ({ value: r, label: DOC_ROLE_LABELS[r], description: DOC_ROLE_SUMMARIES[r] }));
37
38/** A space's settings: what it is, who it's for, its members and their roles, whether editors can share, and how agents work in it. */
39export default function SpaceSettings({ loaderData, params }: Route.ComponentProps) {
40 const slug = params.owner.toLowerCase();
41 const { space, members, teams } = loaderData;
42 const layout = useFoliosData();
43 const navigate = useNavigate();
44 const settings = useFoliosAction(slug);
45 const people = useFoliosAction(slug);
46 const sharing = useFoliosAction(slug);
47 const [adding, setAdding] = useState("");
48 const [addRole, setAddRole] = useState<DocRole>("edit");
49 const [archiving, setArchiving] = useState(false);
50 const [deleting, setDeleting] = useState(false);
51 // How many artifacts the sidebar shows in it, when it shows the space.
52 const inside = layout?.sidebar?.spaces.find((s) => s.id === space.id)?.page_count ?? null;
53 const listed = new Set(members.map((m) => m.key));
54 const candidates = [
55 ...(layout?.mentionables ?? []).map((m) => ({ value: m.kind === "agent" ? `agent:${m.id}` : `username:${m.name}`, label: m.kind === "agent" ? `${m.display_name} (agent)` : `${m.display_name} (@${m.name})` })),
56 ...teams.map((t) => ({ value: `team:${t.slug}`, label: `${t.name} (team)` })),
57 ].filter((c) => !listed.has(c.value));
58 return (
59 <div className="mx-auto max-w-3xl">
60 <p className="text-xs text-faint">
61 <Link to={spacePath(slug, space.slug)} className="hover:text-fg">
62 {space.name}
63 </Link>{" "}
64 / Settings
65 </p>
66 <h1 className="mt-1 text-xl font-semibold tracking-tight">Space settings</h1>
67 <div className="mt-8">
68 <SpaceForm
69 initial={initialSpace(space)}
70 teams={teams}
71 isDefault={space.is_default}
72 submitLabel="Save"
73 busy={settings.busy}
74 error={settings.error}
75 onSubmit={async (value) => {
76 const saved = await settings.send<DocSpace>("update_space", { space_id: space.id, space_change: spaceInput(value) });
77 if (saved.ok && saved.value.slug !== space.slug) navigate(`${spacePath(slug, saved.value.slug)}/settings`, { replace: true });
78 }}
79 />
80 </div>
81
82 <section className="mt-12">
83 <SectionTitle>Sharing</SectionTitle>
84 <Card asChild className="flex items-start gap-3 px-4 py-3">
85 <label>
86 <Share2 size={16} className="mt-0.5 shrink-0 text-faint" />
87 <span className="min-w-0 grow">
88 <span className="block text-sm text-fg">Editors can share</span>
89 <span className="mt-0.5 block text-xs leading-relaxed text-faint">People who can edit what's in this space can also share it with others, up to Can edit. Off: only people with full access can share.</span>
90 </span>
91 <Switch
92 checked={space.editors_can_share}
93 disabled={sharing.busy}
94 onCheckedChange={(on) => sharing.send("update_space", { space_id: space.id, space_change: { editors_can_share: on } })}
95 aria-label="Editors can share"
96 />
97 </label>
98 </Card>
99 {sharing.error && (
100 <div className="mt-2">
101 <ErrorText>{sharing.error}</ErrorText>
102 </div>
103 )}
104 </section>
105
106 <section className="mt-12">
107 <SectionTitle>Members</SectionTitle>
108 <p className="mb-4 text-xs leading-relaxed text-faint">
109 {space.kind === "private"
110 ? "Only the people, agents and teams here can open this space."
111 : "Everyone the space is for already has its base role; add people here to give them more. Workspace owners always have full access."}{" "}
112 An agent added here still never does more than the person it works for.
113 </p>
114 <Card asChild divided className="overflow-hidden">
115 <ul>
116 {members.map((m) => (
117 <li key={m.key} className="flex items-center gap-3 px-4 py-2.5">
118 {m.kind === "team" ? (
119 <span className="flex size-6 items-center justify-center rounded-full bg-raised text-xs">@</span>
120 ) : (
121 <Face who={{ kind: m.kind, id: m.key.slice(m.key.indexOf(":") + 1), name: m.name, avatar: m.avatar, avatar_seed: m.avatar_seed ?? null }} size={24} />
122 )}
123 <span className="min-w-0 grow">
124 <span className="block truncate text-sm">{m.display_name}</span>
125 <span className="block text-xs text-faint">{m.kind === "team" ? "Team" : m.kind === "agent" ? "Agent" : `@${m.name}`}</span>
126 </span>
127 <SelectField aria-label={`${m.display_name}'s role`} value={m.role} onValueChange={(role) => people.send("set_member", { space_id: space.id, member: m.key, role })} options={ROLE_OPTIONS} className="h-8 w-40" />
128 <Button type="button" onClick={() => people.send("set_member", { space_id: space.id, member: m.key, role: null })} variant="link" size="inline" className="text-xs text-faint hover:text-danger font-normal">
129 Remove
130 </Button>
131 </li>
132 ))}
133 {members.length === 0 && <li className="px-4 py-3 text-sm text-muted">Nobody added yet.</li>}
134 </ul>
135 </Card>
136 <div className="mt-3 flex flex-wrap items-center gap-2">
137 <SelectField aria-label="Person, agent or team" value={adding} onValueChange={setAdding} placeholder="Add a person, agent or team" options={candidates} className="h-9 min-w-64 grow" />
138 <SelectField aria-label="Their role" value={addRole} onValueChange={(r) => setAddRole(r as DocRole)} options={ROLE_OPTIONS} className="h-9 w-40" />
139 <Button
140 type="button"
141 variant="outline"
142 disabled={!adding || people.busy}
143 onClick={async () => {
144 // People are named by username here; the service keys them by id.
145 const member = adding.startsWith("username:") ? await memberKey(slug, adding.slice(9)) : adding;
146 if (!member) return people.setError("That person isn't in the workspace.");
147 const done = await people.send("set_member", { space_id: space.id, member, role: addRole });
148 if (done.ok) setAdding("");
149 }}
150 >
151 Add
152 </Button>
153 </div>
154 {people.error && (
155 <div className="mt-2">
156 <ErrorText>{people.error}</ErrorText>
157 </div>
158 )}
159 </section>
160
161 <section className="mt-12 rounded-xl border border-danger/30 p-5">
162 <h2 className="text-sm font-semibold">Archive this space</h2>
163 <p className="mt-1 text-xs leading-relaxed text-muted">It leaves the sidebar and search. What's in it, its history and comments are kept, and it comes back from the Spaces page.</p>
164 <Button type="button" variant="destructive" onClick={() => setArchiving(true)} className="mt-3">
165 <Archive size={14} /> Archive space
166 </Button>
167 <h2 className="mt-6 text-sm font-semibold">Delete this space</h2>
168 <p className="mt-1 text-xs leading-relaxed text-muted">
169 Everything in it goes to the trash, where anyone who could edit it can restore it for 30 days. The space itself, its members and settings are gone for good.
170 {space.is_default ? " A workspace needs no General space; none takes its place." : ""}
171 </p>
172 <Button type="button" variant="destructive" onClick={() => setDeleting(true)} className="mt-3">
173 <Trash2 size={14} /> Delete space
174 </Button>
175 </section>
176 <AlertDialog open={deleting} onOpenChange={setDeleting}>
177 <AlertDialogContent>
178 <AlertDialogHeader>
179 <AlertDialogTitle>Delete {space.name}?</AlertDialogTitle>
180 <AlertDialogDescription>
181 {inside === null ? "Everything in it goes" : inside === 1 ? "The artifact in it goes" : `The ${inside} artifacts in it go`} to the trash, where anyone who could edit it can restore it for 30 days. The space can&apos;t be brought back.
182 </AlertDialogDescription>
183 </AlertDialogHeader>
184 <AlertDialogFooter>
185 <AlertDialogCancel>Keep it</AlertDialogCancel>
186 <AlertDialogAction
187 onClick={async () => {
188 const done = await settings.send("delete_space", { space_id: space.id });
189 if (done.ok) navigate(`/${slug}/-/artifacts`);
190 }}
191 >
192 Delete space
193 </AlertDialogAction>
194 </AlertDialogFooter>
195 </AlertDialogContent>
196 </AlertDialog>
197 <AlertDialog open={archiving} onOpenChange={setArchiving}>
198 <AlertDialogContent>
199 <AlertDialogHeader>
200 <AlertDialogTitle>Archive {space.name}?</AlertDialogTitle>
201 <AlertDialogDescription>Nobody will see it in Artifacts until it is brought back from the Spaces page.</AlertDialogDescription>
202 </AlertDialogHeader>
203 <AlertDialogFooter>
204 <AlertDialogCancel>Keep it</AlertDialogCancel>
205 <AlertDialogAction
206 onClick={async () => {
207 const done = await settings.send("update_space", { space_id: space.id, space_change: { archived: true } });
208 if (done.ok) navigate(`/${slug}/-/artifacts`);
209 }}
210 >
211 Archive
212 </AlertDialogAction>
213 </AlertDialogFooter>
214 </AlertDialogContent>
215 </AlertDialog>
216 </div>
217 );
218}