diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor
index a8d3e0e0..8c9c4591 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor
@@ -130,10 +130,16 @@
private bool _loading = true;
private string? _errorMessage;
+ // "New Instance…" leaves the Design area for a RequireDeployment page; only offer
+ // it to users who hold that role so the menu never leads to a 403.
+ private bool _canCreateInstances;
+
private ToastNotification _toast = default!;
protected override async Task OnInitializedAsync()
{
+ var state = await AuthStateProvider.GetAuthenticationStateAsync();
+ _canCreateInstances = state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Deployer);
await LoadTemplatesAsync();
}
@@ -302,6 +308,13 @@
{
}
+ @if (_canCreateInstances)
+ {
+
+
+
+ }
@if (tmpl != null)
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs
index 255af811..45494b64 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs
@@ -47,15 +47,16 @@ public class TemplatesPageTests : BunitContext
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
}
- private void AddTestAuth()
+ private void AddTestAuth(params string[] roles)
{
// The page resolves the current user via the "Username" claim in
// GetCurrentUserAsync(); supply a stub so OnInitializedAsync doesn't crash.
- var claims = new[]
- {
- new Claim(JwtTokenService.UsernameClaimType, "tester"),
- new Claim(JwtTokenService.RoleClaimType, "Designer")
- };
+ // Calling this again from a test replaces the constructor's Designer-only
+ // default (MS DI resolves the LAST registration of a service), which is how
+ // a test renders the page as a Deployer.
+ if (roles.Length == 0) roles = new[] { Roles.Designer };
+ var claims = new List { new(JwtTokenService.UsernameClaimType, "tester") };
+ claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
var identity = new ClaimsIdentity(claims, "TestAuth");
var user = new ClaimsPrincipal(identity);
Services.AddSingleton(new TestAuthStateProvider(user));
@@ -396,6 +397,57 @@ public class TemplatesPageTests : BunitContext
Assert.EndsWith("/design/templates/create", nav.Uri);
}
+ // ---------------------------------------------------------------
+ // "New Instance…" context-menu item (Deployer-gated)
+ // ---------------------------------------------------------------
+
+ private void SeedOneTemplate(int id, string name)
+ {
+ _repo.GetAllTemplatesAsync(Arg.Any())
+ .Returns(Task.FromResult>(new List { new(name) { Id = id } }));
+ _repo.GetAllFoldersAsync(Arg.Any())
+ .Returns(Task.FromResult>(new List()));
+ }
+
+ private static AngleSharp.Dom.IElement OpenTemplateContextMenu(
+ IRenderedComponent cut, string label)
+ {
+ var row = cut.FindAll("li[role='treeitem']")
+ .First(li => li.TextContent.Contains(label))
+ .QuerySelector(".tv-row")!;
+ row.ContextMenu();
+ return cut.Find(".dropdown-menu.show");
+ }
+
+ [Fact]
+ public void TemplateContextMenu_OffersNewInstance_ForDeployer()
+ {
+ AddTestAuth(Roles.Designer, Roles.Deployer);
+ SeedOneTemplate(42, "Pump");
+
+ var cut = Render();
+ var menu = OpenTemplateContextMenu(cut, "Pump");
+
+ var btn = menu.QuerySelector("button[data-test='ctx-new-instance']");
+ Assert.NotNull(btn);
+ Assert.Equal("New Instance…", btn!.TextContent.Trim());
+
+ btn.Click();
+ var nav = Services.GetRequiredService();
+ Assert.EndsWith("/deployment/instances/create?templateId=42", nav.Uri);
+ }
+
+ [Fact]
+ public void TemplateContextMenu_HidesNewInstance_ForDesignerOnly()
+ {
+ // The constructor already registered the Designer-only principal.
+ SeedOneTemplate(42, "Pump");
+
+ var cut = Render();
+ var menu = OpenTemplateContextMenu(cut, "Pump");
+
+ Assert.Null(menu.QuerySelector("button[data-test='ctx-new-instance']"));
+ }
}
internal sealed class TestAuthStateProvider : AuthenticationStateProvider