Apply technical-light design system to the gateway dashboard
Restyles the Blazor dashboard onto a portable token-based theme so it reads like an instrument panel: warm-paper background, hairline-ruled panels, IBM Plex type, monospace tabular numerics, and status carried by colour chips. Vendors theme.css + IBM Plex fonts, rewrites dashboard.css as a thin token-driven view layer, and swaps the Bootstrap navbar and status badges for the design-system app bar and chips. Also includes pending API-key management, Galaxy hierarchy projection, and constraint-enforcement work with their tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<base href="@DashboardBaseHref" />
|
||||
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="/css/theme.css" />
|
||||
<link rel="stylesheet" href="/css/dashboard.css" />
|
||||
<HeadOutlet @rendermode="InteractiveServer" />
|
||||
</head>
|
||||
|
||||
@@ -2,55 +2,34 @@
|
||||
@inject IOptions<GatewayOptions> GatewayOptions
|
||||
|
||||
<div class="dashboard-shell">
|
||||
<nav class="navbar navbar-expand-lg bg-body border-bottom dashboard-navbar">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="">MXAccess Gateway</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#dashboardNav"
|
||||
aria-controls="dashboardNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="dashboardNav">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">Overview</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="sessions">Sessions</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="workers">Workers</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="events">Events</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="galaxy">Galaxy</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="apikeys">API Keys</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="settings">Settings</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
<AuthorizeView>
|
||||
<Authorized Context="authState">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="navbar-text">@authState.User.Identity?.Name</span>
|
||||
<form method="post" action="@DashboardPath("/logout")">
|
||||
<AntiforgeryToken />
|
||||
<button class="btn btn-outline-secondary btn-sm" type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="@DashboardPath("/login")">Sign in</a>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="container-fluid dashboard-content">
|
||||
<header class="app-bar">
|
||||
<a class="brand" href=""><span class="mark">▮</span> MXAccess Gateway</a>
|
||||
<nav class="app-nav">
|
||||
<NavLink href="" Match="NavLinkMatch.All">Overview</NavLink>
|
||||
<NavLink href="sessions">Sessions</NavLink>
|
||||
<NavLink href="workers">Workers</NavLink>
|
||||
<NavLink href="events">Events</NavLink>
|
||||
<NavLink href="galaxy">Galaxy</NavLink>
|
||||
<NavLink href="apikeys">API Keys</NavLink>
|
||||
<NavLink href="settings">Settings</NavLink>
|
||||
</nav>
|
||||
<span class="spacer"></span>
|
||||
<AuthorizeView>
|
||||
<Authorized Context="authState">
|
||||
<div class="app-user">
|
||||
<span class="meta">@authState.User.Identity?.Name</span>
|
||||
<form method="post" action="@DashboardPath("/logout")">
|
||||
<AntiforgeryToken />
|
||||
<button class="btn btn-outline-secondary btn-sm" type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="@DashboardPath("/login")">Sign in</a>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</header>
|
||||
<main class="page">
|
||||
@Body
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
@page "/apikeys"
|
||||
@page "/dashboard/apikeys"
|
||||
@inherits DashboardPageBase
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject IDashboardApiKeyManagementService ApiKeyManagementService
|
||||
|
||||
<PageTitle>Dashboard API Keys</PageTitle>
|
||||
|
||||
@if (Snapshot is null)
|
||||
{
|
||||
<div class="empty-state">Loading API keys.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="dashboard-page-header">
|
||||
<div>
|
||||
<h1>API Keys</h1>
|
||||
<div class="text-secondary">@Snapshot.ApiKeys.Count key rows</div>
|
||||
</div>
|
||||
@if (CanManageApiKeys)
|
||||
{
|
||||
<button type="button" class="btn btn-primary" @onclick="OpenCreateDialog">
|
||||
Create API Key
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (CanManageApiKeys)
|
||||
{
|
||||
@if (!string.IsNullOrWhiteSpace(ResultMessage))
|
||||
{
|
||||
<div class="alert @(LastOperationSucceeded ? "alert-success" : "alert-danger")" role="alert">
|
||||
@ResultMessage
|
||||
@if (!string.IsNullOrWhiteSpace(LastGeneratedApiKey))
|
||||
{
|
||||
<div class="mt-2">
|
||||
<code class="one-time-secret">@LastGeneratedApiKey</code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (IsCreateDialogOpen)
|
||||
{
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
<div class="modal fade show api-key-create-modal" role="dialog" aria-modal="true" aria-labelledby="createApiKeyTitle">
|
||||
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<EditForm Model="@CreateModel" OnSubmit="@CreateApiKeyAsync">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title h5" id="createApiKeyTitle">Create API Key</h2>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseCreateDialog"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="api-key-management-grid">
|
||||
<div class="mb-3">
|
||||
<label for="keyId" class="form-label">Key ID</label>
|
||||
<input id="keyId" class="form-control" @bind="CreateModel.KeyId" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="displayName" class="form-label">Display Name</label>
|
||||
<input id="displayName" class="form-control" @bind="CreateModel.DisplayName" @bind:event="oninput" />
|
||||
</div>
|
||||
</div>
|
||||
<fieldset class="mb-3">
|
||||
<legend class="form-label">Scopes</legend>
|
||||
<div class="scope-grid">
|
||||
@foreach (string scope in AvailableScopes)
|
||||
{
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
checked="@IsScopeSelected(scope)"
|
||||
@onchange="eventArgs => SetScope(scope, eventArgs)" />
|
||||
<span class="form-check-label">@scope</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="api-key-management-grid">
|
||||
<div class="mb-3">
|
||||
<label for="readSubtrees" class="form-label">Read subtrees</label>
|
||||
<textarea id="readSubtrees" class="form-control" rows="2" @bind="CreateModel.ReadSubtrees" @bind:event="oninput"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="writeSubtrees" class="form-label">Write subtrees</label>
|
||||
<textarea id="writeSubtrees" class="form-control" rows="2" @bind="CreateModel.WriteSubtrees" @bind:event="oninput"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="readTagGlobs" class="form-label">Read tag globs</label>
|
||||
<textarea id="readTagGlobs" class="form-control" rows="2" @bind="CreateModel.ReadTagGlobs" @bind:event="oninput"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="writeTagGlobs" class="form-label">Write tag globs</label>
|
||||
<textarea id="writeTagGlobs" class="form-control" rows="2" @bind="CreateModel.WriteTagGlobs" @bind:event="oninput"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="browseSubtrees" class="form-label">Browse subtrees</label>
|
||||
<textarea id="browseSubtrees" class="form-control" rows="2" @bind="CreateModel.BrowseSubtrees" @bind:event="oninput"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="maxWriteClassification" class="form-label">Max write classification</label>
|
||||
<input id="maxWriteClassification" class="form-control" @bind="CreateModel.MaxWriteClassification" @bind:event="oninput" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<label class="form-check">
|
||||
<InputCheckbox class="form-check-input" @bind-Value="CreateModel.ReadAlarmOnly" />
|
||||
<span class="form-check-label">Read alarm only</span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<InputCheckbox class="form-check-input" @bind-Value="CreateModel.ReadHistorizedOnly" />
|
||||
<span class="form-check-label">Read historized only</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" disabled="@IsBusy" @onclick="CloseCreateDialog">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" disabled="@IsBusy">Create Key</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<section class="dashboard-section">
|
||||
@if (Snapshot.ApiKeys.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No API keys are available for display.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle dashboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Key</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Display Name</th>
|
||||
<th scope="col">Scopes</th>
|
||||
<th scope="col">Constraints</th>
|
||||
<th scope="col">Created</th>
|
||||
<th scope="col">Last Used</th>
|
||||
@if (CanManageApiKeys)
|
||||
{
|
||||
<th scope="col">Actions</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (DashboardApiKeySummary key in Snapshot.ApiKeys)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@key.KeyId</code></td>
|
||||
<td><StatusBadge Text="@(key.RevokedUtc is null ? "Active" : "Revoked")" /></td>
|
||||
<td>@DashboardDisplay.Text(key.DisplayName)</td>
|
||||
<td>@DashboardDisplay.Text(string.Join(", ", key.Scopes.Order(StringComparer.Ordinal)))</td>
|
||||
<td>@DashboardDisplay.Text(ConstraintText(key.Constraints))</td>
|
||||
<td>@DashboardDisplay.DateTime(key.CreatedUtc)</td>
|
||||
<td>@DashboardDisplay.DateTime(key.LastUsedUtc)</td>
|
||||
@if (CanManageApiKeys)
|
||||
{
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="API key actions">
|
||||
<button type="button" class="btn btn-outline-secondary"
|
||||
disabled="@IsBusy"
|
||||
@onclick="() => RotateApiKeyAsync(key.KeyId)">
|
||||
Rotate
|
||||
</button>
|
||||
@if (key.RevokedUtc is null)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
disabled="@IsBusy"
|
||||
@onclick="() => RevokeApiKeyAsync(key.KeyId)">
|
||||
Revoke
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static readonly string[] AvailableScopes =
|
||||
[
|
||||
GatewayScopes.SessionOpen,
|
||||
GatewayScopes.SessionClose,
|
||||
GatewayScopes.InvokeRead,
|
||||
GatewayScopes.InvokeWrite,
|
||||
GatewayScopes.InvokeSecure,
|
||||
GatewayScopes.EventsRead,
|
||||
GatewayScopes.MetadataRead,
|
||||
GatewayScopes.Admin
|
||||
];
|
||||
|
||||
private ApiKeyCreateModel CreateModel { get; } = new();
|
||||
|
||||
private bool CanManageApiKeys { get; set; }
|
||||
|
||||
private bool IsBusy { get; set; }
|
||||
|
||||
private bool IsCreateDialogOpen { get; set; }
|
||||
|
||||
private string? ResultMessage { get; set; }
|
||||
|
||||
private bool LastOperationSucceeded { get; set; }
|
||||
|
||||
private string? LastGeneratedApiKey { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync()
|
||||
.ConfigureAwait(false);
|
||||
CanManageApiKeys = ApiKeyManagementService.CanManage(authenticationState.User);
|
||||
}
|
||||
|
||||
private async Task CreateApiKeyAsync()
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryBuildCreateRequest(out DashboardApiKeyManagementRequest? request, out string? validationMessage))
|
||||
{
|
||||
SetResult(DashboardApiKeyManagementResult.Fail(validationMessage ?? "API key request is invalid."));
|
||||
return;
|
||||
}
|
||||
|
||||
await RunManagementActionAsync(user => ApiKeyManagementService.CreateAsync(
|
||||
user,
|
||||
request,
|
||||
CancellationToken.None))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task RevokeApiKeyAsync(string keyId)
|
||||
{
|
||||
await RunManagementActionAsync(user => ApiKeyManagementService.RevokeAsync(
|
||||
user,
|
||||
keyId,
|
||||
CancellationToken.None))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task RotateApiKeyAsync(string keyId)
|
||||
{
|
||||
await RunManagementActionAsync(user => ApiKeyManagementService.RotateAsync(
|
||||
user,
|
||||
keyId,
|
||||
CancellationToken.None))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task RunManagementActionAsync(
|
||||
Func<System.Security.Claims.ClaimsPrincipal, Task<DashboardApiKeyManagementResult>> action)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync()
|
||||
.ConfigureAwait(false);
|
||||
CanManageApiKeys = ApiKeyManagementService.CanManage(authenticationState.User);
|
||||
DashboardApiKeyManagementResult result = await action(authenticationState.User).ConfigureAwait(false);
|
||||
SetResult(result);
|
||||
if (result.Succeeded && result.ApiKey is not null)
|
||||
{
|
||||
CreateModel.Reset();
|
||||
IsCreateDialogOpen = false;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetResult(DashboardApiKeyManagementResult result)
|
||||
{
|
||||
LastOperationSucceeded = result.Succeeded;
|
||||
ResultMessage = result.Message;
|
||||
LastGeneratedApiKey = result.ApiKey;
|
||||
}
|
||||
|
||||
private void OpenCreateDialog()
|
||||
{
|
||||
IsCreateDialogOpen = true;
|
||||
}
|
||||
|
||||
private void CloseCreateDialog()
|
||||
{
|
||||
if (!IsBusy)
|
||||
{
|
||||
IsCreateDialogOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryBuildCreateRequest(
|
||||
[System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out DashboardApiKeyManagementRequest? request,
|
||||
out string? validationMessage)
|
||||
{
|
||||
request = null;
|
||||
validationMessage = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(CreateModel.MaxWriteClassification)
|
||||
&& !int.TryParse(
|
||||
CreateModel.MaxWriteClassification,
|
||||
System.Globalization.NumberStyles.Integer,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out int _))
|
||||
{
|
||||
validationMessage = "Max write classification must be an integer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
int? maxWriteClassification = string.IsNullOrWhiteSpace(CreateModel.MaxWriteClassification)
|
||||
? null
|
||||
: int.Parse(
|
||||
CreateModel.MaxWriteClassification,
|
||||
System.Globalization.NumberStyles.Integer,
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
request = new DashboardApiKeyManagementRequest(
|
||||
KeyId: CreateModel.KeyId,
|
||||
DisplayName: CreateModel.DisplayName,
|
||||
Scopes: CreateModel.SelectedScopes,
|
||||
Constraints: new MxGateway.Server.Security.Authentication.ApiKeyConstraints(
|
||||
ReadSubtrees: ParseList(CreateModel.ReadSubtrees),
|
||||
WriteSubtrees: ParseList(CreateModel.WriteSubtrees),
|
||||
ReadTagGlobs: ParseList(CreateModel.ReadTagGlobs),
|
||||
WriteTagGlobs: ParseList(CreateModel.WriteTagGlobs),
|
||||
MaxWriteClassification: maxWriteClassification,
|
||||
BrowseSubtrees: ParseList(CreateModel.BrowseSubtrees),
|
||||
ReadAlarmOnly: CreateModel.ReadAlarmOnly,
|
||||
ReadHistorizedOnly: CreateModel.ReadHistorizedOnly));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool IsScopeSelected(string scope)
|
||||
{
|
||||
return CreateModel.SelectedScopes.Contains(scope);
|
||||
}
|
||||
|
||||
private void SetScope(string scope, ChangeEventArgs eventArgs)
|
||||
{
|
||||
bool selected = eventArgs.Value is bool value && value;
|
||||
if (selected)
|
||||
{
|
||||
CreateModel.SelectedScopes.Add(scope);
|
||||
}
|
||||
else
|
||||
{
|
||||
CreateModel.SelectedScopes.Remove(scope);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ConstraintText(MxGateway.Server.Security.Authentication.ApiKeyConstraints constraints)
|
||||
{
|
||||
if (constraints.IsEmpty)
|
||||
{
|
||||
return "unconstrained";
|
||||
}
|
||||
|
||||
List<string> parts = [];
|
||||
AddList(parts, "read_subtrees", constraints.ReadSubtrees);
|
||||
AddList(parts, "write_subtrees", constraints.WriteSubtrees);
|
||||
AddList(parts, "read_tag_globs", constraints.ReadTagGlobs);
|
||||
AddList(parts, "write_tag_globs", constraints.WriteTagGlobs);
|
||||
AddList(parts, "browse_subtrees", constraints.BrowseSubtrees);
|
||||
if (constraints.MaxWriteClassification is { } max)
|
||||
{
|
||||
parts.Add($"max_write_classification={max}");
|
||||
}
|
||||
|
||||
if (constraints.ReadAlarmOnly)
|
||||
{
|
||||
parts.Add("read_alarm_only");
|
||||
}
|
||||
|
||||
if (constraints.ReadHistorizedOnly)
|
||||
{
|
||||
parts.Add("read_historized_only");
|
||||
}
|
||||
|
||||
return string.Join("; ", parts);
|
||||
}
|
||||
|
||||
private static void AddList(List<string> parts, string name, IReadOnlyList<string> values)
|
||||
{
|
||||
if (values.Count > 0)
|
||||
{
|
||||
parts.Add($"{name}=[{string.Join(", ", values)}]");
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseList(string? value)
|
||||
{
|
||||
return (value ?? string.Empty)
|
||||
.Split([',', ';', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private sealed class ApiKeyCreateModel
|
||||
{
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
public HashSet<string> SelectedScopes { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public string ReadSubtrees { get; set; } = string.Empty;
|
||||
|
||||
public string WriteSubtrees { get; set; } = string.Empty;
|
||||
|
||||
public string ReadTagGlobs { get; set; } = string.Empty;
|
||||
|
||||
public string WriteTagGlobs { get; set; } = string.Empty;
|
||||
|
||||
public string BrowseSubtrees { get; set; } = string.Empty;
|
||||
|
||||
public string MaxWriteClassification { get; set; } = string.Empty;
|
||||
|
||||
public bool ReadAlarmOnly { get; set; }
|
||||
|
||||
public bool ReadHistorizedOnly { get; set; }
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
KeyId = string.Empty;
|
||||
DisplayName = string.Empty;
|
||||
SelectedScopes.Clear();
|
||||
ReadSubtrees = string.Empty;
|
||||
WriteSubtrees = string.Empty;
|
||||
ReadTagGlobs = string.Empty;
|
||||
WriteTagGlobs = string.Empty;
|
||||
BrowseSubtrees = string.Empty;
|
||||
MaxWriteClassification = string.Empty;
|
||||
ReadAlarmOnly = false;
|
||||
ReadHistorizedOnly = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<span class="badge @CssClass">@Text</span>
|
||||
<span class="chip @CssClass">@Text</span>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
@@ -6,12 +6,11 @@
|
||||
|
||||
private string CssClass => Text switch
|
||||
{
|
||||
"Ready" or "Healthy" => "text-bg-success",
|
||||
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing" => "text-bg-info",
|
||||
"Closed" => "text-bg-secondary",
|
||||
"Stale" => "text-bg-warning",
|
||||
"Faulted" or "Unavailable" => "text-bg-danger",
|
||||
"Unknown" => "text-bg-light text-dark border",
|
||||
_ => "text-bg-light text-dark border"
|
||||
"Ready" or "Healthy" or "Active" => "chip-ok",
|
||||
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing" => "chip-warn",
|
||||
"Stale" or "Degraded" => "chip-warn",
|
||||
"Faulted" or "Unavailable" => "chip-bad",
|
||||
"Closed" or "Revoked" or "Unknown" => "chip-idle",
|
||||
_ => "chip-idle"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MxGateway.Server.Configuration;
|
||||
|
||||
namespace MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed class DashboardApiKeyAuthorization(IOptions<GatewayOptions> options)
|
||||
{
|
||||
public bool CanManage(ClaimsPrincipal user)
|
||||
{
|
||||
if (user.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string requiredGroup = options.Value.Ldap.RequiredGroup;
|
||||
IEnumerable<string> groups = user.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType)
|
||||
.Select(claim => claim.Value);
|
||||
|
||||
return DashboardAuthenticator.IsMemberOfRequiredGroup(groups, requiredGroup);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
|
||||
namespace MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed record DashboardApiKeyManagementRequest(
|
||||
string KeyId,
|
||||
string DisplayName,
|
||||
IReadOnlySet<string> Scopes,
|
||||
ApiKeyConstraints Constraints);
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed record DashboardApiKeyManagementResult(
|
||||
bool Succeeded,
|
||||
string Message,
|
||||
string? ApiKey)
|
||||
{
|
||||
public static DashboardApiKeyManagementResult Success(string message, string? apiKey = null)
|
||||
{
|
||||
return new DashboardApiKeyManagementResult(true, message, apiKey);
|
||||
}
|
||||
|
||||
public static DashboardApiKeyManagementResult Fail(string message)
|
||||
{
|
||||
return new DashboardApiKeyManagementResult(false, message, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
|
||||
namespace MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed class DashboardApiKeyManagementService(
|
||||
DashboardApiKeyAuthorization authorization,
|
||||
IApiKeyAdminStore adminStore,
|
||||
IApiKeyAuditStore auditStore,
|
||||
IApiKeySecretHasher hasher,
|
||||
IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService
|
||||
{
|
||||
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
|
||||
|
||||
public bool CanManage(ClaimsPrincipal user)
|
||||
{
|
||||
return authorization.CanManage(user);
|
||||
}
|
||||
|
||||
public async Task<DashboardApiKeyManagementResult> CreateAsync(
|
||||
ClaimsPrincipal user,
|
||||
DashboardApiKeyManagementRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!CanManage(user))
|
||||
{
|
||||
return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage);
|
||||
}
|
||||
|
||||
string? validation = ValidateCreateRequest(request);
|
||||
if (validation is not null)
|
||||
{
|
||||
return DashboardApiKeyManagementResult.Fail(validation);
|
||||
}
|
||||
|
||||
string keyId = request.KeyId.Trim();
|
||||
string secret = ApiKeySecretGenerator.Generate();
|
||||
string apiKey = FormatApiKey(keyId, secret);
|
||||
|
||||
try
|
||||
{
|
||||
await adminStore.CreateAsync(
|
||||
new ApiKeyCreateRequest(
|
||||
KeyId: keyId,
|
||||
KeyPrefix: $"mxgw_{keyId}",
|
||||
SecretHash: hasher.HashSecret(secret),
|
||||
DisplayName: request.DisplayName.Trim(),
|
||||
Scopes: request.Scopes,
|
||||
Constraints: request.Constraints,
|
||||
CreatedUtc: DateTimeOffset.UtcNow),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await AppendAuditAsync(keyId, "dashboard-create-key", null, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return DashboardApiKeyManagementResult.Success("API key created. Copy the key now; it will not be shown again.", apiKey);
|
||||
}
|
||||
catch (ApiKeyPepperUnavailableException)
|
||||
{
|
||||
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
|
||||
}
|
||||
catch (SqliteException exception) when (exception.SqliteErrorCode == 19)
|
||||
{
|
||||
return DashboardApiKeyManagementResult.Fail("An API key with that id already exists.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<DashboardApiKeyManagementResult> RevokeAsync(
|
||||
ClaimsPrincipal user,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!CanManage(user))
|
||||
{
|
||||
return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage);
|
||||
}
|
||||
|
||||
string? validation = ValidateKeyId(keyId);
|
||||
if (validation is not null)
|
||||
{
|
||||
return DashboardApiKeyManagementResult.Fail(validation);
|
||||
}
|
||||
|
||||
string normalizedKeyId = keyId.Trim();
|
||||
bool revoked = await adminStore
|
||||
.RevokeAsync(normalizedKeyId, DateTimeOffset.UtcNow, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await AppendAuditAsync(
|
||||
normalizedKeyId,
|
||||
"dashboard-revoke-key",
|
||||
revoked ? "revoked" : "not-found-or-already-revoked",
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return revoked
|
||||
? DashboardApiKeyManagementResult.Success("API key revoked.")
|
||||
: DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked.");
|
||||
}
|
||||
|
||||
public async Task<DashboardApiKeyManagementResult> RotateAsync(
|
||||
ClaimsPrincipal user,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!CanManage(user))
|
||||
{
|
||||
return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage);
|
||||
}
|
||||
|
||||
string? validation = ValidateKeyId(keyId);
|
||||
if (validation is not null)
|
||||
{
|
||||
return DashboardApiKeyManagementResult.Fail(validation);
|
||||
}
|
||||
|
||||
string normalizedKeyId = keyId.Trim();
|
||||
string secret = ApiKeySecretGenerator.Generate();
|
||||
string apiKey = FormatApiKey(normalizedKeyId, secret);
|
||||
|
||||
try
|
||||
{
|
||||
bool rotated = await adminStore
|
||||
.RotateAsync(normalizedKeyId, hasher.HashSecret(secret), DateTimeOffset.UtcNow, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await AppendAuditAsync(
|
||||
normalizedKeyId,
|
||||
"dashboard-rotate-key",
|
||||
rotated ? "rotated" : "not-found",
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rotated
|
||||
? DashboardApiKeyManagementResult.Success("API key rotated. Copy the key now; it will not be shown again.", apiKey)
|
||||
: DashboardApiKeyManagementResult.Fail("API key was not found.");
|
||||
}
|
||||
catch (ApiKeyPepperUnavailableException)
|
||||
{
|
||||
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AppendAuditAsync(
|
||||
string? keyId,
|
||||
string eventType,
|
||||
string? details,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await auditStore.AppendAsync(
|
||||
new ApiKeyAuditEntry(
|
||||
KeyId: keyId,
|
||||
EventType: eventType,
|
||||
RemoteAddress: httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(),
|
||||
Details: details),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string? ValidateCreateRequest(DashboardApiKeyManagementRequest request)
|
||||
{
|
||||
string? keyIdValidation = ValidateKeyId(request.KeyId);
|
||||
if (keyIdValidation is not null)
|
||||
{
|
||||
return keyIdValidation;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.DisplayName))
|
||||
{
|
||||
return "Display name is required.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ValidateKeyId(string keyId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
{
|
||||
return "API key id is required.";
|
||||
}
|
||||
|
||||
return keyId.Trim().All(character =>
|
||||
char.IsAsciiLetterOrDigit(character)
|
||||
|| character is '.' or '-')
|
||||
? null
|
||||
: "API key id may contain only letters, numbers, periods, and hyphens.";
|
||||
}
|
||||
|
||||
private static string FormatApiKey(string keyId, string secret)
|
||||
{
|
||||
return $"mxgw_{keyId}_{secret}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
|
||||
namespace MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed record DashboardApiKeySummary(
|
||||
string KeyId,
|
||||
string DisplayName,
|
||||
IReadOnlySet<string> Scopes,
|
||||
ApiKeyConstraints Constraints,
|
||||
DateTimeOffset CreatedUtc,
|
||||
DateTimeOffset? LastUsedUtc,
|
||||
DateTimeOffset? RevokedUtc);
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace MxGateway.Server.Dashboard;
|
||||
|
||||
public static class DashboardConnectionStringDisplay
|
||||
{
|
||||
public static string GalaxyRepositoryConnectionString(string connectionString)
|
||||
{
|
||||
try
|
||||
{
|
||||
SqlConnectionStringBuilder builder = new(connectionString);
|
||||
SqlConnectionStringBuilder display = new()
|
||||
{
|
||||
DataSource = builder.DataSource,
|
||||
InitialCatalog = builder.InitialCatalog,
|
||||
IntegratedSecurity = builder.IntegratedSecurity,
|
||||
Encrypt = builder.Encrypt,
|
||||
TrustServerCertificate = builder.TrustServerCertificate,
|
||||
};
|
||||
|
||||
return display.ConnectionString;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return "[invalid connection string]";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,11 +169,17 @@ public static class DashboardEndpointRouteBuilderExtensions
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{HtmlEncoder.Default.Encode(title)}</title>
|
||||
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="/css/theme.css" />
|
||||
<link rel="stylesheet" href="/css/dashboard.css" />
|
||||
</head>
|
||||
<body class="dashboard-body">
|
||||
<main class="container py-5">
|
||||
<h1 class="h3 mb-4">{HtmlEncoder.Default.Encode(title)}</h1>
|
||||
<header class="app-bar">
|
||||
<span class="brand"><span class="mark">▮</span> MXAccess Gateway</span>
|
||||
</header>
|
||||
<main class="page">
|
||||
<div class="dashboard-page-header">
|
||||
<h1>{HtmlEncoder.Default.Encode(title)}</h1>
|
||||
</div>
|
||||
{body}
|
||||
</main>
|
||||
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace MxGateway.Server.Dashboard;
|
||||
|
||||
public interface IDashboardApiKeyManagementService
|
||||
{
|
||||
bool CanManage(ClaimsPrincipal user);
|
||||
|
||||
Task<DashboardApiKeyManagementResult> CreateAsync(
|
||||
ClaimsPrincipal user,
|
||||
DashboardApiKeyManagementRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<DashboardApiKeyManagementResult> RevokeAsync(
|
||||
ClaimsPrincipal user,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<DashboardApiKeyManagementResult> RotateAsync(
|
||||
ClaimsPrincipal user,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
Reference in New Issue
Block a user