Add log truncate buttons to admin (#180)

This commit is contained in:
Leendert de Borst
2024-09-01 17:11:52 +02:00
parent df72068e5c
commit eca61933bf
22 changed files with 511 additions and 59 deletions

View File

@@ -4,6 +4,7 @@
@inject GlobalLoadingService GlobalLoadingService
<FullScreenLoadingIndicator @ref="LoadingIndicator" />
<ConfirmModal />
<TopMenu />
<div class="flex pt-16 overflow-hidden bg-gray-50 dark:bg-gray-900">
<div id="main-content" class="relative w-full max-w-screen-2xl mx-auto h-full overflow-y-auto bg-gray-50 dark:bg-gray-900">
@@ -15,6 +16,7 @@
</div>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>

View File

@@ -8,7 +8,7 @@
<Breadcrumb BreadcrumbItems="BreadcrumbItems" />
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Emails</h1>
<RefreshButton OnRefresh="RefreshData" ButtonText="Refresh" />
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
</div>
<p>This page gives an overview of recently received mails by this AliasVault server.</p>
</div>

View File

@@ -9,7 +9,10 @@
<Breadcrumb BreadcrumbItems="BreadcrumbItems" />
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Auth logs</h1>
<RefreshButton OnRefresh="RefreshData" ButtonText="Refresh" />
<div class="flex items-end space-x-2">
<DeleteButton OnClick="DeleteLogsWithConfirmation" ButtonText="Delete all logs" />
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
</div>
</div>
<p>This page gives an overview of recent auth attempts.</p>
</div>
@@ -146,4 +149,25 @@ else
IsLoading = false;
StateHasChanged();
}
private async Task DeleteLogsWithConfirmation()
{
if (await ConfirmModalService.ShowConfirmation("Confirm Delete", "Are you sure you want to delete all logs? This action cannot be undone."))
{
await DeleteLogs();
}
}
private async Task DeleteLogs()
{
IsLoading = true;
StateHasChanged();
DbContext.AuthLogs.RemoveRange(DbContext.AuthLogs);
await DbContext.SaveChangesAsync();
await RefreshData();
IsLoading = false;
StateHasChanged();
}
}

View File

@@ -8,7 +8,10 @@
<Breadcrumb BreadcrumbItems="BreadcrumbItems" />
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">General logs</h1>
<RefreshButton OnRefresh="RefreshData" ButtonText="Refresh" />
<div class="flex items-end space-x-2">
<DeleteButton OnClick="DeleteLogsWithConfirmation" ButtonText="Delete all logs" />
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
</div>
</div>
<p>This page gives an overview of recent system logs.</p>
</div>
@@ -165,4 +168,25 @@ else
IsLoading = false;
StateHasChanged();
}
private async Task DeleteLogsWithConfirmation()
{
if (await ConfirmModalService.ShowConfirmation("Confirm Delete", "Are you sure you want to delete all logs? This action cannot be undone."))
{
await DeleteLogs();
}
}
private async Task DeleteLogs()
{
IsLoading = true;
StateHasChanged();
DbContext.Logs.RemoveRange(DbContext.Logs);
await DbContext.SaveChangesAsync();
await RefreshData();
IsLoading = false;
StateHasChanged();
}
}

View File

@@ -11,6 +11,7 @@ using AliasServerDb;
using AliasVault.Admin.Main.Models;
using AliasVault.Admin.Services;
using AliasVault.AuthLogging;
using AliasVault.RazorComponents.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Microsoft.EntityFrameworkCore;
@@ -72,6 +73,12 @@ public class MainBase : OwningComponentBase
[Inject]
protected AuthLoggingService AuthLoggingService { get; set; } = null!;
/// <summary>
/// Gets or sets the confirm modal service.
/// </summary>
[Inject]
protected ConfirmModalService ConfirmModalService { get; set; } = null!;
/// <summary>
/// Gets or sets the injected JSRuntime instance.
/// </summary>

