File size: 12,966 Bytes
bf8519f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 | // ---------------------------------------------------------------------------
// 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>
);
}
|