| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { useState } from "react"; |
| import type { ReactNode } from "react"; |
| import { FilterBuilderPanel, FieldsHidePanel } from "../filter-kit"; |
| import type { FilterTree } from "../customer-grid/types"; |
| import type { PermsModule, PermsRecord } from "./permsModel"; |
| import { filterOf, hiddenSetFor, hideAllKeys, identityKey, moduleSummary, |
| showAllKeys } from "./permsModel"; |
| import "./perms.css"; |
|
|
| export interface ModulePermsListProps { |
| |
| modules: PermsModule[]; |
| |
| entries: PermsRecord; |
| onAccess: (key: string, on: boolean) => void; |
| onFilter: (key: string, next: FilterTree | null) => void; |
| onToggleHidden: (key: string, fieldKey: string) => void; |
| onSetHidden: (key: string, keys: string[]) => void; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| onMetrics?: (key: string, on: boolean) => void; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| onEdit?: (key: string) => void; |
| |
| userOptions?: string[]; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| headExtra?: (m: PermsModule) => ReactNode; |
| |
| emptyNote?: string; |
| } |
|
|
| export function ModulePermsList({ |
| modules, |
| entries, |
| onAccess, |
| onFilter, |
| onToggleHidden, |
| onSetHidden, |
| onMetrics, |
| onEdit, |
| userOptions, |
| headExtra, |
| emptyNote, |
| }: ModulePermsListProps) { |
| |
| |
| |
| const drilled = onEdit !== undefined; |
| |
| |
| |
| |
| const [openDetail, setOpenDetail] = useState<string | null>(null); |
|
|
| if (modules.length === 0) { |
| return ( |
| <p className="pg-empty"> |
| {emptyNote ?? "This workspace has no databases whose access can be restricted."} |
| </p> |
| ); |
| } |
|
|
| return ( |
| <> |
| {modules.map((m) => { |
| const entry = entries[m.key]; |
| const on = entry?.access === true; |
| const schemaless = m.fields.length === 0; |
| const shown = openDetail === m.key; |
| // ββ WAVE 36 (W36-T22 / CONTRACT C2 / OWNER RULING R6) β THE `enforced` |
| // BRANCH AND ITS "Not set here" CARD ARE DELETED. Owner item 11, verbatim: |
| // *"how come the database is only toggleable for Odoo customers and Odoo |
| // products. EVERY database should be able to be toggleable by admin. I'm |
| // only seeing 'Not set here'."* |
| // |
| // That card was HONEST when it shipped β `perm_scope` was never consulted |
| // on a `ut_*` read, so an access toggle there would have been a control |
| // that lies. W36-T21 armed the wall over every database, so the branch's |
| // premise is now false and rendering it would be the lie it was written to |
| // prevent, facing the other way. Every row below is a row whose rule the |
| // table routes apply. |
| const canDetail = on && !schemaless; |
| |
| 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) => onAccess(m.key, e.target.checked)} |
| /> |
| <span className="set-perm-modname">{m.label}</span> |
| </label> |
| {/* β Wrapped rather than left as siblings of the toggle: |
| `.set-perm-modhead` is `space-between` and lives in `index.css`, |
| which is lane B's under contract C4. Grouping the trailing |
| controls keeps every new one off that rule β one flex child in, |
| one flex child out. */} |
| <span className="set-perm-headend"> |
| {/* β W40-T14 β the summary, the accordion and `headExtra` are the ACCORDION |
| room's furniture. The drill-in row is "a checkbox and one way in", so it |
| renders the Edit button in their place and nothing else. */} |
| {drilled ? null : ( |
| <span className="set-perm-sum">{moduleSummary(entry, schemaless)}</span> |
| )} |
| {canDetail && drilled ? ( |
| // β `canDetail` (= `on && !schemaless`), NOT unconditional: a database nobody |
| // may open has no rule to edit, and a schemaless surface has no fields and no |
| // filter β the help paragraph below already says so for that second case. |
| <button |
| type="button" |
| className="set-secondary set-perm-edit" |
| onClick={() => onEdit(m.key)} |
| > |
| Edit |
| </button> |
| ) : null} |
| {canDetail && !drilled ? ( |
| <button |
| type="button" |
| className="set-secondary set-perm-disclose" |
| aria-expanded={shown} |
| onClick={() => setOpenDetail((k) => (k === m.key ? null : m.key))} |
| > |
| {shown ? "Hide detail" : "Conditions and fields"} |
| </button> |
| ) : null} |
| {drilled ? null : headExtra?.(m) ?? null} |
| </span> |
| </div> |
| |
| {/* ββ W38-T19 β THE METRICS CAPABILITY, one box per database. |
| β GATED ON `on && !schemaless` FOR TWO DIFFERENT REASONS, not one. |
| `on`: a capability inside a database nobody may open is a control |
| that decides nothing, and the row already says "No access". |
| `!schemaless`: an app SURFACE has no grid and therefore no measure |
| door, so a box there would store a rule nothing reads β R9's rule |
| that this editor never writes a restriction it could not honestly |
| show, applied to a capability instead of to a filter. |
| β It sits OUTSIDE `.set-perm-headend` deliberately. That rule is |
| `space-between` and lives in `index.css`, which this ticket's fence |
| does not contain; a new flex child there would need CSS that cannot |
| be written, so the box takes its own line under the head. */} |
| {/* β W40-T14 β `!drilled &&` is the whole change here. Owner instruction 7 moved this |
| box INTO the database's own pane ("put the Metrics field toggle under there"), so |
| the drill-in row must not draw a second one. |
| ββ W40-T16 β AND AFTER THIS TICKET THE BOX BELOW RENDERS NOWHERE AT ALL. Owner |
| instruction 13 folded the blanket toggle into the HIDE-FIELDS list, one checkbox |
| per metric, and `DatabasePermsPane`'s copy is deleted; `PermsEditor` passes |
| `onEdit` for every account row, so `!drilled` suppresses this one. β AND THE |
| OTHER CALLER DOES NOT MOUNT IT EITHER β MEASURED, not assumed: |
| `manage-agent/ManageAgentPane.tsx` passes exactly `modules, entries, onAccess, |
| onFilter, onToggleHidden, onSetHidden, userOptions, emptyNote`, and no |
| `onMetrics`. So nothing in the UI writes `PermsEntry.metrics` any more. |
| β THAT IS INSTRUCTION 13, NOT A DEFECT: a blanket control REPLACED by finer ones |
| is the whole ask. The boolean survives as a legacy value β read by `parseEntry`, |
| emitted by `toPutBody`, honoured on screen by `hiddenSetFor` β and every record |
| written from here on carries it GRANTED. |
| β THE JSX IS KEPT AS A SEAM, DELIBERATELY AND WITH ITS COST NAMED. It is what |
| lets a future room govern the capability without re-deriving the prop, and it |
| carries `verify_ui.py`'s `t19-unmounted` / `t19-control-gone` controls, the only |
| two in that file built from code that actually shipped broken. Deleting it is |
| legal β nothing passes the prop, so `tsc` would not notice β but it is a decision |
| to take out loud, in the same change as those two gate blocks, never a tidy-up. */} |
| {!drilled && on && !schemaless && onMetrics ? ( |
| <label className="set-check"> |
| <input |
| type="checkbox" |
| checked={entry?.metrics !== false} |
| onChange={(e) => onMetrics(m.key, e.target.checked)} |
| /> |
| <span>Metric fields (lookback measures over this database)</span> |
| </label> |
| ) : 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 ? ( |
| <p className="set-help"> |
| {m.surface |
| ? "Access is the whole rule for this module. It has no fields to hide " + |
| "and no records to filter." |
| : "No field list is available for this database, so access is all this " + |
| "editor can set for it. Conditions and hidden fields need a schema."} |
| </p> |
| ) : null} |
| |
| {/* β `!drilled` is written even though `openDetail` can never leave `null` in that |
| mode (nothing renders the disclosure that sets it). An invariant held by an absent |
| button is one refactor away from being false; the gate on the thing itself is not. */} |
| {!drilled && canDetail && shown ? ( |
| <div className="set-perm-panels"> |
| <div className="cg-pop set-perm-pop"> |
| <FilterBuilderPanel |
| fields={m.fields} |
| filters={filterOf(entries, m.key)} |
| onChange={(next) => onFilter(m.key, next)} |
| userOptions={userOptions} |
| /> |
| </div> |
| <div className="cg-pop set-perm-pop"> |
| {/* β W40-T16 β `hiddenSetFor`, the same resolver the manage-user pane uses. |
| ONE spelling of "which keys read as hidden": this room's fields carry no |
| metric flag today (the agent editor's payload has no `measure_` pseudo- |
| fields), so the union adds nothing here and the accordion is unchanged. |
| It is written anyway because the alternative is two answers to one question |
| in two files, which is the drift this component was extracted to prevent. */} |
| <FieldsHidePanel |
| fields={m.fields} |
| hidden={hiddenSetFor(entries, m.key, m.fields)} |
| onToggle={(key) => onToggleHidden(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 the server's PUT |
| // validation would accept because the identity column is a |
| // perfectly KNOWN field key. |
| lockedKey={identityKey(m.fields)} |
| /* ββ W40-T17 (owner instruction 15) β UNCONDITIONAL HERE, NOT A SECOND |
| PROP, AND THAT IS WHAT THIS COMPONENT IS. `ModulePermsList` IS the |
| permissioning list: every mount of it β the account editor's Access |
| section and `manage-agent/ManageAgentPane` alike β is editing the same |
| `perms` record through the same wall, so its panel is always a |
| permissioning panel and there is no caller for whom the shared sections |
| would be right. Owner: *"stop displaying 'Shared with me' / 'Shared with |
| everyone' fields under Hide Fields - permission on pre-set Fields only."* |
| That is a sentence about permissioning, not about one screen. |
| β IT IS ALSO WHAT KEEPS THE TWO ROOMS IDENTICAL. This file exists because |
| the owner ruled manage-agent is *"the same exact layout"* as manage user |
| (wave 33); gating one room and not the other would put the divergence |
| inside the very component extracted to prevent it β and LATENTLY, since |
| neither section fills up until the server ships `custom`/`shared`. */ |
| sharedSections={false} |
| onHideAll={() => onSetHidden(m.key, hideAllKeys(entries, m.key, m.fields))} |
| onShowAll={() => onSetHidden(m.key, showAllKeys(entries, m.key, m.fields))} |
| /> |
| </div> |
| </div> |
| ) : null} |
| </section> |
| ); |
| })} |
| </> |
| ); |
| } |
|
|