| // --------------------------------------------------------------------------- | |
| // settings / permsModel.ts β wave 15, contract C-PERM (ruling R9). | |
| // | |
| // The permission editor's PURE HALF: the wire shapes, the parse, the draft the | |
| // admin is editing, and the body the PUT sends. No React, no fetch, so the | |
| // rules that decide who can see what are testable under node without a browser | |
| // or a server (verify_login's `_test` harness runs them). | |
| // | |
| // β NOTHING HERE ENFORCES ANYTHING. Every rule below is about producing a | |
| // WELL-FORMED and HONEST payload; the wall is `core/perm_scope.permits()` on the | |
| // server, and it re-validates all of this. An editor that produced a malformed | |
| // record would be refused β the point of the care here is that it never gets | |
| // that far, and that the admin is never shown a state the server does not hold. | |
| // | |
| // THREE RULES THAT LOOK LIKE STYLE AND ARE NOT: | |
| // | |
| // 1. **A module with no readable schema renders as an ACCESS TOGGLE ONLY, and | |
| // saves `{access, filter: null, hiddenFields: []}`** (R9). The failure mode | |
| // it prevents: half a filter tree, PUT against a field list nobody could | |
| // read. Fail-closed is not "deny everything" here β it is "never write a | |
| // restriction you could not show the admin". | |
| // 2. **An unknown field TYPE lands on `text`**, it does not disappear and it | |
| // is not passed through. Passing it through would hand the condition | |
| // builder an operator set for a type it does not have; dropping the field | |
| // would take away an admin's ability to HIDE a column just because they | |
| // cannot filter on it. `text` is the house fallback the viz layer already | |
| // uses for exactly this (verify_ui's `field-vocabulary-not-whitelisted`). | |
| // 3. **A module the server no longer declares is DROPPED by the whole-record | |
| // replace β and the editor says so out loud** (`orphanModules`). A silent | |
| // drop of somebody's permanent filter is a permission change nobody made. | |
| // --------------------------------------------------------------------------- | |
| import type { Field, FieldType, FilterTree } from "../customer-grid/types"; | |
| // --- who may reach the editor at all ---------------------------------------- | |
| /** The modal's rail entries. Declared HERE, beside the rule that gates them, so | |
| * the component and the gate read one definition of the vocabulary rather than | |
| * two that can drift by one member. `SettingsModal` re-exports it. */ | |
| export type SettingsSection = | |
| | "account" | |
| | "scope" | |
| | "users" | |
| // Wave 18 (C7): the tenant's credential store and its data-source status board. | |
| | "keychains" | |
| | "connectors" | |
| /** EXIT-6: the statement-of-account sender, ported off `app.py` when Streamlit | |
| * was deleted. Admin-only for the same reason the Streamlit section was β | |
| * it is THE one sanctioned Odoo writer in the product. */ | |
| | "statements" | |
| /** | |
| * Wave 19 (R3 / contract C2): the Loopable admin plane β the cross-TENANT | |
| * console, visible only to a `platform_admin` account. | |
| * | |
| * β DELIBERATELY ABSENT FROM `reachableSection` BELOW, and that is not an | |
| * oversight. Every other admin room is gated on `admin`, a boolean the shell | |
| * already holds on the user record. `platform_admin` is a different predicate | |
| * that arrives ASYNCHRONOUSLY on the settings payload β and R3 is explicit | |
| * that a tenant-scoped `is_admin` does NOT qualify for it. Folding it into a | |
| * function whose only input is `admin` would either grant the section to every | |
| * tenant admin or deny it to the one account that has it. It is gated where | |
| * the flag actually lives, at the render site in `SettingsModal`. | |
| */ | |
| | "padmin"; | |
| /** | |
| * A non-admin never LANDS on the users pane β and therefore never reaches the | |
| * permission editor, which lives inside it. | |
| * | |
| * β THIS IS A COURTESY, NOT THE CHECK. The server refuses every `/admin/*` | |
| * route to a member regardless, and `/perms` is one of them. What this prevents | |
| * is a member deep-linked (or restored) into a room whose every control would | |
| * 403 β an empty frame that reads as a broken product rather than as a closed | |
| * door. It is a named function instead of an inline ternary for one reason: an | |
| * inline ternary cannot have a negative control, and "the pane a non-admin gets | |
| * bounced out of" is exactly the kind of rule that gets refactored away by | |
| * someone who does not know it is load-bearing. | |
| */ | |
| export function reachableSection(section: SettingsSection, admin: boolean): SettingsSection { | |
| // ββ WAVE 32 Β· R4 β `keychains` AND `connectors` LEFT THIS LIST, and the ruling is the reason. | |
| // | |
| // Wave 18 (C7) put them here because "every control inside them would 403 a member" β which was | |
| // TRUE while every credential was tenant-wide. R4 ends that: *"the business-wide vs personal | |
| // split lands on ALL connections β¦ business-wide is admin-only"*, i.e. a member now genuinely | |
| // owns something in these rooms β their own personal connections. | |
| // | |
| // β THE SERVER MOVED FIRST, and this line follows it rather than leading. `routes_keychain` | |
| // dropped `admin_gate` for `require_session` and put the wall in the ROW (`may_see` / | |
| // `_may_touch`): a member sees the business-wide entries plus their own, may create only a | |
| // personal one, and gets `403 not_admin` with a sentence if they ask for business-wide. Had | |
| // this list changed alone, a member would land in a room whose every control 403s β the empty | |
| // frame this function's own header calls "a broken product rather than a closed door". | |
| const adminOnly = | |
| section === "users" || | |
| // EXIT-6: every control in the statements room would 403 a member, and the | |
| // one at the bottom sends customer email. It belongs in this list twice over. | |
| section === "statements"; | |
| return adminOnly && !admin ? "account" : section; | |
| } | |
| // --- the wire (C-PERM) ------------------------------------------------------ | |
| /** One module's rule for one user. The shape `PUT /admin/users/{u}/perms` takes. */ | |
| export interface PermsEntry { | |
| /** May this account open the module at all. `false` β `may_open` denies. */ | |
| access: boolean; | |
| /** The PERMANENT filter, AND-ed under everything the user does. `null` = none. | |
| * β The whole tree β `{conj?, nodes}` β never a bare node list: `[A, B]` under | |
| * `or` means something entirely different from `[A, B]` under `and`, and the | |
| * loss is invisible in every payload (C-PERM amendment 2). */ | |
| filter: FilterTree | null; | |
| /** Field keys this account never receives. Server-stripped from every wire. */ | |
| hiddenFields: string[]; | |
| } | |
| export type PermsRecord = Record<string, PermsEntry>; | |
| /** A module the editor can draw a full rule for: it came with a field list. */ | |
| export interface PermsModule { | |
| key: string; | |
| label: string; | |
| /** Empty β no readable schema β access toggle only (rule 1 above). */ | |
| fields: Field[]; | |
| } | |
| export interface PermsPayload { | |
| /** In server order β the order the sections render in. */ | |
| modules: PermsModule[]; | |
| entries: PermsRecord; | |
| /** Keys in `perms` that the server no longer declares. A whole-record replace | |
| * drops them; the editor states that before the admin saves. */ | |
| orphanModules: string[]; | |
| /** C-PERM amendment 4's migration marker. ABSENT means this record still runs | |
| * under the LEGACY wall (`bus`/`agent` query scope + the `modules` grant), so | |
| * the editor must not present empty perms as "no access" β that would be a | |
| * confident lie about an account that can currently see everything. */ | |
| migrated: boolean; | |
| /** This account is an ADMIN, and admins bypass `perms` entirely. Every rule | |
| * the editor can draw for them is inert β which it has to say out loud. */ | |
| isAdmin: boolean; | |
| } | |
| // --- parsing ---------------------------------------------------------------- | |
| /** Every `FieldType`, as a runtime set. | |
| * | |
| * β The `Record<FieldType, true>` is the point, not the Set: it is a | |
| * COMPILE-TIME exhaustiveness check. Add a type to the union in | |
| * `customer-grid/types.ts` and this file stops compiling until it is listed | |
| * here β which is how a whitelist stays honest across a tree boundary S3 does | |
| * not own. A hand-maintained array would silently narrow instead. */ | |
| const FIELD_TYPE_TABLE: Record<FieldType, true> = { | |
| text: true, status: true, currency: true, int: true, date: true, pct: true, | |
| select: true, user: true, multiselect: true, checkbox: true, phone: true, | |
| email: true, url: true, rating: true, created_time: true, formula: true, | |
| automation: true, | |
| // Wave-22 C7 (added by C, the same one-key edit the alarm demands). | |
| metric: true, | |
| // Wave-19 R7 (added by session A β see the dated amendment in the split doc). This ONE key is | |
| // the whole edit: the exhaustiveness alarm above did exactly what it promises, and the fix it | |
| // names is a listing here. Nowhere near `SettingsSection` / the rail, which is B's half of | |
| // this file. | |
| image: true, | |
| // Wave-23 C7 (added by session D β `settings/**` is frozen this wave and this ONE key is the | |
| // exception the freeze cannot cover: the alarm four lines up is a COMPILE error, so the union | |
| // and this listing cannot land in two different changes. Posted in D's mailbox for C.) | |
| json: true, | |
| // 2026-08-07 β the relational pair, listed for the reason the alarm above states and for no | |
| // other: the exhaustiveness check is a COMPILE error, so the union and this listing cannot | |
| // land in two separate changes. Nothing about the permissions wall treats either kind | |
| // specially β a link/rollup column is granted and hidden like any other column. | |
| link: true, rollup: true, | |
| // β Wave-27 item 13 (R13) β same one-key edit, same reason, and `settings/**` is D's fence | |
| // this wave so it is not even an exception: the alarm above is a COMPILE error, so the union | |
| // and this listing cannot land in two changes. Nothing about the permissions wall treats a | |
| // code column specially β it is granted and hidden like any other column. | |
| code: true, | |
| }; | |
| export const KNOWN_FIELD_TYPES: ReadonlySet<string> = new Set(Object.keys(FIELD_TYPE_TABLE)); | |
| /** Rule 2: whitelist, never pass through, never drop. */ | |
| export function fieldType(raw: unknown): FieldType { | |
| return typeof raw === "string" && KNOWN_FIELD_TYPES.has(raw) ? (raw as FieldType) : "text"; | |
| } | |
| function asStringArray(raw: unknown): string[] { | |
| return Array.isArray(raw) ? raw.filter((v) => typeof v === "string") : []; | |
| } | |
| /** One field of a module's schema, from the nav/schema payload shape. | |
| * `key` and `label` are the minimum that makes a row renderable β a field | |
| * without them cannot be shown OR named in a rule, so it is skipped rather | |
| * than rendered as a blank the admin might tick. */ | |
| export function parseField(raw: unknown): Field | null { | |
| if (!raw || typeof raw !== "object") return null; | |
| const r = raw as Record<string, unknown>; | |
| if (typeof r.key !== "string" || r.key === "") return null; | |
| const label = typeof r.label === "string" && r.label !== "" ? r.label : r.key; | |
| const options = asStringArray(r.options); | |
| return { | |
| key: r.key, | |
| label, | |
| type: fieldType(r.type), | |
| // Anything not explicitly the overlay stratum is treated as source data. | |
| // Consequence in this editor: nothing here offers to EDIT a field, so the | |
| // only thing `source` drives is the "Pre-set" chip in the hide list. | |
| source: r.source === "overlay" ? "overlay" : "odoo", | |
| // Carried because `identityKey` reads it β see the rule there. | |
| ...(r.pinned === true ? { pinned: true } : {}), | |
| ...(options.length ? { options } : {}), | |
| ...(typeof r.note === "string" ? { note: r.note } : {}), | |
| ...(typeof r.description === "string" && r.description !== "" | |
| ? { note: r.description } | |
| : {}), | |
| }; | |
| } | |
| /** `fields_by_module[key]` is a BARE ARRAY of fields β S1's canonical answer | |
| * (routes_admin `get_perms`), not the nav/schema envelope the contract line | |
| * implied. The earlier tolerant branch that also read `{fields: [β¦]}` is gone: | |
| * one shape, asserted here, beats two readings of a sentence. | |
| * | |
| * The LABEL does not come from here β it rides the payload's `modules` list, | |
| * which is also what fixes the ORDER. Anything unreadable yields an EMPTY field | |
| * list, which R9 already has a defined rendering for (access toggle only). */ | |
| export function parseModule(key: string, label: string, raw: unknown): PermsModule { | |
| const fields: Field[] = []; | |
| for (const f of Array.isArray(raw) ? raw : []) { | |
| const parsed = parseField(f); | |
| if (parsed) fields.push(parsed); | |
| } | |
| return { key, label: label || key, fields }; | |
| } | |
| /** A filter is taken WHOLE or not at all. A tree whose `nodes` is not a list is | |
| * not a narrower filter, it is an unreadable one β and this editor's job is to | |
| * never show a rule it could not also save back. */ | |
| export function parseFilter(raw: unknown): FilterTree | null { | |
| if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; | |
| const r = raw as Record<string, unknown>; | |
| if (!Array.isArray(r.nodes)) return null; | |
| const conj = r.conj === "or" ? "or" : r.conj === "and" ? "and" : undefined; | |
| return { | |
| ...(conj ? { conj } : {}), | |
| nodes: r.nodes as FilterTree["nodes"], | |
| }; | |
| } | |
| /** β ABSENT ENTRY β `access: false`, matching what the server will ENFORCE for a | |
| * migrated record (C-PERM amendment 4: `perms_v == 1` + no entry β DENY). The | |
| * editor showing "access on" for a module the wall denies would send an admin | |
| * to debug a permission that was never granted. | |
| * | |
| * For an UN-migrated record the same absence means the opposite β the legacy | |
| * wall still applies and may grant everything β which is why `PermsPayload` | |
| * carries `migrated` and the editor states it rather than letting this default | |
| * speak for a case it does not describe. */ | |
| export function parseEntry(raw: unknown): PermsEntry { | |
| if (!raw || typeof raw !== "object" || Array.isArray(raw)) { | |
| return { access: false, filter: null, hiddenFields: [] }; | |
| } | |
| const r = raw as Record<string, unknown>; | |
| return { | |
| access: r.access === true, | |
| filter: parseFilter(r.filter), | |
| hiddenFields: normalizeHidden(asStringArray(r.hiddenFields)), | |
| }; | |
| } | |
| /** Sorted + de-duplicated, always. Dirty-detection compares payloads, so a list | |
| * whose ORDER can drift would make an untouched module read as edited. */ | |
| export function normalizeHidden(keys: readonly string[]): string[] { | |
| return [...new Set(keys)].sort(); | |
| } | |
| /** | |
| * The IDENTITY column β the one field a module may never hide. | |
| * | |
| * β WHY THIS EXISTS AT ALL. `FieldsHidePanel` takes an optional `lockedKey` and | |
| * documents that "absent = nothing is locked". Absent, a single click on | |
| * "Hide all" hides EVERY field including the row's own name β and with | |
| * amendment 5's server-side transitive closure behind it, that writes a record | |
| * whose faithful enforcement is a table of blank rows. The user could open the | |
| * module and see nothing in it, the wall doing exactly what the record said. | |
| * Nothing in C-PERM's PUT validation refuses that: it refuses UNKNOWN field | |
| * keys, and the identity column is perfectly known. | |
| * | |
| * β MIRRORED, NOT INVENTED: this is `useGridColumns.ts:201-204`'s rule verbatim | |
| * β the pinned field, else the first. The grid computes the same key to decide | |
| * which column its own Hide-fields panel locks, so an editor using a different | |
| * rule would lock a different column from the one the table protects. | |
| */ | |
| export function identityKey(fields: readonly Field[]): string { | |
| return fields.find((f) => f.pinned)?.key ?? fields[0]?.key ?? ""; | |
| } | |
| /** Every field a module MAY hide β the whole list minus its identity column. */ | |
| export function hideableKeys(fields: readonly Field[]): string[] { | |
| const locked = identityKey(fields); | |
| return fields.filter((f) => f.key !== locked).map((f) => f.key); | |
| } | |
| /** `{perms, fields_by_module}` β everything the editor renders. Returns `null` | |
| * only for a body that is not an object at all; a body missing either half | |
| * parses to an editor with nothing to offer, which is the honest rendering of | |
| * "the server told us nothing". */ | |
| export function parsePermsPayload(body: unknown): PermsPayload | null { | |
| if (!body || typeof body !== "object") return null; | |
| const b = body as Record<string, unknown>; | |
| const byModule = | |
| b.fields_by_module && typeof b.fields_by_module === "object" | |
| ? (b.fields_by_module as Record<string, unknown>) | |
| : {}; | |
| const rawPerms = | |
| b.perms && typeof b.perms === "object" && !Array.isArray(b.perms) | |
| ? (b.perms as Record<string, unknown>) | |
| : {}; | |
| // β `modules` CARRIES THE LABELS AND THE ORDER, and `fields_by_module` carries | |
| // neither. Deriving sections from the field map alone renders every heading as | |
| // a registry KEY β "customer_data" where the product says "Customer" β and in | |
| // whatever order the JSON happens to enumerate. The field map is still the | |
| // outer bound: a module named in `modules` with no field list is the | |
| // schema-less case, not an error. | |
| const declared: Array<{ key: string; label: string }> = Array.isArray(b.modules) | |
| ? (b.modules as unknown[]).flatMap((m) => { | |
| if (!m || typeof m !== "object") return []; | |
| const r = m as Record<string, unknown>; | |
| return typeof r.key === "string" && r.key | |
| ? [{ key: r.key, label: typeof r.label === "string" ? r.label : r.key }] | |
| : []; | |
| }) | |
| : Object.keys(byModule).map((k) => ({ key: k, label: k })); | |
| const modules = declared.map((d) => parseModule(d.key, d.label, byModule[d.key])); | |
| const entries: PermsRecord = {}; | |
| for (const m of modules) entries[m.key] = parseEntry(rawPerms[m.key]); | |
| return { | |
| modules, | |
| entries, | |
| orphanModules: Object.keys(rawPerms).filter((k) => !(k in entries)).sort(), | |
| migrated: typeof b.perms_v === "number" && b.perms_v >= 1, | |
| // `role == 'admin'` bypasses `perms` entirely (C-PERM amendment 4). Sent by | |
| // the route so the editor can SAY so instead of rendering stored rules that | |
| // do not apply β the one misreading of that clause that could hurt. | |
| isAdmin: b.is_admin === true, | |
| }; | |
| } | |
| // --- the draft the admin is editing ----------------------------------------- | |
| /** Immutable edits: every setter returns a NEW record, so React sees the change | |
| * and `isDirty` compares against a snapshot that no setter has mutated under | |
| * it. In-place edits are how a Save button ends up permanently greyed. */ | |
| function withEntry(rec: PermsRecord, key: string, patch: Partial<PermsEntry>): PermsRecord { | |
| const cur = rec[key] ?? { access: false, filter: null, hiddenFields: [] }; | |
| return { ...rec, [key]: { ...cur, ...patch } }; | |
| } | |
| export function setAccess(rec: PermsRecord, key: string, access: boolean): PermsRecord { | |
| return withEntry(rec, key, { access }); | |
| } | |
| export function setFilter(rec: PermsRecord, key: string, filter: FilterTree | null): PermsRecord { | |
| // An empty tree is NO filter, not an empty one. `{nodes: []}` matches every | |
| // row, so persisting it would mean "restricted, to everything" β a rule that | |
| // reads as a restriction in the record and is not one on screen. | |
| const empty = !filter || filter.nodes.length === 0; | |
| return withEntry(rec, key, { filter: empty ? null : filter }); | |
| } | |
| export function setHidden(rec: PermsRecord, key: string, hidden: readonly string[]): PermsRecord { | |
| return withEntry(rec, key, { hiddenFields: normalizeHidden(hidden) }); | |
| } | |
| export function toggleHidden(rec: PermsRecord, key: string, fieldKey: string): PermsRecord { | |
| const cur = rec[key]?.hiddenFields ?? []; | |
| const next = cur.includes(fieldKey) | |
| ? cur.filter((k) => k !== fieldKey) | |
| : [...cur, fieldKey]; | |
| return setHidden(rec, key, next); | |
| } | |
| export function hiddenSet(rec: PermsRecord, key: string): ReadonlySet<string> { | |
| return new Set(rec[key]?.hiddenFields ?? []); | |
| } | |
| export function filterOf(rec: PermsRecord, key: string): FilterTree { | |
| // The panel takes a tree, never null β an absent filter is an EMPTY tree to | |
| // edit, which is what "add your first condition" has to render against. | |
| return rec[key]?.filter ?? { nodes: [] }; | |
| } | |
| // --- what gets sent --------------------------------------------------------- | |
| /** | |
| * The PUT body. Whole-record replace, so this emits an entry for EVERY module | |
| * the server declared β including the ones the admin never touched, because a | |
| * missing key in a replace is a deletion, not a no-op. | |
| * | |
| * β A module with no readable schema is emitted `{access, filter: null, | |
| * hiddenFields: []}` (R9). Its access toggle is real and is honoured; what is | |
| * refused is inventing a restriction against a field list nobody could read. | |
| */ | |
| export function toPutBody(payload: PermsPayload, rec: PermsRecord): { perms: PermsRecord } { | |
| const perms: PermsRecord = {}; | |
| for (const m of payload.modules) { | |
| const e = rec[m.key] ?? { access: false, filter: null, hiddenFields: [] }; | |
| // β The identity column is stripped at the BOUNDARY as well as withheld | |
| // from the UI. The panel never offers it, but a record written before this | |
| // rule β or by anything else β could still name it, and this editor is the | |
| // last place that record passes through before the wall enforces it | |
| // faithfully. Two layers for the same reason C-PERM validates at PUT time | |
| // AND `permits()` re-checks: records go stale in ways forms cannot. | |
| const locked = identityKey(m.fields); | |
| perms[m.key] = m.fields.length | |
| ? { | |
| access: e.access, | |
| filter: e.filter, | |
| hiddenFields: normalizeHidden(e.hiddenFields.filter((k) => k !== locked)), | |
| } | |
| : { access: e.access, filter: null, hiddenFields: [] }; | |
| } | |
| return { perms }; | |
| } | |
| /** Which slice of one account's access a copy carries onto another. */ | |
| export type CopyScope = { kind: "all" } | { kind: "module"; key: string }; | |
| /** | |
| * C-PERMCOPY (wave 17, item 16) β the record to PUT onto ONE target, composed | |
| * from that target's OWN payload and this account's SAVED entries. | |
| * | |
| * β WHY THIS IS A FUNCTION AND NOT FOUR LINES IN THE COMPONENT. It is the | |
| * SECOND producer of a PUT body in the product, and the first one aimed at | |
| * somebody else's record. A PUT is a whole-record replace: a key that does not | |
| * come back is DELETED. `toPutBody` already carries a negative control named | |
| * `replace-omits-untouched-modules` for exactly that failure β and every way of | |
| * getting it wrong from here reaches the same place by a different road. Inline | |
| * in a `useCallback`, no gate could see any of it. | |
| * | |
| * THE RULE, in one line: **the target's tree is the base; the copy overwrites | |
| * a slice of it.** Never the source's tree with the target's bits merged in β | |
| * that composes a record out of modules the TARGET may not declare. | |
| * | |
| * Β· `{kind:'all'}` β the whole record is replaced by the source's. | |
| * Β· `{kind:'module'}` β that one module is replaced; every other module keeps | |
| * the value the target's own GET returned, byte for byte. | |
| * | |
| * β TWO EDGES, both currently unreachable because `_PERM_MODULES` is server-wide | |
| * (every payload declares the same modules), and both named rather than left to | |
| * be discovered if that ever stops being true: | |
| * 1. The source has a module the TARGET does not declare. `toPutBody` iterates | |
| * the TARGET's modules, so the copy is dropped β silently, under a message | |
| * that says it was copied. `copyDropped()` below is what lets the caller | |
| * tell the truth about that. | |
| * 2. The target declares a module the SOURCE has no entry for. It lands on | |
| * `NO_ACCESS`, which is the honest reading of "apply this account's access": | |
| * if the source does not grant it, the target must not keep it. Deliberate, | |
| * and it is why a whole-record copy is offered as "replace", not "merge". | |
| */ | |
| export function copyTargetRecord( | |
| target: PermsPayload, | |
| source: PermsRecord, | |
| scope: CopyScope | |
| ): PermsRecord { | |
| if (scope.kind === "all") { | |
| const out: PermsRecord = {}; | |
| for (const m of target.modules) { | |
| out[m.key] = source[m.key] ?? { access: false, filter: null, hiddenFields: [] }; | |
| } | |
| return out; | |
| } | |
| return { | |
| ...target.entries, | |
| [scope.key]: source[scope.key] ?? { access: false, filter: null, hiddenFields: [] }, | |
| }; | |
| } | |
| /** The modules a copy CANNOT carry, because the target does not declare them. | |
| * Empty in every deployment where the module list is server-wide; a caller that | |
| * reports "copied" without consulting it would be guessing. */ | |
| export function copyDropped( | |
| target: PermsPayload, | |
| source: PermsRecord, | |
| scope: CopyScope | |
| ): string[] { | |
| const declared = new Set(target.modules.map((m) => m.key)); | |
| const wanted = scope.kind === "all" ? Object.keys(source) : [scope.key]; | |
| return wanted.filter((k) => !declared.has(k)).sort(); | |
| } | |
| /** Compares the SENT SHAPE, not the draft, so a change the payload cannot carry | |
| * (a hidden-field list on a schema-less module) never lights up Save. */ | |
| export function isDirty(payload: PermsPayload, saved: PermsRecord, draft: PermsRecord): boolean { | |
| return ( | |
| JSON.stringify(toPutBody(payload, saved)) !== JSON.stringify(toPutBody(payload, draft)) | |
| ); | |
| } | |
| // --- how a rule reads ------------------------------------------------------- | |
| /** Counts a tree's leaves, groups included. The editor states the size of a | |
| * restriction rather than showing "Filtered" for one condition and for twenty. */ | |
| export function countLeaves(tree: FilterTree | null): number { | |
| if (!tree) return 0; | |
| let n = 0; | |
| const walk = (nodes: readonly unknown[]) => { | |
| for (const node of nodes) { | |
| if (node && typeof node === "object" && Array.isArray((node as { children?: unknown[] }).children)) { | |
| walk((node as { children: unknown[] }).children); | |
| } else { | |
| n += 1; | |
| } | |
| } | |
| }; | |
| walk(tree.nodes); | |
| return n; | |
| } | |
| /** | |
| * One sentence per module for the accounts list and the section head. | |
| * | |
| * β NO EMOJI, no icon vocabulary β this string is read aloud by a screen reader | |
| * and printed in the gate's output. It also never says "restricted" without | |
| * saying to WHAT: "Filtered" alone is the kind of summary that makes an admin | |
| * open every section to find the one that is set. | |
| */ | |
| export function moduleSummary(entry: PermsEntry | undefined, schemaless = false): string { | |
| if (!entry || !entry.access) return "No access"; | |
| if (schemaless) return "Full access"; | |
| const conds = countLeaves(entry.filter); | |
| const hidden = entry.hiddenFields.length; | |
| if (!conds && !hidden) return "Full access"; | |
| const parts: string[] = []; | |
| if (conds) parts.push(`${conds} condition${conds === 1 ? "" : "s"}`); | |
| if (hidden) parts.push(`${hidden} field${hidden === 1 ? "" : "s"} hidden`); | |
| return parts.join(", "); | |
| } | |
| /** The account-list cell: what this user may open, across all modules. */ | |
| export function accessSummary(payload: PermsPayload | null, rec: PermsRecord): string { | |
| if (!payload || payload.modules.length === 0) return ""; | |
| const open = payload.modules.filter((m) => rec[m.key]?.access); | |
| if (open.length === 0) return "No access"; | |
| // β A SCHEMA-LESS MODULE CANNOT BE RESTRICTED, so it is never counted as one | |
| // β the same guard `moduleSummary` takes. Without it a draft filter on a | |
| // module whose rule `toPutBody` strips would be summarised as "1 restricted", | |
| // describing a restriction that is not going to be saved. The header and the | |
| // Save confirmation both read this sentence, so it must describe the PAYLOAD. | |
| const restricted = open.filter( | |
| (m) => | |
| m.fields.length > 0 && | |
| ((rec[m.key]?.filter?.nodes.length ?? 0) > 0 || | |
| (rec[m.key]?.hiddenFields.length ?? 0) > 0) | |
| ).length; | |
| const all = open.length === payload.modules.length; | |
| const head = all | |
| ? `All ${open.length} module${open.length === 1 ? "" : "s"}` | |
| : `${open.length} of ${payload.modules.length} modules`; | |
| return restricted ? `${head}, ${restricted} restricted` : head; | |
| } | |