| 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 | */ |
| 8 | import { |
| 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"; |
| 18 | import { Check, Globe, Link2, Lock, Users } from "lucide-react"; |
| 19 | import { type ReactNode, useEffect, useMemo, useState } from "react"; |
| 20 | import { Link } from "react-router"; |
| 21 | |
| 22 | import { spacePath } from "../../lib/folios"; |
| 23 | import { ErrorText } from "../ui"; |
| 24 | import { Button } from "../ui/button"; |
| 25 | import { Combobox } from "../ui/combobox"; |
| 26 | import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog"; |
| 27 | import { Hint } from "../ui/hint"; |
| 28 | import { SelectField } from "../ui/select"; |
| 29 | import { Skeleton } from "../ui/skeleton"; |
| 30 | import { foliosQuery, foliosRequest, useFoliosData } from "./actions"; |
| 31 | import { Face } from "./parts"; |
| 32 | |
| 33 | const ROLES: DocRole[] = ["view", "comment", "edit", "manage"]; |
| 34 | |
| 35 | type Candidate = { value: string; label: string; kind: "user" | "team"; name: string }; |
| 36 | |
| 37 | export 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's in. Change who else can open it there, or choose “Only people invited”. |
| 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 | |
| 368 | function 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 | |
| 381 | function 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. */ |
| 390 | export 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 | } |