Files
mxaccessgw/docs/plans/2026-08-11-dashboard-ui-sweeps.md
T
Joseph Doherty 1c30611b1e feat(dashboard): gate the side rail's Secrets link on secrets:manage
Family-wide nav sweep: the Secrets management page should be linked from
each app's UI, visible to Administrator-role users only.

The link already existed in MainLayout's Admin section. The gate did not:
the rail rendered every item for every visitor, including a Viewer and the
anonymous-localhost read-only identity. Not an access hole — the mounted
page carries [Authorize(Policy = "secrets:manage")], so a Viewer clicking
through was denied — but a dead link presented as a live one. There was
also no existing role-gated nav pattern to follow; the rail's only
AuthorizeView was the footer's signed-in/signed-out split.

Gated on the POLICY rather than a role literal, so nav visibility cannot
drift from what the page enforces. In this host the two are equivalent:
GatewayOptionsValidator constrains Dashboard:GroupToRole values to
Administrator or Viewer, so the shared library's other manage-granting
roles (secrets-manager, secrets-reveal) are unreachable. The policy form
stays correct if that ever relaxes, where a role literal would then hide
the link from users who can use the page.

API Keys is deliberately left ungated. It looks like the same case and is
not: ApiKeysPage renders for a Viewer with write affordances hidden, so
hiding its link would remove legitimate read access. The secrets page has
no read-only mode. The rule is "gate the link when the page denies the
role outright", not "gate everything under Admin".

Coverage: three tests pin the policy's verdict per principal
(Administrator admitted, Viewer refused, unauthenticated refused), and
/admin/secrets joins the canonical route list — it is the one nav
destination mounted from an RCL rather than declared here, so a routing
regression could remove it without touching this repo's pages. The
principal helper sets an authentication type deliberately: without one
the role assertions would pass vacuously for the wrong reason.

Not a rendering test — the suite has no component-testing harness, and
adding one to assert a single AuthorizeView would be a large dependency
for a small claim.

Build 0 warnings / 0 errors; suite 895/895.
2026-08-13 09:33:04 -04:00

27 KiB
Raw Blame History

Dashboard UI cleanup sweep (2026-08-11)

Runs the family admin-UI cleanup playbook (../scadaproj/admin_ui_cleanup.md) against the MXAccess Gateway Blazor dashboard. Third app in the family after the ignitionoee router and the OtOpcUa AdminUI.

0. Platform correction — this app is not Bootstrap-free

The umbrella index describes mxaccessgw as the family's Bootstrap-free app. That is wrong, and every Bootstrap-dependent recipe in the playbook applies here unchanged. What actually ships:

Layer Evidence
Bootstrap 5.3.3, self-hosted libman.json:5; wwwroot/lib/bootstrap/css/bootstrap.min.css
Linked first in the head Dashboard/Components/App.razor:7
ZB.MOM.WW.Theme 0.3.1 at discovery, 0.4.0 after §6 ZB.MOM.WW.MxGateway.Server.csproj <PackageReference>; <ThemeHead /> at App.razor:8
App stylesheet, loaded last App.razor:9wwwroot/css/site.css
Bootstrap JS bundle App.razor:14

What CLAUDE.md actually forbids is Blazor component libraries (MudBlazor, Radzen, FluentUI) — not Bootstrap's CSS/JS. No Bootstrap was introduced by this sweep.

Playbook §2 foundation item 1 (app stylesheet after <ThemeHead /> so it wins the cascade) was therefore already satisfied before the sweep.

1. Discovery

1a. Foundation / CSS audit

Shipped-vs-used class matrix. Every class in Dashboard/Components/**/*.razor checked against theme 0.3.1 staticwebassets/css/{theme,layout}.css, bootstrap.min.css, and site.css. Three classes are defined nowhere:

