Skip to content
167 linesCodeBlameRaw
1import { DOC_ROLES, DOC_ROLE_LABELS, DOC_ROLE_SUMMARIES, type DocRole, type DocSpace, type DocSpaceMember } from "@g1t/contracts";
2import { Archive } from "lucide-react";
3import { useState } from "react";
4import { Link, data, useNavigate } from "react-router";
5
6import type { Route } from "./+types/space-settings";
7import { useDocsAction, useDocsData } from "../../../components/docs/actions";
8import { Face, SectionTitle } from "../../../components/docs/parts";
9import { SpaceForm, initialSpace, spaceInput } from "../../../components/docs/space-form";
10import { Button, ErrorText } from "../../../components/ui";
11import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "../../../components/ui/alert-dialog";
12import { SelectField } from "../../../components/ui/select";
13import { page } from "../../../lib/meta";
14import { docs, identity } from "../../../lib/services.server";
15import { requireUser, roleIn } from "../../../lib/session.server";
16
17export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) {
18 return page(args, { title: `Settings · ${loaded?.space.name ?? "Space"} · Docs · ${params.owner} · g1t` });
19}
20
21export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ space: DocSpace; members: DocSpaceMember[]; teams: { slug: string; name: string }[] }> {
22 const viewer = requireUser(context, request);
23 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
24 const slug = params.owner.toLowerCase();
25 const [found, teams] = await Promise.all([docs.space(slug, params.space, viewer), identity.listTeams(viewer, slug).catch(() => null)]);
26 if (!found.ok) throw data(null, { status: 404 });
27 if (found.value.space.viewer_role !== "manage") throw data(null, { status: 403 });
28 return { space: found.value.space, members: found.value.members, teams: teams?.ok ? teams.value.map((t) => ({ slug: t.slug, name: t.name })) : [] };
29}
30
31const ROLE_OPTIONS = DOC_ROLES.map((r) => ({ value: r, label: DOC_ROLE_LABELS[r], description: DOC_ROLE_SUMMARIES[r] }));
32
33/** A space's settings: what it is, who it's for, its members and their roles, and how agents work in it. */
34export default function SpaceSettings({ loaderData, params }: Route.ComponentProps) {
35 const slug = params.owner.toLowerCase();
36 const { space, members, teams } = loaderData;
37 const layout = useDocsData();
38 const navigate = useNavigate();
39 const settings = useDocsAction(slug);
40 const people = useDocsAction(slug);
41 const [adding, setAdding] = useState("");
42 const [addRole, setAddRole] = useState<DocRole>("edit");
43 const [archiving, setArchiving] = useState(false);
44 const listed = new Set(members.map((m) => m.key));
45 const candidates = [
46 ...(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})` })),
47 ...teams.map((t) => ({ value: `team:${t.slug}`, label: `${t.name} (team)` })),
48 ].filter((c) => !listed.has(c.value));
49 return (
50 <div className="mx-auto max-w-3xl">
51 <p className="text-xs text-faint">
52 <Link to={`/${slug}/-/docs/${space.slug}`} className="hover:text-fg">
53 {space.name}
54 </Link>{" "}
55 / Settings
56 </p>
57 <h1 className="mt-1 text-xl font-semibold tracking-tight">Space settings</h1>
58 <div className="mt-8">
59 <SpaceForm
60 initial={initialSpace(space)}
61 teams={teams}
62 isDefault={space.is_default}
63 submitLabel="Save"
64 busy={settings.busy}
65 error={settings.error}
66 onSubmit={async (value) => {
67 const saved = await settings.send<DocSpace>("update_space", { space_id: space.id, space_change: spaceInput(value) });
68 if (saved.ok && saved.value.slug !== space.slug) navigate(`/${slug}/-/docs/${saved.value.slug}/settings`, { replace: true });
69 }}
70 />
71 </div>
72
73 <section className="mt-12">
74 <SectionTitle>Members</SectionTitle>
75 <p className="mb-4 text-xs leading-relaxed text-faint">
76 {space.kind === "private"
77 ? "Only the people, agents and teams here can open this space."
78 : "Everyone the space is for already has its base role; add people here to give them more. Workspace owners always have full access."}{" "}
79 An agent added here still never does more than the person it works for.
80 </p>
81 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
82 {members.map((m) => (
83 <li key={m.key} className="flex items-center gap-3 px-4 py-2.5">
84 {m.kind === "team" ? (
85 <span className="flex size-6 items-center justify-center rounded-full bg-raised text-xs">@</span>
86 ) : (
87 <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} />
88 )}
89 <span className="min-w-0 grow">
90 <span className="block truncate text-sm">{m.display_name}</span>
91 <span className="block text-xs text-faint">{m.kind === "team" ? "Team" : m.kind === "agent" ? "Agent" : `@${m.name}`}</span>
92 </span>
93 <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" />
94 <button type="button" onClick={() => people.send("set_member", { space_id: space.id, member: m.key, role: null })} className="text-xs text-faint hover:text-danger">
95 Remove
96 </button>
97 </li>
98 ))}
99 {members.length === 0 && <li className="px-4 py-3 text-sm text-muted">Nobody added yet.</li>}
100 </ul>
101 <div className="mt-3 flex flex-wrap items-center gap-2">
102 <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" />
103 <SelectField aria-label="Their role" value={addRole} onValueChange={(r) => setAddRole(r as DocRole)} options={ROLE_OPTIONS} className="h-9 w-40" />
104 <Button
105 type="button"
106 variant="quiet"
107 disabled={!adding || people.busy}
108 onClick={async () => {
109 // People are named by username here; the service keys them by id.
110 const member = adding.startsWith("username:") ? await memberKeyFor(slug, adding.slice(9)) : adding;
111 if (!member) return people.setError("That person isn't in the workspace.");
112 const done = await people.send("set_member", { space_id: space.id, member, role: addRole });
113 if (done.ok) setAdding("");
114 }}
115 >
116 Add
117 </Button>
118 </div>
119 {people.error && (
120 <div className="mt-2">
121 <ErrorText>{people.error}</ErrorText>
122 </div>
123 )}
124 </section>
125
126 {!space.is_default && (
127 <section className="mt-12 rounded-xl border border-danger/30 p-5">
128 <h2 className="text-sm font-semibold">Archive this space</h2>
129 <p className="mt-1 text-xs leading-relaxed text-muted">It leaves the sidebar and search. Its pages, history and comments are kept.</p>
130 <Button type="button" variant="danger" onClick={() => setArchiving(true)} className="mt-3">
131 <Archive size={14} /> Archive space
132 </Button>
133 </section>
134 )}
135 <AlertDialog open={archiving} onOpenChange={setArchiving}>
136 <AlertDialogContent>
137 <AlertDialogHeader>
138 <AlertDialogTitle>Archive {space.name}?</AlertDialogTitle>
139 <AlertDialogDescription>Nobody will see it in Docs until it is brought back.</AlertDialogDescription>
140 </AlertDialogHeader>
141 <AlertDialogFooter>
142 <AlertDialogCancel>Keep it</AlertDialogCancel>
143 <AlertDialogAction
144 onClick={async () => {
145 const done = await settings.send("update_space", { space_id: space.id, space_change: { archived: true } });
146 if (done.ok) navigate(`/${slug}/-/docs`);
147 }}
148 >
149 Archive
150 </AlertDialogAction>
151 </AlertDialogFooter>
152 </AlertDialogContent>
153 </AlertDialog>
154 </div>
155 );
156}
157
158/** A person's member key (`user:<id>`), from their username, as the site resolves it. */
159async function memberKeyFor(slug: string, username: string): Promise<string | null> {
160 try {
161 const r = await fetch(`/${slug}/-/docs/api?person=${encodeURIComponent(username)}`, { headers: { accept: "application/json" } });
162 const result = (await r.json()) as { ok: boolean; value?: string };
163 return result.ok && result.value ? result.value : null;
164 } catch {
165 return null;
166 }
167}