// --------------------------------------------------------------------------- // shell/ShareDialog.tsx — WAVE 20 items 18 / 23 / 26 (R10, contract C-SHARE): // ONE dialog that shares a view, a folder or a database, and edits who already // has it. // // ONE dialog for three kinds is the ruling, not a convenience: R10 says folders // and databases share "with the SAME two-role vocabulary views use". Three // dialogs would drift into three vocabularies within a wave. // // It renders over the ALREADY-LOADED state and decides nothing itself — every // rule (what a junk entry means, who may administer, what the PUT carries) is in // `shareModel.ts`, where the access gate can run it. // --------------------------------------------------------------------------- import { useCallback, useEffect, useState } from "react"; import { API_V1, CREDENTIALS } from "../apiContract"; import { SHARE_ROLES, addablePeople, parseShare, sharePutBody, shareSummary, withEntry, withoutEntry, } from "./shareModel"; import type { ShareEntry, ShareKind, ShareRole, ShareState } from "./shareModel"; const KIND_WORD: Record = { view: "view", folder: "folder", database: "database", }; /** What each role MEANS on each kind, in the reader's own terms. A role picker * whose options are two nouns makes the reader guess; these are the sentences * the view rail already uses ("Anyone who can see this table can change it"), * extended to the two new kinds rather than re-invented for them. */ const ROLE_BLURB: Record> = { view: { view: "Can open this view. Cannot rename, refilter or delete it.", edit: "Can change this view's filters, sorts and columns.", }, folder: { view: "Can open the folder and the views inside it.", edit: "Can rename the folder and move views in and out of it.", }, database: { view: "Can open this database and read its records.", edit: "Can add, edit and delete its records.", }, }; export default function ShareDialog({ kind, id, label, me, onClose, onToast, }: { kind: ShareKind; id: string; label: string; /** The signed-in account's username, so the reader recognises themselves in the * list. `people` is deliberately the OTHER accounts (it is the add-picker's * source), so without this the owner row prints a raw login where every other * row prints a name. */ me: string; onClose: () => void; onToast: (message: string) => void; }) { const [state, setState] = useState(null); const [entries, setEntries] = useState([]); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); const [pick, setPick] = useState(""); const [pickRole, setPickRole] = useState("view"); const path = `${API_V1}/share/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`; useEffect(() => { let dead = false; setError(""); void (async () => { try { const res = await fetch(path, { credentials: CREDENTIALS }); if (!res.ok) { // 4xx text is policy the reader needs; a 5xx's internals are not theirs. if (!dead) setError(res.status >= 500 ? "Something went wrong on our side. Try again in a moment." : `The server answered ${res.status}.`); return; } const body = (await res.json().catch(() => null)) as unknown; if (dead) return; const parsed = parseShare(body); setState(parsed); setEntries(parsed.entries); } catch { if (!dead) setError("Cannot reach the server."); } })(); return () => { dead = true; }; }, [path]); // ⛔ ESCAPE CLOSES A MODAL, or its scrim becomes a trap — the wave-18 lesson this // shell already carries at its other two dialogs. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !busy) onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [busy, onClose]); const save = useCallback( async (next: ShareEntry[]) => { setBusy(true); setError(""); try { const res = await fetch(path, { method: "PUT", credentials: CREDENTIALS, headers: { "Content-Type": "application/json" }, // The WHOLE list, every time: the PUT replaces, so a body assembled from // a delta would revoke everyone it failed to mention. body: JSON.stringify(sharePutBody(next)), }); if (!res.ok) { const body = (await res.json().catch(() => null)) as | { error?: { message?: string } } | null; setError( res.status >= 500 ? "Something went wrong on our side. Try again in a moment." : body?.error?.message || `The server answered ${res.status}.` ); return false; } const body = (await res.json().catch(() => null)) as unknown; // Re-read the SERVER's copy rather than trusting the draft: `_clean_entries` // drops what it will not store, and an editor that kept showing a row the // store rejected would be the "shared, silently inert" failure this feature // exists to avoid. // ⚠ Only `.entries` is consumed here — the PUT answers with the stored record // (`{owner, entries}`) and says nothing about this session's standing, so the // literal below feeds the parser's required field and is never read back. The // editor's `mayAdminister` stays the one the GET established; a save cannot // promote anybody, and this line must never be the reason it looks like it can. const saved = parseShare({ ...(body as object), mayAdminister: true }); setEntries(saved.entries); return true; } catch { setError("Cannot reach the server."); return false; } finally { setBusy(false); } }, [path] ); const mayAdminister = !!state?.mayAdminister; const options = state ? addablePeople(state.people, entries) : []; const nameOf = (user: string) => user === "*" ? "Everyone" : user && user === me.trim().toLowerCase() ? "You" : state?.people.find((p) => p.user === user)?.name ?? user; return (
(busy ? null : onClose())}>
e.stopPropagation()} >

Share {KIND_WORD[kind]}

{label} — who can reach it, and what they can do with it. Sharing never widens past this workspace: everyone here can already open the surface it lives on.

{!state && !error ? (
) : null} {state ? ( <> {/* THE MANAGE-ACCESS EDITOR (item 23): the list first, because the question people open this for is "who has this already" — with the one-line answer above it, since the fact that changes everything ("Everyone can edit") is the one a list of rows buries. */}

{shareSummary(entries)}

{state.owner ? (
{nameOf(state.owner)} Owner
) : null} {entries.length === 0 ? (
Not shared with anyone yet.
) : null} {entries.map((e) => (
{nameOf(e.user)}
))}
{mayAdminister ? (
whose value matches nothing renders its first option while holding "", so the box would read as a chosen person and the button beside it would grant somebody nobody picked ([[cg-condition-builder-items]]). */} {options.map((p) => ( ))}

{ROLE_BLURB[kind][pickRole]}

) : ( // ⛔ NOT A HIDDEN EDITOR — a stated refusal. A collaborator who can // change this object's CONTENT still cannot change who else reaches // it (the server's rule; this is the courtesy half). Saying why beats // greying three controls and letting the reader guess.

Only the owner of this {KIND_WORD[kind]} — or an administrator — can change who it is shared with. You can still use it as your role allows.

)} ) : null} {error ?

{error}

: null}
); }