Ghost class Site Consequence Verdict
tree-load-status Shared/BrowseTreeNodeView.razor:42, :49 Real visual defect. The row's indent spacer is <span class="tree-toggle tree-toggle-empty">; .tree-toggle sets flex:none;width:1.1rem, which only takes effect on a flex item. .tree-row/.tree-attr are flex; this container is an undefined block, so the span stays inline and width is ignored — " Loading…" and "Failed to load: …" render flush left, out of alignment with every sibling row. Define it
browse-stale-banner Pages/BrowsePage.razor:78 The banner carries @onclick="ClearStaleBanner" with no pointer affordance and no styling of its own — a click-to-dismiss control that does not look clickable. Define it
tree-node Shared/BrowseTreeNodeView.razor:15 Structural wrapper only; nothing needs to style it. A no-op, but a deliberate one. Leave

(Reported as ghosts by the raw extractor but false positives: h-100 at Shared/MetricCard.razor:1 — Bootstrap-defined, mangled by the extractor's handling of the inline @(...) class expression.)

Scoped-CSS bundle. N/A — the project contains zero *.razor.css files, so no *.bundle.scp.css is emitted and nothing is missing from the head. (This was OtOpcUa's finding; it does not exist here.)

Phantom CSS custom properties. Zero. Dashboard/Components/ contains no var(--…) at all — tokens are used only from site.css, and every one of them (--ink, --ink-soft, --ink-faint, --card, --rule, --rule-strong, --mono, --accent, --accent-deep, --ok, --ok-bg, --bad, --bad-bg, --warn, --warn-bg, --idle, --idle-bg) resolves against theme 0.3.1. This app has the OtOpcUa-clean result, not the router's.

Button sizing — the one real foundation defect. Theme 0.3.1 ships no .btn rule (confirmed: .btn appears in layout.css only inside a comment). But site.css:187 does, and it sets font-size directly rather than through Bootstrap's variable:

.btn { border-radius: 5px; font-size: 0.82rem; font-weight: 500; white-space: nowrap; }

Bootstrap renders .btn { font-size: var(--bs-btn-font-size) }, and .btn-sm / .btn-group-sm > .btn size themselves purely by redefining that variable (.btn-sm{--bs-btn-font-size:0.875rem}). A literal font-size on .btn at equal specificity, loaded later, wins over the variable-driven declaration for every button — so btn-sm and btn-group-sm are font-size no-ops app-wide and small buttons differ from full-size ones by padding alone. This is the same class of defect the other two apps hit from the opposite direction (no .btn rule at all), and it takes the same fix.

Dark scheme. Theme 0.3.1 is light-only; site.css makes no prefers-color-scheme / data-bs-theme claim, and its header comment ("Layers over theme.css … every colour resolves to a theme.css token") is accurate. Nothing to correct.

1b. Button inventory

grep -rn "btn-group" returns three — this app already uses the convention where it matters:

Site Members State
Pages/ApiKeysPage.razor:189 Rotate / Revoke, or Delete Correct — btn-group btn-group-sm, no per-button btn-sm, @if inside the group
Pages/SessionsPage.razor:90 Close / Kill Correct
Pages/SessionDetailsPage.razor:34 Close session / Kill worker Correct

Adjacent related buttons not yet grouped (both are feet, the playbook's named case):

Site Members Fix
Shared/ConfirmDialog.razor:17,22 Cancel + @ConfirmButtonClass confirm, in a modal-footer Wrap in btn-group
Pages/ApiKeysPage.razor:136,137 Save (type="submit") + Cancel, in the create-key card body Wrap in btn-group btn-group-sm; fold the two btn-sm and drop the me-1 spacer

Refused / not candidates:

  • Pages/WorkersPage.razor:74 — a lone Kill button. Nothing to group.
  • Pages/ApiKeysPage.razor:22 — lone page-head "Create API Key".
  • Pages/BrowsePage.razor:109 (Clear all) and :167 (Remove) — lone buttons.
  • Shared/BrowseTreeNodeView.razor:19 .tree-toggle — a bare expander, deliberately unstyled as a button; MainLayout.razor:32 Sign Out / :36 Sign In are the theme's rail-btn, one per auth branch and mutually exclusive.

Row actions styled as links: none. Every action in the app is already a real <button>; the only <a> in Dashboard/Components/ is MainLayout.razor:36, which navigates. btn-link: zero uses. &middot; action separators: zero — the · occurrences (BrowsePage.razor:54,102,106,287, BrowseTreeNodeView.razor:71, AlarmsPage.razor:220) all separate metadata facts or serve as a bullet glyph, never actions. Inline width: sizing hacks: zero. py-0: zero.

Arm→confirm flows (restyle-only; the two-step must survive):

Page Flow
SessionsPage Close / Kill → ConfirmDialogConfirmPendingAsync
SessionDetailsPage Close session / Kill worker → ConfirmDialog
WorkersPage Kill → ConfirmDialog
ApiKeysPage Rotate / Revoke / Delete → ConfirmDialog; Create → modal form

Size mismatches within a group: none.

1c. Prose inventory (DELETE / KEEP / RELOCATE)

Site Text Verdict
Pages/GalaxyPage.razor:134-138 "Browse data is served by the galaxy_repository.v1.GalaxyRepository gRPC service. Clients call DiscoverHierarchy for the full tree and GetLastDeployTime to detect redeployments." DELETE — unconditional client-API documentation on an operator page. Every fact is already in docs/GalaxyRepository.md (service name at :44, GetLastDeployTime at :49, DiscoverHierarchy at :50). Plain delete, no relocation needed.
Pages/AlarmsPage.razor:151-154 "Cleared alarms are not retained — this list reflects only alarms currently Active or ActiveAcked, refreshed every 3 seconds." KEEP — decodes what the list contains and does not contain; the operator cannot infer "cleared alarms are absent" from the data.
Pages/AlarmsPage.razor:26-30 Alarms-disabled banner citing MxGateway:Alarms:Enabled KEEP — conditional state banner. Config key verified against the options class: GatewayOptions.AlarmsAlarmsOptions.Enabled, present in appsettings.json.
Pages/BrowsePage.razor:33-35, :41, :120-124 Empty states KEEP
Pages/BrowsePage.razor:70, :90 "Showing the first N matches — refine the filter." / "Double-click a tag, or right-click for the menu." KEEP — truncation notice, and the only decoder of two interactions that have no visible affordance.
Pages/SessionDetailsPage.razor:115-118 "Waiting for events. The dashboard mirrors the session's gRPC event stream — events appear here only while a gRPC client is also consuming this session's events." KEEP — explains an empty state that otherwise reads as a bug.
Pages/GalaxyPage.razor:33-38 Unknown-status empty state KEEP

Stale-claim hunt (wrong facts outrank style): none found. No milestone labels ("F8/F9 pending", "Batch 2"), no dead repo hyperlinks, no superseded architecture claims. Every config key cited in markup resolves — MxGateway:Alarms:Enabled (above) is the only one. Also checked and cleared: SettingsPage.razor:32 renders Ldap.ServiceAccountPassword, but Configuration/GatewayConfigurationProvider.cs:30 substitutes RedactedValue before the snapshot is built, so no credential reaches the page.

1d. Density / layout scan (per page, not per file)

Structural mitigations already present app-wide: site.css:137 caps .dashboard-table td at max-width: 26rem with overflow-wrap: break-word, so the OtOpcUa /hosts failure mode (one long exception widens the table until the actions column scrolls off) cannot happen here. Every table is also inside .table-responsive. panel-head appears zero times, so the "sections running together" tell does not fire — each group is its own section.dashboard-section card already.

Residual finding — unbounded free-text columns. The 26rem cap converts the horizontal blowout into vertical blowout: a multi-line exception makes one row several times taller than its neighbours and pushes the rest of the table off-screen.

Site Column Bound to
Shared/FaultList.razor:28 Message @fault.Message — worker/COM fault text, uncontrolled
Pages/SessionsPage.razor:86 Fault session.LastFault
Pages/WorkersPage.razor:70 Fault worker.LastFault
Shared/BrowseTreeNodeView.razor:51 (tree row) @Node.LoadErrorworst case: .tree-attr/.tree-row siblings are white-space: nowrap inside a fixed-height scroller, so a long browse error stretches the left pane horizontally
Pages/DashboardHome.razor:47 Galaxy panel Snapshot.Galaxy.LastError on the compact overview

Deliberately not truncated (the full text must stay reachable — playbook rule): SessionDetailsPage.razor:84 "Last fault" and GalaxyPage.razor:47 "Last Error" are both full-width rows on the drill-down page each summary links to.

Not firing: identity slamSessionsPage.razor:75-81 puts a worker pid next to a status chip, which the playbook explicitly permits ("chips stay with the name line"); no cell renders two identifiers. Inline full-width expander rows — zero colspan detail rows in the app.

1e. Tree tables

No adoption. BrowsePage + BrowseTreeNodeView is a lazy-loading nav/picker tree with a context menu and no columns — the rubric's explicit "different animal, leave it alone". No table in the app flattens hierarchy through a path-prefix column, hand-rolled rowspan, indent-by-padding, or faked group-header rows; Sessions/Workers/Events/Alarms/Galaxy are flat fact lists and Top Templates is rank-ordered (a tree would destroy load-bearing ordering). Porting the router's TreeTable here would create an orphan.

Guard tests

Checked for source-scan tests pinning dashboard markup: none. The only .razor reference in the test project is a prose comment (Gateway/GatewayApplicationTests.cs:116). Nothing to re-pin.

2. Changes

Foundation

  1. wwwroot/css/site.css — button sizing via Bootstrap CSS variables. Replace the literal font-size on .btn with --bs-btn-* overrides and add the btn-sm / btn-group-sm block, so the small-button distinction works again. Box properties are not redefined wholesale — border-radius: 5px stays literal because the three existing btn-groups already render seamed under it (.btn-group > .btn:not(:first-child) outranks .btn). Carries the header comment the other two apps carry: delete the local copy when a ZB.MOM.WW.Theme release ships a .btn rule. That release shipped the same day — see §6.
  2. site.css — define .tree-load-status as a flex row matching .tree-row, restoring the indent alignment of the tree's loading/error rows.
  3. site.css — define .browse-stale-banner with cursor: pointer and tightened padding, so the click-to-dismiss banner reads as clickable. No behavior change.
  4. Dashboard/Components/DashboardDisplay.cs — add Abbreviate(string?, int). Safe helper (length-checked, never a raw [..n] slice — the OtOpcUa #504 failure), - for null/whitespace, ellipsis on truncation.

No prose relocation task: the single DELETE is already docs-covered.

Page batches

Batch A — free-text truncation (Shared/FaultList.razor, Pages/SessionsPage.razor, Pages/WorkersPage.razor, Shared/BrowseTreeNodeView.razor, Pages/DashboardHome.razor): Abbreviate + full text on title, at the five sites in 1d.

Batch B — button feet (Shared/ConfirmDialog.razor, Pages/ApiKeysPage.razor): the two btn-group wraps from 1b. @onclick, disabled, type="submit", and both arm→confirm flows unchanged.

Batch C — prose (Pages/GalaxyPage.razor): delete :134-138.

3. Verification

Build + tests

  • dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx — 0 warnings, 0 errors (TreatWarningsAsErrors=true).
  • dotnet test src/ZB.MOM.WW.MxGateway.Tests — 879/879 passed, the macOS baseline.

Post-merge greps

  • Zero phantom var(--…) usages (there were none to begin with).
  • Zero ghost classes remaining except the deliberate tree-node.
  • btn-group count 3 → 5, matching the 1b inventory.
  • Zero &middot; action separators, zero inline width: hacks (none existed).
  • Scoped-bundle link: N/A, no *.razor.css.

Live browser gate

Recorded in §4 below.

4. Live browser gate — results

Run against a local dotnet run of the gateway on macOS (http://localhost:5120, the launch profile's port), not windev — the sweep is CSS/markup-only and redeploying the shared NSSM service was not warranted. Rig configuration (env overrides only, no committed config touched): Dashboard:DisableLogin=true (to reach the Admin-only surfaces), Ldap:Enabled=false, Authentication:Mode=Disabled, SQLite + Galaxy snapshot paths under the session scratchpad, ApiKeyPepper set locally. The rig was stopped and its throwaway auth DB deleted afterwards.

Realistic erroring data: the Galaxy SQL Server (localhost, ZB) does not exist on macOS, so every refresh fails with a genuine 250-character Microsoft.Data.SqlClient SSPI exception, and the alarm monitor's auto-opened session fails on the missing x86 worker — both real, uncontrolled error text of exactly the kind §1d is about.

# Check Result Evidence
1 Computed-style probes PASS body 14.4px; .btn 13.6px (0.85rem); .btn-sm 12.48px (0.78rem) — the small/full distinction is live, where before the fix both computed to 13.12px. btn-group-sm members: both 12.48px, first member's right radius 0px, last member's 5pxseams intact, confirming the literal border-radius does not break group machinery. .tree-load-statusdisplay: flex; .browse-stale-bannercursor: pointer. Stylesheet order: bootstrap → theme → layout → site.css last. The theme's own .rail-toggle btn-sm also picks up 12.48px, so the block reaches theme-owned buttons, not just app markup.
2 Every page leads with data, no unconditional doc block above the fold PASS All 9 routes' first blocks are dashboard-page-headermetric-grid / dashboard-section / state alert. /galaxy now ends at the Sync Info table — the deleted client-API paragraph is gone.
3 Seamed btn-groups; destructive members red PASS /apikeys row actions render Rotate + Revoke as one seamed group with Revoke in btn-outline-danger red. The ConfirmDialog foot renders Cancel + a solid red Revoke seamed together.
4 Arm→confirm: arm → confirm UI appears → Cancel → verify disarm PASS Revoke → "Revoke API key?" dialog → Cancel. Post-cancel probe: dialogOpen:false, backdrops:0, key status still Active, row action back to btn-outline-danger outline. No write completed. Save/Cancel in the create-key modal also verified: Cancel closes without creating; Save creates (validation message surfaced correctly on the first attempt with a missing display name).
5 Anything the scoped bundle resurrected N/A No *.razor.css in the project — see 1a.
6 KEEP-list legends / hints / empty states still render PASS /browse: both empty states plus the "Right-click a tag … Add to subscription panel" hint. /alarms: the "Cleared alarms are not retained …" legend under the table. /galaxy: Object Categories / Top Templates empty states.
7 Density with realistic data; sections as separate cards; no horizontal scroll PASS / renders the SQL error truncated to one line with ; /galaxy renders the same error in full (the drill-down) — the intended split is visible side by side. /apikeys with a 3-entry read_subtrees constraint wraps inside the column with no table overflow. documentElement.scrollWidth == clientWidth on every page walked. Each group is its own card.

Not exercisable on this rig (recorded, not claimed as passing): the Sessions / Workers / Recent Faults truncation call sites and the BrowseTreeNodeView load-error row need a registered session, which requires the x86 worker — on macOS the launch fails before the session is ever registered, so those tables stay empty. Their shared helper and the container CSS were both verified live by other means (the / Galaxy error for Abbreviate, the computed-style probe for .tree-load-status). Re-check them on windev the next time that service is redeployed.

Side note surfaced, not acted on: SettingsPage renders LDAP service password as [redacted] in the browser — confirming GatewayConfigurationProvider's substitution end to end.

5. Follow-up gate: /admin/secrets delete modal (scadaproj#2)

Not part of the sweep. Run because the same stale "mxgw is Bootstrap-free" claim corrected in §0 was the stated reason this app was excused from the 2026-07-19 family-wide /admin/secrets modal sweep: ConfirmDeleteModal shipped a bare class="modal", which Bootstrap 5's .modal{display:none} made permanently invisible on every Bootstrap host. The exemption's reasoning was false, so mxgw was in scope and its delete modal had never been live-gated. 8f7ee49 bumped ZB.MOM.WW.Secrets.Ui to 0.2.3, which is supposed to carry the fix — but "supposed to" is what the original claim was.

Static check — decoding the UTF-16 literals out of ZB.MOM.WW.Secrets.Ui.dll 0.2.3 shows the component now inlines its own <style> under a private namespace: .zb-secrets-modal with display: flex; position: fixed; inset: 0; z-index: 1081, over a .zb-secrets-modal-backdrop at z-index: 1080. No bare modal class.

Live check — local rig, scratch secrets store (Secrets:SqlitePath + ZB_SECRETS_MASTER_KEY under the session scratchpad), throwaway secret uisweep/throwaway, delete armed but never confirmed:

Probe Result
Modal root classes zb-secrets-modal — and document.querySelector('.modal') is null, so Bootstrap's .modal{display:none} has nothing to bite
Modal computed style display: flex, position: fixed, z-index: 1081, visibility: visible, opacity: 1, box 1600×827
Backdrop display: block, position: fixed, z-index: 1080, rgba(15,18,20,.45), covers the full viewport
Confirm button hit-testable elementFromPoint at its centre returns the button itself — not covered by the backdrop
Cancel → disarm modal and backdrop both removed; secret still listed; no delete performed

PASS. The 0.2.3 fix holds on this Bootstrap host. The rig was stopped and both throwaway stores (secrets + auth) deleted.

6. Follow-up: ZB.MOM.WW.Theme 0.3.1 → 0.4.0 (dependency bump, not UI cleanup)

0.4.0 upstreams the button-sizing block this sweep installed locally, written against the finding in §1a — the kit sizes only through --bs-btn-* variables and carries a comment in layout.css saying why, so nobody simplifies it back into literals.

  • ZB.MOM.WW.MxGateway.Server.csproj: 0.3.10.4.0. No Directory.Packages.props in this repo; the version lives on the PackageReference.
  • site.css: the four --bs-btn-* overrides and the whole .btn-group-sm > .btn, .btn-sm rule deleted — layout.css now supplies them and <ThemeHead /> emits it before site.css.

Trimmed, not deleted wholesale. The local .btn rule also carried border-radius: 5px, font-weight: 500, white-space: nowrap, which predate the sweep and which 0.4.0 does not ship — it upstreamed sizing only. Removing the block entirely would have silently dropped three app-specific declarations. What remains is .btn { border-radius: 5px; font-weight: 500; white-space: nowrap; } — shape, not size. border-radius deliberately stays a literal rather than --bs-btn-border-radius, because .btn-sm redefines that variable and small buttons would shrink to the Bootstrap small radius.

Verification

  • Build: 0 warnings, 0 errors. Suite: 879/879.
  • Computed-style probe re-run on 0.4.0 with the local block gone: .btn 13.6px, .btn-sm 12.48px — identical to the pre-bump numbers, so the kit rule reaches. btn-group-sm members both 12.48px with the seam intact (first member right radius 0px, last 5px). Retained declarations confirmed live on both sizes: radius 5px, weight 500, nowrap.
  • Cascade confirmed by enumerating document.styleSheets: --bs-btn-font-size is now declared in exactly two sheets — bootstrap.min.css (1rem / 0.875rem) and layout.css (.85rem / .78rem). site.css no longer declares it, so the duplicate is gone.

Tree-hygiene note. The first suite run after the bump failed GatewayTreeHygieneTests.SourceTree_ContainsNoSqliteDatabaseFiles — unrelated to the bump. The §4 rig's first start used the default relative Secrets:SqlitePath (mxgateway-secrets.db), which resolved against the server project directory and left a DB in the source tree; only the §5 run redirected it to the scratchpad. The file was untracked, was deleted, and the suite went green. The §4/§5 cleanup notes were therefore incomplete as originally written — the scratchpad copies were removed but this one was missed. The hygiene test is what caught it, which is what it exists for.

7. Follow-up: ZB.MOM.WW.Theme 0.4.0 → 0.4.1 (pin-only, no rendering change here)

0.4.1 was published the same day to fix .rail-btn-block, a modifier shipped broken in 0.4.0 (display: block; width: auto fills for an <a> but shrink-wraps a <button>; now width: calc(100% - 1.2rem) with box-sizing: border-box, accounting for .rail-btn's side margins). Bumped for family-pin alignment.

Why nothing needed re-verifying. Confirmed rather than assumed:

  • diff of the two restored packages: theme.css byte-identical; layout.css differs only inside the .rail-btn-block rule and its comment. The .btn sizing block is unchanged, so the §6 measurements (.btn 13.6px, .btn-sm 12.48px) still hold and the probe was not re-run.
  • This app does not use rail-btn-block. It does carry the exact element pair the bug turned on — MainLayout.razor:32 is a form-submit <button class="rail-btn"> (Sign Out) and MainLayout.razor:36 an <a class="rail-btn"> (Sign In) — but base .rail-btn is display: inline-block, which shrink-wraps both element types identically. The asymmetry only appears once the block modifier is applied, so this dashboard was never affected.

Verification. Build 0 warnings / 0 errors; suite 879/879; staticwebassets.build.json resolves zb.mom.ww.theme/0.4.1. No stale-HTTP-cache clear was needed — restore picked 0.4.1 directly.

Requested as a family-wide sweep: every app's UI should link to the Secrets management page, visible to Administrator-role users only.

Found state. The link already existed — MainLayout.razor, Admin section, /admin/secrets. What did not exist was any gate: the rail rendered every item for every visitor, including a Viewer and the anonymous-localhost read-only identity. The premise that there was an "existing role-gated nav pattern" to follow was false; the rail's only AuthorizeView was the footer's signed-in/signed-out split, so this introduces the pattern rather than extending it.

Not an access hole — the mounted page carries [Authorize(Policy = "secrets:manage")], so a Viewer clicking through was denied. It was a dead link presented as a live one.

Gate chosen: the policy, not the role. <AuthorizeView Policy="@SecretsAuthorization.ManagePolicy">, i.e. the same policy the page itself enforces, so nav visibility cannot drift from page access. The sweep asked for a role literal (DashboardRoles.Admin = "Administrator"), and in this host the two are equivalent: GatewayOptionsValidator constrains Dashboard:GroupToRole values to Administrator or Viewer, so the shared library's other manage-granting roles (secrets-manager, secrets-reveal) are unreachable here. The policy form was preferred because it stays correct if that constraint ever relaxes — a role literal would then hide the link from users who can use the page.

Deliberate asymmetry — API Keys stays ungated. Its sibling item looks like the same case and is not. ApiKeysPage renders for a Viewer with write affordances hidden (@if (CanManageApiKeys)), so hiding its nav item would remove legitimate read access. The secrets page has no read-only mode. The rule is "gate the link when the page denies the role outright", not "gate everything under Admin".

Coverage. Three tests pin the policy's verdict per principal (Administrator admitted, Viewer refused, unauthenticated refused) in SecretsNavGateTests, and /admin/secrets joins the canonical route list in GatewayApplicationTests — it is the one nav destination mounted from an RCL rather than declared here, so a routing regression could remove it without touching this repo's pages. Not a rendering test: the suite has no component-testing harness, and adding one to assert a single AuthorizeView would be a large dependency for a small claim.

Verification. Build 0 warnings / 0 errors; suite 895/895 (892 + 3).