File size: 55,479 Bytes
092334a
 
 
 
 
 
 
 
 
ea7b176
 
 
 
 
 
 
 
 
 
 
 
 
da5297e
092334a
 
 
 
 
 
 
 
 
 
 
 
016c754
ea7b176
092334a
016c754
7127075
 
 
 
 
 
 
4748aae
 
 
 
 
609fb78
4748aae
 
 
 
da5297e
ea7b176
 
62d663e
da5297e
092334a
da5297e
 
 
 
092334a
da5297e
 
 
 
 
 
092334a
 
 
 
 
 
 
 
 
 
 
7127075
 
 
 
 
 
 
 
 
ea7b176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62d663e
 
 
 
 
 
 
 
ea7b176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
092334a
 
 
 
7127075
 
 
 
 
 
 
 
 
 
 
 
 
092334a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7127075
 
 
 
 
 
 
 
 
092334a
7127075
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da5297e
 
7127075
 
 
 
 
 
 
 
 
da5297e
7127075
 
 
 
 
 
 
da5297e
 
 
 
 
 
 
 
 
092334a
 
ea7b176
 
 
 
 
 
092334a
 
 
 
 
 
7127075
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
092334a
 
 
 
 
 
 
 
 
 
 
 
 
 
da5297e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
092334a
7127075
 
 
 
 
 
 
 
 
 
 
 
 
 
092334a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da5297e
 
 
 
 
 
 
 
 
092334a
 
 
 
 
 
 
 
 
 
 
 
da5297e
 
 
 
092334a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
016c754
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7127075
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
016c754
7127075
016c754
 
7127075
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
016c754
 
 
 
 
 
7127075
016c754
 
 
7127075
016c754
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7127075
 
 
 
 
 
 
 
 
 
016c754
7127075
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
016c754
7127075
 
 
 
 
 
 
016c754
7127075
016c754
 
 
092334a
 
 
 
 
 
 
016c754
da5297e
092334a
 
 
 
 
016c754
 
 
da5297e
 
 
 
 
 
092334a
 
 
ea7b176
 
092334a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea7b176
 
 
 
 
4748aae
 
ea7b176
4748aae
ea7b176
 
62d663e
 
 
 
ea7b176
 
 
 
 
 
 
 
 
 
4748aae
092334a
 
 
da5297e
 
ea7b176
 
 
 
 
 
 
 
 
 
 
092334a
 
 
 
 
 
 
 
ea7b176
 
 
 
 
 
 
092334a
ea7b176
 
092334a
 
 
 
 
 
ea7b176
092334a
 
 
 
 
 
ea7b176
092334a
 
 
4748aae
 
62d663e
ea7b176
 
 
 
 
 
 
 
 
 
4748aae
092334a
 
 
7127075
 
 
 
 
 
 
 
 
 
092334a
 
 
 
 
 
da5297e
 
 
 
 
 
 
7127075
da5297e
 
092334a
7127075
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da5297e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7127075
092334a
 
 
 
 
 
 
 
 
4748aae
 
 
 
609fb78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4748aae
 
 
 
 
609fb78
 
 
 
4748aae
 
 
609fb78
4748aae
 
 
 
 
 
 
 
 
 
609fb78
 
4748aae
 
 
 
 
 
 
 
 
 
609fb78
 
 
 
 
4748aae
 
 
 
 
 
609fb78
4748aae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609fb78
 
 
4748aae
 
 
 
 
 
609fb78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4748aae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609fb78
 
4748aae
 
609fb78
4748aae
609fb78
 
 
 
 
 
4748aae
 
 
 
 
609fb78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4748aae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609fb78
 
 
 
 
 
 
 
 
 
 
 
4748aae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
// ---------------------------------------------------------------------------
// settings / SettingsModal.tsx β€” EXIT wave 2 (W2-8, contract Y4).
//
// Settings and Manage users as ONE centred modal with a left rail, matching the
// shipped Streamlit design (`app.py:settings_dialog`, item 8) and the reference
// the owner named: *"when I click 'Setting' this whole thing pops up? I want
// that exact thing for when we click Settings and Manage User, where the
// background is clearly blurred too."*
//
// ⚠ WAVE 19 R12 β€” THE SURFACE IS CALLED "SETTINGS" AGAIN, and this note replaces
// the wave-15 one that said the opposite ("the surface is called PROFILE now, in
// this shell only"). The owner has ruled the rename back: one account-menu row,
// called Settings, with a gear, shown to everyone, landing on Account. The file,
// the export and the `SettingsSection` type were never renamed in either
// direction β€” which is exactly why the wave-15 trade was the right one to make
// and the cheap one to undo. A LABEL moved twice; no import ever did.
//
// β›” `verify_ui.py`'s naming check enforced the wave-15 wording, so the correct
// edit would have turned it red. It is inverted in the same change (bare
// "Profile" must not survive as copy; bare "Settings" must be PRESENT) and
// booked as wave-19 amendment 1. A gate that pins a superseded ruling is not a
// gate, it is a second place the old decision lives.
//
// A MODAL, NOT A PAGE, and the shipped version's reasoning holds here too:
// settings are a detour, not a destination β€” you change a toggle and go back to
// what you were doing. The Streamlit app kept a settings PAGE alongside the
// modal because a deep link is a poor thing to land on a modal; the shell has
// no settings route at all, so there is nothing to keep.
//
// β›” EVERY ADMIN CONTROL HERE IS A COURTESY, NOT A CHECK. The server refuses a
// non-admin on every `/admin/*` route, fail-closed, and Y4's gate proves it by
// having a viewer try. Hiding the rail entry only spares a member a door that
// would not open.
// ---------------------------------------------------------------------------

