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";
}
|