loopable / web /src /settings /ModulePermsList.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
cf17b22 verified
Raw
History Blame
8.19 kB
// ---------------------------------------------------------------------------
// settings / ModulePermsList.tsx β€” THE per-database permission list. (wave 33,
// owner items 10 + 11 Β· W33-T34 / W33-T38.)
//
// ⭐ WHY THIS FILE EXISTS, IN THE OWNER'S OWN WORDS: *"under manage user (and
// manage agent too since its the same exact layout)"*. "The same exact layout"
// has exactly one honest implementation, and it is not two components kept in
// step by intention. `PermsEditor` and `ManageAgentPane` both render THIS, so a
// change to the disclosure or the summary line reaches both or neither β€” and
// wave 36 is the proof: deleting the "Not set here" card (W36-T22 / C2, owner
// item 11) took it out of the agent pane too, in the same edit, with nothing to
// remember.
//
// β›” THE ALTERNATIVE, NAMED SO NOBODY RE-DERIVES IT. Copying the JSX into the
// agent pane would have been faster today and is the exact shape of every defect
// this repo keeps re-finding: `may_open` vs the automation table picker (wave
// 20), `clean_fields` vs `_clean_field` (wave 28), `FILTER_OPS`' three mirrors.
// Two renderings of one rule disagree in front of a user, and each looks correct
// read on its own.
//
// PURELY PRESENTATIONAL. It owns exactly one piece of state β€” WHICH row is
// expanded β€” because that is a property of this list and of nothing else. Every
// value it shows and every write it makes belongs to the caller: the account
// editor PUTs to `/admin/users/{u}/perms`, the agent editor to its own door, and
// this component knows about neither.
// ---------------------------------------------------------------------------
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 {
/** In server order β€” the order the sections render in. */
modules: PermsModule[];
/** The DRAFT being edited, keyed by module. */
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;
/** People this tenant can name in a `user` condition. Absent β‡’ the panel says so. */
userOptions?: string[];
/** Anything the CALLER wants in a row's head β€” the account editor's "Copy to…"
* door. Returning null is the normal case and costs the row nothing. */
headExtra?: (m: PermsModule) => ReactNode;
/** Shown when the tenant governs nothing at all. */
emptyNote?: string;
}
export function ModulePermsList({
modules,
entries,
onAccess,
onFilter,
onToggleHidden,
onSetHidden,
userOptions,
headExtra,
emptyNote,
}: ModulePermsListProps) {
// ⭐ Owner item 11: *"the database should not show all immediately the detail
// (for filter or hide fields)"*. Single-valued rather than a Set, deliberately
// β€” the complaint was a wall of panels, and an accordion cannot become one by
// accumulation.
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">
<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>
);
})}
</>
);
}