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.

217 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, and whether editors can share. */
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: `username:${m.name}`, label: `${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 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 </p>
113 <Card asChild divided className="overflow-hidden">
114 <ul>
115 {members.map((m) => (
116 <li key={m.key} className="flex items-center gap-3 px-4 py-2.5">
117 {m.kind === "team" ? (
118 <span className="flex size-6 items-center justify-center rounded-full bg-raised text-xs">@</span>
119 ) : (
120 <Face who={{ kind: m.kind, id: m.key.slice(m.key.indexOf(":") + 1), name: m.name, avatar: m.avatar }} size={24} />
121 )}
122 <span className="min-w-0 grow">
123 <span className="block truncate text-sm">{m.display_name}</span>
124 <span className="block text-xs text-faint">{m.kind === "team" ? "Team" : m.kind === "agent" ? "Agent" : `@${m.name}`}</span>
125 </span>
126 <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" />
127 <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">
128 Remove
129 </Button>
130 </li>
131 ))}
132 {members.length === 0 && <li className="px-4 py-3 text-sm text-muted">Nobody added yet.</li>}
133 </ul>
134 </Card>
135 <div className="mt-3 flex flex-wrap items-center gap-2">
136 <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" />
137 <SelectField aria-label="Their role" value={addRole} onValueChange={(r) => setAddRole(r as DocRole)} options={ROLE_OPTIONS} className="h-9 w-40" />
138 <Button
139 type="button"
140 variant="outline"
141 disabled={!adding || people.busy}
142 onClick={async () => {
143 // People are named by username here; the service keys them by id.
144 const member = adding.startsWith("username:") ? await memberKey(slug, adding.slice(9)) : adding;
145 if (!member) return people.setError("That person isn't in the workspace.");
146 const done = await people.send("set_member", { space_id: space.id, member, role: addRole });
147 if (done.ok) setAdding("");
148 }}
149 >
150 Add
151 </Button>
152 </div>
153 {people.error && (
154 <div className="mt-2">
155 <ErrorText>{people.error}</ErrorText>
156 </div>
157 )}
158 </section>
159
160 <section className="mt-12 rounded-xl border border-danger/30 p-5">
161 <h2 className="text-sm font-semibold">Archive this space</h2>
162 <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>
163 <Button type="button" variant="destructive" onClick={() => setArchiving(true)} className="mt-3">
164 <Archive size={14} /> Archive space
165 </Button>
166 <h2 className="mt-6 text-sm font-semibold">Delete this space</h2>
167 <p className="mt-1 text-xs leading-relaxed text-muted">
168 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.
169 {space.is_default ? " A workspace needs no General space; none takes its place." : ""}
170 </p>
171 <Button type="button" variant="destructive" onClick={() => setDeleting(true)} className="mt-3">
172 <Trash2 size={14} /> Delete space
173 </Button>
174 </section>
175 <AlertDialog open={deleting} onOpenChange={setDeleting}>
176 <AlertDialogContent>
177 <AlertDialogHeader>
178 <AlertDialogTitle>Delete {space.name}?</AlertDialogTitle>
179 <AlertDialogDescription>
180 {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.
181 </AlertDialogDescription>
182 </AlertDialogHeader>
183 <AlertDialogFooter>
184 <AlertDialogCancel>Keep it</AlertDialogCancel>
185 <AlertDialogAction
186 onClick={async () => {
187 const done = await settings.send("delete_space", { space_id: space.id });
188 if (done.ok) navigate(`/${slug}/-/artifacts`);
189 }}
190 >
191 Delete space
192 </AlertDialogAction>
193 </AlertDialogFooter>
194 </AlertDialogContent>
195 </AlertDialog>
196 <AlertDialog open={archiving} onOpenChange={setArchiving}>
197 <AlertDialogContent>
198 <AlertDialogHeader>
199 <AlertDialogTitle>Archive {space.name}?</AlertDialogTitle>
200 <AlertDialogDescription>Nobody will see it in Artifacts until it is brought back from the Spaces page.</AlertDialogDescription>
201 </AlertDialogHeader>
202 <AlertDialogFooter>
203 <AlertDialogCancel>Keep it</AlertDialogCancel>
204 <AlertDialogAction
205 onClick={async () => {
206 const done = await settings.send("update_space", { space_id: space.id, space_change: { archived: true } });
207 if (done.ok) navigate(`/${slug}/-/artifacts`);
208 }}
209 >
210 Archive
211 </AlertDialogAction>
212 </AlertDialogFooter>
213 </AlertDialogContent>
214 </AlertDialog>
215 </div>
216 );
217}