Sidebar's 'Signed in as' line now wraps the display name in a link to /account so the existing sidebar-compact view becomes the entry point for the fuller page — keeps the sign-out button where it was for muscle memory, just adds the detail page one click away. Page is gated with [Authorize] (any authenticated admin) rather than a specific role — the capability table deliberately works for every signed-in user so they can see what they DON'T have access to, which helps them file the right ticket with their LDAP admin instead of getting a plain Access Denied when navigating blindly.
Capability → required-role table is defined as a private readonly record list in the page rather than pulled from a service because it's a UI-presentation concern, not runtime policy state — the runtime policy IS Program.cs's AddAuthorizationBuilder + each page's [Authorize] attribute, and this table just mirrors it for operator readability. Comment on the list reminds future-me to extend it when a new policy or [Authorize] page lands. No behavior change if roles are empty, but the page surfaces a hint ('Sign-in would have been blocked, so if you're seeing this, the session claim is likely stale') that nudges the operator toward signing out + back in.
No new tests added — the page is pure display over claims; its only logic is the 'has-capability' Any-overlap check which is exactly what ASP.NET's [Authorize(Roles=...)] does in-framework, and duplicating that in a unit test would test the framework rather than our code. Admin.Tests Unit stays 23 pass / 0 fail. Admin build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
130 lines
6.1 KiB
Plaintext
130 lines
6.1 KiB
Plaintext
@page "/account"
|
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
|
@using System.Security.Claims
|
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
|
|
|
<h1 class="mb-4">My account</h1>
|
|
|
|
<AuthorizeView>
|
|
<Authorized>
|
|
@{
|
|
var username = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "—";
|
|
var displayName = context.User.Identity?.Name ?? "—";
|
|
var roles = context.User.Claims
|
|
.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value).ToList();
|
|
var ldapGroups = context.User.Claims
|
|
.Where(c => c.Type == "ldap_group").Select(c => c.Value).ToList();
|
|
}
|
|
|
|
<div class="row g-4">
|
|
<div class="col-md-6">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h5 class="card-title">Identity</h5>
|
|
<dl class="row mb-0">
|
|
<dt class="col-sm-4">Username</dt><dd class="col-sm-8"><code>@username</code></dd>
|
|
<dt class="col-sm-4">Display name</dt><dd class="col-sm-8">@displayName</dd>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-md-6">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h5 class="card-title">Admin roles</h5>
|
|
@if (roles.Count == 0)
|
|
{
|
|
<p class="text-muted mb-0">No Admin roles mapped — sign-in would have been blocked, so if you're seeing this, the session claim is likely stale.</p>
|
|
}
|
|
else
|
|
{
|
|
<div class="mb-2">
|
|
@foreach (var r in roles)
|
|
{
|
|
<span class="badge bg-primary me-1">@r</span>
|
|
}
|
|
</div>
|
|
<small class="text-muted">LDAP groups: @(ldapGroups.Count == 0 ? "(none surfaced)" : string.Join(", ", ldapGroups))</small>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h5 class="card-title">Capabilities</h5>
|
|
<p class="text-muted small">
|
|
Each Admin role grants a fixed capability set per <code>admin-ui.md</code> §Admin Roles.
|
|
Pages below reflect what this session can access; the route's <code>[Authorize]</code> guard
|
|
is the ground truth — this table mirrors it for readability.
|
|
</p>
|
|
<table class="table table-sm align-middle mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>Capability</th>
|
|
<th>Required role(s)</th>
|
|
<th class="text-end">You have it?</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var cap in Capabilities)
|
|
{
|
|
var has = cap.RequiredRoles.Any(r => roles.Contains(r, StringComparer.OrdinalIgnoreCase));
|
|
<tr>
|
|
<td>@cap.Name<br /><small class="text-muted">@cap.Description</small></td>
|
|
<td>@string.Join(" or ", cap.RequiredRoles)</td>
|
|
<td class="text-end">
|
|
@if (has)
|
|
{
|
|
<span class="badge bg-success">Yes</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="badge bg-secondary">No</span>
|
|
}
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-4">
|
|
<form method="post" action="/auth/logout">
|
|
<button class="btn btn-outline-danger" type="submit">Sign out</button>
|
|
</form>
|
|
</div>
|
|
</Authorized>
|
|
</AuthorizeView>
|
|
|
|
@code {
|
|
private sealed record Capability(string Name, string Description, string[] RequiredRoles);
|
|
|
|
// Kept in sync with Program.cs authorization policies + each page's [Authorize] attribute.
|
|
// When a new page or policy is added, extend this list so operators can self-service check
|
|
// whether their session has access without trial-and-error navigation.
|
|
private static readonly IReadOnlyList<Capability> Capabilities =
|
|
[
|
|
new("View clusters + fleet status",
|
|
"Read-only access to the cluster list, fleet dashboard, and generation history.",
|
|
[AdminRoles.ConfigViewer, AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]),
|
|
new("Edit configuration drafts",
|
|
"Create and edit draft generations, manage namespace bindings and node ACLs. CanEdit policy.",
|
|
[AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]),
|
|
new("Publish generations",
|
|
"Promote a draft to Published — triggers node roll-out. CanPublish policy.",
|
|
[AdminRoles.FleetAdmin]),
|
|
new("Manage certificate trust",
|
|
"Trust rejected client certs + revoke trust. FleetAdmin-only because the trust decision gates OPC UA client access.",
|
|
[AdminRoles.FleetAdmin]),
|
|
new("Manage external-ID reservations",
|
|
"Reserve / release external IDs that map into Galaxy contained names.",
|
|
[AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]),
|
|
];
|
|
}
|