import { useCallback, useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import type { SessionUser } from "../shell/session";
import { isAdmin, parseUserEnvelope } from "../shell/session";
import type { AdminUser, SettingsPayload } from "./settingsApi";
// ⚠ `patchUser` and `setPassword` left this file with `EditUser` (R7) β€” they are
// imported by `PermsEditor` now, where the form that writes them lives, and it
// spells the patch shape `Parameters<typeof patchUser>[1]` rather than importing
// `UserPatch` separately. `noUnusedLocals` is what walks a move like this down to
// the last orphaned import, which is the tsconfig earning its keep again.
import { clearAvatar, createUser, getSettings, listUsers, setAvatar } from "./settingsApi";
import {
  addKeychainEntry,
  deleteKeychainEntry,
  getConnectors,
  listKeychain,
  setKeychainScope,
  pauseConnector,
  testKeychainEntry,
} from "./settingsApi";
import type { ConnectorRow, KeyEntry, UnsyncedInfo } from "./settingsApi";
import { PermsEditor } from "./PermsEditor";
// Wave 19 contract C2 β€” session D's self-contained plane; see the mount below.
import { AdminPane } from "./AdminPane";
import { StatementsPane } from "./StatementsPane";
import { moduleSummary, parseEntry, reachableSection } from "./permsModel";

// The vocabulary lives beside the rule that gates it (permsModel), and is
// re-exported here so `Shell.tsx`'s import does not have to know that.
export type { SettingsSection } from "./permsModel";
import type { SettingsSection } from "./permsModel";

// β›” `BU_LABELS`, `BU_IDS` and `scopeWords` ALL WENT WITH THE PICKER (R1).
// Nothing offers, writes or RENDERS a business unit in this shell any more β€”
// the last reader was the accounts list, and its column is now "Access". The
// concept survives only as a legacy value on the record, which S1's migration
// converts to a permanent filter once. `noUnusedLocals` is what walked the
// removal down to the last orphan, which is the tsconfig earning its keep.

function moduleWords(mods: string[] | "all"): string {
  // ⚠ `[]` READS as unrestricted β€” the existing semantic Y4 says not to "fix"
  // this wave. Saying "All modules" here is therefore the TRUE statement, and
  // the write side is what refuses to create the ambiguity (see settingsApi).
  if (mods === "all" || !Array.isArray(mods) || mods.length === 0) return "All modules";
  return `${mods.length} module${mods.length === 1 ? "" : "s"}: ${mods.join(", ")}`;
}

// ------------------------------------------------------------------ the users pane

/** The trailing affordance on an account card. SVG, never a character glyph. */
function ChevIcon() {
  return (
    <svg className="set-acct-chev" viewBox="0 0 12 12" aria-hidden="true">
      <path d="M4.4 2.4 8 6l-3.6 3.6" />
    </svg>
  );
}

// ── WAVE 19 R13: one mark per rail tab ───────────────────────────────────────
//
// The rail was five words in a column, and at a glance five words in a column is
// a list you read rather than a place you navigate. A mark per row is what makes
// "Keychains" findable without reading "Connectors" first.
//
// ⚠ PURE FUNCTIONS OF NOTHING, and that is a requirement here rather than a
// style: `verify_ui.py` renders this module through `react-dom/server`, where
// `window` and `document` do not exist. An icon that measured anything, or read
// a theme off the DOM at module scope, would take the whole gate down with it β€”
// and the failure would look like a broken gate rather than a broken icon.
// SVG only, never emoji (the platform's own standing rule).
function RailIcon({ children }: { children: ReactNode }) {
  return (
    <svg className="set-rail-icon" viewBox="0 0 16 16" aria-hidden="true">
      {children}
    </svg>
  );
}

/** Account β€” the person whose settings these are. */
const IconAccount = (
  <RailIcon>
    <circle cx="8" cy="5.6" r="2.7" />
    <path d="M2.9 13.4c0-2.5 2.3-4.1 5.1-4.1s5.1 1.6 5.1 4.1" />
  </RailIcon>
);
/** Your access β€” a closed padlock: what this account may open, and what narrows it. */
const IconAccess = (
  <RailIcon>
    <rect x="3.3" y="7.1" width="9.4" height="6.3" rx="1.3" />
    <path d="M5.6 7.1V5.3a2.4 2.4 0 0 1 4.8 0v1.8" />
  </RailIcon>
);
/** Manage users β€” more than one person. */
const IconUsers = (
  <RailIcon>
    <circle cx="6.2" cy="5.9" r="2.3" />
    <path d="M1.9 13.2c0-2.2 1.9-3.5 4.3-3.5s4.3 1.3 4.3 3.5" />
    <path d="M10.7 4.1a2.3 2.3 0 0 1 0 4.3M11.6 9.9c1.5.4 2.5 1.5 2.5 3.3" />
  </RailIcon>
);
/** Keychains β€” a key. */
const IconKey = (
  <RailIcon>
    <circle cx="5.4" cy="10.6" r="2.6" />
    <path d="M7.3 8.8 13.1 3M11 5.1l1.5 1.5M9.4 6.7l1.4 1.4" />
  </RailIcon>
);
/** Connectors β€” two links joined: where the data comes from. */
/** Statements β€” an envelope. The one room in here that sends something OUT of
 *  the building, so it wears the only outbound-shaped mark on the rail. */
const IconMail = (
  <RailIcon>
    <path d="M2 4.5h12v7H2z" />
    <path d="M2.4 5 8 9l5.6-4" />
  </RailIcon>
);
const IconPlug = (
  <RailIcon>
    <path d="M6.6 9.4 4.5 11.5a2.6 2.6 0 0 1-3.7-3.7l2.1-2.1" />
    <path d="M9.4 6.6l2.1-2.1a2.6 2.6 0 0 1 3.7 3.7l-2.1 2.1" />
    <path d="M6.2 9.8l3.6-3.6" />
  </RailIcon>
);
/** Loopable admin β€” stacked plates, i.e. MANY tenants. Deliberately not another
 *  lock or shield: "Your access" already wears a padlock, and two authority
 *  glyphs in one rail would read as two grades of the same thing rather than as
 *  a different scope entirely. */
const IconTenants = (
  <RailIcon>
    <path d="M8 1.9 14.3 5 8 8.1 1.7 5z" />
    <path d="M2.4 8.1 8 10.9l5.6-2.8M2.4 11.2 8 14l5.6-2.8" />
  </RailIcon>
);

function UsersPane() {
  const [users, setUsers] = useState<AdminUser[] | null>(null);
  const [error, setError] = useState("");
  const [notice, setNotice] = useState("");
  /**
   * ⚠ WAVE 17 R7 β€” ONE STATE, WHERE THERE WERE TWO. This pane held `editing`
   * (the inline account-details card, at the BOTTOM of the list) and `permsFor`
   * (the full-pane access page) as separate things, on the wave-15 reasoning
   * that *"they are two different jobs: one changes who the account IS, the
   * other changes what it may SEE"*. True about the data, wrong about the
   * person: an admin opening an account does not know in advance which of the
   * two they came for, and the product offered them two doors, one of which
   * scrolled the page instead of changing it. The owner met that as *"Edit and
   * clicking a user name should just be the EXACT same opening of a full page
   * so its not confusing"*. There is one door now, and one thing behind it.
   */
  const [open, setOpen] = useState<string | null>(null);

  const reload = useCallback(() => {
    void listUsers().then((r) => {
      if (r.ok) {
        setUsers(r.data.users ?? []);
        setError("");
      } else {
        setUsers([]);
        setError(r.message);
      }
    });
  }, []);

  useEffect(reload, [reload]);

  // wave17 item 3 (R6). ⚠ `.pg-empty` was the WRONG BOX as well as the wrong
  // words: it is the dashed empty-state frame, so a list that was merely still
  // arriving was drawn in the chrome that means "there is nothing here".
  if (users === null)
    return (
      <div className="set-loading">
        <span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
      </div>
    );

  // The FULL-PANE TAKEOVER: the users pane IS the modal body, so returning the
  // page here replaces the whole body and nothing else has to know. The rail
  // stays β€” it is chrome, not body, and an admin who wants out of this page
  // entirely should not have to find a Back button first.
  if (open) {
    const who = users.find((u) => u.username === open);
    // The record can genuinely vanish under an open page: `reload()` runs after
    // every write, and another admin may have removed the account in between.
    // Saying so beats rendering a page about nobody.
    if (!who) {
      return (
        <div className="set-pane">
          <section className="set-card">
            <h4 className="set-h4">That account is no longer listed</h4>
            <p className="set-help">It may have been removed since this list was loaded.</p>
            <div className="set-actions">
              <button type="button" className="set-secondary" onClick={() => setOpen(null)}>
                Back to accounts
              </button>
            </div>
          </section>
        </div>
      );
    }
    return (
      <PermsEditor
        // Remount per account: the page holds a DRAFT of somebody's permissions
        // and an unsaved display name, and carrying either across a switch of
        // subject is how one account's edits get saved onto another.
        key={who.username}
        user={who}
        // ⚠ TWO LISTS, TWO JOBS, AND MIXING THEM MAKES A WRONG URL. These are
        // DISPLAY NAMES, for `user`-typed filter conditions β€” the cells hold
        // names, so the builder must offer names. `copyTargets` below carries
        // the RECORDS, because a copy addresses accounts by USERNAME.
        userOptions={users.map((u) => u.name || u.username)}
        copyTargets={users.filter((u) => u.username !== who.username)}
        onBack={() => setOpen(null)}
        // ⚠ IT DOES NOT CLOSE THE PAGE ANY MORE. Wave 15's version returned to
        // the list on save, which was right when this was only an access
        // editor. It is one page with several things to save now, and a page
        // that ejects you after the first of them is a page you have to
        // re-open to finish the job. The way out stays visible instead.
        onSaved={(msg) => {
          setNotice(msg);
          setError("");
          reload();
        }}
      />
    );
  }

  return (
    <div className="set-pane">
      {/* ⚠ WAVE 19 (owner item 9) β€” THE HEADING MATCHES THE RAIL ENTRY THAT
          OPENS IT. It said "Accounts" while the only way in was a tab reading
          "Manage users", so the pane appeared to be somewhere other than the
          place that was clicked β€” the cheapest kind of "am I in the right
          screen?" there is. The rail is the name; the body agrees with it. */}
      <h3 className="set-h">Manage users</h3>
      {error ? <p className="set-error">{error}</p> : null}
      {notice ? <p className="set-notice">{notice}</p> : null}

      {users.length === 0 && !error ? (
        <p className="pg-empty">No accounts are configured on this deployment yet.</p>
      ) : (
        /* ⚠ WAVE 17 R7 β€” A LIST OF CARDS, NOT A TABLE, and the shape is the
           feature. The owner asked to "click the whole user's Card, not just
           name, so it has bigger hit box": in a table the only click target
           that could exist was the name itself, because a `<tr onClick>` is
           invisible to a keyboard and wrapping every cell in a button is five
           tab stops per row. One `<button>` per account is ONE control with the
           whole card as its hit box, focusable and Enter-able for free.
           β›” "Business units" IS STILL GONE (R1) and did not come back as a
           card line. What replaced it is the access sentence, which is a page
           now rather than a cell because a permanent filter does not fit in
           one. */
        <ul className="set-acctlist">
          {users.map((u) => (
            <li key={u.username}>
              <button
                type="button"
                className={"set-acct" + (u.active ? "" : " is-inactive")}
                onClick={() => setOpen(u.username)}
              >
                <Monogram name={u.name || u.username} />
                <span className="set-acct-body">
                  <span className="set-acct-top">
                    <span className="set-acct-name">{u.name || u.username}</span>
                    <RoleChip admin={u.role === "admin"} />
                    {u.active ? null : <span className="set-chip set-chip--off">Deactivated</span>}
                  </span>
                  <span className="set-acct-sub">{u.username}</span>
                  {/* ⚠ An admin BYPASSES perms entirely (C-PERM amendment 4) β€”
                      the clause that also keeps break-glass alive during a
                      store outage. Saying "Everything (admin)" rather than
                      rendering a rule prevents the one dangerous misreading:
                      that the restrictions on an admin's access page are in
                      force. */}
                  <span className="set-acct-access">
                    {u.role === "admin"
                      ? "Everything (admin)"
                      : (u.accessSummary ?? moduleWords(u.modules))}
                  </span>
                </span>
                {/* ⚠ A SPAN, NOT A BUTTON, AND THAT IS THE POINT OF R7. It is
                    inside the card's own button, so clicking the word "Edit"
                    opens exactly the page clicking anywhere else on the card
                    opens β€” which is the ruling, literally. A real nested button
                    is invalid markup, and the stretched-link alternative would
                    put two tab stops on one destination: the confusion the
                    ruling names, rebuilt in the accessibility tree. */}
                <span className="set-acct-go">
                  Edit
                  <ChevIcon />
                </span>
              </button>
            </li>
          ))}
        </ul>
      )}

      <AddUser
        onCreated={(msg) => {
          setNotice(msg);
          setError("");
          reload();
        }}
        onError={setError}
      />
    </div>
  );
}

// β›” `BuPicker` WAS HERE, AND IT IS NOT COMING BACK (wave 15, R1). Business unit
// died as a USER-FACING CONCEPT: BU access is now one permanent filter among
// others under the per-module permissioning engine β€” "dba is any of [Fisch,
// Both]" says exactly what the two checkboxes used to say, in the same grammar
// as every other restriction, and an admin who wants a different cut is no
// longer limited to the two cuts somebody hard-coded.
//
// The picker's old help text is worth keeping in one form, because the property
// it named still holds and is still the point: a member scoped to one unit sees
// no other unit's data anywhere. What changed is WHERE that is enforced β€” the
// permanent filter and its server-side wall (C-PERM), not a `bus` list.
//
// ⚠ `bus` still exists on the RECORD and in `AdminUser` during the strangler
// period (legacy Streamlit pages read it; S1's migration converts it once). What
// no longer exists is a UI that WRITES it β€” see EditUser/AddUser below, which
// deliberately stopped sending the field they can no longer edit.

// β›” `EditUser` WAS HERE. It is not deleted so much as RELOCATED β€” every control
// it held (display name, role, deactivate/reactivate, password reset with its
// signs-them-out-everywhere warning) now lives at the top of the user page in
// `PermsEditor`, which is the whole of R7: *"Edit and clicking a user name
// should just be the EXACT same opening of a full page"*. What genuinely died
// is the SHAPE β€” a card that appeared at the bottom of the accounts list, below
// the fold, describing a row somewhere above it. Two doors, two layouts and one
// of them scrolled instead of navigating.
//
// ⚠ The load-bearing note from this block MOVES WITH THE FORM and is restated
// there: the PATCH still carries no `bus` (R1). With the picker gone the form
// cannot edit that field, so sending 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.

function AddUser({
  onCreated,
  onError,
}: {
  onCreated: (msg: string) => void;
  onError: (msg: string) => void;
}) {
  const [open, setOpen] = useState(false);
  const [username, setUsername] = useState("");
  const [name, setName] = useState("");
  const [role, setRole] = useState("user");
  const [password, setPassword_] = useState("");
  const [busy, setBusy] = useState(false);

  if (!open)
    return (
      <button type="button" className="set-primary set-add" onClick={() => setOpen(true)}>
        Add an account
      </button>
    );

  const valid = username.trim().length > 0 && password.length > 0;
  return (
    <section className="set-card">
      <h4 className="set-h4">New account</h4>
      <div className="set-field">
        <label className="set-label" htmlFor="set-new-user">Username</label>
        <input
          id="set-new-user"
          className="set-input"
          value={username}
          autoComplete="off"
          onChange={(e) => setUsername(e.target.value)}
        />
        {/* S1's Y4 amendment 1, surfaced as guidance rather than as a 409. */}
        <p className="set-help">
          Must be new. To change an existing account, edit it above β€” re-creating
          one would revive sessions that were revoked.
        </p>
      </div>
      <div className="set-field">
        <label className="set-label" htmlFor="set-new-name">Display name</label>
        <input id="set-new-name" className="set-input" value={name} onChange={(e) => setName(e.target.value)} />
      </div>
      <div className="set-field">
        <label className="set-label" htmlFor="set-new-role">Role</label>
        <select id="set-new-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 className="set-field">
        <label className="set-label" htmlFor="set-new-pw">Password</label>
        <input
          id="set-new-pw"
          className="set-input"
          type="password"
          value={password}
          autoComplete="new-password"
          onChange={(e) => setPassword_(e.target.value)}
        />
        {/* Said before the click, because a new account is created OPEN and is
            narrowed afterwards. That order is the server's (an absent `bus`
            defaults to every unit) and this wave does not change it β€” what the
            wave changes is that "afterwards" now leads somewhere: open the
            account and set its per-module access, filters and hidden fields. */}
        <p className="set-help">
          A new account starts with access to everything this deployment shows.
          Open it from the list afterwards to set what they may see.
        </p>
      </div>
      <div className="set-actions">
        <button
          type="button"
          className="set-primary"
          disabled={busy || !valid}
          onClick={() => {
            setBusy(true);
            void createUser({
              username: username.trim().toLowerCase(),
              name: name || username.trim(),
              role,
              // ⚠ `bus` IS DELIBERATELY ABSENT (R1) β€” see the note where the
              // picker used to be. The route defaults an absent `bus` to "all",
              // so this is the same account it always created, minus a client
              // asserting a value it cannot edit.
              // ⚠ `"all"`, never `[]`. An empty list READS as unrestricted, so
              // sending one to mean "nothing" would grant everything.
              modules: "all",
              password,
            }).then((r) => {
              setBusy(false);
              if (r.ok) {
                onCreated(`Created ${username.trim().toLowerCase()}.`);
                setOpen(false);
                setUsername("");
                setName("");
                setPassword_("");
              } else {
                onError(r.message);
              }
            });
          }}
        >
          Create account
        </button>
        <button type="button" className="set-secondary" onClick={() => setOpen(false)}>
          Cancel
        </button>
      </div>
    </section>
  );
}

// ------------------------------------------------------------------ profile photo (wave 14)

/** Downscale + centre-crop to a <=128px JPEG data URL. JPEG at this size lands ~5-10KB,
 *  comfortably under the server's 64KB decoded wall; the server re-validates regardless. */
async function fileToAvatarDataUrl(file: File): Promise<string | null> {
  const url = URL.createObjectURL(file);
  try {
    const img = new Image();
    await new Promise<void>((res, rej) => {
      img.onload = () => res();
      img.onerror = () => rej(new Error("unreadable image"));
      img.src = url;
    });
    if (!img.width || !img.height) return null;
    const side = Math.min(128, Math.max(img.width, img.height));
    const canvas = document.createElement("canvas");
    canvas.width = side;
    canvas.height = side;
    const ctx = canvas.getContext("2d");
    if (!ctx) return null;
    const s = Math.min(img.width, img.height);
    ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, side, side);
    return canvas.toDataURL("image/jpeg", 0.85);
  } catch {
    return null;
  } finally {
    URL.revokeObjectURL(url);
  }
}

