File size: 43,119 Bytes
da5297e
7127075
 
da5297e
7127075
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da5297e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7127075
 
 
 
 
 
 
 
da5297e
 
7127075
 
da5297e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7127075
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da5297e
7127075
da5297e
7127075
da5297e
 
 
7127075
 
 
 
 
 
 
da5297e
 
7127075
 
da5297e
7127075
 
 
 
 
da5297e
7127075
 
da5297e
 
7127075
 
 
da5297e
 
 
 
 
7127075
da5297e
 
7127075
 
 
 
 
 
 
 
 
da5297e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7127075
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da5297e
 
7127075
da5297e
7127075
da5297e
 
 
7127075
 
 
 
 
da5297e
 
7127075
 
da5297e
 
 
7127075
 
 
 
 
 
 
da5297e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7127075
da5297e
 
 
 
7127075
da5297e
 
 
 
 
 
 
 
 
 
 
7127075
da5297e
 
 
 
 
 
 
7127075
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da5297e
 
 
 
 
 
 
7127075
da5297e
 
7127075
 
 
 
da5297e
 
 
7127075
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da5297e
7127075
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da5297e
 
 
 
 
 
 
 
 
 
 
7127075
da5297e
 
 
 
 
 
 
 
 
 
 
7127075
da5297e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7127075
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da5297e
 
 
 
 
 
 
7127075
 
 
 
 
 
 
 
 
 
da5297e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7127075
 
 
 
 
 
 
 
 
 
da5297e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7127075
 
 
da5297e
7127075
da5297e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7127075
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da5297e
 
 
 
 
 
 
 
 
 
 
7127075
 
 
 
 
 
 
 
 
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
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
// ---------------------------------------------------------------------------
// settings / PermsEditor.tsx β€” THE USER PAGE. (wave 15 R9: the permission
// editor Β· wave 17 R7: everything else about the account, merged in.)
//
// "Manage users β†’ click a user β†’ the modal body BECOMES that user's page." A
// full-pane takeover, not a drawer beside the list and not a third dialog: the
// thing being edited is a whole account, and giving it a room of its own is
// what makes room for a filter builder and a field list per module without
// either being a 200px scroll-box.
//
// ⚠ WHY THE FILE NAME NO LONGER DESCRIBES THE FILE. Wave 17's R7, verbatim:
// *"Edit and clicking a user name should just be the EXACT same opening of a
// full page so its not confusing."* Before it there were two doors β€” the name
// opened this page, "Edit" unfolded a card at the BOTTOM of the accounts list β€”
// and which one an admin needed depended on a distinction only the code cared
// about (who the account IS vs what it may SEE). Both jobs live here now. The
// export keeps its name because renaming it would ripple through the shell, the
// gate's module list and the render smoke for no user-visible gain β€” the same
// trade R12 made for `SettingsModal`.
//
// β›” EVERY CONTROL HERE IS A COURTESY. `/admin/*` is refused to a member at the
// server, fail-closed, and the wall that acts on what this page writes is
// `core/perm_scope.permits()` β€” which re-validates the whole record. Nothing on
// this page is a permission check; this page is where an admin SAYS what the
// wall should be.
//
// THE PANELS ARE THE GRID'S OWN (contract C-KIT). Not lookalikes β€” the same
// `FilterBuilderPanel` and `FieldsHidePanel` the toolbar mounts, from
// `filter-kit/`, which is the entire reason that extraction happened before this
// file existed. An admin writing "customers where dba is Fisch" uses the control
// they already know, and there is one condition grammar in the product rather
// than two that drift within a wave.
//
// ⚠ WHAT THIS EDITOR CANNOT OFFER, AND WHY IT IS RIGHT THAT IT DOES NOT:
//   Β· **Cohort conditions** β€” cohorts are a user's own workspace objects and an
//     admin editing someone else's account holds no list of them. The prop is
//     absent, so the affordance is absent (never present-and-inert).
//   Β· **`status` choice lists** β€” the grid discovers those from ROWS it has
//     loaded; this page has loaded none. Select/multiselect/user fields still
//     offer their declared options, so only Odoo lifecycle columns come up bare.
// Both are C-KIT's documented absences, restated here because a reader of this
// file will meet them as "why is that dropdown empty".
// ---------------------------------------------------------------------------

import { useCallback, useEffect, useRef, useState } from "react";
import { FilterBuilderPanel, FieldsHidePanel } from "../filter-kit";
import {
  getUserPerms,
  patchUser,
  putUserPerms,
  setPassword,
} from "./settingsApi";
import type { AdminUser } from "./settingsApi";
import type { CopyScope, PermsPayload, PermsRecord } from "./permsModel";
import {
  accessSummary,
  copyDropped,
  copyTargetRecord,
  filterOf,
  hiddenSet,
  hideableKeys,
  identityKey,
  isDirty,
  moduleSummary,
  parsePermsPayload,
  setAccess,
  setFilter,
  setHidden,
  toPutBody,
  toggleHidden,
} from "./permsModel";

