loopable / web /src /settings /DatabasePermsPane.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
1f123e1 verified
Raw
History Blame
10.6 kB
// ---------------------------------------------------------------------------
// settings / DatabasePermsPane.tsx β€” ONE DATABASE'S RULE, given the whole pane.
// (wave 40, owner instruction 7 Β· W40-T14.)
//
// ⭐ WHY IT EXISTS, IN THE OWNER'S OWN WORDS: *"Manage user, Access section: show
// only the Database, with just a checkbox and an Edit button (put the Metrics
// field toggle under there). Edit opens the whole database's Fields and Filters
// to toggle, with a Back button to return."*
//
// ⚠ THE PARENTHESIS IN THAT QUOTE IS SUPERSEDED, AND ONLY THE PARENTHESIS. Owner instruction 13
// (W40-T16) folded the Metrics toggle one level further in: it is no longer a box "under there"
// but one CHECKBOX PER METRIC inside this pane's Hide-fields list. The room, the Back button and
// the Fields/Filters pairing are instruction 7's and are unchanged. Read the quote as the reason
// this file exists, never as a description of the controls it draws today.
//
// What it replaces is a single-row INLINE ACCORDION. That shape was right when
// the list was two databases and the panels were the only thing under them; it
// is wrong now, because a filter builder and a field list opened inside a row of
// a list of every database the tenant has are two menus that failed to close.
// A rule about one database gets a room, and the room says which database.
//
// β›” IT OWNS NO STATE AND WRITES NOTHING. Every value comes from the caller's
// DRAFT and every change goes back out as a callback, exactly as
// `ModulePermsList` does β€” `PermsEditor` holds the draft, the confirm and the
// PUT. This component is a layout and a set of handlers, so the account page
// keeps one source of truth for what is about to be saved.
//
// β›”β›” THE `lockedKey` / `hideableKeys` PAIR BELOW IS LOAD-BEARING AND ITS
// FAILURE IS SILENT. Drop either and one click on "Hide all" hides the database's
// own identity column: a record whose faithful enforcement is a table of blank
// rows, which the server's PUT validation ACCEPTS because the identity column is
// a perfectly known field key. Both halves ship together or the pane is a defect.
// ---------------------------------------------------------------------------
import { FilterBuilderPanel, FieldsHidePanel } from "../filter-kit";
import type { FilterTree } from "../customer-grid/types";
import type { PermsModule, PermsRecord } from "./permsModel";
import { filterOf, hiddenSetFor, hideAllKeys, identityKey, showAllKeys } from "./permsModel";
import "./perms.css";
export interface DatabasePermsPaneProps {
/** The database being edited: its label titles the pane, its fields feed both panels. */
module: PermsModule;
/**
* ⚠ THE WHOLE DRAFT, keyed by module, NOT this module's entry. `filterOf` and `hiddenSetFor`
* are where "an absent filter is an empty tree" and "absent hidden fields is an empty set" are
* spelled, once; taking a bare entry would make this file spell them a second time, and two
* spellings of one default are what those helpers exist to prevent.
*/
entries: PermsRecord;
/** Return to the account page. The SAME function the account page's Escape handler calls. */
onBack: () => void;
onFilter: (next: FilterTree | null) => void;
onToggleHidden: (fieldKey: string) => void;
onSetHidden: (keys: string[]) => void;
/*
* ⭐⭐ W40-T16 (OWNER INSTRUCTION 13) β€” `onMetrics` IS GONE FROM THIS PANE, AND SO IS THE BOX
* IT DREW. Owner, verbatim: *"Fold 'Metrics fields (lookback measures over this database)' into
* the 'Hide fields' checkboxes, one row per metric, named 'Metric - Revenue', 'Metric - Order'
* and so on, so a user can check the ones the permissioning is limited to."*
*
* β›” A DELETION RATHER THAN A HIDE, because the control it replaces answered a COARSER
* question. One box said "this account may build measures over this database, or may not";
* the Hide-fields list below now carries one checkbox per bound measure, so the same admin
* decides WHICH ones. Leaving the blanket box beside them would put two controls on one screen
* that can contradict each other, and the record has no way to express the contradiction.
*
* ⚠ THE CAPABILITY ITSELF IS UNTOUCHED. `PermsEntry.metrics` still rides the wire, still parses
* (`parseEntry`) and is still emitted (`toPutBody`), because a record written before this
* ticket may carry an explicit revocation. What honours it here is `hiddenSetFor` below, on the
* READ side: a blanket `metrics: false` paints every metric row as hidden without rewriting
* anybody's stored rule. See the note at that call.
*/
/** People this tenant can name in a `user` condition. Absent β‡’ the panel says so. */
userOptions?: string[];
}
export function DatabasePermsPane({
module,
entries,
onBack,
onFilter,
onToggleHidden,
onSetHidden,
userOptions,
}: DatabasePermsPaneProps) {
return (
<div className="set-dbperm">
<div className="set-dbperm-head">
{/* β›” ONE WORD, AND THE SHORTNESS IS FORCED RATHER THAN CHOSEN. The account's own exit,
"Back to accounts", is rendered by `PermsEditor` DIRECTLY ABOVE this pane and stays
there while the room is open. Spelling this one "Back to account" would put two
controls one letter apart on one screen, going to different places, which is a worse
room than either label is a good one. Position already says where this one goes: it
sits on the database title's line, so it leaves the database. `DESIGN.md` Β§4 asks for
the shorter label wherever the longer one is narration, and here it also happens to
be the only unambiguous one. */}
<button type="button" className="set-secondary set-dbperm-back" onClick={onBack}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden focusable="false">
<path
d="M9.5 4 5.5 8l4 4"
stroke="currentColor"
strokeWidth={1.3}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Back
</button>
{/* The pane says which database it is about. Without it every one of these rooms looks
identical, and the reader's only clue is what they clicked a scroll ago. */}
<h4 className="set-h4 set-dbperm-title">{module.label}</h4>
</div>
{/* THE KIT'S OWN PANELS (contract C-KIT), not lookalikes: the same two the grid toolbar
mounts, so an admin writes a condition with the control they already know and there is
one condition grammar in the product rather than two that drift within a wave.
They STACK here rather than sitting side by side: the pane is theirs alone now, and a
filter and a field list read as two decisions taken in order, not one wide row. */}
<div className="set-dbperm-panels">
<div className="cg-pop set-perm-pop">
<FilterBuilderPanel
fields={module.fields}
filters={filterOf(entries, module.key)}
onChange={onFilter}
userOptions={userOptions}
/>
</div>
<div className="cg-pop set-perm-pop">
{/* ⭐⭐ W40-T16 β€” THIS LIST IS WHERE THE METRICS RULE NOW LIVES. `module.fields` carries
C3's `measure_`-namespaced pseudo-fields, `permsModel::parseField` marks each one,
and the panel draws it as an ordinary checkbox named "Metric - Revenue". Checking it
puts that field's key into `hiddenFields` through the same `onToggle` every other
row uses, so there is no second write path for a metric and no second record shape.
β›” `hiddenSetFor`, NOT `hiddenSet`, AND THE DIFFERENCE IS THE MIGRATION. A record
written before this ticket can carry a blanket `metrics: false` (W38-T19's box), and
the control that wrote it is gone from this pane. The union paints every metric row
as hidden for such a record, so the old revocation is HONOURED on screen rather than
silently reading as a grant. It is a read-side union on purpose: folding it into
`hiddenFields` would edit somebody's stored rule as a side effect of opening a page. */}
<FieldsHidePanel
fields={module.fields}
hidden={hiddenSetFor(entries, module.key, module.fields)}
onToggle={onToggleHidden}
/* ⭐⭐ W40-T17 (OWNER INSTRUCTION 15) β€” THE ONE PROP THAT MAKES THIS MOUNT DIFFER
FROM THE GRID'S. Owner, verbatim: *"stop displaying 'Shared with me' / 'Shared
with everyone' fields under Hide Fields - permission on pre-set Fields only."*
`false` drops both sections AND swaps the membership rule for AM-2's
`metric || !custom`, so this list is the database's own columns plus its bound
measures and nothing else.
β›” IT IS PASSED HERE AND NOWHERE ELSE. `customer-grid/Toolbar.tsx` mounts the same
panel and passes no `sharedSections`, so the grid keeps all three sections exactly
as the 2026-08-21 hotfix left them. That is the whole reason this is a prop rather
than a deletion: the two surfaces are supposed to differ now. */
sharedSections={false}
// β›”β›” BOTH HALVES OF THE TRAP, and neither is decoration. `lockedKey` disables the
// identity column's own checkbox; `hideableKeys` is what keeps "Hide all" from
// including it anyway. One without the other still ships blank rows.
lockedKey={identityKey(module.fields)}
/* ⭐⭐ W40-T17 β€” THE BULK ACTIONS REACH EXACTLY WHAT THIS ROOM LISTS, and the
narrowing above is what owes them the change: a list showing four rows whose
"Show all" clears hidden state for seven fields would WIDEN a permission through
a control that never displayed the columns it just revealed. `showAllKeys`
subtracts the governed keys instead of emptying the record; `hideAllKeys` adds
them without disturbing what the record hides elsewhere. Both strip the identity
key, so the trap above stays closed from this side too. */
onHideAll={() => onSetHidden(hideAllKeys(entries, module.key, module.fields))}
onShowAll={() => onSetHidden(showAllKeys(entries, module.key, module.fields))}
/>
</div>
</div>
</div>
);
}