| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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<ShareKind, string> = { |
| view: "view", |
| folder: "folder", |
| database: "database", |
| }; |
|
|
| |
| |
| |
| |
| const ROLE_BLURB: Record<ShareKind, Record<ShareRole, string>> = { |
| 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<ShareState | null>(null); |
| const [entries, setEntries] = useState<ShareEntry[]>([]); |
| const [error, setError] = useState(""); |
| const [busy, setBusy] = useState(false); |
| const [pick, setPick] = useState(""); |
| const [pickRole, setPickRole] = useState<ShareRole>("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) { |
| |
| 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]); |
|
|
| |
| |
| 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" }, |
| |
| |
| 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; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 ( |
| <div className="shell-newdb-scrim" onClick={() => (busy ? null : onClose())}> |
| <div |
| className="shell-newdb shell-share" |
| role="dialog" |
| aria-label={`Share ${label}`} |
| onClick={(e) => e.stopPropagation()} |
| > |
| <h2>Share {KIND_WORD[kind]}</h2> |
| <p className="shell-newdb-sub"> |
| <strong>{label}</strong> — 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. |
| </p> |
| |
| {!state && !error ? ( |
| <div className="shell-share-wait"> |
| <span className="lp-spin" role="status" aria-label="Loading" /> |
| </div> |
| ) : 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. */} |
| <p className="shell-share-summary">{shareSummary(entries)}</p> |
| <div className="shell-share-list"> |
| {state.owner ? ( |
| <div className="shell-share-row is-owner"> |
| <span className="shell-share-who">{nameOf(state.owner)}</span> |
| <span className="shell-share-role">Owner</span> |
| </div> |
| ) : null} |
| {entries.length === 0 ? ( |
| <div className="shell-share-empty"> |
| Not shared with anyone yet. |
| </div> |
| ) : null} |
| {entries.map((e) => ( |
| <div className="shell-share-row" key={e.user}> |
| <span className="shell-share-who">{nameOf(e.user)}</span> |
| <select |
| className="shell-share-select" |
| value={e.role} |
| disabled={!mayAdminister || busy} |
| aria-label={`Role for ${nameOf(e.user)}`} |
| onChange={(ev) => { |
| const next = withEntry(entries, e.user, ev.target.value as ShareRole); |
| setEntries(next); |
| void save(next); |
| }} |
| > |
| {SHARE_ROLES.map((r) => ( |
| <option key={r} value={r}> |
| {r === "edit" ? "Can edit" : "Can view"} |
| </option> |
| ))} |
| </select> |
| <button |
| type="button" |
| className="shell-share-revoke" |
| disabled={!mayAdminister || busy} |
| onClick={() => { |
| const next = withoutEntry(entries, e.user); |
| setEntries(next); |
| void save(next).then((ok) => { |
| if (ok) onToast(`${nameOf(e.user)} no longer has this ${KIND_WORD[kind]}.`); |
| }); |
| }} |
| > |
| Remove |
| </button> |
| </div> |
| ))} |
| </div> |
| |
| {mayAdminister ? ( |
| <div className="shell-share-add"> |
| <select |
| className="shell-share-select" |
| value={pick} |
| disabled={busy} |
| aria-label="Who to share with" |
| onChange={(e) => setPick(e.target.value)} |
| > |
| {/* ⚠ An explicit placeholder OPTION, not a blank first row: a |
| <select> 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]]). */} |
| <option value="">Choose a person…</option> |
| <option value="*">Everyone in this workspace</option> |
| {options.map((p) => ( |
| <option key={p.user} value={p.user}> |
| {p.name} |
| </option> |
| ))} |
| </select> |
| <select |
| className="shell-share-select" |
| value={pickRole} |
| disabled={busy} |
| aria-label="Role for the person being added" |
| onChange={(e) => setPickRole(e.target.value as ShareRole)} |
| > |
| {SHARE_ROLES.map((r) => ( |
| <option key={r} value={r}> |
| {r === "edit" ? "Can edit" : "Can view"} |
| </option> |
| ))} |
| </select> |
| <button |
| type="button" |
| className="login-submit shell-share-grant" |
| disabled={busy || !pick} |
| onClick={() => { |
| const next = withEntry(entries, pick, pickRole); |
| setEntries(next); |
| void save(next).then((ok) => { |
| if (ok) { |
| onToast(`${nameOf(pick)} can now ${pickRole} this ${KIND_WORD[kind]}.`); |
| setPick(""); |
| } |
| }); |
| }} |
| > |
| Share |
| </button> |
| <p className="shell-share-blurb">{ROLE_BLURB[kind][pickRole]}</p> |
| </div> |
| ) : ( |
| // ⛔ 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. |
| <p className="shell-share-note"> |
| 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. |
| </p> |
| )} |
| </> |
| ) : null} |
| |
| {error ? <p className="shell-newdb-err">{error}</p> : null} |
| <div className="shell-newdb-actions"> |
| <button type="button" className="login-submit" disabled={busy} onClick={onClose}> |
| Done |
| </button> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|