Skip to content
325 linesCodeBlameRaw
1/**
2 * Share an artifact (docs.g1t.sh/guides/artifacts/, "Sharing"): invite people,
3 * agents and teams with a role, see everyone who has access and where it
4 * comes from, choose whether it follows its space or parent or only
5 * people invited, open it to the workspace or to anyone in it with the
6 * link, and say how agents may change it. People who can't share see it
7 * read-only.
8 */
9import { DOC_AGENT_MODE_LABELS, DOC_ROLE_LABELS, FOLIO_GENERAL_ROLES, type DocRole, type Folio, type FolioAccessChange, type FolioAccessList, type FolioGeneralAccess, type FolioGeneralRole } from "@g1t/contracts";
10import { Check, Globe, Link2, Lock, Users } from "lucide-react";
11import { type ReactNode, useEffect, useMemo, useState } from "react";
12import { Link } from "react-router";
13
14import { spacePath } from "../../lib/folios";
15import { ErrorText } from "../ui";
16import { Button } from "../ui/button";
17import { Combobox } from "../ui/combobox";
18import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog";
19import { Hint } from "../ui/hint";
20import { SelectField } from "../ui/select";
21import { Skeleton } from "../ui/skeleton";
22import { foliosQuery, foliosRequest, useFoliosData } from "./actions";
23import { Face } from "./parts";
24
25const ROLES: DocRole[] = ["view", "comment", "edit", "manage"];
26
27type Candidate = { value: string; label: string; kind: "user" | "agent" | "team"; name: string };
28
29export function ShareDialog({ slug, folio, open, onOpenChange, onChanged }: { slug: string; folio: Pick<Folio, "id" | "title" | "path" | "space" | "parent_id" | "viewer_role">; open: boolean; onOpenChange: (o: boolean) => void; onChanged?: () => void }) {
30 const layout = useFoliosData();
31 const [access, setAccess] = useState<FolioAccessList | null>(null);
32 const [teams, setTeams] = useState<{ slug: string; name: string }[]>([]);
33 const [error, setError] = useState<string | null>(null);
34 const [saved, setSaved] = useState(false);
35 const [busy, setBusy] = useState(false);
36 const [who, setWho] = useState("");
37 const [role, setRole] = useState<DocRole>("edit");
38 const [note, setNote] = useState("");
39 const [copied, setCopied] = useState(false);
40
41 useEffect(() => {
42 if (!open) return;
43 setAccess(null);
44 setError(null);
45 setSaved(false);
46 void foliosQuery<FolioAccessList>(slug, { access: folio.id }).then((r) => (r.ok ? setAccess(r.value) : setError(r.error.message)));
47 void foliosQuery<{ slug: string; name: string }[]>(slug, { teams: "1" }).then((r) => r.ok && setTeams(r.value));
48 }, [open, slug, folio.id]);
49
50 const change = async (body: FolioAccessChange) => {
51 setBusy(true);
52 setError(null);
53 setSaved(false);
54 const done = await foliosRequest<FolioAccessList>(slug, "access", { folio_id: folio.id, access: body });
55 setBusy(false);
56 if (!done.ok) {
57 setError(done.error.message);
58 return false;
59 }
60 setAccess(done.value);
61 setSaved(true);
62 onChanged?.();
63 return true;
64 };
65
66 const manager = folio.viewer_role === "manage";
67 const canShare = !!access?.can_share;
68 const listed = new Set([...(access?.rows ?? []).map((r) => r.principal), access ? `user:${access.owner.id}` : ""]);
69 const candidates: Candidate[] = useMemo(
70 () => [
71 ...(layout?.mentionables ?? []).map((m): Candidate => (m.kind === "agent" ? { value: `agent:${m.id}`, label: `${m.display_name} (agent)`, kind: "agent", name: m.name } : { value: `username:${m.name}`, label: `${m.display_name} (@${m.name})`, kind: "user", name: m.name })),
72 ...teams.map((t): Candidate => ({ value: `team:${t.slug}`, label: `${t.name} (team)`, kind: "team", name: t.slug })),
73 ],
74 [layout?.mentionables, teams],
75 );
76 const usernameOf = new Map((access?.rows ?? []).filter((r) => r.profile.kind === "user").map((r) => [r.profile.name, r.principal]));
77 const offered = candidates.filter((c) => !listed.has(c.value) && !(c.kind === "user" && (usernameOf.has(c.name) || access?.owner.name === c.name)));
78
79 const invite = async () => {
80 if (!who) return;
81 const principal = who.startsWith("username:") ? await memberKey(slug, who.slice(9)) : who;
82 if (!principal) return setError("That person isn't in the workspace.");
83 if (await change({ op: "grant", principal, role, notify: note.trim() || null })) {
84 setWho("");
85 setNote("");
86 }
87 };
88
89 const root = !folio.parent_id || access?.inherit === false;
90 const general: FolioGeneralAccess = access?.general_access ?? "none";
91 const generalRole: FolioGeneralRole = access?.general_role ?? "view";
92 const roleOptions = ROLES.filter((r) => manager || r !== "manage").map((r) => ({ value: r, label: DOC_ROLE_LABELS[r] }));
93
94 return (
95 <Dialog open={open} onOpenChange={onOpenChange}>
96 <DialogContent className="max-w-xl">
97 <DialogHeader>
98 <DialogTitle className="truncate pr-6">Share “{folio.title || "Untitled"}”</DialogTitle>
99 <DialogDescription>{canShare ? "People you add get a notification with a link." : `Only people with full access can change who can open it. Ask ${access?.owner.display_name ?? "its owner"} to share it.`}</DialogDescription>
100 </DialogHeader>
101
102 {canShare && (
103 <div className="space-y-2">
104 <div className="flex flex-wrap gap-2">
105 <Combobox options={offered.map((c) => ({ value: c.value, label: c.label }))} value={who} onValueChange={setWho} placeholder="Add people, agents or teams" searchPlaceholder="Search the workspace" emptyText="Nobody else to add." className="h-9 min-w-0 grow basis-56" />
106 <SelectField aria-label="Their role" value={role} onValueChange={(r) => setRole(r as DocRole)} options={roleOptions} className="h-9 w-36" />
107 </div>
108 {who && (
109 <div className="flex gap-2">
110 <input aria-label="Message" placeholder="Add a message (optional)" value={note} onChange={(e) => setNote(e.target.value)} maxLength={2000} className="h-9 min-w-0 grow rounded-md border border-line bg-bg px-3 text-sm outline-none focus:border-accent/60" />
111 <Button type="button" variant="accent" disabled={busy} onClick={invite}>
112 Invite
113 </Button>
114 </div>
115 )}
116 </div>
117 )}
118
119 <section className="mt-2">
120 <h3 className="mb-1.5 flex items-center gap-2 text-xs font-medium text-faint">
121 Who has access
122 {saved && (
123 <span className="inline-flex items-center gap-1 text-success">
124 <Check size={12} /> Updated
125 </span>
126 )}
127 </h3>
128 {!access ? (
129 <div className="space-y-2 py-1" aria-busy="true">
130 <Skeleton className="h-6 w-56" />
131 <Skeleton className="h-6 w-44" />
132 </div>
133 ) : (
134 <ul className="max-h-64 space-y-0.5 overflow-y-auto [scrollbar-width:thin]">
135 <Row face={<Face who={access.owner} size={26} />} name={access.owner.display_name} sub={`@${access.owner.name}`} end={<span className="text-xs text-faint">Owner</span>} />
136 {access.rows.map((r) => {
137 const face = r.profile.kind === "team" ? <TeamFace /> : <Face who={r.profile} size={26} />;
138 const sub = r.profile.kind === "team" ? "Team" : r.profile.kind === "agent" ? "Agent" : `@${r.profile.name}`;
139 if (r.source.kind === "grant") {
140 const editable = canShare && (manager || r.role !== "manage");
141 return (
142 <Row
143 key={r.principal}
144 face={face}
145 name={r.profile.display_name}
146 sub={sub}
147 end={
148 editable ? (
149 <SelectField
150 aria-label={`${r.profile.display_name}'s access`}
151 value={r.role}
152 onValueChange={(v) => (v === "remove" ? change({ op: "revoke", principal: r.principal }) : change({ op: "grant", principal: r.principal, role: v as DocRole }))}
153 options={[...roleOptions, { value: "remove", label: "Remove access" }]}
154 className="h-8 w-36"
155 />
156 ) : (
157 <span className="text-xs text-muted">{DOC_ROLE_LABELS[r.role]}</span>
158 )
159 }
160 />
161 );
162 }
163 const from = r.source;
164 return (
165 <Row
166 key={r.principal}
167 face={face}
168 name={r.profile.display_name}
169 sub={
170 <>
171 From{" "}
172 <Link to={from.kind === "folio" ? from.path : `/${slug}/-/artifacts`} onClick={() => onOpenChange(false)} className="text-muted hover:text-fg hover:underline">
173 {from.kind === "folio" ? from.title : from.kind === "space" ? from.name : "its owner"}
174 </Link>
175 </>
176 }
177 end={<span className="text-xs text-muted">{DOC_ROLE_LABELS[r.role]}</span>}
178 />
179 );
180 })}
181 </ul>
182 )}
183 </section>
184
185 {access && (
186 <section className="mt-2 space-y-3 border-t border-line pt-4">
187 <h3 className="text-xs font-medium text-faint">General access</h3>
188 {(folio.space || folio.parent_id) && (
189 <div className="flex items-start gap-3">
190 <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-raised text-muted">{access.inherit ? <Users size={15} /> : <Lock size={15} />}</span>
191 <div className="min-w-0 grow">
192 <SelectField
193 aria-label="Follows its space or parent"
194 disabled={!manager || busy}
195 value={access.inherit ? "inherit" : "restrict"}
196 onValueChange={(v) => change({ op: "inherit", inherit: v === "inherit" })}
197 options={[
198 { value: "inherit", label: access.inherited_from ? `Everyone in ${access.inherited_from.name}` : folio.space ? `Everyone in ${folio.space.name}` : "Follows the doc it's in" },
199 { value: "restrict", label: "Only people invited" },
200 ]}
201 className="h-8 w-full max-w-xs"
202 />
203 <p className="mt-1 text-xs text-faint">
204 {access.inherit ? "Whoever can open " : "Access stops here: whoever can open "}
205 {folio.parent_id ? "the doc it's in" : folio.space ? <Link to={spacePath(slug, folio.space.slug)} className="hover:text-fg hover:underline">{folio.space.name}</Link> : "its space"}
206 {access.inherit ? " can open this, with the same role." : " can't open this unless invited."}
207 </p>
208 </div>
209 </div>
210 )}
211 {root ? (
212 <div className="flex items-start gap-3">
213 <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-raised text-muted">{general === "workspace" ? <Globe size={15} /> : general === "link" ? <Link2 size={15} /> : <Lock size={15} />}</span>
214 <div className="flex min-w-0 grow flex-wrap items-center gap-2">
215 <SelectField
216 aria-label="General access"
217 disabled={!manager || busy}
218 value={general}
219 onValueChange={(v) => change(v === "none" ? { op: "general", access: "none", role: null } : { op: "general", access: v as FolioGeneralAccess, role: generalRole })}
220 options={[
221 { value: "none", label: "Restricted" },
222 { value: "workspace", label: `Everyone in ${layout?.slug ?? "the workspace"}` },
223 { value: "link", label: "Anyone in the workspace with the link" },
224 ]}
225 className="h-8 min-w-0 grow basis-56"
226 />
227 {general !== "none" && (
228 <SelectField
229 aria-label="Their role"
230 disabled={!manager || busy}
231 value={generalRole}
232 onValueChange={(v) => change({ op: "general", access: general, role: v as FolioGeneralRole })}
233 options={FOLIO_GENERAL_ROLES.map((r) => ({ value: r, label: DOC_ROLE_LABELS[r] }))}
234 className="h-8 w-36"
235 />
236 )}
237 <p className="w-full text-xs text-faint">
238 {general === "none" ? "Only the people above can open it." : general === "workspace" ? "Every member can find and open it." : "Members who open the link can. It isn't listed or searchable until they do."}
239 </p>
240 </div>
241 </div>
242 ) : (
243 <p className="text-xs text-faint">It follows the doc it&apos;s in. Change who else can open it there, or choose &ldquo;Only people invited&rdquo;.</p>
244 )}
245 <div className="flex items-center gap-3">
246 <span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-raised text-faint">
247 <Globe size={15} />
248 </span>
249 <Hint label="Links for people outside the workspace aren't available yet.">
250 <span className="text-sm text-faint" tabIndex={0}>
251 Public link: off for this workspace
252 </span>
253 </Hint>
254 </div>
255 <div className="flex flex-wrap items-center gap-2">
256 <span className="text-sm text-muted">Agents may</span>
257 <SelectField
258 aria-label="How agents change it"
259 disabled={!manager || busy}
260 value={access.agent_mode ?? "default"}
261 onValueChange={(v) => change({ op: "agent_mode", agent_mode: v === "default" ? null : (v as "suggest" | "edit") })}
262 options={[
263 { value: "default", label: folio.space ? `As ${folio.space.name} says` : "Suggest changes (default)" },
264 { value: "suggest", label: DOC_AGENT_MODE_LABELS.suggest },
265 { value: "edit", label: DOC_AGENT_MODE_LABELS.edit },
266 ]}
267 className="h-8 w-52"
268 />
269 </div>
270 </section>
271 )}
272
273 {error && <ErrorText>{error}</ErrorText>}
274
275 <div className="mt-2 flex items-center justify-between gap-2 border-t border-line pt-4">
276 <Button
277 type="button"
278 variant="outline"
279 onClick={async () => {
280 try {
281 await navigator.clipboard.writeText(`${window.location.origin}${folio.path}`);
282 setCopied(true);
283 setTimeout(() => setCopied(false), 1600);
284 } catch {
285 setError("Your browser didn't allow copying.");
286 }
287 }}
288 >
289 {copied ? <Check size={14} /> : <Link2 size={14} />} {copied ? "Copied" : "Copy link"}
290 </Button>
291 <Button type="button" variant="accent" onClick={() => onOpenChange(false)}>
292 Done
293 </Button>
294 </div>
295 </DialogContent>
296 </Dialog>
297 );
298}
299
300function Row({ face, name, sub, end }: { face: ReactNode; name: string; sub: ReactNode; end: ReactNode }) {
301 return (
302 <li className="flex items-center gap-3 rounded-md px-1 py-1.5">
303 {face}
304 <span className="min-w-0 grow">
305 <span className="block truncate text-sm text-fg">{name}</span>
306 <span className="block truncate text-xs text-faint">{sub}</span>
307 </span>
308 {end}
309 </li>
310 );
311}
312
313function TeamFace() {
314 return (
315 <span className="flex size-[26px] shrink-0 items-center justify-center rounded-full bg-raised text-muted">
316 <Users size={13} />
317 </span>
318 );
319}
320
321/** A person's member key (`user:<id>`), from their username, as the site resolves it. */
322export async function memberKey(slug: string, username: string): Promise<string | null> {
323 const found = await foliosQuery<string>(slug, { person: username });
324 return found.ok ? found.value : null;
325}