loopable / web /src /shell /ShareDialog.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
f546440 verified
Raw
History Blame
17.1 kB
// ---------------------------------------------------------------------------
// 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 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",
};
/** 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<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]);
// β›” 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 (
<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>
);
}