fix(adminui): make /admin/secrets interactive - wrap the Secrets.Ui page with a render mode (#483)
The Secrets.Ui RCL's routable page was routed directly, but this host's router is deliberately static (cookie SignInAsync needs SSR) with per-page @rendermode opt-in - a directive the RCL page cannot carry, because the other three family hosts render it under a globally interactive router where a nested render mode throws. Routing straight to the RCL page therefore rendered /admin/secrets with no circuit: it displayed, but every @onclick was silently dead. Fix: a host-side wrapper page (Pages/SecretsAdmin.razor) now owns /admin/secrets, carries the standard per-page InteractiveServer render mode, re-states the RCL page's own authorization policy (the router only enforces [Authorize] on the routed component, which is now the wrapper), and renders the RCL page as an ordinary child. The RCL assembly is de-registered from both AdditionalAssemblies sites (router + endpoint) so the route is unambiguous. Guards: SecretsPageWiringTests pins the render mode (the #483 regression), route parity with the RCL page, and policy parity; the page census in PageAuthorizationGuardTests classifies the new page and admits the secrets:manage policy. AdminUI.Tests 665/665. Live-verified on the rebuilt docker-dev rig with Playwright: /_blazor circuit negotiated on /admin/secrets and Add secret opens the editor (before the fix: zero interactive markers, no circuit, dead click). Closes #483. Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This commit is contained in:
@@ -19,7 +19,11 @@
|
|||||||
<HeadOutlet/>
|
<HeadOutlet/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<Routes AdditionalAssemblies="@(new[] { typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly })" />
|
@* No AdditionalAssemblies: the Secrets.Ui RCL's routable page is NOT routed directly in this
|
||||||
|
host — Pages/Secrets.razor wraps it so the route can carry this host's per-page
|
||||||
|
@rendermode (lmxopcua#483); registering the RCL routes too would make /admin/secrets
|
||||||
|
ambiguous. *@
|
||||||
|
<Routes />
|
||||||
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
<ThemeScripts />
|
<ThemeScripts />
|
||||||
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js"></script>
|
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js"></script>
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
@page "/admin/secrets"
|
||||||
|
@attribute [Authorize(Policy = ZB.MOM.WW.Secrets.Ui.SecretsAuthorization.ManagePolicy)]
|
||||||
|
@rendermode RenderMode.InteractiveServer
|
||||||
|
@using ZB.MOM.WW.Secrets.Ui
|
||||||
|
|
||||||
|
@* Host-side wrapper for the ZB.MOM.WW.Secrets.Ui secrets page (lmxopcua#483).
|
||||||
|
|
||||||
|
This host's <Routes/> is deliberately static (cookie SignInAsync needs SSR), so every
|
||||||
|
interactive page opts in per-page with @rendermode — a directive the RCL's own routable
|
||||||
|
SecretsPage cannot carry, because the other three family hosts render it under a globally
|
||||||
|
interactive router where a nested render mode throws. Routing straight to the RCL page
|
||||||
|
therefore produced a statically-rendered, dead page here: no circuit, @onclick inert.
|
||||||
|
|
||||||
|
So this wrapper owns the /admin/secrets route in THIS host (the RCL assembly is no longer
|
||||||
|
passed to either AdditionalAssemblies registration — doing both would make the route
|
||||||
|
ambiguous), carries the standard per-page render mode, re-states the RCL page's own
|
||||||
|
authorization policy (the router only enforces [Authorize] on the routed page itself,
|
||||||
|
which is now this one), and renders the RCL page as an ordinary child component. *@
|
||||||
|
|
||||||
|
<SecretsPage />
|
||||||
@@ -30,13 +30,14 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
// Razor class library static assets (_content/ZB.MOM.WW.OtOpcUa.AdminUI/**) are
|
// Razor class library static assets (_content/ZB.MOM.WW.OtOpcUa.AdminUI/**) are
|
||||||
// served via the Host's app.UseStaticFiles() middleware which must run BEFORE
|
// served via the Host's app.UseStaticFiles() middleware which must run BEFORE
|
||||||
// UseAuthentication() — see Program.cs.
|
// UseAuthentication() — see Program.cs.
|
||||||
// The /admin/secrets management page lives in the external ZB.MOM.WW.Secrets.Ui RCL, so its
|
// The Secrets.Ui RCL's routable page is deliberately NOT registered here (lmxopcua#483):
|
||||||
// routable component must be discovered at the endpoint layer (AddAdditionalAssemblies) —
|
// this host's router is static with per-page @rendermode opt-in, so routing straight to
|
||||||
// the interactive Router's AdditionalAssemblies (App.razor) alone is not enough for SSR
|
// the RCL page rendered it without a circuit — it displayed but every @onclick was dead.
|
||||||
// endpoint discovery. Both registrations are required or /admin/secrets 404s.
|
// Pages/Secrets.razor in THIS assembly owns /admin/secrets instead (wrapping the RCL
|
||||||
|
// page with the render mode); registering the RCL routes too would make the route
|
||||||
|
// ambiguous.
|
||||||
app.MapRazorComponents<TApp>()
|
app.MapRazorComponents<TApp>()
|
||||||
.AddInteractiveServerRenderMode()
|
.AddInteractiveServerRenderMode();
|
||||||
.AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly);
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
@@ -47,6 +47,11 @@ public class PageAuthorizationGuardTests
|
|||||||
// ── FleetAdmin (1) ──
|
// ── FleetAdmin (1) ──
|
||||||
[typeof(RoleGrants)] = AdminUiPolicies.FleetAdmin,
|
[typeof(RoleGrants)] = AdminUiPolicies.FleetAdmin,
|
||||||
|
|
||||||
|
// ── Secrets (1): host wrapper for the ZB.MOM.WW.Secrets.Ui RCL page (lmxopcua#483) —
|
||||||
|
// carries the RCL page's own policy, registered additively by AddAdminUI via
|
||||||
|
// AddSecretsAuthorization(). ──
|
||||||
|
[typeof(SecretsAdmin)] = global::ZB.MOM.WW.Secrets.Ui.SecretsAuthorization.ManagePolicy,
|
||||||
|
|
||||||
// ── AuthenticatedRead (16): dashboards, lists, live tails (read-only) + the dev ContextMenu demo ──
|
// ── AuthenticatedRead (16): dashboards, lists, live tails (read-only) + the dev ContextMenu demo ──
|
||||||
[typeof(Home)] = AdminUiPolicies.AuthenticatedRead,
|
[typeof(Home)] = AdminUiPolicies.AuthenticatedRead,
|
||||||
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Dev.ContextMenuDemo)] = AdminUiPolicies.AuthenticatedRead,
|
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Dev.ContextMenuDemo)] = AdminUiPolicies.AuthenticatedRead,
|
||||||
@@ -75,6 +80,9 @@ public class PageAuthorizationGuardTests
|
|||||||
AdminUiPolicies.DriverOperator,
|
AdminUiPolicies.DriverOperator,
|
||||||
AdminUiPolicies.ConfigEditor,
|
AdminUiPolicies.ConfigEditor,
|
||||||
AdminUiPolicies.AuthenticatedRead,
|
AdminUiPolicies.AuthenticatedRead,
|
||||||
|
// Not an AdminUiPolicies constant: the /admin/secrets wrapper re-states the Secrets.Ui
|
||||||
|
// RCL page's own policy, which AddAdminUI registers via AddSecretsAuthorization().
|
||||||
|
global::ZB.MOM.WW.Secrets.Ui.SecretsAuthorization.ManagePolicy,
|
||||||
];
|
];
|
||||||
|
|
||||||
private static IReadOnlyList<Type> RoutablePages() =>
|
private static IReadOnlyList<Type> RoutablePages() =>
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages;
|
||||||
|
using ZB.MOM.WW.Secrets.Ui;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pins the host-side wiring that makes <c>/admin/secrets</c> interactive (lmxopcua#483).
|
||||||
|
/// This host's router is static with per-page <c>@rendermode</c> opt-in; routing straight to
|
||||||
|
/// the Secrets.Ui RCL's routable page rendered it with no circuit — the page displayed but
|
||||||
|
/// every <c>@onclick</c> was dead, and nothing failed. The fix is a wrapper page in this
|
||||||
|
/// assembly that owns the route and carries the render mode. Razor directives compile to
|
||||||
|
/// class-level attributes, so a metadata scan is authoritative (same technique as
|
||||||
|
/// <c>PageAuthorizationGuardTests</c>; the repo has no bUnit).
|
||||||
|
/// </summary>
|
||||||
|
public class SecretsPageWiringTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// THE #483 regression pin: without a render mode this host serves the page as static SSR
|
||||||
|
/// and it silently loses all interactivity.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Wrapper_declares_the_InteractiveServer_render_mode()
|
||||||
|
{
|
||||||
|
var mode = typeof(SecretsAdmin).GetCustomAttributes<RenderModeAttribute>(inherit: false)
|
||||||
|
.SingleOrDefault();
|
||||||
|
|
||||||
|
mode.ShouldNotBeNull(
|
||||||
|
"/admin/secrets lost its @rendermode — in this host's static router that renders a " +
|
||||||
|
"dead page (displays, but no circuit and every @onclick inert), exactly lmxopcua#483");
|
||||||
|
mode.Mode.ShouldBeOfType<InteractiveServerRenderMode>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The wrapper must shadow the RCL page's route exactly — if Secrets.Ui ever moves its
|
||||||
|
/// page, the wrapper must move with it or the nav rail and bookmarks split from the RCL.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Wrapper_owns_the_same_route_as_the_RCL_page()
|
||||||
|
{
|
||||||
|
string[] wrapper = [.. typeof(SecretsAdmin).GetCustomAttributes<RouteAttribute>(inherit: false).Select(r => r.Template)];
|
||||||
|
string[] rcl = [.. typeof(SecretsPage).GetCustomAttributes<RouteAttribute>(inherit: false).Select(r => r.Template)];
|
||||||
|
|
||||||
|
wrapper.ShouldBe(["/admin/secrets"]);
|
||||||
|
wrapper.ShouldBe(rcl, "the wrapper must track the RCL page's route exactly");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The router enforces [Authorize] only on the routed component — which is now the wrapper,
|
||||||
|
/// with the RCL page rendered as a plain child whose own attribute is inert. The wrapper
|
||||||
|
/// must therefore re-state the RCL page's policy verbatim, or the fix silently widens access.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Wrapper_restates_the_RCL_pages_authorization_policy()
|
||||||
|
{
|
||||||
|
var wrapper = typeof(SecretsAdmin).GetCustomAttributes<AuthorizeAttribute>(inherit: false).Single();
|
||||||
|
var rcl = typeof(SecretsPage).GetCustomAttributes<AuthorizeAttribute>(inherit: false).Single();
|
||||||
|
|
||||||
|
wrapper.Policy.ShouldBe(SecretsAuthorization.ManagePolicy);
|
||||||
|
wrapper.Policy.ShouldBe(rcl.Policy, "the wrapper must enforce exactly what the RCL page declares");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user