/** The first letter of a name, the way both shells compute it.
 *  ⚠ Unicode letter/number, not `[a-z0-9]` β€” the app's `_account_css` uses
 *  Python's `str.isalnum()`, so an ASCII-only class here would give a non-Latin
 *  name an initial in one door and a blank circle in the other. */
function initialOf(name: string): string {
  return (name.match(/[\p{L}\p{N}]/u)?.[0] ?? "?").toUpperCase();
}

/**
 * ⚠ WAVE 17 (16b) β€” THE INLINE STYLES ARE GONE, AND SO IS THEIR JUSTIFICATION.
 * This block used to carry twelve declarations in a `style` object under the
 * note *"the `.set-*`/`.shell-*` stylesheet is another wave-14 session's file"*.
 * That was true then and is not true now β€” SHELL owns the `.set-*` region this
 * wave β€” and an inline `background: "var(--lp-blue-tint)"` is still a colour
 * decision living outside the stylesheet, which is the thing the file-wide
 * token rule exists to stop. Two sizes are needed (the identity header and an
 * accounts card), so there are two classes rather than a size prop threading a
 * number into CSS.
 */
function AvatarPreview({ user, large }: { user: SessionUser; large?: boolean }) {
  const cls = large ? "set-avatar set-avatar--lg" : "set-avatar";
  if (user.avatar) {
    return <img className={cls} src={user.avatar} alt="" aria-hidden="true" />;
  }
  return (
    <span className={cls} aria-hidden="true">
      {initialOf(user.name)}
    </span>
  );
}

