loopable / web /src /shell /ShareDialog.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
bf8519f verified
Raw
History Blame Contribute Delete
13 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 {
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,
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) {
// 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>
<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>
);
}