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.

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