View File

@@ -8,7 +8,7 @@
<Breadcrumb BreadcrumbItems="BreadcrumbItems" />
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Users</h1>
<RefreshButton OnRefresh="RefreshData" ButtonText="Refresh" />
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
</div>
<p>This page gives an overview of all registered users and the associated vaults.</p>
</div>

View File

@@ -19,6 +19,7 @@
@using AliasVault.Admin.Main.Components.WorkerStatus
@using AliasVault.RazorComponents
@using AliasVault.RazorComponents.Alerts
@using AliasVault.RazorComponents.Buttons
@using AliasVault.Admin.Main.Models
@using AliasVault.Admin.Main.Pages
@using AliasVault.Admin.Services

View File

@@ -15,6 +15,7 @@ using AliasVault.Admin.Main;
using AliasVault.Admin.Services;
using AliasVault.AuthLogging;
using AliasVault.Logging;
using AliasVault.RazorComponents.Services;
using Cryptography.Server;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Identity;
@@ -48,6 +49,7 @@ builder.Services.AddScoped<NavigationService>();
builder.Services.AddScoped<AuthenticationStateProvider, RevalidatingAuthenticationStateProvider>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<AuthLoggingService>();
builder.Services.AddScoped<ConfirmModalService>();
builder.Services.AddSingleton(new VersionedContentService(Directory.GetCurrentDirectory() + "/wwwroot"));
builder.Services.AddAuthentication(options =>

View File

@@ -600,6 +600,10 @@ video {
border-width: 0;
}
.visible {
visibility: visible;
}
.invisible {
visibility: hidden;
}
@@ -620,6 +624,10 @@ video {
position: relative;
}
.inset-0 {
inset: 0px;
}
.right-0 {
right: 0px;
}
@@ -739,6 +747,14 @@ video {
margin-top: 2rem;
}
.mt-2 {
margin-top: 0.5rem;
}
.mt-3 {
margin-top: 0.75rem;
}
.line-clamp-1 {
overflow: hidden;
display: -webkit-box;
@@ -858,6 +874,10 @@ video {
width: 100%;
}
.w-96 {
width: 24rem;
}
.max-w-2xl {
max-width: 42rem;
}
@@ -892,6 +912,16 @@ video {
animation: spin 1s linear infinite;
}
@keyframes pulse {
50% {
opacity: .5;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.cursor-not-allowed {
cursor: not-allowed;
}
@@ -936,6 +966,10 @@ video {
align-items: flex-start;
}
.items-end {
align-items: flex-end;
}
.items-center {
align-items: center;
}
@@ -1004,6 +1038,12 @@ video {
margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
}
.space-x-3 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.75rem * var(--tw-space-x-reverse));
margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse)));
}
.divide-y > :not([hidden]) ~ :not([hidden]) {
--tw-divide-y-reverse: 0;
border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
@@ -1260,6 +1300,21 @@ video {
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
}
.bg-gray-300 {
--tw-bg-opacity: 1;
background-color: rgb(209 213 219 / var(--tw-bg-opacity));
}
.bg-gray-700 {
--tw-bg-opacity: 1;
background-color: rgb(55 65 81 / var(--tw-bg-opacity));
}
.bg-primary-500 {
--tw-bg-opacity: 1;
background-color: rgb(244 149 65 / var(--tw-bg-opacity));
}
.bg-opacity-50 {
--tw-bg-opacity: 0.5;
}
@@ -1284,6 +1339,10 @@ video {
padding: 1.5rem;
}
.p-5 {
padding: 1.25rem;
}
.px-2 {
padding-left: 0.5rem;
padding-right: 0.5rem;
@@ -1349,6 +1408,11 @@ video {
padding-bottom: 2rem;
}
.px-7 {
padding-left: 1.75rem;
padding-right: 1.75rem;
}
.pl-2 {
padding-left: 0.5rem;
}
@@ -1640,6 +1704,25 @@ video {
background-color: rgb(153 27 27 / var(--tw-bg-opacity));
}
.hover\:bg-blue-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
}
.hover\:bg-gray-400:hover {
--tw-bg-opacity: 1;
background-color: rgb(156 163 175 / var(--tw-bg-opacity));
}
.hover\:bg-gray-800:hover {
--tw-bg-opacity: 1;
background-color: rgb(31 41 55 / var(--tw-bg-opacity));
}
.hover\:bg-opacity-80:hover {
--tw-bg-opacity: 0.8;
}
.hover\:text-gray-900:hover {
--tw-text-opacity: 1;
color: rgb(17 24 39 / var(--tw-text-opacity));
@@ -1731,6 +1814,16 @@ video {
--tw-ring-color: rgb(252 165 165 / var(--tw-ring-opacity));
}
.focus\:ring-green-300:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(134 239 172 / var(--tw-ring-opacity));
}
.focus\:ring-yellow-300:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(253 224 71 / var(--tw-ring-opacity));
}
.focus\:ring-offset-2:focus {
--tw-ring-offset-width: 2px;
}
@@ -2013,6 +2106,26 @@ video {
--tw-ring-color: rgb(127 29 29 / var(--tw-ring-opacity));
}
.dark\:focus\:ring-blue-800:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(30 64 175 / var(--tw-ring-opacity));
}
.dark\:focus\:ring-green-800:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(22 101 52 / var(--tw-ring-opacity));
}
.dark\:focus\:ring-yellow-800:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(133 77 14 / var(--tw-ring-opacity));
}
.dark\:focus\:ring-gray-800:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(31 41 55 / var(--tw-ring-opacity));
}
@media (min-width: 640px) {
.sm\:flex {
display: flex;

View File

@@ -5,6 +5,7 @@
<CascadingAuthenticationState>
<AuthorizeView>
<Authorized>
<ConfirmModal />
<FullScreenLoadingIndicator @ref="LoadingIndicator" />
<TopMenu />
<div class="flex pt-16 overflow-hidden bg-gray-50 dark:bg-gray-900">

View File

@@ -9,7 +9,7 @@
<Breadcrumb BreadcrumbItems="BreadcrumbItems" />
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Credentials</h1>
<RefreshButton OnRefresh="LoadCredentialsAsync" ButtonText="Refresh" />
<RefreshButton OnClick="LoadCredentialsAsync" ButtonText="Refresh" />
</div>
<p>Find all of your credentials below.</p>
</div>

View File

@@ -21,7 +21,7 @@
<Breadcrumb BreadcrumbItems="BreadcrumbItems"/>
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Emails</h1>
<RefreshButton OnRefresh="RefreshData" ButtonText="Refresh" />
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
</div>
<p>Below you can find all recent emails sent to one of the email addresses used in your credentials.</p>
</div>

View File

@@ -9,7 +9,7 @@
<Breadcrumb BreadcrumbItems="BreadcrumbItems"/>
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Security settings</h1>
<RefreshButton OnRefresh="LoadData" ButtonText="Refresh" />
<RefreshButton OnClick="LoadData" ButtonText="Refresh" />
</div>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Configure security settings.</p>
</div>

View File

@@ -7,6 +7,7 @@
using AliasVault.Client;
using AliasVault.Client.Providers;
using AliasVault.RazorComponents.Services;
using Blazored.LocalStorage;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Web;
@@ -72,6 +73,7 @@ builder.Services.AddScoped<KeyboardShortcutService>();
builder.Services.AddScoped<JsInteropService>();
builder.Services.AddScoped<EmailService>();
builder.Services.AddSingleton<ClipboardCopyService>();
builder.Services.AddScoped<ConfirmModalService>();
builder.Services.AddAuthorizationCore();
builder.Services.AddBlazoredLocalStorage();

View File

@@ -26,6 +26,7 @@
@using AliasVault.Client.Services.Database
@using AliasVault.RazorComponents
@using AliasVault.RazorComponents.Alerts
@using AliasVault.RazorComponents.Buttons
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@using Blazored.LocalStorage

View File

@@ -600,6 +600,10 @@ video {
border-width: 0;
}
.visible {
visibility: visible;
}
.static {
position: static;
}
@@ -1394,6 +1398,21 @@ video {
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
}
.bg-gray-300 {
--tw-bg-opacity: 1;
background-color: rgb(209 213 219 / var(--tw-bg-opacity));
}
.bg-yellow-500 {
--tw-bg-opacity: 1;
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
}
.bg-red-700 {
--tw-bg-opacity: 1;
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
}
.bg-opacity-50 {
--tw-bg-opacity: 0.5;
}
@@ -1921,6 +1940,15 @@ video {
background-color: rgb(153 27 27 / var(--tw-bg-opacity));
}
.hover\:bg-gray-400:hover {
--tw-bg-opacity: 1;
background-color: rgb(156 163 175 / var(--tw-bg-opacity));
}
.hover\:bg-opacity-80:hover {
--tw-bg-opacity: 0.8;
}
.hover\:from-primary-600:hover {
--tw-gradient-from: #d68338 var(--tw-gradient-from-position);
--tw-gradient-to: rgb(214 131 56 / 0) var(--tw-gradient-to-position);
@@ -2062,6 +2090,11 @@ video {
--tw-ring-color: rgb(239 68 68 / var(--tw-ring-opacity));
}
.focus\:ring-yellow-300:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(253 224 71 / var(--tw-ring-opacity));
}
.focus\:ring-offset-2:focus {
--tw-ring-offset-width: 2px;
}
@@ -2147,6 +2180,11 @@ video {
background-color: rgb(30 41 59 / var(--tw-bg-opacity));
}
.dark\:bg-red-600:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
}
.dark\:bg-opacity-80:is(.dark *) {
--tw-bg-opacity: 0.8;
}
@@ -2280,6 +2318,11 @@ video {
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
}
.dark\:hover\:bg-red-700:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
}
.dark\:hover\:from-primary-500:hover:is(.dark *) {
--tw-gradient-from: #f49541 var(--tw-gradient-from-position);
--tw-gradient-to: rgb(244 149 65 / 0) var(--tw-gradient-to-position);
@@ -2375,6 +2418,11 @@ video {
--tw-ring-color: rgb(127 29 29 / var(--tw-ring-opacity));
}
.dark\:focus\:ring-yellow-800:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(133 77 14 / var(--tw-ring-opacity));
}
@media (min-width: 640px) {
.sm\:col-span-3 {
grid-column: span 3 / span 3;

View File

@@ -0,0 +1,67 @@
<button @onclick="HandleClick"
disabled="@IsDisabled"
class="@GetButtonClasses()">
@ChildContent
</button>
@code {
/// <summary>
/// The content to be displayed inside the button.
/// </summary>
[Parameter]
public RenderFragment? ChildContent { get; set; }
/// <summary>
/// The event to call when the button is clicked.
/// </summary>
[Parameter]
public EventCallback OnClick { get; set; }
/// <summary>
/// Specifies whether the button is disabled.
/// </summary>
[Parameter]
public bool IsDisabled { get; set; }
/// <summary>
/// The color theme of the button.
/// </summary>
[Parameter]
public string Color { get; set; } = "primary";
/// <summary>
/// Additional CSS classes to apply to the button.
/// </summary>
[Parameter]
public string AdditionalClasses { get; set; } = "";
/// <summary>
/// Handles the button click event.
/// </summary>
private async Task HandleClick()
{
if (!IsDisabled)
{
await OnClick.InvokeAsync();
}
}
/// <summary>
/// Gets the CSS classes for the button based on its state and color.
/// </summary>
/// <returns>A string containing the CSS classes for the button.</returns>
private string GetButtonClasses()
{
var baseClasses = "flex center items-center px-3 py-2 text-sm font-medium text-white rounded-lg focus:outline-none focus:ring-4";
var colorClasses = Color switch
{
"primary" => "bg-primary-700 hover:bg-primary-800 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800",
"danger" => "bg-red-700 hover:bg-red-800 focus:ring-red-300 dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800",
"secondary" => "bg-secondary-700 hover:bg-secondary-800 focus:ring-secondary-300 dark:bg-secondary-600 dark:hover:bg-secondary-700 dark:focus:ring-secondary-800",
_ => "bg-gray-700 hover:bg-gray-800 focus:ring-gray-300 dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800"
};
var disabledClasses = IsDisabled ? "bg-gray-400 cursor-not-allowed" : "";
return $"{baseClasses} {colorClasses} {disabledClasses} {AdditionalClasses}".Trim();
}
}

View File

@@ -0,0 +1,29 @@
<Button OnClick="HandleClick"
Color="danger">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
<span class="ml-2">@ButtonText</span>
</Button>
@code {
/// <summary>
/// The event to call in the parent when the delete button is clicked.
/// </summary>
[Parameter]
public EventCallback OnClick { get; set; }
/// <summary>
/// The text to display on the button.
/// </summary>
[Parameter]
public string ButtonText { get; set; } = "Delete";
/// <summary>
/// Handles the button click event.
/// </summary>
private async Task HandleClick()
{
await OnClick.InvokeAsync();
}
}

View File

@@ -0,0 +1,76 @@
@using System.Timers
<Button OnClick="HandleClick"
IsDisabled="@IsRefreshing"
Color="@Color"
AdditionalClasses="@AdditionalClasses">
<svg class="@GetIconClasses()" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
<span class="ml-2">@ButtonText</span>
</Button>
@code {
/// <summary>
/// The event to call in the parent when the button is clicked.
/// </summary>
[Parameter]
public EventCallback OnClick { get; set; }
/// <summary>
/// The text to display on the button.
/// </summary>
[Parameter]
public string ButtonText { get; set; } = "Refresh";
/// <summary>
/// The color theme of the button.
/// </summary>
[Parameter]
public string Color { get; set; } = "primary";
/// <summary>
/// Additional CSS classes to apply to the button.
/// </summary>
[Parameter]
public string AdditionalClasses { get; set; } = "";
/// <summary>
/// Indicates whether the button is currently in a refreshing state.
/// </summary>
private bool IsRefreshing;
/// <summary>
/// Timer used to control the refreshing state duration.
/// </summary>
private Timer Timer = new();
/// <summary>
/// Handles the button click event.
/// </summary>
private async Task HandleClick()
{
if (IsRefreshing) return;
IsRefreshing = true;
await OnClick.InvokeAsync();
Timer = new Timer(500);
Timer.Elapsed += (sender, args) =>
{
IsRefreshing = false;
Timer.Dispose();
InvokeAsync(StateHasChanged);
};
Timer.Start();
}
/// <summary>
/// Gets the CSS classes for the refresh icon based on the refreshing state.
/// </summary>
/// <returns>A string containing the CSS classes for the icon.</returns>
private string GetIconClasses()
{
return $"w-4 h-4 {(IsRefreshing ? "animate-spin" : "")}";
}
}

View File

@@ -0,0 +1,39 @@
@inject ConfirmModalService ModalService
@using AliasVault.RazorComponents.Services
@implements IDisposable
@if (ModalService.IsVisible)
{
<div class="fixed inset-0 z-50 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center">
<div class="relative p-5 border w-96 shadow-lg rounded-md bg-white">
<div class="mt-3 text-center">
<h3 class="text-lg leading-6 font-medium text-gray-900">@ModalService.Title</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500">
@ModalService.Message
</p>
</div>
<div class="items-center px-4 py-3">
<button id="confirmButton" class="px-4 py-2 bg-primary-500 text-white text-base font-medium rounded-md w-full shadow-sm hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-300" @onclick="() => ModalService.CloseModal(true)">
Confirm
</button>
<button id="cancelButton" class="mt-3 px-4 py-2 bg-gray-300 text-gray-800 text-base font-medium rounded-md w-full shadow-sm hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-300" @onclick="() => ModalService.CloseModal(false)">
Cancel
</button>
</div>
</div>
</div>
</div>
}
@code {
protected override void OnInitialized()
{
ModalService.OnChange += StateHasChanged;
}
public void Dispose()
{
ModalService.OnChange -= StateHasChanged;
}
}

View File

@@ -1,52 +0,0 @@
@using System.Timers
<button @onclick="HandleClick"
disabled="@IsRefreshing"
class="@GetButtonClasses()">
<svg class="@GetIconClasses()" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
<span class="ml-2">@ButtonText</span>
</button>
@code {
/// <summary>
/// The event to call in the parent when the button is clicked.
/// </summary>
[Parameter] public EventCallback OnRefresh { get; set; }
/// <summary>
/// The text to display on the button.
/// </summary>
[Parameter] public string ButtonText { get; set; } = "Refresh";
private bool IsRefreshing;
private Timer Timer = new();
private async Task HandleClick()
{
if (IsRefreshing) return;
IsRefreshing = true;
await OnRefresh.InvokeAsync();
Timer = new Timer(500);
Timer.Elapsed += (sender, args) =>
{
IsRefreshing = false;
Timer.Dispose();
InvokeAsync(StateHasChanged);
};
Timer.Start();
}
private string GetButtonClasses()
{
return $"flex items-center px-3 py-2 text-sm font-medium text-white rounded-lg focus:outline-none focus:ring-4 focus:ring-primary-300 dark:focus:ring-primary-800 {(IsRefreshing ? "bg-gray-400 cursor-not-allowed" : "bg-primary-700 hover:bg-primary-800 dark:bg-primary-600 dark:hover:bg-primary-700")}";
}
private string GetIconClasses()
{
return $"w-4 h-4 {(IsRefreshing ? "animate-spin" : "")}";
}
}

View File

@@ -0,0 +1,68 @@
//-----------------------------------------------------------------------
// <copyright file="ConfirmModalService.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.RazorComponents.Services;
using System;
using System.Threading.Tasks;
/// <summary>
/// Service for managing confirmation modals.
/// </summary>
public class ConfirmModalService
{
/// <summary>
/// Event triggered when the modal state changes.
/// </summary>
public event Action OnChange = default!;
/// <summary>
/// Gets or sets the title of the modal.
/// </summary>
public string Title { get; private set; } = "Are you sure?";
/// <summary>
/// Gets or sets the message of the modal.
/// </summary>
public string Message { get; private set; } = "Are you sure you want to do this?";
/// <summary>
/// Gets or sets a value indicating whether the modal is visible.
/// </summary>
public bool IsVisible { get; private set; }
private TaskCompletionSource<bool> _modalTaskCompletionSource = default!;
/// <summary>
/// Shows the confirmation modal and waits for user response.
/// </summary>
/// <param name="title">The title of the modal.</param>
/// <param name="message">The message to display in the modal.</param>
/// <returns>A task that completes when the user responds, returning true if confirmed, false if cancelled.</returns>
public Task<bool> ShowConfirmation(string title, string message)
{
Title = title;
Message = message;
IsVisible = true;
_modalTaskCompletionSource = new TaskCompletionSource<bool>();
NotifyStateChanged();
return _modalTaskCompletionSource.Task;
}
/// <summary>
/// Closes the modal with the specified result.
/// </summary>
/// <param name="result">The result of the confirmation.</param>
public void CloseModal(bool result)
{
IsVisible = false;
_modalTaskCompletionSource.TrySetResult(result);
NotifyStateChanged();
}
private void NotifyStateChanged() => OnChange?.Invoke();
}