/** The accounts list's monogram. A separate component because `AdminUser`
 *  carries no `avatar` β€” only the SIGNED-IN user's photo is on the wire β€” so
 *  offering a photo slot here would be a hole nothing can fill. */
function Monogram({ name }: { name: string }) {
  return (
    <span className="set-avatar" aria-hidden="true">
      {initialOf(name)}
    </span>
  );
}

/** The role, as a chip rather than a table cell (16b). Two roles, one of which
 *  bypasses every rule in the product β€” that is a property of the account, and a
 *  property belongs beside the name, not in a row of key/value pairs. */
function RoleChip({ admin }: { admin: boolean }) {
  return (
    <span className={admin ? "set-chip set-chip--admin" : "set-chip"}>
      {admin ? "Admin" : "Member"}
    </span>
  );
}

function ProfilePhotoBlock({
  user,
  admin,
  onUser,
}: {
  user: SessionUser;
  admin: boolean;
  onUser?: (u: SessionUser) => void;
}) {
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState("");
  const fileRef = useRef<HTMLInputElement>(null);

  const upload = useCallback(
    async (file: File) => {
      setBusy(true);
      setError("");
      const dataUrl = await fileToAvatarDataUrl(file);
      if (!dataUrl) {
        setBusy(false);
        setError("That file could not be read as an image.");
        return;
      }
      const r = await setAvatar(dataUrl);
      setBusy(false);
      if (r.ok) {
        const fresh = parseUserEnvelope(r.data);
        if (fresh) onUser?.(fresh);
      } else {
        setError(r.message);
      }
    },
    [onUser]
  );

  const remove = useCallback(async () => {
    setBusy(true);
    setError("");
    const r = await clearAvatar();
    setBusy(false);
    if (r.ok) {
      const fresh = parseUserEnvelope(r.data);
      if (fresh) onUser?.(fresh);
    } else {
      setError(r.message);
    }
  }, [onUser]);

  // β›” THE HELP SENTENCE IS DELETED (wave 17, item 16b, owner's instruction:
  // "replace with layout, not copy"). It read "Shown on Assignee fields and your
  // account chip. Square images look best; large ones are scaled down
  // automatically." Every clause was work the interface should do rather than
  // explain: WHERE the photo appears is answered by showing it at the size and
  // shape it will be worn, beside the name it belongs to; SQUARE IS BEST and
  // LARGE ONES ARE SCALED are apologies for a centre-crop-and-downscale that
  // already happens without asking (`fileToAvatarDataUrl`) and that the user
  // cannot influence. A sentence describing behaviour the reader has no control
  // over is decoration.
  return (
    <div className="set-identity">
      <AvatarPreview user={user} large />
      <div className="set-identity-body">
        <div className="set-identity-head">
          <span className="set-identity-name">{user.name}</span>
          <RoleChip admin={admin} />
        </div>
        <div className="set-identity-sub">{user.username}</div>
        <div className="set-identity-acts">
          <input
            ref={fileRef}
            type="file"
            accept="image/png,image/jpeg"
            className="set-filepick"
            onChange={(e) => {
              const f = e.target.files?.[0];
              e.target.value = "";
              if (f) void upload(f);
            }}
          />
          <button
            type="button"
            className="set-secondary"
            disabled={busy}
            onClick={() => fileRef.current?.click()}
          >
            {user.avatar ? "Change photo" : "Upload photo"}
          </button>
          {user.avatar ? (
            <button type="button" className="set-secondary" disabled={busy} onClick={remove}>
              Remove
            </button>
          ) : null}
        </div>
        {error ? <p className="set-error set-identity-err">{error}</p> : null}
      </div>
    </div>
  );
}

