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.

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