type Load =
  | { phase: "loading" }
  | { phase: "error"; message: string }
  | { phase: "ready"; payload: PermsPayload };

/** What the confirm step is asking about. `null` = nothing is being confirmed. */
type Ask = null | { kind: "save" } | { kind: "discard" };

// --- C-PERMCOPY -------------------------------------------------------------
//
// CLIENT-SIDE ONLY, by contract: `GET /admin/users/{u}/perms` per target,
// compose, then `PUT` the WHOLE tree back. No new route.
//
// β›” THE WHOLE-TREE LAW IS WHY THE GET IS NOT OPTIONAL, and why the composition
// itself lives in `permsModel.copyTargetRecord` rather than in this file: a PUT
// replaces the record, a key omitted from it is a DELETION, and that failure
// already has a named negative control on the OTHER producer of PUT bodies.
// A second producer with no gate is the same bug waiting for a different road.

/** The UI's scope: the model's `CopyScope` plus the label this page shows. */
type CopyScopeUI = CopyScope & { label?: string };

/** One target, already fetched, with everything the confirm has to disclose. */
interface CopyPlan {
  user: AdminUser;
  payload: PermsPayload;
  /** What that account's access says TODAY β€” the thing being overwritten. */
  before: string;
  /** Consequences beyond the overwrite itself, named per target. */
  notes: string[];
}

type Copy =
  | { phase: "pick"; scope: CopyScopeUI; picked: ReadonlySet<string> }
  | { phase: "preparing"; scope: CopyScopeUI }
  | {
      phase: "confirm";
      scope: CopyScopeUI;
      plans: CopyPlan[];
      /** Targets whose record could not be READ. Named, never dropped. */
      refused: Array<{ username: string; why: string }>;
    }
  | { phase: "writing"; scope: CopyScopeUI };

/** "a", "a and b", "a, b and c" β€” a list a person reads, in a message that
 *  names every account it touched. */
function nameList(items: readonly string[]): string {
  if (items.length <= 1) return items[0] ?? "";
  return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
}

function scopeWords(scope: CopyScopeUI): string {
  return scope.kind === "all" ? "this account's whole access" : `${scope.label} access`;
}

