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