@page "/apikeys" @inherits DashboardPageBase @using ZB.MOM.WW.MxGateway.Server.Security.Authentication @inject AuthenticationStateProvider AuthenticationStateProvider @inject IDashboardApiKeyManagementService ApiKeyManagementService Dashboard API Keys @if (Snapshot is null) {
Loading API keys.
} else {

API Keys

@Snapshot.ApiKeys.Count key rows
@if (CanManageApiKeys) { }
@if (CanManageApiKeys) { @if (!string.IsNullOrWhiteSpace(ResultMessage)) { } @if (IsCreateDialogOpen) { } }
@if (Snapshot.ApiKeys.Count == 0) {
No API keys are available for display.
} else {
@if (CanManageApiKeys) { } @foreach (DashboardApiKeySummary key in Snapshot.ApiKeys) { @if (CanManageApiKeys) { } }
Key Status Display Name Scopes Constraints Created Last Used ExpiresActions
@key.KeyId @DashboardDisplay.Text(key.DisplayName) @DashboardDisplay.Text(string.Join(", ", key.Scopes.Order(StringComparer.Ordinal))) @DashboardDisplay.Text(ConstraintText(key.Constraints)) @DashboardDisplay.DateTime(key.CreatedUtc) @DashboardDisplay.DateTime(key.LastUsedUtc) @(key.ExpiresUtc is null ? "Never" : DashboardDisplay.DateTime(key.ExpiresUtc))
@if (key.RevokedUtc is null) { @* Rotate clears revoked_utc, which would silently reactivate a deliberately revoked key. Only offer it for active keys so a revoked key is not un-revoked as a side effect of rotation. *@ } else { }
}
} @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() { await base.OnInitializedAsync().ConfigureAwait(false); 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 PendingConfirm? PendingAction { get; set; } private void RequestRotate(string keyId) { if (IsBusy) { return; } PendingAction = new PendingConfirm( Title: "Rotate API key?", Message: $"Rotate the secret for key {keyId}? Any client still using the previous secret will start failing immediately.", ConfirmLabel: "Rotate", ConfirmButtonClass: "btn-warning", Action: user => ApiKeyManagementService.RotateAsync(user, keyId, CancellationToken.None)); } private void RequestRevoke(string keyId) { if (IsBusy) { return; } PendingAction = new PendingConfirm( Title: "Revoke API key?", Message: $"Revoke key {keyId}? Clients using it will be rejected on the next request.", ConfirmLabel: "Revoke", ConfirmButtonClass: "btn-danger", Action: user => ApiKeyManagementService.RevokeAsync(user, keyId, CancellationToken.None)); } private void RequestDelete(string keyId) { if (IsBusy) { return; } PendingAction = new PendingConfirm( Title: "Delete API key?", Message: $"Permanently delete revoked key {keyId}? This removes the row from the auth database — only the audit log will retain the history.", ConfirmLabel: "Delete", ConfirmButtonClass: "btn-danger", Action: user => ApiKeyManagementService.DeleteAsync(user, keyId, CancellationToken.None)); } private void CancelPending() { if (!IsBusy) { PendingAction = null; } } private async Task ConfirmPendingAsync() { if (IsBusy || PendingAction is null) { return; } // Server-047: align the pending-action lifecycle with SessionsPage / SessionDetailsPage — // hold PendingAction while the awaited action runs so the shared ConfirmDialog can render // its in-flight (IsBusy) state, then clear in finally regardless of outcome. Func> action = PendingAction.Action; try { await RunManagementActionAsync(action).ConfigureAwait(false); } finally { PendingAction = null; } } private sealed record PendingConfirm( string Title, string Message, string ConfirmLabel, string ConfirmButtonClass, Func> Action); private async Task RunManagementActionAsync( Func> 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 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); } } // Window before an expiry within which a key is flagged as "Expiring" (warn) rather than "Active". private static readonly TimeSpan ExpiringSoonWindow = TimeSpan.FromDays(7); // Status vocabulary understood by StatusBadge: Revoked wins over expiry; a past expiry is Expired // (bad), an expiry inside ExpiringSoonWindow is Expiring (warn), otherwise Active (SEC-10). private static string KeyStatus(DashboardApiKeySummary key) { if (key.RevokedUtc is not null) { return "Revoked"; } if (key.ExpiresUtc is { } expiresAt) { DateTimeOffset now = DateTimeOffset.UtcNow; if (expiresAt <= now) { return "Expired"; } if (expiresAt - now <= ExpiringSoonWindow) { return "Expiring"; } } return "Active"; } private static string ConstraintText(ApiKeyConstraints constraints) { if (constraints.IsEmpty) { return "unconstrained"; } List 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 parts, string name, IReadOnlyList values) { if (values.Count > 0) { parts.Add($"{name}=[{string.Join(", ", values)}]"); } } private static IReadOnlyList 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 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; } } }