| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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, hiddenSet, hideableKeys, identityKey, moduleSummary } 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; |
| |
| userOptions?: string[]; |
| |
| |
| headExtra?: (m: PermsModule) => ReactNode; |
| |
| emptyNote?: string; |
| } |
|
|
| export function ModulePermsList({ |
| modules, |
| entries, |
| onAccess, |
| onFilter, |
| onToggleHidden, |
| onSetHidden, |
| userOptions, |
| headExtra, |
| emptyNote, |
| }: ModulePermsListProps) { |
| |
| |
| |
| |
| 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; |
| // ββ `!== false`, NOT truthiness. `verify_ui`'s NC |
| // `perms-editor-renders-controls-before-the-record-arrives` injects a raw |
| // module literal with no `enforced` key; read as truthiness that module |
| // renders as UNENFORCED β no checkbox β and the NC stops biting for a |
| // reason unrelated to what it guards [[gate-negative-control]]. It caught |
| // exactly that within an hour of the flag existing. Stated the same way |
| // `permsModel.parsePermsPayload` states it: only an explicit `false` |
| // disarms a row, because every module a payload could carry before this |
| // flag existed WAS enforced [[default-must-pass-its-own-guard]]. |
| const enforced = m.enforced !== false; |
| const canDetail = on && !schemaless && enforced; |
| |
| // β A database this editor does not decide gets NO access toggle β not a |
| // disabled one, not an unchecked one. An unchecked box beside a name |
| // reads as "denied", and nothing here denies it. |
| if (!enforced) { |
| return ( |
| <section className="set-card set-perm-mod" key={m.key}> |
| <div className="set-perm-modhead"> |
| <span className="set-perm-modname">{m.label}</span> |
| <span className="set-perm-headend"> |
| <span className="set-perm-sum">Not set here</span> |
| </span> |
| </div> |
| <p className="set-help"> |
| Access to this database is decided by who created it and who it has been |
| shared with, not by this editor. A rule saved here would not be applied, |
| so none is offered. |
| </p> |
| </section> |
| ); |
| } |
| |
| 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"> |
| <span className="set-perm-sum">{moduleSummary(entry, schemaless)}</span> |
| {canDetail ? ( |
| <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} |
| {headExtra?.(m) ?? null} |
| </span> |
| </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 database, so access is all this editor |
| can set for it. Conditions and hidden fields need a schema. |
| </p> |
| ) : null} |
| |
| {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"> |
| <FieldsHidePanel |
| fields={m.fields} |
| hidden={hiddenSet(entries, m.key)} |
| 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)} |
| onHideAll={() => onSetHidden(m.key, hideableKeys(m.fields))} |
| onShowAll={() => onSetHidden(m.key, [])} |
| /> |
| </div> |
| </div> |
| ) : null} |
| </section> |
| ); |
| })} |
| </> |
| ); |
| } |
|
|