Appearance
[UX] customer-portal — User administration (admin)
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch:
develop@693a76c· Files reviewed: 16 Patterns:data-display/table
Summary
The two admin screens that govern who can sign in and what they can see have one outright break and a cluster of silent-failure defects around destructive actions. admin.users cannot create a user at all: DataTable gates its create button on the :collection prop, UsersPage never passes one, so the "Add user" button, the mobile FAB and the AddUserModal supplied to #create-modal are all dropped on the floor. Beyond that, delete-user swallows its own error behind a closing modal, approve/reject failures go to console.error only, and bulk link deletion acts on rows the current search has hidden.
Priority check — does "removed / archived / disabled" actually revoke access? Answer: this project PASSES, and by a different mechanism than tt-time-tracker. Reported in full because a confirmed pass is the point:
- There is no archive/disable state to get wrong.
User(packages/database/prisma/app/user.prisma:1-21) has noarchived,disabled,bannedoractivecolumn. The only offboarding controls are (a) hard delete and (b) removing or downgrading the user's customer links. - Hard delete really ends sessions.
UsersService.remove(apps/api/src/modules/admin/users/users.service.ts:111-119) callsprisma.user.delete, andSession.userisonDelete: Cascade(packages/database/prisma/app/session.prisma:11), so every session row for that user is destroyed by the same statement.Account(the credential row) cascades too (account.prisma:16). NoDELETE-means-archive trick here — the row is gone. - Link status is consulted on every request, not cached in the session.
PermissionsService.buildContext(apps/api/src/permissions/permissions.service.ts:43-104) re-readsuser,customerLinksandrolesfrom Postgres on every ability-gated call, and filterslink.status === "linked"at:66before derivingcustomerIds/resourceIds.inferBaseRole(permissions/roles.ts:199-205) likewise ignores pending/rejected links. So flipping a link torejected, or deleting it in the bulk bar, revokes that account's data on the next request — no re-login needed, no stale scope. This is exactly the revalidation that tt-time-tracker'sOrganizationGuardand customer-portal's ownadmin-selected-accountguard skip; the admin/user-link path does it correctly. - What unlinking does not do: it does not sign the user out. A user whose last link is deleted still authenticates, still gets base role
customer(roles.ts:203— no links meanscustomer, not "none"), and lands in a portal where every account-scoped query returns nothing. Access to data is revoked; access to the app is not. Nothing inUserLinksPagewarns the admin that removing the last link leaves a signed-in, empty account rather than an offboarded one. That is the residual gap, and it is a UX gap, not a security one. - The one real revocation failure is the admin password change — finding 3.
Project-level, already filed, not re-reported here: NAV-03 (one static title), MSG-02 (getErrorMessage prefers the raw backend string — it is on this feature's path at UsersPage.vue:149,165,179 and UserLinksPage.vue:89,104,120), list state not in the URL / no KeepAlive, and the shared packages/uiDataTable defects catalogued in drafts/customer-portal--catalog.md findings 4-7 (no live region and isLoading-not-isFetching; unlabelled desktop search box; dead-end empty state with no clear control; untranslated pagination with unnamed arrows). All four hit both pages here. The 403 branch of errorStateMessage (DataTable.vue:266-272) is likewise dead code on both pages, since neither passes :status — a permissions failure reads "Failed to load users".
A11Y-04 passes. Both pages use PageLayout, which renders a real <h1> at packages/ui/src/components/PageLayout.vue:103. Per the PROJECT-LEVEL correction, judged on the shell in use rather than assumed.
Findings
1. "Add user" does not exist — the create button and its modal are silently dropped — Blocker · proposed CTRL-ACTION-DROPPED
Where: apps/web/src/pages/admin/UsersPage.vue:198-234 (specifically :201:create-label and :225-233 #create-modal) against packages/ui/src/components/DataTable.vue:278What: DataTable decides whether a create affordance exists with const canCreate = computed(() => props.collection !== undefined); (:278). Three separate render sites are behind it: the desktop button (:444-459, v-if="canCreate"), the mobile FAB in the sticky header (:340, v-if="$slots['mobile-actions'] || (canCreate && !hasCreateSlot)"), and the create-modal slot itself (:706, v-else-if="canCreate && hasCreateModalSlot"). UsersPage passes card-only, :create-label="'Add user'" and a full #create-modal template wiring AddUserModal + handleCreate + createMutation — but no :collection. UserLinksPage.vue:360 does pass :collection="userLinksCollection", which is why the equivalent "Add link" button works. Grepped every page under apps/web/src/pages/ that uses #create-modal: UsersPage.vue is the only one missing :collection, so this is a feature-specific break, not the shared-component pattern. Why it matters: There is no other route to user creation in the app — POST /admin/users/create exists and is policy-guarded, AddUserModal is fully built, useCreateUserMutation is wired, and none of it is reachable. An admin opens Users, sees a table and a search box, and has no way to add anyone. The onboard/sign-up flow covers self-registering customers, so the internal path for creating an admin, a logistics user, or a customer who cannot self-register is simply absent. This is the Blocker definition verbatim: a required control is unreachable. Fix: Immediately, pass a collection-shaped prop from UsersPage (or add :collection="true"-style opt-in). Properly: canCreate should be Boolean(props.collection) || hasCreateSlot.value || hasCreateModalSlot.value || Boolean(props.createModal) || Boolean(props.createLabel) — a host that supplies a create modal has unambiguously asked for a create action, and a shared component must not discard a supplied slot on an undocumented condition. Consider a dev-mode console.warn when a #create-modal slot is provided but canCreate is false, so the next occurrence fails loudly.
2. Deleting a user fails silently — the error is written to a field the closing modal takes away — High · MSG-01, MSG-03
Where: apps/web/src/pages/admin/UsersPage.vue:171-189What: handleDelete catches the failure into updateError and does not rethrow (:178-180). handleDeleteEditing (:183-189) then runs editingUser.value = null unconditionally on the next line, which flips isEditModalVisible (:49-57) to false and unmounts EditUserModal — the only component that renders updateError (EditUserModal.vue:173-179). The message is assigned to a ref whose sole consumer has just been destroyed. There is no toast, no page-level banner, no role="alert" region on UsersPage at all. Why it matters: An admin clicks Delete on a user, the confirm accepts, the modal closes as if it worked — and on a 403, a network drop, or a foreign-key failure the user is still in the list. The admin's only signal is that the row did not disappear, which is indistinguishable from a stale cache. On an offboarding task this is the worst possible failure mode: the admin believes access was removed when it was not. Fix: Have handleDelete rethrow (like handleUpdate at :167 does) and have handleDeleteEditing close the modal only on success. Render updateError in a page-level role="alert" block as well, so a failure raised after the modal closes is still visible.
3. An admin password reset does not end the user's existing sessions — High · SEC-03
Where: apps/api/src/modules/admin/users/users.repository.prisma.ts:133-151 (and the caller users.service.ts:99-101) What: The admin password path bypasses better-auth entirely: it hashes with hashPassword (users.service.ts:50,100) and writes straight to the Account row via tx.account.upsert. Nothing in that transaction — or anywhere in apps/api/src — deletes Session rows. Verified by grep: the only session revocation in the repo is better-auth's own revokeSessionsOnPasswordReset: true (apps/api/src/auth/auth.config.ts:187), which fires on the self-service /reset-password route, and the user-initiated revokeOtherSessions flag in apps/web/src/domains/profile/profile.mutations.ts:49-58. Neither is on the admin path. Sessions last 7 days (auth.config.ts:196). Why it matters: The realistic reason an admin types into "New password" on EditUserModal.vue:158-171 is that the account is suspected compromised or the holder has left. The label says "Leave blank to keep current" — nothing says the change will not log anyone out. An attacker holding a stolen session cookie keeps full access for up to seven more days while the admin believes they have locked the account. There is also no session list and no "sign out everywhere" control anywhere in this feature. Severity call: High rather than Blocker: it needs the other condition of an already-stolen session, so it is a hardening gap under the normalisation rule, not something an outsider acts on directly. Fix: In updateWithAccount, add await tx.session.deleteMany({ where: { userId: data.userId } }) inside the same transaction whenever passwordHash is set (and on email change, which likewise moves the credential identity). Surface it in the UI: "Saving a new password signs this user out of all devices."
4. The only offboarding control is a hard delete that destroys the user's business records, behind a one-line window.confirm — High · MSG-05
Where: apps/web/src/pages/admin/UsersPage.vue:172 and apps/api/src/modules/admin/users/users.service.ts:111-119What: The confirm is window.confirm(\Delete ${user.email}?`)— the email and nothing else.deleteById (users.repository.prisma.ts:175-179) is prisma.user.delete, and **eight** relations cascade off User: sessions, accounts, roles, customerLinks, notifications, chatConversations, and — the consequential two — serviceRequests (packages/database/prisma/app/service_request.prisma:18) and tradeInSubmissions (trade_in_submission.prisma:25). So deleting an employee who has left also erases the service requests they raised and the trade-in machines they submitted. There is no soft-delete, no undo, and no "this user has N service requests" warning. EditUserModal.vue:273-281puts the Delete button in the same flex row as Save/Cancel with no separation beyondseverity="danger". **Why it matters:** Offboarding is the single most common admin task on this screen, and the only tool provided is irreversible and takes business records with it. The admin cannot know that from the dialog: "Delete edward@example.com?" implies an account is being removed, not that a customer's service history is. window.confirmalso renders as an OS-chrome dialog with no product context, is suppressed in some Capacitor webviews, and is not the app's own dialog pattern —UserLinkModal/EditUserModalboth use PrimeVueDialog. **Fix:** Two parts. (a) Replace window.confirm with a PrimeVue confirm dialog that enumerates what is destroyed — sessions, credential, N links, N service requests, N trade-in submissions — and requires typing the email to proceed, as the standard destructive-confirm shape. (b) Add a real disable/deactivate state (User.disabledAt) that buildContext` checks alongside link status, so the common case — stop this person signing in, keep the records — has a non-destructive control. Today it does not exist.
5. Bulk-deleting user links destroys rows the current search has hidden — High · MSG-05
Where: apps/web/src/pages/admin/UserLinksPage.vue:42-67, :111-122, :336-356What: selectedIds is a Set<string> that is never reconciled with filteredLinks. handleDeleteSelected deletes Array.from(selectedIds.value) — the whole set, not the visible subset. Nothing clears or prunes the selection when searchQuery changes (:163-169 only recomputes the display list). The selection bar (:340) and the confirm text (:114) both report selectedIds.size, which stays at the full count while the table shows fewer rows. toggleSelectAll (:50-56) compounds it: "select all" adds only the filtered ids, but the deselect branch wipes the entire set, so the header checkbox is not a reliable inverse of itself either. Why it matters: The realistic sequence — select several links across the table, then type in the search box to find one more — leaves the admin looking at two rows above a button reading "Delete 7". Clicking it silently revokes five customers' portal access to accounts the admin never re-checked, and (per the priority check above) that revocation is effective immediately on the next request. There is no undo and no per-row confirmation. Severity call: High rather than Blocker — it needs the admin to filter after selecting, so it is not wrong for every user on every run; but the consequence is unrecoverable revocation of real customers' access. Fix: Prune selectedIds to allFilteredIds in a watch on filteredLinks, or intersect at delete time and say so. Make the confirm list the affected user/account pairs rather than a bare count, and make toggleSelectAll remove only the filtered ids.
6. Approve and reject failures are reported to the browser console only — High · MSG-01, MSG-03
Where: apps/web/src/pages/admin/UserLinksPage.vue:88-91 and :103-106What: Both handlers are catch (error) { console.error(getErrorMessage(...)) }. On failure the optimistic writeUpdate at :86 / :102 never runs, the finally clears the spinner, and the row silently stays at link_request. There is no toast, no inline message, and the page's only error surface — the deleteError banner at :329-334 — is written by the bulk-delete path alone. That banner is also a plain <div> with no role="alert", so even the one error that does render is announced to nobody. Why it matters: These two buttons are the gate on a customer's access to their own portal, and compareUserLinks (:149-161) deliberately floats pending requests to the top so admins clear them first. A failed approve looks exactly like a slow one: the button stops spinning and nothing changes. The admin clicks again, and again, while the customer keeps waiting. "No silent failures" is the repo's own stated rule (apps/web/CLAUDE.md §7). Fix: Surface both errors in a shared role="alert" region (or a PrimeVue Toast) naming the user and the action that failed, and give the mutations the same success confirmation. Add role="alert" to the deleteError div at :329.
7. <tr role="button"> wraps checkboxes and Approve/Reject buttons, destroying both the table semantics and the nested controls — Medium · A11Y-03, A11Y-04
Where: packages/ui/src/components/DataTable.vue:608-618 as consumed by apps/web/src/pages/admin/UserLinksPage.vue:174-190 (checkbox column) and :244-275 (Approve/Reject column) What: When onRowClick is set, DataTable renders each <tr> with :role="isClickable ? 'button' : undefined" and tabindex="0". On UserLinksPage those rows contain a PrimeVue Checkbox and, for pending requests, two Buttons. role="button" is a leaf role: it strips the row's row role (so column-header association is lost for the entire table on both pages) and it must not contain focusable descendants. Enter is handled but Space is not (:618), which a native button would fire. The UX Patterns data-display/table accessibility section models the correct shape explicitly — <tr aria-selected="true" aria-rowindex="5"> containing <input type="checkbox" aria-label="Select row 5, John Doe"> — a row that is selectable and clickable without becoming a button. Related: neither checkbox has an accessible name (:176-180 header, :181-189 cell) — a screen-reader user tabbing the selection column hears "checkbox" nine times with nothing to distinguish them (A11Y-05). Why it matters: A screen-reader user on User Links cannot read the table as a table — no "Status, column 7" announcements — and hears each row as a single button whose interior controls are ambiguously exposed. Approving a link request by keyboard is not reliably possible: focus lands on the row-button, and the Approve button inside it is a focusable descendant of a leaf role. Fix: In DataTable, drop role="button" from clickable <tr>s; keep tabindex="0", add @keydown.space.prevent, and rely on the existing chevron cell — or better, make the chevron cell a real <button> with an accessible name ("Open <row title>") and leave the row itself non-interactive when the row contains other controls. In UserLinksPage, give each Checkbox an aria-label ("Select link for {user.email} / {account}") and the header one "Select all links". This is a new shared-DataTable defect not covered by the catalog draft's list (catalog uses card-only), so it belongs with those in the packages/ui issue.
8. Both pages are 100% hardcoded English — and so is the rest of the admin section — Medium · CONTENT-01
Where: apps/web/src/pages/admin/UsersPage.vue (zero t() calls; literals at :116,120,123,130,196,201-213) and UserLinksPage.vue (one, and literals at :132,194-243,255-263,340-353,363-369), plus AddUserModal.vue, EditUserModal.vue, UserLinkModal.vue, UserCard.vueWhat: Column headers ("Name", "Email", "Roles", "Verified", "Service partner", "Source"), buttons ("Add user", "Approve", "Reject", "Delete N", "Clear selection"), form labels, empty/error copy and the confirm strings are all literals. Counted across pages/admin/: 14 of 15 admin pages contain zero i18n calls, so this is a section-wide condition rather than a slip on these two files. Separately, the Status column renders the raw enum through Tag (UserLinksPage.vue:237) and EditUserModal.vue:211, so admins read link_request, and the Source column shows admin/self verbatim (:240-243). Why it matters: Aadalen's own staff run these screens and the portal ships nb as a first-class locale (en/nb, per-feature CONTENT-01 in the baseline). link_request is not a word in either language, and "Verified: Yes/No" is a column header a Norwegian admin has to translate mentally on the one screen where misreading a row grants or denies access. Fix: Add an admin.users.* / admin.userLinks.* block to both YAML files and route every literal through t(), mapping statuses to admin.userLinks.status.linkRequest etc. Given the 14-page spread, this is a candidate for promotion to PROJECT-LEVEL.md as "the admin section has no i18n" rather than 15 separate findings.
9. The password field has no reveal toggle, no minimum length, and no stated rules — Medium · SEC-05, FORM-03, FORM-04
Where: apps/web/src/components/admin/AddUserModal.vue:144-158 and EditUserModal.vue:158-171; server side apps/api/src/modules/admin/users/users.dto.ts:95-98What: Both modals use InputText type="password", not PrimeVue's Password — so no reveal toggle, no strength meter, and no minlength. The baseline's per-project note names Password as the preferred control for exactly this. The server accepts anything non-empty: @ApiString() @IsString() @IsNotEmpty() password!: string. Because the admin path hashes and writes directly (users.service.ts:50,100) rather than going through better-auth, better-auth's own password-length handling never applies. A one-character password is accepted end to end. Why it matters: The admin is typing a credential they cannot see, for someone else, with no confirmation field (FORM-07 has nothing to validate against) and no rule shown before or after typing. A typo becomes an account nobody can sign into and nobody can diagnose. And nothing stops "1". Severity call: Medium, not High — the weak value is chosen by an already authenticated, policy-guarded admin rather than reachable by an outsider, and there is no enumeration or bypass. It is still a floor the baseline sets at 8. Fix: Swap both fields for PrimeVue Password with toggleMask and :feedback="true", state the rule above the field, and add @MinLength(8) to CreateUserCommandBody.password in users.dto.ts so the partial UpdateUserCommandBody inherits it.
10. The password field is inert for exactly the users an admin creates it for — Medium · FORM-04, proposed FORM-INERT-CONTROL
Where: apps/web/src/components/admin/AddUserModal.vue:144-174 (Password required, Roles picker immediately below) against apps/api/src/auth/auth.config.ts:140-160What: assertEmailPasswordSignInAllowed is a better-auth before hook on /sign-in/email that rejects any user holding an internal role (admin, logistics) with FORBIDDEN — "Use Microsoft sign-in". Internal employees must use Entra ID. But AddUserModal marks Password required and puts the Roles MultiSelect directly beneath it, with no coupling between the two: an admin creating a colleague must invent a password that the sign-in path will never accept, and no copy on either modal mentions Microsoft sign-in. The same applies to EditUserModal's "New password" for any user who already holds an internal role. Why it matters: The primary internal-onboarding flow guarantees a wrong outcome: the admin creates the account, passes the password to the new employee, and the employee gets "Use Microsoft sign-in" — the raw hook message, surfaced through the project-level getErrorMessage defect. Nothing on the admin screen explains why. The same class of defect as PROJECT-LEVEL's FORM-12(c) (controls that never reach the API). Severity call: Medium — the outcome is recoverable once someone understands it, so friction and avoidable confusion rather than user harm; but it recurs on every internal-user creation. Fix: Make Password conditional on the selected roles: when the roles selection includes an internal role, hide the field and show "Internal users sign in with Microsoft — no password is set." Mirror the same rule in EditUserModal.
11. Security-relevant changes are never communicated to the affected user — Medium · SEC-04
Where: apps/api/src/modules/admin/users/users.service.ts:61-119 and apps/api/src/modules/admin/user-links/user-links.service.ts:53-104What: update changes email, password and roles; remove deletes the account; the user-links service links, rejects and deletes access grants. None of these imports or calls the notification service, and no email or in-app notification is emitted from any of them. Grep confirms zero notification usage in either module. The repo ships a notification-service and better-auth already sends verification and reset mail (auth.config.ts:87-95), so the channel exists. Why it matters: An admin can move an account's login email to an address they control, or reset its password, and the account holder gets no signal at all. On the benign side, a customer whose link is approved or rejected in UserLinksPage learns nothing — they discover it by logging in and finding the portal either populated or empty, which is precisely the zero-scope confusion described in the summary. Severity call: High under the normalisation table's "missing out-of-band notification (SEC-04)" line for the email/password cases; recorded as Medium overall because the surrounding admin surface is already policy-guarded to manage all and the highest-risk half (email change) is a subset. Reconcile with the orchestrator if the table's reading should win. Fix: Emit a notification on email change, password change, role grant/revoke, account deletion, and link approve/reject — to the affected user's previous address on an email change.
12. "No linked accounts." is shown while the links collection is still loading — Medium · MSG-06(b)
Where: apps/web/src/pages/admin/UsersPage.vue:72-88 and components/admin/EditUserModal.vue:216-222What: editingUserLinkedAccounts reads linksQuery.data.value ?? [] and filters — there is no gate on linksQuery.isLoading or isError. EditUserModal branches only on length === 0, so an unresolved or failed collection renders the affirmative statement "No linked accounts." The userLinksCollection syncs independently of the users list (user-links.collection.ts:12-21, startSync: true), so on a slow link the users page can paginate before the collection lands. Why it matters: "Linked accounts" is the field an admin checks before deleting a user — the whole point is to see what access is about to be revoked. Reading "No linked accounts." for a user with four of them, in a modal whose Delete button sits eight lines below, is the wrong answer at the worst moment. Fix: Pass linksQuery.isLoading / isError into the modal and render a skeleton and an error state; assert "No linked accounts." only when a load has actually succeeded with zero rows.
13. Nothing stops an admin from deleting or demoting themselves — Medium · MSG-05
Where: apps/api/src/modules/admin/users/users.service.ts:111-119, :61-109, and apps/web/src/components/admin/EditUserModal.vue:141-156,273-281What: remove checks only that the user exists; it never compares userId against the session (the controller does not even pass req — users.controller.ts:81-88). update will happily strip admin from the roles array. The Roles MultiSelect and the Delete button are both in the modal an admin reaches by clicking their own row, with no self-recognition and no warning. Deleting yourself cascades your own sessions, so the response and the sign-out race. Why it matters: Removing the last admin role — or deleting the last admin account — locks every administrative surface in the app permanently: admin.users is gated on manage all (router/index.ts:418-424), the API endpoints on the same policy, and there is no recovery path outside a direct database edit. Severity call: Medium — it requires a deliberate self-directed action, and it is recoverable at the DB. It is listed because the UI gives no signal at all that the row being edited is your own. Fix: Mark the acting user's row ("You"), disable Delete on it, and refuse server-side to remove the last remaining holder of the admin role — returning a message that says why.
Unverified
- A11Y-01 (contrast) — the status
Tagseverities onUserLinksPage.vue:228-237and thebg-status-*-bg/text-status-*-fgpairs inEditUserModal.vue:204-209need computed colour values. Not assertable from class names. - A11Y-06 (short viewport / mobile keyboard) — needs a rendered viewport.
EditUserModalandUserLinkModalare long forms inDialog class="w-full max-w-xl"with no:breakpointsprop, whichapps/web/CLAUDE.mdrequires for literal-width dialogs; whether they actually clamp at 320 px needs measuring. - FORM-06 / FORM-11 — PrimeVue
Button :loadingmapping to nativedisabled(hence the focus drop when Save/Approve/Reject is pressed) andDialog's autofocus behaviour both depend on PrimeVue internals.node_modulesis not installed, so unverifiable here. - PrimeVue
Messageandrole="alert"— whether theMessageblocks inAddUserModal.vue:177-183,EditUserModal.vue:173-179andUserLinkModal.vue:332-338carry an alert role. The hand-rolleddeleteErrordiv (UserLinksPage.vue:329-334) definitely does not — that part is asserted.
Baseline additions
CTRL-ACTION-DROPPED— A shared component must render every action a host supplies. If a slot,createLabel, or modal prop is provided but an internal condition suppresses it, that is a defect in the component, not the host; the component should fall back to rendering or warn in development. (Finding 1.)FORM-INERT-CONTROL— A form field must not be required, or even shown, when another value in the same form makes it unusable by the backend. Collides with the existing FORM-12(c) proposal (filters that never reach the API); same rule, merge them. (Finding 10.)SEC-SESSION-ON-CREDENTIAL-CHANGE— Any credential change, by whoever initiates it, invalidates that user's other sessions server-side. SEC-03 as written says "a password change", which agents have read as covering only the self-service path; the admin-initiated path is where it actually fails. Suggest amending SEC-03's wording rather than adding an ID.DATA-SELECTION-SCOPE— A bulk action operates only on rows currently visible under the active search/filter, or names the hidden rows it will also affect. (Finding 5.) Likely applies to every multi-select list in the programme.
Cross-project note
- Finding 5 (bulk action on hidden selection) — check every multi-select table: playout has none obvious, but tt-time-tracker's admin tables and members' users & roles screen are prime candidates. This is the generalisable one.
- Finding 3 (admin credential change leaves sessions alive) — check tt-time-tracker #Users and members #25. tt-time-tracker already fails the harder version of this (archived users keep signing in); whether its password path revokes is unknown.
- Finding 7 (
role="button"on rows containing controls) — the sharedpackages/uiDataTableserves ~15 customer-portal list features, so it is repo-wide here. PROJECT-LEVEL already recordsA11Y-03click-only rows failing in all four projects; this is the inverse defect (over-applied role) and worth distinguishing. - Priority check result — customer-portal passes the archive/disable revocation test that tt-time-tracker fails, because
PermissionsServicerevalidates links and roles from the database on every request instead of trusting a session-time snapshot. That is the fix shape to recommend for tt-time-tracker'sOrganizationGuard, and it is worth checking playout #18 and members #25 against the same standard. Note the caveat: customer-portal passes by having no disable state to ignore, and its own persistedadmin-selected-accountscope (PROJECT-LEVEL) still fails the same class of test — so the project is not uniformly correct here, just correct on this path.