diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor
index 4697fe81..07b3d1ac 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor
@@ -19,7 +19,11 @@
-
+ @* 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. *@
+
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/SecretsAdmin.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/SecretsAdmin.razor
new file mode 100644
index 00000000..cba83c86
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/SecretsAdmin.razor
@@ -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 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. *@
+
+
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs
index 8bff8987..11d629c5 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs
@@ -30,13 +30,14 @@ public static class EndpointRouteBuilderExtensions
// Razor class library static assets (_content/ZB.MOM.WW.OtOpcUa.AdminUI/**) are
// served via the Host's app.UseStaticFiles() middleware which must run BEFORE
// UseAuthentication() — see Program.cs.
- // The /admin/secrets management page lives in the external ZB.MOM.WW.Secrets.Ui RCL, so its
- // routable component must be discovered at the endpoint layer (AddAdditionalAssemblies) —
- // the interactive Router's AdditionalAssemblies (App.razor) alone is not enough for SSR
- // endpoint discovery. Both registrations are required or /admin/secrets 404s.
+ // The Secrets.Ui RCL's routable page is deliberately NOT registered here (lmxopcua#483):
+ // this host's router is static with per-page @rendermode opt-in, so routing straight to
+ // the RCL page rendered it without a circuit — it displayed but every @onclick was dead.
+ // 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()
- .AddInteractiveServerRenderMode()
- .AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly);
+ .AddInteractiveServerRenderMode();
return app;
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs
index 27d01ceb..4a21b837 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs
@@ -47,6 +47,11 @@ public class PageAuthorizationGuardTests
// ── FleetAdmin (1) ──
[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 ──
[typeof(Home)] = 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.ConfigEditor,
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 RoutablePages() =>
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/SecretsPageWiringTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/SecretsPageWiringTests.cs
new file mode 100644
index 00000000..f1be1c71
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/SecretsPageWiringTests.cs
@@ -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;
+
+///
+/// Pins the host-side wiring that makes /admin/secrets interactive (lmxopcua#483).
+/// This host's router is static with per-page @rendermode opt-in; routing straight to
+/// the Secrets.Ui RCL's routable page rendered it with no circuit — the page displayed but
+/// every @onclick 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
+/// PageAuthorizationGuardTests; the repo has no bUnit).
+///
+public class SecretsPageWiringTests
+{
+ ///
+ /// THE #483 regression pin: without a render mode this host serves the page as static SSR
+ /// and it silently loses all interactivity.
+ ///
+ [Fact]
+ public void Wrapper_declares_the_InteractiveServer_render_mode()
+ {
+ var mode = typeof(SecretsAdmin).GetCustomAttributes(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();
+ }
+
+ ///
+ /// 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.
+ ///
+ [Fact]
+ public void Wrapper_owns_the_same_route_as_the_RCL_page()
+ {
+ string[] wrapper = [.. typeof(SecretsAdmin).GetCustomAttributes(inherit: false).Select(r => r.Template)];
+ string[] rcl = [.. typeof(SecretsPage).GetCustomAttributes(inherit: false).Select(r => r.Template)];
+
+ wrapper.ShouldBe(["/admin/secrets"]);
+ wrapper.ShouldBe(rcl, "the wrapper must track the RCL page's route exactly");
+ }
+
+ ///
+ /// 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.
+ ///
+ [Fact]
+ public void Wrapper_restates_the_RCL_pages_authorization_policy()
+ {
+ var wrapper = typeof(SecretsAdmin).GetCustomAttributes(inherit: false).Single();
+ var rcl = typeof(SecretsPage).GetCustomAttributes(inherit: false).Single();
+
+ wrapper.Policy.ShouldBe(SecretsAuthorization.ManagePolicy);
+ wrapper.Policy.ShouldBe(rcl.Policy, "the wrapper must enforce exactly what the RCL page declares");
+ }
+}