Files
scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor
T

141 lines
4.7 KiB
Plaintext

@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;
bool deleted = false;
try
{
string actor = await SecretActorResolver.ResolveAsync(Services, AuthState);
try
{
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);
deleted = true;
}
catch (Exception)
{
// An unexpected store fault must still be audited as a Failure and surfaced to the
// operator instead of tearing down the circuit.
_error = "Unable to delete the secret.";
await WriteFailureAuditAsync(actor);
}
}
finally
{
_busy = false;
}
if (deleted)
{
// Evict any cached plaintext so the ${secret:} / ISecretResolver path does not serve a
// stale value after this delete for the remainder of its TTL. Optional seam — a no-op
// when the host has not registered an invalidator.
Services.GetService<ISecretCacheInvalidator>()?.Invalidate(Name);
await OnDeleted.InvokeAsync();
}
}
private async Task WriteFailureAuditAsync(string actor)
{
try
{
await Audit.WriteAsync(
new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = actor,
Action = "secret.delete",
Outcome = AuditOutcome.Failure,
Category = "Secrets",
Target = Name.Value,
DetailsJson = "{\"reason\":\"error\"}",
},
_cts.Token);
}
catch
{
// Best-effort audit of a failure; never let audit itself surface into the event pipeline.
}
}
private Task CancelAsync() => OnCancelled.InvokeAsync();
/// <inheritdoc />
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
}