// ------------------------------------------------------------------ the modal

export function SettingsModal({
  user,
  section,
  onSection,
  onClose,
  onUser,
  moduleLabels,
}: {
  user: SessionUser;
  section: SettingsSection;
  onSection: (s: SettingsSection) => void;
  onClose: () => void;
  /** Wave 14 C-AVATAR β€” the shell's session setter, so a photo change updates the account
   *  chip live instead of on the next full reload. Optional: absent, the photo still saves. */
  onUser?: (u: SessionUser) => void;
  /** Registry key β†’ the label the nav already shows for it. Passed down rather
   *  than fetched or hardcoded: the shell holds a server-filtered nav with every
   *  label in it, and a second copy of that mapping in this file would be a
   *  place for "Customer" to become "Customers" in one door only. Absent β‡’ rows
   *  fall back to the key, which is honest if ugly. */
  moduleLabels?: Record<string, string>;
}) {
  const [settings, setSettings] = useState<SettingsPayload | null>(null);
  const admin = isAdmin(user);
  // Wave 19 (C2) β€” `=== true`, not truthy: absent or malformed reads as NO.
  const platformAdmin = settings?.platformAdmin === true;

  useEffect(() => {
    void getSettings().then((r) => {
      if (r.ok) setSettings(r.data);
    });
  }, []);

  // Escape closes. A modal a keyboard cannot dismiss is a trap, and this one
  // covers the whole application.
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") onClose();
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose]);

  // WAVE 19 R13 β€” the tuple grew a third member: the tab's mark. ONE data change
  // and one line in the render loop below, which is the whole of the ruling.
  const rail: Array<[SettingsSection, string, ReactNode]> = [
    ["account", "Account", IconAccount],
    ["scope", "Your access", IconAccess],
    ...(admin
      ? ([
          ["users", "Manage users", IconUsers],
          // Wave 18 (C7): the tenant's credential store + data-source status board.
          ["keychains", "Keychains", IconKey],
          ["connectors", "Connectors", IconPlug],
          // EXIT-6: the statement sender, ported off app.py. Admin-only, and the
          // server ALSO refuses it to any tenant but Royal (the send client is
          // env-credentialed) β€” this entry is the courtesy, not the wall.
          ["statements", "Statements", IconMail],
        ] as Array<[SettingsSection, string, ReactNode]>)
      : []),
    // Wave 19 (R3 / C2): the Loopable admin plane. Gated on the SERVER's
    // `platformAdmin`, never on `admin` β€” R3 is explicit that a tenant-scoped
    // admin does not qualify, so this entry appears for exactly one account and
    // is invisible to every tenant admin in the product.
    ...(platformAdmin
      ? ([["padmin", "Loopable admin", IconTenants]] as Array<
          [SettingsSection, string, ReactNode]
        >)
      : []),
  ];
  // A non-admin deep-linked to the users pane lands on Account rather than on
  // an empty frame. The server would refuse the data anyway; this just avoids
  // showing a room with nothing in it. The rule is a NAMED function so the gate
  // can negative-control it β€” see `permsModel.reachableSection`.
  //
  // β›” `padmin` IS GATED HERE, NOT IN THAT FUNCTION, and the reason is that its
  // input does not exist there. `reachableSection` takes `admin` β€” a boolean off
  // the user record β€” while `platformAdmin` arrives asynchronously on the
  // settings payload and (per R3) is NOT implied by tenant admin. Passing it
  // through `reachableSection` would have meant either widening that function's
  // signature and re-aiming a negative control that guards a different rule, or
  // conflating the two admin concepts the ruling exists to keep apart. Composed
  // instead: the section falls back to Account until the server has said yes.
  const active =
    section === "padmin" && !platformAdmin ? "account" : reachableSection(section, admin);

  return (
    <div
      className="set-backdrop"
      onMouseDown={(e) => {
        if (e.target === e.currentTarget) onClose();
      }}
    >
      {/* ⚠ WAVE 19 R12 β€” the modal is "Settings", and the ACCESSIBLE name tracks
          the visible one, which is the half that is easy to forget. A dialog a
          screen reader announces as "Profile" while the rail reads "Settings" is
          the same drift as a stale comment, only harder to notice: nobody
          looking at the screen can see it. Manage users is still a SECTION
          inside it (R12 is a rename, not a re-architecture). */}
      <div className="set-modal" role="dialog" aria-modal="true" aria-label="Settings">
        <nav className="set-rail">
          <span className="set-rail-cap">Settings</span>
          {rail.map(([key, label, icon]) => (
            <button
              key={key}
              type="button"
              className={"set-rail-item" + (active === key ? " is-active" : "")}
              onClick={() => onSection(key)}
            >
              {icon}
              {label}
            </button>
          ))}
        </nav>

        <div className="set-body">
          <button type="button" className="set-close" onClick={onClose} aria-label="Close settings">
            Γ—
          </button>

          {active === "keychains" ? <KeychainPane /> : null}
          {active === "connectors" ? <ConnectorsPane /> : null}
          {active === "statements" ? <StatementsPane /> : null}
          {/* Wave 19 contract C2 β€” the wiring record, verified at the MOUNT SITE
              (the wave-14 lesson: an occurrence-grep proves an import, not a
              render): `settings/AdminPane.tsx β†’ SettingsModal.tsx pane switch β†’
              prop user`. The pane is session D's, self-contained: it fetches its
              own data and owns its loading/empty/error states, so mounting it
              cannot fail on plumbing. Its prop is typed STRUCTURALLY on D's side
              ({username; name; role}), so the shell's `SessionUser` satisfies it
              by structural typing β€” no cast here, and no import from `shell/`
              there. `active` cannot be "padmin" unless the server said so. */}
          {active === "padmin" ? <AdminPane user={user} /> : null}

          {active === "account" ? (
            <div className="set-pane">
              <h3 className="set-h">Account</h3>
              {/* ⚠ WAVE 17 (16b) β€” THE THREE KEY/VALUE ROWS ARE NOT DELETED,
                  THEY ARE LAID OUT. "Signed in as", "Username" and "Role" were a
                  three-row table restating what a photo, a name and a chip say
                  at a glance β€” and the photo block sat UNDER them, so the pane
                  opened on a label reading "Signed in as" above the picture of
                  the person it was labelling. The identity header carries all
                  three now: the name at title weight, the username beneath it,
                  the role as a chip beside it. Nothing was dropped; the labels
                  were, because each one named the obvious. */}
              <ProfilePhotoBlock user={user} admin={admin} onUser={onUser} />
            </div>
          ) : null}

          {active === "scope" ? (
            <div className="set-pane">
              <h3 className="set-h">Your access</h3>
              {/* β›” THE "Business units" ROW IS GONE (R1) β€” the concept it
                  reported no longer exists on this side of the product. It is
                  NOT replaced by nothing: what shapes a restricted account's
                  rows is now a permanent filter per module, and the rows below
                  say so. A pane reporting "All modules" while a filter quietly
                  halves somebody's book is worse than the row it replaced β€”
                  that is the user who files a bug about the numbers. */}
              <p className="set-help set-pane-intro">
                What this account may open, and anything that narrows it. Only an
                administrator can change this.
              </p>
              {/* 16b β€” one card, so the restrictions read as a group belonging to
                  the heading rather than as loose rows floating under it. */}
              <section className="set-card set-card--rows">
                <div className="pg-drill-row">
                  <span className="pg-drill-key">Modules</span>
                  <span className="pg-drill-val">
                    {moduleWords(settings?.scope.modules ?? user.modules)}
                  </span>
                </div>
                {/* An admin's "All modules" is true but does not say WHY, and the
                    why is the part that matters: it is not a grant somebody can
                    revoke module by module, it is a bypass. The accounts list
                    already says "Everything (admin)" β€” this is the same fact, on
                    the account's own page, so the two doors agree. */}
                {admin ? (
                  <div className="pg-drill-row">
                    <span className="pg-drill-key">Restrictions</span>
                    <span className="pg-drill-val">
                      None apply β€” administrators bypass every rule
                    </span>
                  </div>
                ) : null}
              {/* One row per module the account is restricted in. `moduleSummary`
                  is the SAME sentence the admin editor shows, from the same
                  function β€” two dialects of "2 conditions, 3 fields hidden"
                  would eventually disagree about what a rule says. Admins are
                  skipped: they bypass perms entirely, so any stored rule is
                  inert and printing it here would be a lie about their access. */}
              {!admin
                ? Object.entries(settings?.scope.perms ?? {}).map(([key, raw]) => {
                    const entry = parseEntry(raw);
                    const words = moduleSummary(entry);
                    // "Full access" adds nothing beside the Modules row above;
                    // only a REAL narrowing earns a line.
                    if (words === "Full access") return null;
                    return (
                      <div className="pg-drill-row" key={key}>
                        <span className="pg-drill-key">{moduleLabels?.[key] ?? key}</span>
                        <span className="pg-drill-val">{words}</span>
                      </div>
                    );
                  })
                : null}
              </section>
            </div>
          ) : null}

          {active === "users" ? <UsersPane /> : null}
        </div>
      </div>
    </div>
  );
}

