01033d7aaf
Family-wide admin-UI cleanup pass (scadaproj admin_ui_cleanup.md) applied to the Blazor dashboard. Behaviour is unchanged throughout — no @onclick, disabled, binding, auth gate, or arm->confirm flow was touched. Uncontrolled error text is now truncated at the render site. Fault messages, Galaxy load errors, and browse-tree load failures were rendered in full into fixed-width table cells, where a long exception string blows out the column. Each site gets DashboardDisplay.Abbreviate plus a title attribute carrying the untruncated text, so nothing becomes unreachable. Abbreviate is length-checked rather than a bare range slice: `value[..n]` on a shorter string throws and takes the whole page render down with it. The two detail views whose entire purpose is to show one fault in full — SessionDetailsPage and GalaxyPage's Last Error — are deliberately left untruncated. Two classes referenced from markup had no definition anywhere in the sheet. .browse-stale-banner was inert; .tree-load-status was a real visual defect — loading and failed-to-load rows sit among .tree-row siblings and carry the same leading .tree-toggle-empty spacer, but that spacer only takes its width as a flex item, so without a flex container those rows lost their indent. Confirm/cancel pairs in ConfirmDialog and the API-key create form are now btn-groups with role="group" and an aria-label, replacing margin-spaced loose buttons. Removes a paragraph on GalaxyPage naming internal RPCs (DiscoverHierarchy, GetLastDeployTime) — implementation detail with no meaning to a dashboard operator. Verified in a real browser, not bUnit: full build clean, 879/879 tests, and a live gate against a running dashboard with a genuine ~250-char SqlClient exception as the erroring row. Results per check, including the checks that could NOT be exercised without an x86 worker, are recorded in docs/plans/2026-08-11-dashboard-ui-sweeps.md. That plan doc also records a correction: this app is NOT Bootstrap-free. The sweep brief said it was, citing the scadaproj index; libman.json pins bootstrap 5.3.3 and App.razor:7 links it ahead of the theme. The stale claim had already cost this app one skipped family sweep (scadaproj#2, the /admin/secrets modal), so that modal was live-gated here too and passes.
591 lines
25 KiB
Plaintext
591 lines
25 KiB
Plaintext
@page "/apikeys"
|
|
@inherits DashboardPageBase
|
|
@using ZB.MOM.WW.MxGateway.Server.Security.Authentication
|
|
@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>
|
|
}
|
|
|
|
<ConfirmDialog IsOpen="@(PendingAction is not null)"
|
|
Title="@(PendingAction?.Title ?? string.Empty)"
|
|
Message="@(PendingAction?.Message ?? string.Empty)"
|
|
ConfirmLabel="@(PendingAction?.ConfirmLabel ?? "Confirm")"
|
|
ConfirmButtonClass="@(PendingAction?.ConfirmButtonClass ?? "btn-primary")"
|
|
IsBusy="IsBusy"
|
|
OnConfirm="ConfirmPendingAsync"
|
|
OnCancel="CancelPending" />
|
|
|
|
@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">
|
|
@* Master SiteForm pattern: card-wrapped body with stacked
|
|
subsections (h6 text-muted border-bottom). Modal chrome
|
|
is kept (close button), but the form-internal layout
|
|
now matches ScadaLink's admin forms — form-control-sm,
|
|
form-label small, mb-2/mb-3 row wrappers, Save/Cancel
|
|
inline at the bottom of the card-body (no modal-footer). *@
|
|
<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">
|
|
<EditForm Model="@CreateModel" OnSubmit="@CreateApiKeyAsync">
|
|
<div class="card mb-3">
|
|
<div class="card-body">
|
|
<h6 class="card-title">Create API Key</h6>
|
|
<div class="mb-2">
|
|
<label for="keyId" class="form-label small">Key ID</label>
|
|
<input id="keyId" class="form-control form-control-sm" @bind="CreateModel.KeyId" @bind:event="oninput" />
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="displayName" class="form-label small">Display Name</label>
|
|
<input id="displayName" class="form-control form-control-sm" @bind="CreateModel.DisplayName" @bind:event="oninput" />
|
|
</div>
|
|
|
|
<h6 class="text-muted border-bottom pb-1">Scopes</h6>
|
|
<div class="mb-3">
|
|
<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>
|
|
</div>
|
|
|
|
<h6 class="text-muted border-bottom pb-1">Constraints</h6>
|
|
<div class="mb-2">
|
|
<label for="readSubtrees" class="form-label small">Read subtrees</label>
|
|
<textarea id="readSubtrees" class="form-control form-control-sm" rows="2" @bind="CreateModel.ReadSubtrees" @bind:event="oninput"></textarea>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label for="writeSubtrees" class="form-label small">Write subtrees</label>
|
|
<textarea id="writeSubtrees" class="form-control form-control-sm" rows="2" @bind="CreateModel.WriteSubtrees" @bind:event="oninput"></textarea>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label for="readTagGlobs" class="form-label small">Read tag globs</label>
|
|
<textarea id="readTagGlobs" class="form-control form-control-sm" rows="2" @bind="CreateModel.ReadTagGlobs" @bind:event="oninput"></textarea>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label for="writeTagGlobs" class="form-label small">Write tag globs</label>
|
|
<textarea id="writeTagGlobs" class="form-control form-control-sm" rows="2" @bind="CreateModel.WriteTagGlobs" @bind:event="oninput"></textarea>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label for="browseSubtrees" class="form-label small">Browse subtrees</label>
|
|
<textarea id="browseSubtrees" class="form-control form-control-sm" rows="2" @bind="CreateModel.BrowseSubtrees" @bind:event="oninput"></textarea>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="maxWriteClassification" class="form-label small">Max write classification</label>
|
|
<input id="maxWriteClassification" class="form-control form-control-sm" @bind="CreateModel.MaxWriteClassification" @bind:event="oninput" />
|
|
</div>
|
|
|
|
<h6 class="text-muted border-bottom pb-1">Filters</h6>
|
|
<div class="mb-3 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 class="mt-3">
|
|
<div class="btn-group btn-group-sm" role="group" aria-label="Create API key actions">
|
|
<button type="submit" class="btn btn-success" disabled="@IsBusy">Save</button>
|
|
<button type="button" class="btn btn-outline-secondary" disabled="@IsBusy" @onclick="CloseCreateDialog">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</EditForm>
|
|
</div>
|
|
</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>
|
|
<th scope="col">Expires</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="@KeyStatus(key)" /></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>
|
|
<td>@(key.ExpiresUtc is null ? "Never" : DashboardDisplay.DateTime(key.ExpiresUtc))</td>
|
|
@if (CanManageApiKeys)
|
|
{
|
|
<td>
|
|
<div class="btn-group btn-group-sm" role="group" aria-label="API key actions">
|
|
@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. *@
|
|
<button type="button" class="btn btn-outline-secondary"
|
|
disabled="@IsBusy"
|
|
@onclick="() => RequestRotate(key.KeyId)">
|
|
Rotate
|
|
</button>
|
|
<button type="button" class="btn btn-outline-danger"
|
|
disabled="@IsBusy"
|
|
@onclick="() => RequestRevoke(key.KeyId)">
|
|
Revoke
|
|
</button>
|
|
}
|
|
else
|
|
{
|
|
<button type="button" class="btn btn-outline-danger"
|
|
disabled="@IsBusy"
|
|
@onclick="() => RequestDelete(key.KeyId)">
|
|
Delete
|
|
</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()
|
|
{
|
|
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<System.Security.Claims.ClaimsPrincipal, Task<DashboardApiKeyManagementResult>> 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<System.Security.Claims.ClaimsPrincipal, Task<DashboardApiKeyManagementResult>> Action);
|
|
|
|
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 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<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;
|
|
}
|
|
}
|
|
}
|