| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { useCallback, useEffect, useRef, useState } from "react"; |
| import { FilterBuilderPanel, FieldsHidePanel } from "../filter-kit"; |
| import { |
| getUserPerms, |
| patchUser, |
| putUserPerms, |
| setPassword, |
| } from "./settingsApi"; |
| import type { AdminUser } from "./settingsApi"; |
| import type { CopyScope, PermsPayload, PermsRecord } from "./permsModel"; |
| import { |
| accessSummary, |
| copyDropped, |
| copyTargetRecord, |
| filterOf, |
| hiddenSet, |
| hideableKeys, |
| identityKey, |
| isDirty, |
| moduleSummary, |
| parsePermsPayload, |
| setAccess, |
| setFilter, |
| setHidden, |
| toPutBody, |
| toggleHidden, |
| } from "./permsModel"; |
|
|
| type Load = |
| | { phase: "loading" } |
| | { phase: "error"; message: string } |
| | { phase: "ready"; payload: PermsPayload }; |
|
|
| |
| type Ask = null | { kind: "save" } | { kind: "discard" }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| type CopyScopeUI = CopyScope & { label?: string }; |
|
|
| |
| interface CopyPlan { |
| user: AdminUser; |
| payload: PermsPayload; |
| |
| before: string; |
| |
| notes: string[]; |
| } |
|
|
| type Copy = |
| | { phase: "pick"; scope: CopyScopeUI; picked: ReadonlySet<string> } |
| | { phase: "preparing"; scope: CopyScopeUI } |
| | { |
| phase: "confirm"; |
| scope: CopyScopeUI; |
| plans: CopyPlan[]; |
| |
| refused: Array<{ username: string; why: string }>; |
| } |
| | { phase: "writing"; scope: CopyScopeUI }; |
|
|
| |
| |
| function nameList(items: readonly string[]): string { |
| if (items.length <= 1) return items[0] ?? ""; |
| return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`; |
| } |
|
|
| function scopeWords(scope: CopyScopeUI): string { |
| return scope.kind === "all" ? "this account's whole access" : `${scope.label} access`; |
| } |
|
|
| export function PermsEditor({ |
| user, |
| userOptions, |
| copyTargets, |
| onBack, |
| onSaved, |
| }: { |
| /** |
| * ⚠ THE WHOLE RECORD, not a username and a display name (wave 17 R7). The |
| * page edits the account as well as its access, so it needs `role` and |
| * `active` — and taking the record rather than five scalars means one place |
| * (the pane's `reload`) refreshes everything after a write. |
| */ |
| user: AdminUser; |
| /** The people a `user`-typed field may name, for the condition builder. Real |
| * accounts, never invented: an admin filtering on an owner who does not exist |
| * writes a rule that matches nothing and looks like it should match. |
| * ⚠ DISPLAY NAMES — the cells hold names. Not the copy targets below. */ |
| userOptions?: string[]; |
| /** Every OTHER account, as records, for C-PERMCOPY. |
| * ⚠ RECORDS, because a copy addresses an account by USERNAME and puts it in |
| * a URL. Handing this the display-name list would build a wrong path that |
| * 404s at best and writes to the wrong account at worst. */ |
| copyTargets?: readonly AdminUser[]; |
| onBack: () => void; |
| /** "The server took a write" — the list behind this page reloads. It does NOT |
| * close the page (R7: one page, several things to save on it). */ |
| onSaved: (message: string) => void; |
| }) { |
| const displayName = user.name || user.username; |
| const username = user.username; |
| |
| const [load, setLoad] = useState<Load>({ phase: "loading" }); |
| /** The record as the SERVER holds it — the baseline `isDirty` compares to. */ |
| const [saved, setSaved] = useState<PermsRecord>({}); |
| const [draft, setDraft] = useState<PermsRecord>({}); |
| const [ask, setAsk] = useState<Ask>(null); |
| const [copy, setCopy] = useState<Copy | null>(null); |
| const [busy, setBusy] = useState(false); |
| const [error, setError] = useState(""); |
| /** What just happened, said ON THIS PAGE. The pane's own notice renders in the |
| * accounts LIST, which the admin is no longer sent back to on save — so |
| * without this a successful write would land in an empty room. */ |
| const [said, setSaid] = useState(""); |
| |
| // --- the account half (R7, moved here from the accounts list's `EditUser`) -- |
| const [name, setName] = useState(user.name ?? ""); |
| const [role, setRole] = useState(user.role ?? "user"); |
| const [pw, setPw] = useState(""); |
| |
| useEffect(() => { |
| let dead = false; |
| setLoad({ phase: "loading" }); |
| void getUserPerms(username).then((r) => { |
| if (dead) return; |
| if (!r.ok) { |
| setLoad({ phase: "error", message: r.message }); |
| return; |
| } |
| const payload = parsePermsPayload(r.data); |
| if (!payload) { |
| // An unreadable payload is NOT an empty one. Rendering "no modules" |
| // here would invite an admin to save a record that grants nothing, |
| // over a record they never saw. |
| setLoad({ |
| phase: "error", |
| message: "The permission record could not be read. Close and try again.", |
| }); |
| return; |
| } |
| setSaved(payload.entries); |
| setDraft(payload.entries); |
| setLoad({ phase: "ready", payload }); |
| }); |
| return () => { |
| dead = true; |
| }; |
| }, [username]); |
| |
| const payload = load.phase === "ready" ? load.payload : null; |
| const dirty = payload ? isDirty(payload, saved, draft) : false; |
| /** |
| * ⚠ `payload.isAdmin` ALONE GOES STALE THE MOMENT THIS PAGE CAN CHANGE THE |
| * ROLE — which is new in wave 17. It is a snapshot taken by the perms GET on |
| * mount; promote the account to Admin from the card above and that GET is not |
| * repeated, so the "these rules are inert" banner would stay hidden over an |
| * account whose rules had just become inert. `user.role` is the fresher |
| * truth: the accounts list reloads after every write here, and the record |
| * comes back with it. Both are read, because either can be the newer one — |
| * and the failure this prevents is an admin restricting an administrator and |
| * walking away believing they did, which is the exact misreading the banner |
| * exists to stop. |
| * |
| * ⚠ NOT the local `role` state: that is the DRAFT, and an unsaved role change |
| * has not made anything inert yet. |
| */ |
| const subjectIsAdmin = user.role === "admin" || payload?.isAdmin === true; |
| /** |
| * ⚠ THE GUARD HAD TO WIDEN WITH THE PAGE. `dirty` describes the PERMISSION |
| * draft only, and it was the whole story while this file was only a |
| * permission editor. Merge the account form in and Escape would throw away a |
| * half-typed display name or a role change with no prompt and no undo — |
| * exactly the failure the capture-phase listener below exists to prevent, |
| * arriving through a second door. |
| * |
| * ⚠ A TYPED PASSWORD IS DELIBERATELY NOT COUNTED. It is a one-shot field with |
| * its own button, retyped in seconds, and prompting "discard changes?" because |
| * a password box has characters in it trains people to click through the |
| * prompt — which weakens the guard for the edits that actually cost something. |
| */ |
| const accountDirty = |
| name.trim() !== (user.name ?? "").trim() || role !== (user.role ?? "user"); |
| const anyDirty = dirty || accountDirty; |
| |
| const goBack = useCallback(() => { |
| if (anyDirty) setAsk({ kind: "discard" }); |
| else onBack(); |
| }, [anyDirty, onBack]); |
| |
| // ⚠ CAPTURE PHASE, ON PURPOSE. The settings modal binds its own window |
| // `keydown` and closes the WHOLE dialog on Escape — which, on this page, |
| // would throw away an admin's unsaved edits with no prompt and no undo. A |
| // capture-phase listener on the same target runs before that bubble-phase |
| // one, so `stopPropagation()` here actually stops it. |
| const dirtyRef = useRef(anyDirty); |
| dirtyRef.current = anyDirty; |
| const askRef = useRef(ask); |
| askRef.current = ask; |
| const copyRef = useRef(copy); |
| copyRef.current = copy; |
| useEffect(() => { |
| const onKey = (e: KeyboardEvent) => { |
| if (e.key !== "Escape") return; |
| // A copy in flight owns Escape first: it is the innermost thing open, and |
| // dismissing it must not also dismiss the page under it. |
| if (copyRef.current) { |
| e.stopPropagation(); |
| if (copyRef.current.phase !== "writing") setCopy(null); |
| return; |
| } |
| if (askRef.current) { |
| e.stopPropagation(); |
| setAsk(null); |
| return; |
| } |
| if (dirtyRef.current) { |
| e.stopPropagation(); |
| setAsk({ kind: "discard" }); |
| } |
| }; |
| window.addEventListener("keydown", onKey, true); |
| return () => window.removeEventListener("keydown", onKey, true); |
| }, []); |
| |
| // ⚠ THE CONFIRM MUST BE BROUGHT INTO VIEW, and this is not polish. Its scrim |
| // is scoped to THIS PANE rather than the viewport (a second full-screen scrim |
| // over a dialog that already has one reads as the application losing its |
| // place) — but the pane grows with the module list while `.set-body` is what |
| // scrolls, so a card centred in the PANE can sit hundreds of pixels above the |
| // visible area. The admin clicks Save at the bottom of a long page and |
| // nothing appears to happen. One module fits today; the second one does not. |
| const confirmRef = useRef<HTMLDivElement | null>(null); |
| useEffect(() => { |
| if (!ask && !copy) return; |
| confirmRef.current?.scrollIntoView({ block: "center" }); |
| // Focus follows the question, so Enter answers it and a screen reader is |
| // taken to the alert rather than left where the pointer was. |
| confirmRef.current?.querySelector<HTMLButtonElement>("button")?.focus(); |
| }, [ask, copy]); |
| |
| const save = useCallback(() => { |
| if (!payload) return; |
| setBusy(true); |
| setError(""); |
| const body = toPutBody(payload, draft); |
| void putUserPerms(username, body).then((r) => { |
| setBusy(false); |
| setAsk(null); |
| if (r.ok) { |
| setSaved(body.perms); |
| setSaid(`Saved access for ${username}.`); |
| onSaved(`Saved access for ${username}.`); |
| } else { |
| setError(r.message); |
| } |
| }); |
| }, [payload, draft, username, onSaved]); |
| |
| /** The account half's write. ⚠ THE PATCH STILL CARRIES NO `bus` (R1), and |
| * that is load-bearing rather than tidy: with the picker gone this form |
| * cannot edit the field, so a PATCH that still sent it would be a client |
| * echoing back a value it only read — and once the migration converts `bus` |
| * into a permanent filter and clears the field, that echo would RESURRECT |
| * it. A form writes what it edits and nothing else. */ |
| const applyAccount = useCallback( |
| (patch: Parameters<typeof patchUser>[1], said: string) => { |
| setBusy(true); |
| setError(""); |
| void patchUser(username, patch).then((r) => { |
| setBusy(false); |
| if (r.ok) { |
| setSaid(said); |
| onSaved(said); |
| } else { |
| setError(r.message); |
| } |
| }); |
| }, |
| [username, onSaved] |
| ); |
| |
| // --- C-PERMCOPY: prepare, then confirm, then write ------------------------ |
| |
| /** |
| * ⚠ THE TARGETS ARE FETCHED BEFORE THE CONFIRM, NOT AFTER IT. The contract |
| * asks for "a confirm that names the overwrite", and a confirm written from |
| * this page's knowledge alone can only name the SOURCE — it would say what is |
| * being copied and stay silent on what is being destroyed. Reading each |
| * target first lets the question state, per account, what that account's |
| * access says today and what else this write does to it. The payload fetched |
| * here is the same one the PUT is built from, so it costs nothing extra. |
| */ |
| const prepareCopy = useCallback( |
| async (scope: CopyScopeUI, picked: readonly string[]) => { |
| setCopy({ phase: "preparing", scope }); |
| const plans: CopyPlan[] = []; |
| const refused: Array<{ username: string; why: string }> = []; |
| for (const target of copyTargets ?? []) { |
| if (!picked.includes(target.username)) continue; |
| const r = await getUserPerms(target.username); |
| if (!r.ok) { |
| refused.push({ username: target.username, why: r.message }); |
| continue; |
| } |
| const p = parsePermsPayload(r.data); |
| if (!p) { |
| refused.push({ |
| username: target.username, |
| why: "its permission record could not be read", |
| }); |
| continue; |
| } |
| const notes: string[] = []; |
| // An admin bypasses `perms` entirely, so a copy onto one is stored and |
| // inert. Silently writing rules that do nothing is how an admin comes |
| // to believe an account is restricted when it is not. |
| if (p.isAdmin) { |
| notes.push( |
| "is an administrator — the rules will be stored but stay inert until the account is changed to Member" |
| ); |
| } |
| // ⚠ A COPY MIGRATES THE TARGET. An un-migrated record runs under the |
| // PREVIOUS wall; a PUT moves it to the per-module model, which changes |
| // what governs that account from that moment on. Fresh accounts are |
| // exactly the ones an admin reaches for the copy button over |
| // (`create_user` stamps no perms), so this is the common case, not the |
| // exotic one. |
| if (!p.migrated) { |
| notes.push( |
| "still uses the previous access model — this write moves it to the per-module model" |
| ); |
| } |
| // The same disclosure this page already makes for the account it has |
| // open. A whole-record replace drops what it does not name, and a copy |
| // does it to somebody the admin never opened. |
| if (p.orphanModules.length) { |
| notes.push( |
| `has rules for ${p.orphanModules.join(", ")}, which this deployment no longer offers — those are removed` |
| ); |
| } |
| // ⚠ DISCLOSED BEFORE THE WRITE, not only after it. `runCopy` also reports |
| // this, but a report arrives when the admin can no longer decide |
| // anything; the confirm is where the decision is. The payload needed to |
| // know it is already in hand, so there is no reason to make them find out |
| // the expensive way. |
| const cannot = copyDropped(p, saved, scope); |
| if (cannot.length) { |
| const named = cannot.map( |
| (k) => payload?.modules.find((m) => m.key === k)?.label ?? k |
| ); |
| notes.push( |
| `does not offer ${named.join(", ")}, so that part of this copy cannot be written at all` |
| ); |
| } |
| plans.push({ |
| user: target, |
| payload: p, |
| before: |
| scope.kind === "all" |
| ? accessSummary(p, p.entries) || "No access" |
| : moduleSummary( |
| p.entries[scope.key], |
| (p.modules.find((m) => m.key === scope.key)?.fields.length ?? 0) === 0 |
| ), |
| notes, |
| }); |
| } |
| setCopy({ phase: "confirm", scope, plans, refused }); |
| }, |
| // `saved` and `payload`: the drop disclosure is computed against the record |
| // as STORED (what a copy actually sends) and labelled from this account's |
| // own module list, which is the only place a label for an undeclared-on-the- |
| // target module exists. |
| [copyTargets, saved, payload] |
| ); |
| |
| const runCopy = useCallback( |
| async (scope: CopyScopeUI, plans: readonly CopyPlan[]) => { |
| setCopy({ phase: "writing", scope }); |
| setError(""); |
| const done: string[] = []; |
| const failed: string[] = []; |
| const dropped = new Set<string>(); |
| for (const plan of plans) { |
| // ⚠ THE COMPOSITION LIVES IN `permsModel`, NOT HERE — it is the second |
| // producer of a PUT body in the product and the first aimed at somebody |
| // else's record, so it belongs where the gate can reach it and where its |
| // negative control can prove it capable of going red. See |
| // `copyTargetRecord`'s own note for the whole-tree rule it enforces. |
| const merged = copyTargetRecord(plan.payload, saved, scope); |
| for (const k of copyDropped(plan.payload, saved, scope)) dropped.add(k); |
| const r = await putUserPerms(plan.user.username, toPutBody(plan.payload, merged)); |
| if (r.ok) done.push(plan.user.username); |
| else failed.push(`${plan.user.username} (${r.message})`); |
| } |
| setCopy(null); |
| // ⚠ ONE MESSAGE PER OUTCOME, NEVER ONE MESSAGE FOR THE BATCH. Three |
| // targets with the second refused, reported as "that change could not be |
| // saved", is a sentence that lies about the two that took it — and the |
| // admin's only recovery is to re-copy onto accounts that already have it. |
| if (done.length) { |
| const msg = `Copied ${scopeWords(scope)} to ${nameList(done)}.`; |
| setSaid(msg); |
| onSaved(msg); |
| } |
| // ⚠ ONE MESSAGE, BUILT FROM WHAT ACTUALLY WENT WRONG — the refusals and |
| // the modules that could not ride the write are different failures and |
| // both have to survive into it. "Copied" alone over a dropped module would |
| // be a success message for a write that did not fully happen. |
| const problems: string[] = []; |
| if (failed.length) problems.push(`Not copied to ${nameList(failed)}.`); |
| if (dropped.size) { |
| const list = nameList([...dropped].sort()); |
| problems.push( |
| `${list} could not be copied — the accounts you picked do not offer ${dropped.size === 1 ? "it" : "them"}.` |
| ); |
| } |
| if (problems.length) setError(problems.join(" ")); |
| }, |
| [saved, onSaved] |
| ); |
| |
| const canCopy = (copyTargets?.length ?? 0) > 0 && !!payload && !dirty; |
| const openCopy = useCallback((scope: CopyScopeUI) => { |
| setAsk(null); |
| setError(""); |
| setSaid(""); |
| setCopy({ phase: "pick", scope, picked: new Set<string>() }); |
| }, []); |
| |
| return ( |
| <div className="set-pane set-perms"> |
| <div className="set-perm-head"> |
| <button type="button" className="set-secondary" onClick={goBack}> |
| Back to accounts |
| </button> |
| <div className="set-perm-who"> |
| <h3 className="set-h set-perm-title">{displayName}</h3> |
| <span className="set-username">{username}</span> |
| </div> |
| <span className={role === "admin" ? "set-chip set-chip--admin" : "set-chip"}> |
| {role === "admin" ? "Admin" : "Member"} |
| </span> |
| {user.active ? null : <span className="set-chip set-chip--off">Deactivated</span>} |
| </div> |
| |
| {error ? <p className="set-error">{error}</p> : null} |
| {said ? <p className="set-notice">{said}</p> : null} |
| |
| {/* ── the account (R7) ─────────────────────────────────────────────── */} |
| <section className="set-card"> |
| <h4 className="set-h4">Account</h4> |
| <div className="set-grid2"> |
| <div className="set-field"> |
| <label className="set-label" htmlFor="set-name">Display name</label> |
| <input |
| id="set-name" |
| className="set-input" |
| value={name} |
| onChange={(e) => setName(e.target.value)} |
| /> |
| </div> |
| <div className="set-field"> |
| <label className="set-label" htmlFor="set-role">Role</label> |
| <select |
| id="set-role" |
| className="pg-select" |
| value={role} |
| onChange={(e) => setRole(e.target.value)} |
| > |
| <option value="user">Member</option> |
| <option value="admin">Admin</option> |
| </select> |
| </div> |
| </div> |
| {/* An admin bypasses every rule below. Changing the role here therefore |
| changes whether this whole page means anything, and saying so at the |
| control beats discovering it from an access page that does nothing. */} |
| {role === "admin" && (user.role ?? "user") !== "admin" ? ( |
| <p className="set-help"> |
| Saving this makes the account an administrator, which bypasses every |
| access rule set below. |
| </p> |
| ) : null} |
| <div className="set-actions"> |
| <button |
| type="button" |
| className="set-primary" |
| disabled={busy || !accountDirty} |
| onClick={() => applyAccount({ name, role }, `Saved ${username}.`)} |
| > |
| Save account |
| </button> |
| <button |
| type="button" |
| className="set-secondary" |
| disabled={busy} |
| onClick={() => |
| applyAccount( |
| { active: !user.active }, |
| `${user.active ? "Deactivated" : "Reactivated"} ${username}.` |
| ) |
| } |
| > |
| {user.active ? "Deactivate account" : "Reactivate account"} |
| </button> |
| </div> |
| |
| {/* ⚠ THE SAME GRID as the two fields above it, so the password box is |
| exactly the width of the display-name box. Left full-bleed it |
| stretched the whole card while the field above it was half of one — |
| an accident of markup that reads as the two being different kinds of |
| thing. The warning moves alongside, which is also where it belongs: |
| beside the control it is about, not under it. */} |
| <div className="set-danger set-grid2"> |
| <div className="set-field"> |
| <label className="set-label" htmlFor="set-pw">Set a new password</label> |
| <input |
| id="set-pw" |
| className="set-input" |
| type="password" |
| value={pw} |
| autoComplete="new-password" |
| onChange={(e) => setPw(e.target.value)} |
| /> |
| </div> |
| <div className="set-pw-side"> |
| {/* Said plainly BEFORE the click. Resetting a password bumps the |
| session epoch, which signs the person out of every device they |
| are using — an admin should not discover that from a support |
| ticket. */} |
| <p className="set-help">This signs {username} out everywhere immediately.</p> |
| <button |
| type="button" |
| className="set-secondary" |
| disabled={busy || pw.length === 0} |
| onClick={() => { |
| setError(""); |
| void setPassword(username, pw).then((r) => { |
| if (r.ok) { |
| setPw(""); |
| setSaid(`Password reset; ${username} was signed out.`); |
| } else { |
| setError(r.message); |
| } |
| }); |
| }} |
| > |
| Reset password |
| </button> |
| </div> |
| </div> |
| </section> |
| |
| {/* wave17 item 3 (R6) — the mark, not "Loading access…". The way OUT is |
| rendered above this and stays rendered, which is what the loading state |
| actually owed the reader; the sentence never was. */} |
| {load.phase === "loading" ? ( |
| <div className="set-loading"> |
| <span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" /> |
| </div> |
| ) : null} |
| {load.phase === "error" ? <p className="set-error">{load.message}</p> : null} |
| |
| {payload ? ( |
| <> |
| {/* ⛔ AN ADMIN BYPASSES `perms` ENTIRELY (C-PERM amendment 4) — the |
| clause that also keeps break-glass alive during a store outage. So |
| every rule below is INERT for this account, and the page has to say |
| so: an editor that renders a filter and a hidden-field list without |
| a word is inviting somebody to "restrict" an administrator and walk |
| away believing they did. The controls stay usable on purpose — the |
| rules are real the moment the account stops being an admin. */} |
| {subjectIsAdmin ? ( |
| <p className="set-notice set-perm-banner"> |
| This is an administrator account. Administrators see everything, so |
| nothing set here applies to them — these rules take effect only if |
| the account is changed to Member. |
| </p> |
| ) : null} |
| |
| {/* C-PERM amendment 4 — an un-migrated record still runs under the |
| PREVIOUS wall, so its empty perms mean "not written yet", not "no |
| access". Saying so is the difference between an admin reading this |
| page correctly and one who thinks the account is locked out. */} |
| {!payload.migrated && !subjectIsAdmin ? ( |
| <p className="set-notice set-perm-banner"> |
| This account still uses the previous access model, so the rules below |
| are not in force yet. Saving moves it to the per-module model — set |
| every module deliberately before you do. |
| </p> |
| ) : null} |
| |
| {/* A whole-record replace drops what it does not name. An admin should |
| learn that here, not from a support ticket about a filter that |
| vanished. */} |
| {payload.orphanModules.length ? ( |
| <p className="set-error set-perm-banner"> |
| This account has rules for {payload.orphanModules.join(", ")}, which |
| this deployment no longer offers. Saving removes them. |
| </p> |
| ) : null} |
| |
| <div className="set-perm-sect"> |
| <h4 className="set-h4">Access</h4> |
| {copyTargets?.length ? ( |
| <button |
| type="button" |
| className="set-secondary set-copy-door" |
| disabled={!canCopy || busy} |
| title={ |
| dirty |
| ? "Save this account's access first — a copy sends what is stored, not what is on screen." |
| : undefined |
| } |
| onClick={() => openCopy({ kind: "all" })} |
| > |
| Apply this access to… |
| </button> |
| ) : null} |
| </div> |
| |
| <p className="set-help set-perm-intro"> |
| {accessSummary(payload, draft)}. Each module can be turned off entirely, |
| narrowed to the records a condition describes, or shown with fields |
| hidden. Restrictions apply everywhere the account looks — the tables, |
| the exports and every number computed from them. |
| </p> |
| |
| {/* ⚠ NOT A DISABLED BUTTON'S TOOLTIP ALONE. A control that is refused |
| has to say why where the eye already is; a `title` only answers the |
| reader who thought to hover. */} |
| {dirty && copyTargets?.length ? ( |
| <p className="set-help set-copy-why"> |
| Copying is unavailable while there are unsaved changes — a copy sends |
| what is stored, not what is on screen. |
| </p> |
| ) : null} |
| |
| {payload.modules.length === 0 ? ( |
| <p className="pg-empty"> |
| This deployment declares no modules that can be restricted. |
| </p> |
| ) : null} |
| |
| {payload.modules.map((m) => { |
| const entry = draft[m.key]; |
| const on = entry?.access === true; |
| const schemaless = m.fields.length === 0; |
| return ( |
| <section className="set-card set-perm-mod" key={m.key}> |
| <div className="set-perm-modhead"> |
| <label className="set-check set-perm-toggle"> |
| <input |
| type="checkbox" |
| checked={on} |
| onChange={(e) => setDraft((d) => setAccess(d, m.key, e.target.checked))} |
| /> |
| <span className="set-perm-modname">{m.label}</span> |
| </label> |
| <span className="set-perm-sum">{moduleSummary(entry, schemaless)}</span> |
| {copyTargets?.length ? ( |
| <button |
| type="button" |
| className="set-secondary set-copy-door" |
| disabled={!canCopy || busy} |
| onClick={() => openCopy({ kind: "module", key: m.key, label: m.label })} |
| > |
| Copy to… |
| </button> |
| ) : null} |
| </div> |
| |
| {/* R9's fail-closed rendering: no readable schema means the |
| access toggle and nothing else. The record it saves says the |
| same thing — no filter, no hidden fields — so the editor and |
| the payload cannot disagree (permsModel.toPutBody). */} |
| {on && schemaless ? ( |
| <p className="set-help"> |
| No field list is available for this module, so access is all this |
| editor can set for it. Conditions and hidden fields need a schema. |
| </p> |
| ) : null} |
| |
| {on && !schemaless ? ( |
| <div className="set-perm-panels"> |
| <div className="cg-pop set-perm-pop"> |
| <FilterBuilderPanel |
| fields={m.fields} |
| filters={filterOf(draft, m.key)} |
| onChange={(next) => setDraft((d) => setFilter(d, m.key, next))} |
| userOptions={userOptions} |
| /> |
| </div> |
| <div className="cg-pop set-perm-pop"> |
| <FieldsHidePanel |
| fields={m.fields} |
| hidden={hiddenSet(draft, m.key)} |
| onToggle={(key) => setDraft((d) => toggleHidden(d, m.key, key))} |
| // ⚠ `lockedKey` IS NOT OPTIONAL HERE, whatever the prop |
| // says. Absent, the panel locks nothing and one click on |
| // "Hide all" hides the row's own name too — a record |
| // whose faithful enforcement is a table of blank rows, |
| // which C-PERM's PUT validation would accept because the |
| // identity column is a perfectly KNOWN field key. |
| lockedKey={identityKey(m.fields)} |
| onHideAll={() => |
| setDraft((d) => setHidden(d, m.key, hideableKeys(m.fields))) |
| } |
| onShowAll={() => setDraft((d) => setHidden(d, m.key, []))} |
| /> |
| </div> |
| </div> |
| ) : null} |
| </section> |
| ); |
| })} |
| |
| <div className="set-actions set-perm-actions"> |
| <button |
| type="button" |
| className="set-primary" |
| disabled={busy || !dirty} |
| onClick={() => setAsk({ kind: "save" })} |
| > |
| Save access |
| </button> |
| <button type="button" className="set-secondary" disabled={busy} onClick={goBack}> |
| Cancel |
| </button> |
| {dirty ? <span className="set-help set-perm-dirty">Unsaved changes</span> : null} |
| </div> |
| </> |
| ) : null} |
| |
| {ask ? ( |
| <div className="set-confirm-wrap"> |
| <div |
| ref={confirmRef} |
| className="set-confirm" |
| role="alertdialog" |
| aria-modal="true" |
| aria-label={ask.kind === "save" ? "Confirm access change" : "Discard changes"} |
| > |
| {ask.kind === "save" ? ( |
| <> |
| <h4 className="set-h4">Change what {displayName} can see?</h4> |
| <p className="set-help"> |
| This takes effect the next time they load a page. {accessSummaryLine(payload, draft)} |
| </p> |
| </> |
| ) : ( |
| <> |
| <h4 className="set-h4">Discard these changes?</h4> |
| {/* ⚠ IT NAMES WHICH ONES. The page holds two kinds of unsaved |
| work now, and "your changes" over a form the reader cannot |
| see behind the scrim is a question they cannot answer. */} |
| <p className="set-help"> |
| {unsavedWords(dirty, accountDirty)} for {displayName} stay as they were. |
| </p> |
| </> |
| )} |
| <div className="set-actions"> |
| <button |
| type="button" |
| className="set-primary" |
| disabled={busy} |
| onClick={() => (ask.kind === "save" ? save() : (setAsk(null), onBack()))} |
| > |
| {ask.kind === "save" ? "Save access" : "Discard"} |
| </button> |
| <button |
| type="button" |
| className="set-secondary" |
| disabled={busy} |
| onClick={() => setAsk(null)} |
| > |
| Keep editing |
| </button> |
| </div> |
| </div> |
| </div> |
| ) : null} |
| |
| {copy ? ( |
| <div className="set-confirm-wrap"> |
| <div |
| ref={confirmRef} |
| className="set-confirm set-copy" |
| role="alertdialog" |
| aria-modal="true" |
| aria-label={`Copy ${scopeWords(copy.scope)}`} |
| > |
| {copy.phase === "pick" ? ( |
| <> |
| <h4 className="set-h4">Copy {scopeWords(copy.scope)} to…</h4> |
| <p className="set-help"> |
| {copy.scope.kind === "all" |
| ? `Every account you pick has its whole access record replaced with ${displayName}'s.` |
| : `Every account you pick has its ${copy.scope.label} access replaced with ${displayName}'s. Their other modules are left alone.`} |
| </p> |
| <div className="set-copy-list"> |
| {(copyTargets ?? []).map((t) => ( |
| <label className="set-check set-copy-row" key={t.username}> |
| <input |
| type="checkbox" |
| checked={copy.picked.has(t.username)} |
| onChange={(e) => { |
| const next = new Set(copy.picked); |
| if (e.target.checked) next.add(t.username); |
| else next.delete(t.username); |
| setCopy({ ...copy, picked: next }); |
| }} |
| /> |
| <span className="set-copy-name">{t.name || t.username}</span> |
| <span className="set-username set-copy-user">{t.username}</span> |
| {t.role === "admin" ? ( |
| <span className="set-chip set-chip--admin">Admin</span> |
| ) : null} |
| {t.active ? null : ( |
| <span className="set-chip set-chip--off">Deactivated</span> |
| )} |
| </label> |
| ))} |
| </div> |
| <div className="set-actions"> |
| <button |
| type="button" |
| className="set-primary" |
| disabled={copy.picked.size === 0} |
| onClick={() => void prepareCopy(copy.scope, [...copy.picked])} |
| > |
| Continue |
| </button> |
| <button type="button" className="set-secondary" onClick={() => setCopy(null)}> |
| Cancel |
| </button> |
| </div> |
| </> |
| ) : null} |
| |
| {copy.phase === "preparing" || copy.phase === "writing" ? ( |
| <div className="set-loading"> |
| <span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" /> |
| </div> |
| ) : null} |
| |
| {copy.phase === "confirm" ? ( |
| <> |
| <h4 className="set-h4"> |
| Replace {scopeWords(copy.scope)} for {copy.plans.length} account |
| {copy.plans.length === 1 ? "" : "s"}? |
| </h4> |
| {copy.plans.length === 0 ? ( |
| <p className="set-help"> |
| None of the accounts picked could be read, so there is nothing to write. |
| </p> |
| ) : ( |
| <ul className="set-copy-plans"> |
| {copy.plans.map((p) => ( |
| <li key={p.user.username}> |
| {/* ⚠ THE OVERWRITE, NAMED PER TARGET. What is destroyed |
| is what this account has TODAY, so the question shows |
| it — a confirm that only describes the source asks |
| the admin to remember the rest. */} |
| <span className="set-copy-name">{p.user.name || p.user.username}</span>{" "} |
| <span className="set-copy-before">now: {p.before}</span> |
| {p.notes.map((n) => ( |
| <span className="set-copy-note" key={n}> |
| {p.user.username} {n} |
| </span> |
| ))} |
| </li> |
| ))} |
| </ul> |
| )} |
| {copy.refused.length ? ( |
| <p className="set-error"> |
| {/* ⚠ The reason comes from the API and ends in a full stop |
| of its own ("Administrators only."), which read as |
| "(Administrators only.) could not be read" — a stray |
| period mid-sentence. Trimmed where the sentence is |
| built, not in the message the API returns. */} |
| {nameList( |
| copy.refused.map((r) => `${r.username} (${r.why.replace(/\.\s*$/, "")})`) |
| )}{" "} |
| could not be read and will not be changed. |
| </p> |
| ) : null} |
| <div className="set-actions"> |
| <button |
| type="button" |
| className="set-primary" |
| disabled={copy.plans.length === 0} |
| onClick={() => void runCopy(copy.scope, copy.plans)} |
| > |
| Replace access |
| </button> |
| <button type="button" className="set-secondary" onClick={() => setCopy(null)}> |
| Cancel |
| </button> |
| </div> |
| </> |
| ) : null} |
| </div> |
| </div> |
| ) : null} |
| </div> |
| ); |
| } |
| |
| /** The confirm's one-line restatement. Deliberately the SAME sentence the pane |
| * header shows: a confirm that summarises differently from the page behind it |
| * is asking the reader to reconcile two descriptions under time pressure. */ |
| function accessSummaryLine(payload: PermsPayload | null, draft: PermsRecord): string { |
| const s = accessSummary(payload, draft); |
| return s ? `${s} after this change.` : ""; |
| } |
| |
| /** Which kinds of unsaved work the discard question is about. Exported-shaped |
| * as a pure function so it reads once and cannot drift between the two places |
| * a reader meets it. */ |
| function unsavedWords(permsDirty: boolean, accountDirty: boolean): string { |
| if (permsDirty && accountDirty) return "The account details and access rules"; |
| if (accountDirty) return "The account details"; |
| return "The access rules"; |
| } |
| |