// ── Wave 18 (C7): the Keychain pane ─────────────────────────────────────────────────────────
// Secrets go IN and never come back out: rows carry a masked preview, the add form clears on
// success, and there is no reveal affordance by design (the server has no route for one).
/**
 * ⭐⭐ WAVE 32 Β· R4 / contract C1 β€” WHO A CONNECTION IS FOR, in the two words the ruling uses.
 *
 * β›” THE VOCABULARY IS THE SERVER'S. `routes_keychain.SCOPES` declares it and `verify_meta`
 * asserts these two literals appear on both sides, so the label map is a PRESENTATION of a
 * server word and never a client-side union β€” a `type Scope = …` here would turn "the server
 * added a scope" into "the client drops the row" (the wave-9 law).
 */
const SCOPE_LABEL: Record<string, string> = {
  business: "Business-wide",
  personal: "Personal",
};
const scopeLabel = (s: string | undefined): string =>
  SCOPE_LABEL[String(s || "business")] || String(s);

function KeychainPane() {
  const [entries, setEntries] = useState<KeyEntry[]>([]);
  const [locked, setLocked] = useState(false);
  const [loaded, setLoaded] = useState(false);
  const [err, setErr] = useState("");
  /** R4: only an administrator may make a connection business-wide. Served, not inferred from
   *  the account record β€” the server is the wall and the client renders what it will honour. */
  const [canBusiness, setCanBusiness] = useState(false);
  const [tenantWide, setTenantWide] = useState<string[]>([]);
  const [form, setForm] = useState<{
    type: "odoo" | "generic";
    label: string;
    scope: string;
    fields: Record<string, string>;
    busy: boolean;
  } | null>(null);
  const [testResult, setTestResult] = useState<Record<string, string>>({});

  const reload = useCallback(async () => {
    const r = await listKeychain();
    if (r.ok) {
      setEntries(r.data.entries);
      setLocked(r.data.locked);
      setCanBusiness(Boolean(r.data.canBusiness));
      setTenantWide(Array.isArray(r.data.tenantWideTypes) ? r.data.tenantWideTypes : []);
      setErr("");
    } else {
      setErr(r.message);
    }
    setLoaded(true);
  }, []);
  useEffect(() => {
    void reload();
  }, [reload]);

  /** The scope a NEW key of this type may take. `odoo` and `meta_ads` are what the whole
   *  workspace's databases are read through, so the server refuses to make them personal β€” the
   *  picker says so here instead of letting the save fail. */
  const forcedBusiness = (t: string) => tenantWide.includes(t);

  const submit = async () => {
    if (!form || form.busy) return;
    setForm({ ...form, busy: true });
    const fields = Object.fromEntries(
      Object.entries(form.fields).filter(([, v]) => v.trim() !== "")
    );
    const r = await addKeychainEntry(form.label, form.type, fields, form.scope);
    if (r.ok) {
      setForm(null);
      void reload();
    } else {
      setErr(r.message);
      setForm({ ...form, busy: false });
    }
  };

  const odooFields: Array<[string, string]> = [
    ["url", "Server URL (https://...)"],
    ["db", "Database"],
    ["user", "API user"],
    ["api_key", "API key"],
  ];

  return (
    <div className="set-pane">
      <h3 className="set-h">Keychains</h3>
      <p className="set-help set-pane-intro">
        Credentials for this workspace's data sources, encrypted at rest. A stored secret is
        never shown again β€” only its last four characters identify it.
      </p>
      {locked ? (
        <p className="set-error">
          The keychain is locked: the platform's AIOS_KEYCHAIN_KEY is not configured on this
          server. Entries can be listed but nothing can be added or decrypted.
        </p>
      ) : null}
      {err ? <p className="set-error">{err}</p> : null}
      {!loaded ? (
        <p className="set-help">Loading…</p>
      ) : entries.length === 0 ? (
        <p className="set-help">No keys stored yet.</p>
      ) : (
        <div className="set-card">
          {entries.map((e) => (
            <div key={e.id} className="set-kv-row kc-row">
              <div className="kc-main">
                <strong>{e.label}</strong>
                <span className="kc-meta">
                  {e.type}
                  {e.preview ? " Β· " + e.preview : ""} Β· {scopeLabel(e.scope)}
                  {e.scope === "personal" && e.owner ? " (" + e.owner + ")" : ""} Β· added by{" "}
                  {e.createdBy || "?"}
                </span>
                {testResult[e.id] ? (
                  <span className="kc-test">{testResult[e.id]}</span>
                ) : null}
              </div>
              <div className="kc-actions">
                {/* ⭐ R4's scope door. Shown ONLY where it can be honoured: a member cannot make
                    anything business-wide, and `odoo`/`meta_ads` are business-wide by
                    construction β€” offering a control that the server would refuse is the
                    "button that leads nowhere" R7 banned one module over. */}
                {canBusiness && !forcedBusiness(e.type) ? (
                  <button
                    type="button"
                    className="set-btn"
                    onClick={async () => {
                      const next = e.scope === "personal" ? "business" : "personal";
                      const r = await setKeychainScope(e.id, next);
                      if (r.ok) void reload();
                      else setErr(r.message);
                    }}
                  >
                    {e.scope === "personal" ? "Make business-wide" : "Make personal"}
                  </button>
                ) : null}
                <button
                  type="button"
                  className="set-btn"
                  onClick={async () => {
                    setTestResult((t) => ({ ...t, [e.id]: "Testing…" }));
                    const r = await testKeychainEntry(e.id);
                    setTestResult((t) => ({
                      ...t,
                      [e.id]: r.ok
                        ? (r.data.ok ? "OK β€” " : "Failed β€” ") + r.data.message
                        : r.message,
                    }));
                  }}
                >
                  Test
                </button>
                <button
                  type="button"
                  className="set-btn kc-danger"
                  onClick={async () => {
                    const sure = window.confirm(
                      'Delete the key "' + e.label + '"? This cannot be undone.'
                    );
                    if (!sure) return;
                    const r = await deleteKeychainEntry(e.id);
                    if (r.ok) void reload();
                    else setErr(r.message);
                  }}
                >
                  Delete
                </button>
              </div>
            </div>
          ))}
        </div>
      )}
      {form ? (
        <div className="set-card kc-form">
          <div className="set-field">
            <label>Type</label>
            <select
              value={form.type}
              onChange={(e) => {
                const type = e.target.value as "odoo" | "generic";
                setForm({
                  ...form,
                  type,
                  fields: {},
                  // A tenant-wide type has exactly one legal scope; snapping the picker is the
                  // honest move, because the alternative is a save that 400s on a choice the
                  // form offered.
                  scope: forcedBusiness(type) ? "business" : form.scope,
                });
              }}
            >
              <option value="odoo">Odoo</option>
              <option value="generic">Generic secret</option>
            </select>
          </div>
          {/* ⭐⭐ WAVE 32 Β· R4 β€” WHO IS THIS CONNECTION FOR. Two options, no more: the ruling says
              there is a business-wide kind and a personal kind, and business-wide is an
              administrator's to give. A member sees the sentence instead of a disabled radio,
              because a control you cannot use is worse than a line telling you why. */}
          <div className="set-field">
            <label>Who can use it</label>
            {forcedBusiness(form.type) ? (
              <p className="set-help">
                Business-wide. This workspace's databases are read through an {form.type}{" "}
                connection, so it applies to everyone here and cannot be personal.
              </p>
            ) : canBusiness ? (
              <select
                value={form.scope}
                onChange={(e) => setForm({ ...form, scope: e.target.value })}
              >
                <option value="business">Business-wide β€” everyone in this workspace</option>
                <option value="personal">Personal β€” only me</option>
              </select>
            ) : (
              <p className="set-help">
                Personal β€” only you. An administrator can add a connection for the whole
                workspace.
              </p>
            )}
          </div>
          <div className="set-field">
            <label>Label</label>
            <input
              value={form.label}
              maxLength={80}
              placeholder={form.type === "odoo" ? "Production Odoo" : "e.g. Shipping API"}
              onChange={(e) => setForm({ ...form, label: e.target.value })}
            />
          </div>
          {(form.type === "odoo"
            ? odooFields
            : ([
                ["name", "Name"],
                ["value", "Secret value"],
              ] as Array<[string, string]>)
          ).map(([k, label]) => (
            <div className="set-field" key={k}>
              <label>{label}</label>
              <input
                value={form.fields[k] ?? ""}
                type={k === "api_key" || k === "value" ? "password" : "text"}
                onChange={(e) =>
                  setForm({ ...form, fields: { ...form.fields, [k]: e.target.value } })
                }
              />
            </div>
          ))}
          <div className="set-actions">
            <button type="button" className="set-btn" onClick={() => setForm(null)}>
              Cancel
            </button>
            <button
              type="button"
              className="set-btn set-btn-primary"
              disabled={form.busy || !form.label.trim() || locked}
              onClick={() => void submit()}
            >
              {form.busy ? "Saving…" : "Save key"}
            </button>
          </div>
        </div>
      ) : (
        <button
          type="button"
          className="set-btn set-btn-primary"
          disabled={locked}
          onClick={() =>
            setForm({
              type: "odoo",
              label: "",
              // ⚠ The default is the SAFE one for the account opening the form: a member can only
              // create a personal connection, so starting on `business` would put a value in the
              // body the server refuses.
              scope: canBusiness ? "business" : "personal",
              fields: {},
              busy: false,
            })
          }
        >
          Add a key
        </button>
      )}
    </div>
  );
}

