// --------------------------------------------------------------------------- // settings / PermsEditor.tsx — THE USER PAGE. (wave 15 R9: the permission // editor · wave 17 R7: everything else about the account, merged in.) // // "Manage users → click a user → the modal body BECOMES that user's page." A // full-pane takeover, not a drawer beside the list and not a third dialog: the // thing being edited is a whole account, and giving it a room of its own is // what makes room for a filter builder and a field list per module without // either being a 200px scroll-box. // // ⚠ WHY THE FILE NAME NO LONGER DESCRIBES THE FILE. Wave 17's R7, verbatim: // *"Edit and clicking a user name should just be the EXACT same opening of a // full page so its not confusing."* Before it there were two doors — the name // opened this page, "Edit" unfolded a card at the BOTTOM of the accounts list — // and which one an admin needed depended on a distinction only the code cared // about (who the account IS vs what it may SEE). Both jobs live here now. The // export keeps its name because renaming it would ripple through the shell, the // gate's module list and the render smoke for no user-visible gain — the same // trade R12 made for `SettingsModal`. // // ⛔ EVERY CONTROL HERE IS A COURTESY. `/admin/*` is refused to a member at the // server, fail-closed, and the wall that acts on what this page writes is // `core/perm_scope.permits()` — which re-validates the whole record. Nothing on // this page is a permission check; this page is where an admin SAYS what the // wall should be. // // THE PANELS ARE THE GRID'S OWN (contract C-KIT). Not lookalikes — the same // `FilterBuilderPanel` and `FieldsHidePanel` the toolbar mounts, from // `filter-kit/`, which is the entire reason that extraction happened before this // file existed. An admin writing "customers where dba is Fisch" uses the control // they already know, and there is one condition grammar in the product rather // than two that drift within a wave. // // ⚠ WHAT THIS EDITOR CANNOT OFFER, AND WHY IT IS RIGHT THAT IT DOES NOT: // · **Cohort conditions** — cohorts are a user's own workspace objects and an // admin editing someone else's account holds no list of them. The prop is // absent, so the affordance is absent (never present-and-inert). // · **`status` choice lists** — the grid discovers those from ROWS it has // loaded; this page has loaded none. Select/multiselect/user fields still // offer their declared options, so only Odoo lifecycle columns come up bare. // Both are C-KIT's documented absences, restated here because a reader of this // file will meet them as "why is that dropdown empty". // --------------------------------------------------------------------------- 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 }; /** What the confirm step is asking about. `null` = nothing is being confirmed. */ type Ask = null | { kind: "save" } | { kind: "discard" }; // --- C-PERMCOPY ------------------------------------------------------------- // // CLIENT-SIDE ONLY, by contract: `GET /admin/users/{u}/perms` per target, // compose, then `PUT` the WHOLE tree back. No new route. // // ⛔ THE WHOLE-TREE LAW IS WHY THE GET IS NOT OPTIONAL, and why the composition // itself lives in `permsModel.copyTargetRecord` rather than in this file: a PUT // replaces the record, a key omitted from it is a DELETION, and that failure // already has a named negative control on the OTHER producer of PUT bodies. // A second producer with no gate is the same bug waiting for a different road. /** The UI's scope: the model's `CopyScope` plus the label this page shows. */ type CopyScopeUI = CopyScope & { label?: string }; /** One target, already fetched, with everything the confirm has to disclose. */ interface CopyPlan { user: AdminUser; payload: PermsPayload; /** What that account's access says TODAY — the thing being overwritten. */ before: string; /** Consequences beyond the overwrite itself, named per target. */ notes: string[]; } type Copy = | { phase: "pick"; scope: CopyScopeUI; picked: ReadonlySet } | { phase: "preparing"; scope: CopyScopeUI } | { phase: "confirm"; scope: CopyScopeUI; plans: CopyPlan[]; /** Targets whose record could not be READ. Named, never dropped. */ refused: Array<{ username: string; why: string }>; } | { phase: "writing"; scope: CopyScopeUI }; /** "a", "a and b", "a, b and c" — a list a person reads, in a message that * names every account it touched. */ 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({ phase: "loading" }); /** The record as the SERVER holds it — the baseline `isDirty` compares to. */ const [saved, setSaved] = useState({}); const [draft, setDraft] = useState({}); const [ask, setAsk] = useState(null); const [copy, setCopy] = useState(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(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("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[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(); 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() }); }, []); return (

{displayName}

{username}
{role === "admin" ? "Admin" : "Member"} {user.active ? null : Deactivated}
{error ?

{error}

: null} {said ?

{said}

: null} {/* ── the account (R7) ─────────────────────────────────────────────── */}

Account

setName(e.target.value)} />
{/* 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" ? (

Saving this makes the account an administrator, which bypasses every access rule set below.

) : null}
{/* ⚠ 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. */}
setPw(e.target.value)} />
{/* 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. */}

This signs {username} out everywhere immediately.

{/* 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" ? (
) : null} {load.phase === "error" ?

{load.message}

: 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 ? (

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.

) : 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 ? (

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.

) : 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 ? (

This account has rules for {payload.orphanModules.join(", ")}, which this deployment no longer offers. Saving removes them.

) : null}

Access

{copyTargets?.length ? ( ) : null}

{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.

{/* ⚠ 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 ? (

Copying is unavailable while there are unsaved changes — a copy sends what is stored, not what is on screen.

) : null} {payload.modules.length === 0 ? (

This deployment declares no modules that can be restricted.

) : null} {payload.modules.map((m) => { const entry = draft[m.key]; const on = entry?.access === true; const schemaless = m.fields.length === 0; return (
{moduleSummary(entry, schemaless)} {copyTargets?.length ? ( ) : null}
{/* 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 ? (

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.

) : null} {on && !schemaless ? (
setDraft((d) => setFilter(d, m.key, next))} userOptions={userOptions} />
) : null}
); })}
{dirty ? Unsaved changes : null}
) : null} {ask ? (
{ask.kind === "save" ? ( <>

Change what {displayName} can see?

This takes effect the next time they load a page. {accessSummaryLine(payload, draft)}

) : ( <>

Discard these changes?

{/* ⚠ 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. */}

{unsavedWords(dirty, accountDirty)} for {displayName} stay as they were.

)}
) : null} {copy ? (
{copy.phase === "pick" ? ( <>

Copy {scopeWords(copy.scope)} to…

{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.`}

{(copyTargets ?? []).map((t) => ( ))}
) : null} {copy.phase === "preparing" || copy.phase === "writing" ? (
) : null} {copy.phase === "confirm" ? ( <>

Replace {scopeWords(copy.scope)} for {copy.plans.length} account {copy.plans.length === 1 ? "" : "s"}?

{copy.plans.length === 0 ? (

None of the accounts picked could be read, so there is nothing to write.

) : (
    {copy.plans.map((p) => (
  • {/* ⚠ 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. */} {p.user.name || p.user.username}{" "} now: {p.before} {p.notes.map((n) => ( {p.user.username} {n} ))}
  • ))}
)} {copy.refused.length ? (

{/* ⚠ 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.

) : null}
) : null}
) : null}
); } /** 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"; }