feat(secrets-ui): add/rotate/delete modal + audited gated reveal

This commit is contained in:
Joseph Doherty
2026-07-15 17:20:01 -04:00
parent d7f8ca455b
commit 21556cc1a7
15 changed files with 950 additions and 14 deletions
@@ -0,0 +1,95 @@
@namespace ZB.MOM.WW.Secrets.Ui.Components
@implements IDisposable
@inject ISecretStore Store
@inject IAuditWriter Audit
@inject IServiceProvider Services
@* Components/ConfirmDeleteModal.razor — an in-page Blazor overlay (NOT a JS confirm/alert dialog).
Shown while Visible is true; Confirm tombstones + audits the delete, Cancel dismisses. *@
@if (Visible)
{
<div class="modal-backdrop-inpage" data-testid="delete-modal-backdrop"></div>
<div class="modal" role="dialog" aria-modal="true" data-testid="delete-modal">
<div class="modal-card">
<h3 class="modal-title">Delete secret '@Name.Value'?</h3>
<p>This tombstones the secret so the removal can propagate. The action is audited.</p>
@if (_error is not null)
{
<p class="field-error s-bad" data-testid="delete-error">@_error</p>
}
<div class="btn-row">
<TechButton Variant="ButtonVariant.Danger" Busy="_busy"
data-testid="confirm-delete" @onclick="ConfirmAsync">
Delete
</TechButton>
<TechButton Variant="ButtonVariant.Secondary"
data-testid="cancel-delete" @onclick="CancelAsync">
Cancel
</TechButton>
</div>
</div>
</div>
}
@code {
private readonly CancellationTokenSource _cts = new();
private string? _error;
private bool _busy;
/// <summary>Whether the modal overlay is rendered.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>The secret the modal offers to delete.</summary>
[Parameter] public SecretName Name { get; set; }
/// <summary>Raised after the secret has been tombstoned and audited.</summary>
[Parameter] public EventCallback OnDeleted { get; set; }
/// <summary>Raised when the user cancels the deletion.</summary>
[Parameter] public EventCallback OnCancelled { get; set; }
/// <summary>The cascading authentication state, used as the fallback source of the audit actor.</summary>
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
private async Task ConfirmAsync()
{
_error = null;
_busy = true;
try
{
string actor = await SecretActorResolver.ResolveAsync(Services, AuthState);
await Store.DeleteAsync(Name, actor, _cts.Token);
await Audit.WriteAsync(
new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = actor,
Action = "secret.delete",
Outcome = AuditOutcome.Success,
Category = "Secrets",
Target = Name.Value,
},
_cts.Token);
await OnDeleted.InvokeAsync();
}
finally
{
_busy = false;
}
}
private Task CancelAsync() => OnCancelled.InvokeAsync();
/// <inheritdoc />
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
}