// ── Wave 18 (C7): the Connectors pane β€” sources + R3's Unsynced-records guardrail ───────────
function ConnectorsPane() {
  const [rows, setRows] = useState<ConnectorRow[]>([]);
  const [note, setNote] = useState("");
  const [unsynced, setUnsynced] = useState<UnsyncedInfo | null>(null);
  const [showOrphans, setShowOrphans] = useState(false);
  const [loaded, setLoaded] = useState(false);
  const [err, setErr] = useState("");

  const reload = useCallback(async () => {
    const r = await getConnectors();
    if (r.ok) {
      setRows(r.data.connectors);
      setNote(r.data.pausedNote || "");
      setUnsynced(r.data.unsynced ?? null);
      setErr("");
    } else {
      setErr(r.message);
    }
    setLoaded(true);
  }, []);
  useEffect(() => {
    void reload();
  }, [reload]);

  return (
    <div className="set-pane">
      <h3 className="set-h">Connectors</h3>
      <p className="set-help set-pane-intro">
        Where this workspace's data comes from. Add credentials under Keychains; sources appear
        here with their status.
      </p>
      {err ? <p className="set-error">{err}</p> : null}
      {!loaded ? (
        <p className="set-help">Loading…</p>
      ) : rows.length === 0 ? (
        <p className="set-help">
          No connectors yet. Store a key under Keychains to register a source β€” nothing syncs
          until a later step turns it on.
        </p>
      ) : (
        <div className="set-card">
          {rows.map((r) => (
            <div key={r.key} className="set-kv-row kc-row">
              <div className="kc-main">
                <strong>{r.label}</strong>
                <span className="kc-meta">
                  {r.type} Β· {r.source === "env" ? "environment credentials" : "keychain"}
                  {r.paused ? " Β· paused" : " Β· active"}
                </span>
              </div>
              <div className="kc-actions">
                <button
                  type="button"
                  className="set-btn"
                  onClick={async () => {
                    const res = await pauseConnector(r.key, !r.paused);
                    if (res.ok) void reload();
                    else setErr(res.message);
                  }}
                >
                  {r.paused ? "Resume" : "Pause"}
                </button>
              </div>
            </div>
          ))}
        </div>
      )}
      {note ? <p className="set-help kc-note">{note}</p> : null}
      {unsynced ? (
        <div className="set-card kc-unsynced">
          <strong>Unsynced records</strong>
          {unsynced.known ? (
            unsynced.count === 0 ? (
              <p className="set-help">
                None β€” every record holding notes or custom-field data matches a row the
                connector currently serves.
              </p>
            ) : (
              <>
                <p className="set-help">
                  {unsynced.count} record{unsynced.count === 1 ? "" : "s"} carry saved data
                  (notes, custom fields) but no longer match a synced row. Nothing is deleted β€”
                  they reattach by ID if the source serves them again.
                </p>
                <button
                  type="button"
                  className="set-btn"
                  onClick={() => setShowOrphans((s) => !s)}
                >
                  {showOrphans ? "Hide" : "Show " + String(unsynced.shown ?? unsynced.rows.length)}
                </button>
                {showOrphans ? (
                  <ul className="kc-orphans">
                    {unsynced.rows.map((o) => (
                      <li key={String(o.pid)}>
                        #{o.pid} β€” {o.fields} saved field{o.fields === 1 ? "" : "s"}
                        {o.hint ? " Β· " + o.hint : ""}
                      </li>
                    ))}
                  </ul>
                ) : null}
              </>
            )
          ) : (
            <p className="set-help">
              Count unavailable right now{unsynced.note ? " (" + unsynced.note + ")" : ""} β€” the
              pool could not be consulted; nothing was assumed.
            </p>
          )}
        </div>
      ) : null}
    </div>
  );
}