export function PermsEditor({
  user,
  userOptions,
  copyTargets,
  onBack,
  onSaved,
}: {
  /**
   * ⚠ THE WHOLE RECORD, not a username and a display name (wave 17 R7). The
   * page edits the account as well as its access, so it needs `role` and
   * `active` β€” and taking the record rather than five scalars means one place
   * (the pane's `reload`) refreshes everything after a write.
   */
  user: AdminUser;
  /** The people a `user`-typed field may name, for the condition builder. Real
   *  accounts, never invented: an admin filtering on an owner who does not exist
   *  writes a rule that matches nothing and looks like it should match.
   *  ⚠ DISPLAY NAMES β€” the cells hold names. Not the copy targets below. */
  userOptions?: string[];
  /** Every OTHER account, as records, for C-PERMCOPY.
   *  ⚠ RECORDS, because a copy addresses an account by USERNAME and puts it in
   *  a URL. Handing this the display-name list would build a wrong path that
   *  404s at best and writes to the wrong account at worst. */
  copyTargets?: readonly AdminUser[];
  onBack: () => void;
  /** "The server took a write" β€” the list behind this page reloads. It does NOT
   *  close the page (R7: one page, several things to save on it). */
  onSaved: (message: string) => void;
}) {
  const displayName = user.name || user.username;
  const username = user.username;

  const [load, setLoad] = useState<Load>({ phase: "loading" });
  /** The record as the SERVER holds it β€” the baseline `isDirty` compares to. */
  const [saved, setSaved] = useState<PermsRecord>({});
  const [draft, setDraft] = useState<PermsRecord>({});
  const [ask, setAsk] = useState<Ask>(null);
  const [copy, setCopy] = useState<Copy | null>(null);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState("");
  /** What just happened, said ON THIS PAGE. The pane's own notice renders in the
   *  accounts LIST, which the admin is no longer sent back to on save β€” so
   *  without this a successful write would land in an empty room. */
  const [said, setSaid] = useState("");

  // --- the account half (R7, moved here from the accounts list's `EditUser`) --
  const [name, setName] = useState(user.name ?? "");
  const [role, setRole] = useState(user.role ?? "user");
  const [pw, setPw] = useState("");

  useEffect(() => {
    let dead = false;
    setLoad({ phase: "loading" });
    void getUserPerms(username).then((r) => {
      if (dead) return;
      if (!r.ok) {
        setLoad({ phase: "error", message: r.message });
        return;
      }
      const payload = parsePermsPayload(r.data);
      if (!payload) {
        // An unreadable payload is NOT an empty one. Rendering "no modules"
        // here would invite an admin to save a record that grants nothing,
        // over a record they never saw.
        setLoad({
          phase: "error",
          message: "The permission record could not be read. Close and try again.",
        });
        return;
      }
      setSaved(payload.entries);
      setDraft(payload.entries);
      setLoad({ phase: "ready", payload });
    });
    return () => {
      dead = true;
    };
  }, [username]);

  const payload = load.phase === "ready" ? load.payload : null;
  const dirty = payload ? isDirty(payload, saved, draft) : false;
  /**
   * ⚠ `payload.isAdmin` ALONE GOES STALE THE MOMENT THIS PAGE CAN CHANGE THE
   * ROLE β€” which is new in wave 17. It is a snapshot taken by the perms GET on
   * mount; promote the account to Admin from the card above and that GET is not
   * repeated, so the "these rules are inert" banner would stay hidden over an
   * account whose rules had just become inert. `user.role` is the fresher
   * truth: the accounts list reloads after every write here, and the record
   * comes back with it. Both are read, because either can be the newer one β€”
   * and the failure this prevents is an admin restricting an administrator and
   * walking away believing they did, which is the exact misreading the banner
   * exists to stop.
   *
   * ⚠ NOT the local `role` state: that is the DRAFT, and an unsaved role change
   * has not made anything inert yet.
   */
  const subjectIsAdmin = user.role === "admin" || payload?.isAdmin === true;
  /**
   * ⚠ THE GUARD HAD TO WIDEN WITH THE PAGE. `dirty` describes the PERMISSION
   * draft only, and it was the whole story while this file was only a
   * permission editor. Merge the account form in and Escape would throw away a
   * half-typed display name or a role change with no prompt and no undo β€”
   * exactly the failure the capture-phase listener below exists to prevent,
   * arriving through a second door.
   *
   * ⚠ A TYPED PASSWORD IS DELIBERATELY NOT COUNTED. It is a one-shot field with
   * its own button, retyped in seconds, and prompting "discard changes?" because
   * a password box has characters in it trains people to click through the
   * prompt β€” which weakens the guard for the edits that actually cost something.
   */
  const accountDirty =
    name.trim() !== (user.name ?? "").trim() || role !== (user.role ?? "user");
  const anyDirty = dirty || accountDirty;

  const goBack = useCallback(() => {
    if (anyDirty) setAsk({ kind: "discard" });
    else onBack();
  }, [anyDirty, onBack]);

  // ⚠ CAPTURE PHASE, ON PURPOSE. The settings modal binds its own window
  // `keydown` and closes the WHOLE dialog on Escape β€” which, on this page,
  // would throw away an admin's unsaved edits with no prompt and no undo. A
  // capture-phase listener on the same target runs before that bubble-phase
  // one, so `stopPropagation()` here actually stops it.
  const dirtyRef = useRef(anyDirty);
  dirtyRef.current = anyDirty;
  const askRef = useRef(ask);
  askRef.current = ask;
  const copyRef = useRef(copy);
  copyRef.current = copy;
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key !== "Escape") return;
      // A copy in flight owns Escape first: it is the innermost thing open, and
      // dismissing it must not also dismiss the page under it.
      if (copyRef.current) {
        e.stopPropagation();
        if (copyRef.current.phase !== "writing") setCopy(null);
        return;
      }
      if (askRef.current) {
        e.stopPropagation();
        setAsk(null);
        return;
      }
      if (dirtyRef.current) {
        e.stopPropagation();
        setAsk({ kind: "discard" });
      }
    };
    window.addEventListener("keydown", onKey, true);
    return () => window.removeEventListener("keydown", onKey, true);
  }, []);

  // ⚠ THE CONFIRM MUST BE BROUGHT INTO VIEW, and this is not polish. Its scrim
  // is scoped to THIS PANE rather than the viewport (a second full-screen scrim
  // over a dialog that already has one reads as the application losing its
  // place) β€” but the pane grows with the module list while `.set-body` is what
  // scrolls, so a card centred in the PANE can sit hundreds of pixels above the
  // visible area. The admin clicks Save at the bottom of a long page and
  // nothing appears to happen. One module fits today; the second one does not.
  const confirmRef = useRef<HTMLDivElement | null>(null);
  useEffect(() => {
    if (!ask && !copy) return;
    confirmRef.current?.scrollIntoView({ block: "center" });
    // Focus follows the question, so Enter answers it and a screen reader is
    // taken to the alert rather than left where the pointer was.
    confirmRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
  }, [ask, copy]);

  const save = useCallback(() => {
    if (!payload) return;
    setBusy(true);
    setError("");
    const body = toPutBody(payload, draft);
    void putUserPerms(username, body).then((r) => {
      setBusy(false);
      setAsk(null);
      if (r.ok) {
        setSaved(body.perms);
        setSaid(`Saved access for ${username}.`);
        onSaved(`Saved access for ${username}.`);
      } else {
        setError(r.message);
      }
    });
  }, [payload, draft, username, onSaved]);

  /** The account half's write. ⚠ THE PATCH STILL CARRIES NO `bus` (R1), and
   *  that is load-bearing rather than tidy: with the picker gone this form
   *  cannot edit the field, so a PATCH that still sent it would be a client
   *  echoing back a value it only read β€” and once the migration converts `bus`
   *  into a permanent filter and clears the field, that echo would RESURRECT
   *  it. A form writes what it edits and nothing else. */
  const applyAccount = useCallback(
    (patch: Parameters<typeof patchUser>[1], said: string) => {
      setBusy(true);
      setError("");
      void patchUser(username, patch).then((r) => {
        setBusy(false);
        if (r.ok) {
          setSaid(said);
          onSaved(said);
        } else {
          setError(r.message);
        }
      });
    },
    [username, onSaved]
  );

  // --- C-PERMCOPY: prepare, then confirm, then write ------------------------

  /**
   * ⚠ THE TARGETS ARE FETCHED BEFORE THE CONFIRM, NOT AFTER IT. The contract
   * asks for "a confirm that names the overwrite", and a confirm written from
   * this page's knowledge alone can only name the SOURCE β€” it would say what is
   * being copied and stay silent on what is being destroyed. Reading each
   * target first lets the question state, per account, what that account's
   * access says today and what else this write does to it. The payload fetched
   * here is the same one the PUT is built from, so it costs nothing extra.
   */
  const prepareCopy = useCallback(
    async (scope: CopyScopeUI, picked: readonly string[]) => {
      setCopy({ phase: "preparing", scope });
      const plans: CopyPlan[] = [];
      const refused: Array<{ username: string; why: string }> = [];
      for (const target of copyTargets ?? []) {
        if (!picked.includes(target.username)) continue;
        const r = await getUserPerms(target.username);
        if (!r.ok) {
          refused.push({ username: target.username, why: r.message });
          continue;
        }
        const p = parsePermsPayload(r.data);
        if (!p) {
          refused.push({
            username: target.username,
            why: "its permission record could not be read",
          });
          continue;
        }
        const notes: string[] = [];
        // An admin bypasses `perms` entirely, so a copy onto one is stored and
        // inert. Silently writing rules that do nothing is how an admin comes
        // to believe an account is restricted when it is not.
        if (p.isAdmin) {
          notes.push(
            "is an administrator β€” the rules will be stored but stay inert until the account is changed to Member"
          );
        }
        // ⚠ A COPY MIGRATES THE TARGET. An un-migrated record runs under the
        // PREVIOUS wall; a PUT moves it to the per-module model, which changes
        // what governs that account from that moment on. Fresh accounts are
        // exactly the ones an admin reaches for the copy button over
        // (`create_user` stamps no perms), so this is the common case, not the
        // exotic one.
        if (!p.migrated) {
          notes.push(
            "still uses the previous access model β€” this write moves it to the per-module model"
          );
        }
        // The same disclosure this page already makes for the account it has
        // open. A whole-record replace drops what it does not name, and a copy
        // does it to somebody the admin never opened.
        if (p.orphanModules.length) {
          notes.push(
            `has rules for ${p.orphanModules.join(", ")}, which this deployment no longer offers β€” those are removed`
          );
        }
        // ⚠ DISCLOSED BEFORE THE WRITE, not only after it. `runCopy` also reports
        // this, but a report arrives when the admin can no longer decide
        // anything; the confirm is where the decision is. The payload needed to
        // know it is already in hand, so there is no reason to make them find out
        // the expensive way.
        const cannot = copyDropped(p, saved, scope);
        if (cannot.length) {
          const named = cannot.map(
            (k) => payload?.modules.find((m) => m.key === k)?.label ?? k
          );
          notes.push(
            `does not offer ${named.join(", ")}, so that part of this copy cannot be written at all`
          );
        }
        plans.push({
          user: target,
          payload: p,
          before:
            scope.kind === "all"
              ? accessSummary(p, p.entries) || "No access"
              : moduleSummary(
                  p.entries[scope.key],
                  (p.modules.find((m) => m.key === scope.key)?.fields.length ?? 0) === 0
                ),
          notes,
        });
      }
      setCopy({ phase: "confirm", scope, plans, refused });
    },
    // `saved` and `payload`: the drop disclosure is computed against the record
    // as STORED (what a copy actually sends) and labelled from this account's
    // own module list, which is the only place a label for an undeclared-on-the-
    // target module exists.
    [copyTargets, saved, payload]
  );

  const runCopy = useCallback(
    async (scope: CopyScopeUI, plans: readonly CopyPlan[]) => {
      setCopy({ phase: "writing", scope });
      setError("");
      const done: string[] = [];
      const failed: string[] = [];
      const dropped = new Set<string>();
      for (const plan of plans) {
        // ⚠ THE COMPOSITION LIVES IN `permsModel`, NOT HERE β€” it is the second
        // producer of a PUT body in the product and the first aimed at somebody
        // else's record, so it belongs where the gate can reach it and where its
        // negative control can prove it capable of going red. See
        // `copyTargetRecord`'s own note for the whole-tree rule it enforces.
        const merged = copyTargetRecord(plan.payload, saved, scope);
        for (const k of copyDropped(plan.payload, saved, scope)) dropped.add(k);
        const r = await putUserPerms(plan.user.username, toPutBody(plan.payload, merged));
        if (r.ok) done.push(plan.user.username);
        else failed.push(`${plan.user.username} (${r.message})`);
      }
      setCopy(null);
      // ⚠ ONE MESSAGE PER OUTCOME, NEVER ONE MESSAGE FOR THE BATCH. Three
      // targets with the second refused, reported as "that change could not be
      // saved", is a sentence that lies about the two that took it β€” and the
      // admin's only recovery is to re-copy onto accounts that already have it.
      if (done.length) {
        const msg = `Copied ${scopeWords(scope)} to ${nameList(done)}.`;
        setSaid(msg);
        onSaved(msg);
      }
      // ⚠ ONE MESSAGE, BUILT FROM WHAT ACTUALLY WENT WRONG β€” the refusals and
      // the modules that could not ride the write are different failures and
      // both have to survive into it. "Copied" alone over a dropped module would
      // be a success message for a write that did not fully happen.
      const problems: string[] = [];
      if (failed.length) problems.push(`Not copied to ${nameList(failed)}.`);
      if (dropped.size) {
        const list = nameList([...dropped].sort());
        problems.push(
          `${list} could not be copied β€” the accounts you picked do not offer ${dropped.size === 1 ? "it" : "them"}.`
        );
      }
      if (problems.length) setError(problems.join(" "));
    },
    [saved, onSaved]
  );

  const canCopy = (copyTargets?.length ?? 0) > 0 && !!payload && !dirty;
  const openCopy = useCallback((scope: CopyScopeUI) => {
    setAsk(null);
    setError("");
    setSaid("");
    setCopy({ phase: "pick", scope, picked: new Set<string>() });
  }, []);

  return (
    <div className="set-pane set-perms">
      <div className="set-perm-head">
        <button type="button" className="set-secondary" onClick={goBack}>
          Back to accounts
        </button>
        <div className="set-perm-who">
          <h3 className="set-h set-perm-title">{displayName}</h3>
          <span className="set-username">{username}</span>
        </div>
        <span className={role === "admin" ? "set-chip set-chip--admin" : "set-chip"}>
          {role === "admin" ? "Admin" : "Member"}
        </span>
        {user.active ? null : <span className="set-chip set-chip--off">Deactivated</span>}
      </div>

      {error ? <p className="set-error">{error}</p> : null}
      {said ? <p className="set-notice">{said}</p> : null}

      {/* ── the account (R7) ─────────────────────────────────────────────── */}
      <section className="set-card">
        <h4 className="set-h4">Account</h4>
        <div className="set-grid2">
          <div className="set-field">
            <label className="set-label" htmlFor="set-name">Display name</label>
            <input
              id="set-name"
              className="set-input"
              value={name}
              onChange={(e) => setName(e.target.value)}
            />
          </div>
          <div className="set-field">
            <label className="set-label" htmlFor="set-role">Role</label>
            <select
              id="set-role"
              className="pg-select"
              value={role}
              onChange={(e) => setRole(e.target.value)}
            >
              <option value="user">Member</option>
              <option value="admin">Admin</option>
            </select>
          </div>
        </div>
        {/* An admin bypasses every rule below. Changing the role here therefore
            changes whether this whole page means anything, and saying so at the
            control beats discovering it from an access page that does nothing. */}
        {role === "admin" && (user.role ?? "user") !== "admin" ? (
          <p className="set-help">
            Saving this makes the account an administrator, which bypasses every
            access rule set below.
          </p>
        ) : null}
        <div className="set-actions">
          <button
            type="button"
            className="set-primary"
            disabled={busy || !accountDirty}
            onClick={() => applyAccount({ name, role }, `Saved ${username}.`)}
          >
            Save account
          </button>
          <button
            type="button"
            className="set-secondary"
            disabled={busy}
            onClick={() =>
              applyAccount(
                { active: !user.active },
                `${user.active ? "Deactivated" : "Reactivated"} ${username}.`
              )
            }
          >
            {user.active ? "Deactivate account" : "Reactivate account"}
          </button>
        </div>

        {/* ⚠ THE SAME GRID as the two fields above it, so the password box is
            exactly the width of the display-name box. Left full-bleed it
            stretched the whole card while the field above it was half of one β€”
            an accident of markup that reads as the two being different kinds of
            thing. The warning moves alongside, which is also where it belongs:
            beside the control it is about, not under it. */}
        <div className="set-danger set-grid2">
          <div className="set-field">
            <label className="set-label" htmlFor="set-pw">Set a new password</label>
            <input
              id="set-pw"
              className="set-input"
              type="password"
              value={pw}
              autoComplete="new-password"
              onChange={(e) => setPw(e.target.value)}
            />
          </div>
          <div className="set-pw-side">
            {/* Said plainly BEFORE the click. Resetting a password bumps the
                session epoch, which signs the person out of every device they
                are using β€” an admin should not discover that from a support
                ticket. */}
            <p className="set-help">This signs {username} out everywhere immediately.</p>
            <button
              type="button"
              className="set-secondary"
              disabled={busy || pw.length === 0}
              onClick={() => {
                setError("");
                void setPassword(username, pw).then((r) => {
                  if (r.ok) {
                    setPw("");
                    setSaid(`Password reset; ${username} was signed out.`);
                  } else {
                    setError(r.message);
                  }
                });
              }}
            >
              Reset password
            </button>
          </div>
        </div>
      </section>

      {/* wave17 item 3 (R6) β€” the mark, not "Loading access…". The way OUT is
          rendered above this and stays rendered, which is what the loading state
          actually owed the reader; the sentence never was. */}
      {load.phase === "loading" ? (
        <div className="set-loading">
          <span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
        </div>
      ) : null}
      {load.phase === "error" ? <p className="set-error">{load.message}</p> : null}

      {payload ? (
        <>
          {/* β›” AN ADMIN BYPASSES `perms` ENTIRELY (C-PERM amendment 4) β€” the
              clause that also keeps break-glass alive during a store outage. So
              every rule below is INERT for this account, and the page has to say
              so: an editor that renders a filter and a hidden-field list without
              a word is inviting somebody to "restrict" an administrator and walk
              away believing they did. The controls stay usable on purpose β€” the
              rules are real the moment the account stops being an admin. */}
          {subjectIsAdmin ? (
            <p className="set-notice set-perm-banner">
              This is an administrator account. Administrators see everything, so
              nothing set here applies to them β€” these rules take effect only if
              the account is changed to Member.
            </p>
          ) : null}

          {/* C-PERM amendment 4 β€” an un-migrated record still runs under the
              PREVIOUS wall, so its empty perms mean "not written yet", not "no
              access". Saying so is the difference between an admin reading this
              page correctly and one who thinks the account is locked out. */}
          {!payload.migrated && !subjectIsAdmin ? (
            <p className="set-notice set-perm-banner">
              This account still uses the previous access model, so the rules below
              are not in force yet. Saving moves it to the per-module model β€” set
              every module deliberately before you do.
            </p>
          ) : null}

          {/* A whole-record replace drops what it does not name. An admin should
              learn that here, not from a support ticket about a filter that
              vanished. */}
          {payload.orphanModules.length ? (
            <p className="set-error set-perm-banner">
              This account has rules for {payload.orphanModules.join(", ")}, which
              this deployment no longer offers. Saving removes them.
            </p>
          ) : null}

          <div className="set-perm-sect">
            <h4 className="set-h4">Access</h4>
            {copyTargets?.length ? (
              <button
                type="button"
                className="set-secondary set-copy-door"
                disabled={!canCopy || busy}
                title={
                  dirty
                    ? "Save this account's access first β€” a copy sends what is stored, not what is on screen."
                    : undefined
                }
                onClick={() => openCopy({ kind: "all" })}
              >
                Apply this access to…
              </button>
            ) : null}
          </div>

          <p className="set-help set-perm-intro">
            {accessSummary(payload, draft)}. Each module can be turned off entirely,
            narrowed to the records a condition describes, or shown with fields
            hidden. Restrictions apply everywhere the account looks β€” the tables,
            the exports and every number computed from them.
          </p>

          {/* ⚠ NOT A DISABLED BUTTON'S TOOLTIP ALONE. A control that is refused
              has to say why where the eye already is; a `title` only answers the
              reader who thought to hover. */}
          {dirty && copyTargets?.length ? (
            <p className="set-help set-copy-why">
              Copying is unavailable while there are unsaved changes β€” a copy sends
              what is stored, not what is on screen.
            </p>
          ) : null}

          {payload.modules.length === 0 ? (
            <p className="pg-empty">
              This deployment declares no modules that can be restricted.
            </p>
          ) : null}

          {payload.modules.map((m) => {
            const entry = draft[m.key];
            const on = entry?.access === true;
            const schemaless = m.fields.length === 0;
            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) => setDraft((d) => setAccess(d, m.key, e.target.checked))}
                    />
                    <span className="set-perm-modname">{m.label}</span>
                  </label>
                  <span className="set-perm-sum">{moduleSummary(entry, schemaless)}</span>
                  {copyTargets?.length ? (
                    <button
                      type="button"
                      className="set-secondary set-copy-door"
                      disabled={!canCopy || busy}
                      onClick={() => openCopy({ kind: "module", key: m.key, label: m.label })}
                    >
                      Copy to…
                    </button>
                  ) : null}
                </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 module, so access is all this
                    editor can set for it. Conditions and hidden fields need a schema.
                  </p>
                ) : null}

                {on && !schemaless ? (
                  <div className="set-perm-panels">
                    <div className="cg-pop set-perm-pop">
                      <FilterBuilderPanel
                        fields={m.fields}
                        filters={filterOf(draft, m.key)}
                        onChange={(next) => setDraft((d) => setFilter(d, m.key, next))}
                        userOptions={userOptions}
                      />
                    </div>
                    <div className="cg-pop set-perm-pop">
                      <FieldsHidePanel
                        fields={m.fields}
                        hidden={hiddenSet(draft, m.key)}
                        onToggle={(key) => setDraft((d) => toggleHidden(d, 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 C-PERM's PUT validation would accept because the
                        // identity column is a perfectly KNOWN field key.
                        lockedKey={identityKey(m.fields)}
                        onHideAll={() =>
                          setDraft((d) => setHidden(d, m.key, hideableKeys(m.fields)))
                        }
                        onShowAll={() => setDraft((d) => setHidden(d, m.key, []))}
                      />
                    </div>
                  </div>
                ) : null}
              </section>
            );
          })}

          <div className="set-actions set-perm-actions">
            <button
              type="button"
              className="set-primary"
              disabled={busy || !dirty}
              onClick={() => setAsk({ kind: "save" })}
            >
              Save access
            </button>
            <button type="button" className="set-secondary" disabled={busy} onClick={goBack}>
              Cancel
            </button>
            {dirty ? <span className="set-help set-perm-dirty">Unsaved changes</span> : null}
          </div>
        </>
      ) : null}

      {ask ? (
        <div className="set-confirm-wrap">
          <div
            ref={confirmRef}
            className="set-confirm"
            role="alertdialog"
            aria-modal="true"
            aria-label={ask.kind === "save" ? "Confirm access change" : "Discard changes"}
          >
            {ask.kind === "save" ? (
              <>
                <h4 className="set-h4">Change what {displayName} can see?</h4>
                <p className="set-help">
                  This takes effect the next time they load a page. {accessSummaryLine(payload, draft)}
                </p>
              </>
            ) : (
              <>
                <h4 className="set-h4">Discard these changes?</h4>
                {/* ⚠ IT NAMES WHICH ONES. The page holds two kinds of unsaved
                    work now, and "your changes" over a form the reader cannot
                    see behind the scrim is a question they cannot answer. */}
                <p className="set-help">
                  {unsavedWords(dirty, accountDirty)} for {displayName} stay as they were.
                </p>
              </>
            )}
            <div className="set-actions">
              <button
                type="button"
                className="set-primary"
                disabled={busy}
                onClick={() => (ask.kind === "save" ? save() : (setAsk(null), onBack()))}
              >
                {ask.kind === "save" ? "Save access" : "Discard"}
              </button>
              <button
                type="button"
                className="set-secondary"
                disabled={busy}
                onClick={() => setAsk(null)}
              >
                Keep editing
              </button>
            </div>
          </div>
        </div>
      ) : null}

      {copy ? (
        <div className="set-confirm-wrap">
          <div
            ref={confirmRef}
            className="set-confirm set-copy"
            role="alertdialog"
            aria-modal="true"
            aria-label={`Copy ${scopeWords(copy.scope)}`}
          >
            {copy.phase === "pick" ? (
              <>
                <h4 className="set-h4">Copy {scopeWords(copy.scope)} to…</h4>
                <p className="set-help">
                  {copy.scope.kind === "all"
                    ? `Every account you pick has its whole access record replaced with ${displayName}'s.`
                    : `Every account you pick has its ${copy.scope.label} access replaced with ${displayName}'s. Their other modules are left alone.`}
                </p>
                <div className="set-copy-list">
                  {(copyTargets ?? []).map((t) => (
                    <label className="set-check set-copy-row" key={t.username}>
                      <input
                        type="checkbox"
                        checked={copy.picked.has(t.username)}
                        onChange={(e) => {
                          const next = new Set(copy.picked);
                          if (e.target.checked) next.add(t.username);
                          else next.delete(t.username);
                          setCopy({ ...copy, picked: next });
                        }}
                      />
                      <span className="set-copy-name">{t.name || t.username}</span>
                      <span className="set-username set-copy-user">{t.username}</span>
                      {t.role === "admin" ? (
                        <span className="set-chip set-chip--admin">Admin</span>
                      ) : null}
                      {t.active ? null : (
                        <span className="set-chip set-chip--off">Deactivated</span>
                      )}
                    </label>
                  ))}
                </div>
                <div className="set-actions">
                  <button
                    type="button"
                    className="set-primary"
                    disabled={copy.picked.size === 0}
                    onClick={() => void prepareCopy(copy.scope, [...copy.picked])}
                  >
                    Continue
                  </button>
                  <button type="button" className="set-secondary" onClick={() => setCopy(null)}>
                    Cancel
                  </button>
                </div>
              </>
            ) : null}

            {copy.phase === "preparing" || copy.phase === "writing" ? (
              <div className="set-loading">
                <span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
              </div>
            ) : null}

            {copy.phase === "confirm" ? (
              <>
                <h4 className="set-h4">
                  Replace {scopeWords(copy.scope)} for {copy.plans.length} account
                  {copy.plans.length === 1 ? "" : "s"}?
                </h4>
                {copy.plans.length === 0 ? (
                  <p className="set-help">
                    None of the accounts picked could be read, so there is nothing to write.
                  </p>
                ) : (
                  <ul className="set-copy-plans">
                    {copy.plans.map((p) => (
                      <li key={p.user.username}>
                        {/* ⚠ THE OVERWRITE, NAMED PER TARGET. What is destroyed
                            is what this account has TODAY, so the question shows
                            it β€” a confirm that only describes the source asks
                            the admin to remember the rest. */}
                        <span className="set-copy-name">{p.user.name || p.user.username}</span>{" "}
                        <span className="set-copy-before">now: {p.before}</span>
                        {p.notes.map((n) => (
                          <span className="set-copy-note" key={n}>
                            {p.user.username} {n}
                          </span>
                        ))}
                      </li>
                    ))}
                  </ul>
                )}
                {copy.refused.length ? (
                  <p className="set-error">
                    {/* ⚠ The reason comes from the API and ends in a full stop
                        of its own ("Administrators only."), which read as
                        "(Administrators only.) could not be read" β€” a stray
                        period mid-sentence. Trimmed where the sentence is
                        built, not in the message the API returns. */}
                    {nameList(
                      copy.refused.map((r) => `${r.username} (${r.why.replace(/\.\s*$/, "")})`)
                    )}{" "}
                    could not be read and will not be changed.
                  </p>
                ) : null}
                <div className="set-actions">
                  <button
                    type="button"
                    className="set-primary"
                    disabled={copy.plans.length === 0}
                    onClick={() => void runCopy(copy.scope, copy.plans)}
                  >
                    Replace access
                  </button>
                  <button type="button" className="set-secondary" onClick={() => setCopy(null)}>
                    Cancel
                  </button>
                </div>
              </>
            ) : null}
          </div>
        </div>
      ) : null}
    </div>
  );
}

/** The confirm's one-line restatement. Deliberately the SAME sentence the pane
 *  header shows: a confirm that summarises differently from the page behind it
 *  is asking the reader to reconcile two descriptions under time pressure. */
function accessSummaryLine(payload: PermsPayload | null, draft: PermsRecord): string {
  const s = accessSummary(payload, draft);
  return s ? `${s} after this change.` : "";
}

/** Which kinds of unsaved work the discard question is about. Exported-shaped
 *  as a pure function so it reads once and cannot drift between the two places
 *  a reader meets it. */
function unsavedWords(permsDirty: boolean, accountDirty: boolean): string {
  if (permsDirty && accountDirty) return "The account details and access rules";
  if (accountDirty) return "The account details";
  return "The access rules";
}