| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { useCallback, useEffect, useState } from "react"; |
| import { API_V1, CREDENTIALS } from "../apiContract"; |
| import PublishPanel from "../publish/PublishPanel"; |
| 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, |
| topic, |
| me, |
| onClose, |
| onToast, |
| }: { |
| kind: ShareKind; |
| id: string; |
| label: string; |
| /** |
| * β W33-T27 (item 8b, R5) β the DATABASE a shared VIEW belongs to, so the publish control can |
| * address it as `(topic, view)`. Empty for a folder or a database share, and empty is honest: |
| * the panel simply does not render, which is also what happens on a view the server refuses to |
| * publish. β Supplied by `Shell` at the mount (`shareModel.ShareRequest.topic`), not by this |
| * dialog β see that field's note for why it is optional and how the mount is asserted. |
| */ |
| topic?: 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) { |
| // 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]); |
|
|
| |
| |
| 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> |
| {/* ββ W33-T30 / audit S-8 β THIS SENTENCE WAS FALSE FOR ONE OF THE THREE KINDS. |
| It read, unconditionally, "Sharing never widens past this workspace: everyone here |
| can already open the surface it lives on." True for a VIEW or a FOLDER on a governed |
| module, where the receiver's row scope and hidden-field closure run before any |
| foreign view is merged. FALSE for a `database` grant on a `ut_*` table: no row filter |
| and no hidden field can even be DECLARED there (`_PERM_MODULES` is two keys and |
| `_clean_perms` 400s the rest), so the grant registry IS the only wall and the grant |
| is all-or-nothing. The audit's fix is "say so at BOTH doors"; `core/shares.py` and |
| `routes_shares.py` are the other two, and this is the one a PERSON reads. */} |
| <p className="shell-newdb-sub"> |
| <strong>{label}</strong> β who can reach it, and what they can do with it.{" "} |
| {kind === "database" |
| ? "Sharing a database shares all of it: every record and every column. There is no per-row or per-column limit on a database grant." |
| : "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> |
| {/* β W33-T30 / audit S-5 β `*` + `edit` IS A TENANT-WIDE WRITE GRANT AND LOOKED |
| LIKE ANY OTHER ROW. Two ordinary choices β a population and a role, each |
| unremarkable alone β combine into "anybody here may change this", and the |
| dialog said nothing at the point where the two meet. The audit's fix is |
| "make the population legible at the point of choice": one sentence, only on |
| the combination, in the words of the consequence rather than of the setting. |
| β It is NOT a confirm step. The capability is real and deliberate; a person |
| choosing it on purpose should not be interrogated, only told what it means. */} |
| {/* β `shell-share-note`, the class the refusal below already uses, rather than a |
| new `shell-share-warn`: `index.css` is B's file this wave (contract C4), and a |
| new class with no rule renders as unstyled body text β which for a sentence |
| whose whole job is to be NOTICED is worse than reusing a quiet one. */} |
| {pick === "*" && pickRole === "edit" ? ( |
| <p className="shell-share-note"> |
| Everyone in this workspace will be able to change this {KIND_WORD[kind]} β |
| not just open it. |
| </p> |
| ) : null} |
| </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} |
| |
| {/* ββ W33-T27 (item 8b, R5) β PUBLISH, below the grant editor and inside the same |
| dialog, because this is where a person comes to ask "who can reach this?". |
| β IT IS NOT A FOURTH ROLE IN THE LIST ABOVE, and the panel says so in its own |
| words: a published link names nobody, creates no grant and is revoked by replacing |
| the link rather than by removing a person. Folding it into the entries list would |
| have made "the public" look like a grantee β which is precisely the conflation the |
| sharing audit's S-4 row is about (two systems already answer "is this shared"; this |
| is a third thing). |
| β ONLY FOR A VIEW, and only when the shell knew which database it belongs to. The |
| panel then asks the SERVER whether this particular view may be published and renders |
| nothing if not β so "absent on non-interface views" is one evaluator, not two. */} |
| {kind === "view" && topic ? <PublishPanel topic={topic} view={id} /> : 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> |
| ); |
| } |
|
|