File size: 17,456 Bytes
f546440
 
 
 
 
 
 
 
cf17b22
 
 
 
f546440
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1f123e1
 
f546440
 
 
 
 
 
 
 
 
 
 
15e3e59
 
 
 
 
 
 
 
 
 
 
 
1f123e1
 
 
 
 
 
 
 
 
 
 
 
 
f546440
 
1f123e1
 
 
 
 
 
 
 
 
 
 
f546440
 
 
 
 
 
 
 
 
 
 
 
15e3e59
1f123e1
f546440
 
 
 
1f123e1
 
 
 
f546440
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cf17b22
 
 
 
 
 
 
 
 
 
 
 
 
f546440
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1f123e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f546440
 
 
 
 
 
 
 
 
1f123e1
f546440
 
 
15e3e59
 
 
 
 
 
 
 
 
 
 
 
1f123e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15e3e59
 
 
 
 
 
 
 
 
 
f546440
 
 
 
 
 
aef86ea
 
 
 
 
f546440
 
 
1f123e1
 
 
 
f546440
 
 
 
 
 
 
 
 
 
1f123e1
 
 
 
 
 
f546440
 
1f123e1
f546440
 
 
 
 
 
 
 
1f123e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f546440
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
// ---------------------------------------------------------------------------
// 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, hiddenSetFor, hideAllKeys, identityKey, moduleSummary,
         showAllKeys } 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;
  /**
   * ⭐⭐ W38-T19 β€” the per-database METRICS capability. Absent means the caller does not govern
   * it and no box is drawn, which is the account editor's and the agent editor's genuine
   * difference rather than a stub: an agent's reads run through `agent_rows` / `agent_may_read`
   * and never open a grid door, so a Metrics toggle in that room would be a stored rule nothing
   * applies, the exact class W36's contract C2 deleted.
   *
   * β›” OPTIONAL, AND FORCED RATHER THAN CHOSEN. `manage-agent/ManageAgentPane.tsx` mounts this
   * same component and is outside W38-T19's fence; a required prop stops it compiling, which
   * reds `web_ui` on a build break in a file that ticket may not repair.
   */
  onMetrics?: (key: string, on: boolean) => void;
  /**
   * ⭐⭐ W40-T14 (owner instruction 7) β€” THE DRILL-IN. When supplied, the row is a checkbox and
   * ONE way in: this opens a full-pane editor of that database's Fields and Filters, and the row
   * renders no summary, no accordion, no inline panels, no Metrics box and no `headExtra`. Owner,
   * verbatim: *"show only the Database, with just a checkbox and an Edit button (put the Metrics
   * field toggle under there)"*.
   *
   * β›” OPTIONAL FOR THE SAME FORCED REASON `onMetrics` IS. `manage-agent/ManageAgentPane.tsx`
   * mounts this component and is outside W40-T14's fence; a required prop stops it compiling in a
   * file this ticket may not repair. Absent β‡’ every branch below is the accordion this file has
   * always rendered, byte for byte.
   */
  onEdit?: (key: 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. Returning null is the normal case and costs the
   * row nothing.
   *
   * ⚠ NO CALLER SUPPLIES IT TODAY, AND THE PROP IS KEPT ANYWAY. It carried the account editor's
   * per-row "Copy to" door, which owner instruction 7 DELETED (W40-T14) β€” the account-level
   * "Apply this access to…" survives and is the whole copy affordance now. What is left here is
   * a seam, declared because `manage-agent/ManageAgentPane.tsx` mounts this same component from
   * outside that ticket's fence and removing a prop is a breaking change to a file it may not
   * repair. A drilled row ignores it regardless: `onEdit` means "a checkbox and one way in".
   */
  headExtra?: (m: PermsModule) => ReactNode;
  /** Shown when the tenant governs nothing at all. */
  emptyNote?: string;
}

export function ModulePermsList({
  modules,
  entries,
  onAccess,
  onFilter,
  onToggleHidden,
  onSetHidden,
  onMetrics,
  onEdit,
  userOptions,
  headExtra,
  emptyNote,
}: ModulePermsListProps) {
  // ⭐ W40-T14 β€” WHICH MODE, read ONCE. Every branch below asks this same question, and a second
  // spelling of it (`onEdit !== undefined` here, `!!onEdit` there) is how two of them come to
  // disagree about which room they are in.
  const drilled = onEdit !== undefined;
  // ⭐ 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">
                {/* ⭐ 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>
        );
      })}
    </>
  );
}