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.

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