From 2071a7c4fe3cdbbd10170043eb358fd536bbf282 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Thu, 20 Mar 2025 13:02:23 +0100 Subject: [PATCH] Add email claim enable/disable toggle to admin (#711) --- .../Main/Models/UserEmailClaimWithCount.cs | 5 + .../Components/EmailClaimsCard.razor | 11 +- src/AliasVault.Admin/Main/Pages/Emails.razor | 15 +- .../View/Components/EmailClaimTable.razor | 200 ++++++++++++++++-- .../Main/Pages/Users/View/Index.razor | 25 +-- src/AliasVault.Admin/wwwroot/css/tailwind.css | 119 +++-------- 6 files changed, 244 insertions(+), 131 deletions(-) diff --git a/src/AliasVault.Admin/Main/Models/UserEmailClaimWithCount.cs b/src/AliasVault.Admin/Main/Models/UserEmailClaimWithCount.cs index 8149256cb..32590c141 100644 --- a/src/AliasVault.Admin/Main/Models/UserEmailClaimWithCount.cs +++ b/src/AliasVault.Admin/Main/Models/UserEmailClaimWithCount.cs @@ -32,6 +32,11 @@ public class UserEmailClaimWithCount /// public string AddressDomain { get; set; } = string.Empty; + /// + /// Gets or sets a value indicating whether the email claim is disabled. + /// + public bool Disabled { get; set; } + /// /// Gets or sets the created at timestamp. /// diff --git a/src/AliasVault.Admin/Main/Pages/Dashboard/Components/EmailClaimsCard.razor b/src/AliasVault.Admin/Main/Pages/Dashboard/Components/EmailClaimsCard.razor index 4a4162e5b..2764776c0 100644 --- a/src/AliasVault.Admin/Main/Pages/Dashboard/Components/EmailClaimsCard.razor +++ b/src/AliasVault.Admin/Main/Pages/Dashboard/Components/EmailClaimsCard.razor @@ -32,7 +32,7 @@ } - + @if (ShowChart && !IsLoading) {
@@ -65,6 +65,7 @@ /// private bool ShowChart { get; set; } = false; + /// protected override async Task OnInitializedAsync() { await RefreshData(); @@ -128,12 +129,12 @@ Date = g.Key, Count = g.Count() }).ToListAsync(); - + // Fill in any missing days with zero counts var allDates = Enumerable.Range(0, DaysToShow) .Select(offset => DateTime.UtcNow.Date.AddDays(-offset)) .Reverse(); - + DailyEmailClaimCounts = allDates .GroupJoin( DailyEmailClaimCounts, @@ -148,7 +149,7 @@ private void ToggleChart() { ShowChart = !ShowChart; - + // If we're showing the chart but haven't loaded the data yet if (ShowChart && DailyEmailClaimCounts.Count == 0) { @@ -167,7 +168,7 @@ public int Days7 { get; set; } public int Days14 { get; set; } } - + private sealed class DailyEmailClaimCount { public DateTime Date { get; set; } diff --git a/src/AliasVault.Admin/Main/Pages/Emails.razor b/src/AliasVault.Admin/Main/Pages/Emails.razor index 17607b559..367b78725 100644 --- a/src/AliasVault.Admin/Main/Pages/Emails.razor +++ b/src/AliasVault.Admin/Main/Pages/Emails.razor @@ -59,6 +59,13 @@ else } @code { + /// + /// The search term from the query parameter. + /// + [Parameter] + [SupplyParameterFromQuery(Name = "search")] + public string? SearchTermFromQuery { get; set; } + private readonly List _tableColumns = [ new TableColumn { Title = "ID", PropertyName = "Id" }, new TableColumn { Title = "Time", PropertyName = "DateSystem" }, @@ -70,9 +77,7 @@ else private List EmailViewModelList { get; set; } = []; private bool IsInitialized { get; set; } = false; - private bool IsLoading { get; set; } = true; - private int CurrentPage { get; set; } = 1; private int PageSize { get; set; } = 50; private int TotalRecords { get; set; } @@ -110,6 +115,12 @@ else { if (firstRender) { + // Set the search term from the query parameter if it exists + if (!string.IsNullOrEmpty(SearchTermFromQuery)) + { + _searchTerm = SearchTermFromQuery; + } + await RefreshData(); } } diff --git a/src/AliasVault.Admin/Main/Pages/Users/View/Components/EmailClaimTable.razor b/src/AliasVault.Admin/Main/Pages/Users/View/Components/EmailClaimTable.razor index 4061b2941..4cac12772 100644 --- a/src/AliasVault.Admin/Main/Pages/Users/View/Components/EmailClaimTable.razor +++ b/src/AliasVault.Admin/Main/Pages/Users/View/Components/EmailClaimTable.razor @@ -1,35 +1,125 @@ @using AliasVault.RazorComponents.Tables - - @foreach (var entry in SortedEmailClaimList) - { - - @entry.Id - @entry.CreatedAt.ToString("yyyy-MM-dd HH:mm") - @entry.Address - @entry.EmailCount - - } - +
+
+ + @if (EmailClaimList.Any(e => !e.Disabled)) + { + + } +
+
+ +@if (IsLoading) +{ + +} +else +{ + + @foreach (var entry in SortedEmailClaimList) + { + + @entry.Id + @entry.CreatedAt.ToString("yyyy-MM-dd HH:mm") + @entry.Address + @entry.EmailCount + @(entry.Disabled ? "Disabled" : "Enabled") + + @if (entry.Disabled) + { + + } + else + { + + } + + + } + +} @code { /// - /// Gets or sets the list of email claims to display. + /// Gets or sets the user. /// [Parameter] - public List EmailClaimList { get; set; } = []; + public AliasVaultUser User { get; set; } = new(); + + /// + /// Gets or sets the callback for when an email claim is enabled or disabled. + /// + [Parameter] + public EventCallback<(Guid id, bool disabled)> OnEmailClaimStatusChanged { get; set; } + + /// + /// Gets or sets the list of email claims to display. + /// + private List EmailClaimList { get; set; } = []; + + private bool IsLoading { get; set; } = true; private string SortColumn { get; set; } = "CreatedAt"; private SortDirection SortDirection { get; set; } = SortDirection.Descending; + private bool ShowDisabled { get; set; } = false; private readonly List _emailClaimTableColumns = [ new TableColumn { Title = "ID", PropertyName = "Id" }, new TableColumn { Title = "Created", PropertyName = "CreatedAt" }, new TableColumn { Title = "Email", PropertyName = "Address" }, new TableColumn { Title = "Email Count", PropertyName = "EmailCount" }, + new TableColumn { Title = "Status", PropertyName = "Disabled" }, + new TableColumn { Title = "Actions", PropertyName = "" }, ]; - private IEnumerable SortedEmailClaimList => SortList(EmailClaimList, SortColumn, SortDirection); + private IEnumerable SortedEmailClaimList => + SortList(ShowDisabled ? EmailClaimList : EmailClaimList.Where(e => !e.Disabled).ToList(), SortColumn, SortDirection); + + /// + protected override async Task OnInitializedAsync() + { + IsLoading = true; + StateHasChanged(); + + await RefreshData(); + + IsLoading = false; + StateHasChanged(); + } + + /// + /// This method will refresh the email claim list. + /// + private async Task RefreshData() + { + await using var dbContext = await DbContextFactory.CreateDbContextAsync(); + + if (string.IsNullOrEmpty(User.Id)) + { + EmailClaimList = []; + return; + } + + // Load all email claims for this user. + EmailClaimList = await dbContext.UserEmailClaims + .Where(x => x.UserId == User.Id) + .Select(x => new UserEmailClaimWithCount + { + Id = x.Id, + Address = x.Address, + AddressLocal = x.AddressLocal, + AddressDomain = x.AddressDomain, + CreatedAt = x.CreatedAt, + UpdatedAt = x.UpdatedAt, + EmailCount = dbContext.Emails.Count(e => e.To == x.Address), + Disabled = x.Disabled + }) + .OrderBy(x => x.CreatedAt) + .ToListAsync(); + } private void HandleSortChanged((string column, SortDirection direction) sort) { @@ -38,6 +128,87 @@ StateHasChanged(); } + private void ToggleShowDisabled() + { + ShowDisabled = !ShowDisabled; + StateHasChanged(); + } + + /// + /// This method will toggle the disabled status of an email claim. + /// + private async Task ToggleEmailClaimStatus(UserEmailClaimWithCount entry) + { + await using var dbContext = await DbContextFactory.CreateDbContextAsync(); + + if (entry.Disabled) + { + // Enable email claim without confirmation. + var emailClaim = await dbContext.UserEmailClaims.FindAsync(entry.Id); + if (emailClaim != null) + { + // Re-enable the email claim. + emailClaim.Disabled = false; + emailClaim.UpdatedAt = DateTime.UtcNow; + await dbContext.SaveChangesAsync(); + await RefreshData(); + } + } + else + { + if (await ConfirmModalService.ShowConfirmation( + title: "Confirm Email Claim Disable", + message: @"Are you sure you want to disable this email claim? + +Important notes: +• Disabling an email claim means that emails will no longer be received for this address and will be rejected by the server. +• The user can re-enable this at will by re-saving their vault which will claim it again. + +Do you want to proceed with disabling this claim?")) + { + // Load email claim + var emailClaim = await dbContext.UserEmailClaims.FindAsync(entry.Id); + if (emailClaim != null) + { + // Set the disabled status to true. + emailClaim.Disabled = true; + emailClaim.UpdatedAt = DateTime.UtcNow; + await dbContext.SaveChangesAsync(); + await RefreshData(); + } + } + } + } + + private async Task DisableAllEmailClaims() + { + if (await ConfirmModalService.ShowConfirmation( + title: "Confirm Email Claim Disable", + message: @"Are you sure you want to disable all email claims? + +Important notes: +• Disabling an email claim means that emails will no longer be received for this address and will be rejected by the server. +• The user can re-enable this at will by re-saving their vault which will claim it again. + +Do you want to proceed with disabling all email claims?")) + { + await using var dbContext = await DbContextFactory.CreateDbContextAsync(); + + // Load email claims + var emailClaims = await dbContext.UserEmailClaims.Where(x => x.UserId == User.Id).ToListAsync(); + + // Disable all email claims. + foreach (var emailClaim in emailClaims) + { + emailClaim.Disabled = true; + emailClaim.UpdatedAt = DateTime.UtcNow; + } + + await dbContext.SaveChangesAsync(); + await RefreshData(); + } + } + private static IEnumerable SortList(List emailClaims, string sortColumn, SortDirection sortDirection) { return sortColumn switch @@ -46,6 +217,7 @@ "CreatedAt" => SortableTable.SortListByProperty(emailClaims, e => e.CreatedAt, sortDirection), "Address" => SortableTable.SortListByProperty(emailClaims, e => e.Address, sortDirection), "EmailCount" => SortableTable.SortListByProperty(emailClaims, e => e.EmailCount, sortDirection), + "Disabled" => SortableTable.SortListByProperty(emailClaims, e => e.Disabled, sortDirection), _ => emailClaims }; } diff --git a/src/AliasVault.Admin/Main/Pages/Users/View/Index.razor b/src/AliasVault.Admin/Main/Pages/Users/View/Index.razor index 3bad988dc..93fc3cb98 100644 --- a/src/AliasVault.Admin/Main/Pages/Users/View/Index.razor +++ b/src/AliasVault.Admin/Main/Pages/Users/View/Index.razor @@ -105,8 +105,12 @@ else

Email claims

- - +

+ Email claims represent the email addresses that the user has (historically) used. Whenever a user deletes an email alias + the claim gets disabled and the server will reject all emails sent to that alias. A user can always re-enable + the claim by using it again. Email claims are permanently tied to a user and cannot be transferred to another user. +

+
@@ -126,7 +130,6 @@ else private int TwoFactorKeysCount { get; set; } private List RefreshTokenList { get; set; } = []; private List VaultList { get; set; } = []; - private List EmailClaimList { get; set; } = []; /// protected override async Task OnInitializedAsync() @@ -201,22 +204,6 @@ else .OrderBy(x => x.UpdatedAt) .ToListAsync(); - // Load all email claims for this user. - EmailClaimList = await dbContext.UserEmailClaims - .Where(x => x.UserId == User.Id) - .Select(x => new UserEmailClaimWithCount - { - Id = x.Id, - Address = x.Address, - AddressLocal = x.AddressLocal, - AddressDomain = x.AddressDomain, - CreatedAt = x.CreatedAt, - UpdatedAt = x.UpdatedAt, - EmailCount = dbContext.Emails.Count(e => e.To == x.Address) - }) - .OrderBy(x => x.CreatedAt) - .ToListAsync(); - IsLoading = false; StateHasChanged(); } diff --git a/src/AliasVault.Admin/wwwroot/css/tailwind.css b/src/AliasVault.Admin/wwwroot/css/tailwind.css index 500a49005..7a3386738 100644 --- a/src/AliasVault.Admin/wwwroot/css/tailwind.css +++ b/src/AliasVault.Admin/wwwroot/css/tailwind.css @@ -603,6 +603,10 @@ video { bottom: 0px; } +.left-0 { + left: 0px; +} + .right-0 { right: 0px; } @@ -611,10 +615,6 @@ video { top: 38px; } -.left-0 { - left: 0px; -} - .z-10 { z-index: 10; } @@ -663,10 +663,18 @@ video { margin-bottom: 0.5rem; } +.mb-3 { + margin-bottom: 0.75rem; +} + .mb-4 { margin-bottom: 1rem; } +.mb-5 { + margin-bottom: 1.25rem; +} + .mb-6 { margin-bottom: 1.5rem; } @@ -715,6 +723,10 @@ video { margin-inline-start: 0.25rem; } +.ms-2 { + margin-inline-start: 0.5rem; +} + .mt-0 { margin-top: 0px; } @@ -743,18 +755,6 @@ video { margin-top: 2rem; } -.ms-4 { - margin-inline-start: 1rem; -} - -.mb-3 { - margin-bottom: 0.75rem; -} - -.mb-5 { - margin-bottom: 1.25rem; -} - .line-clamp-1 { overflow: hidden; display: -webkit-box; @@ -914,10 +914,6 @@ video { max-width: 36rem; } -.flex-1 { - flex: 1 1 0%; -} - .flex-shrink-0 { flex-shrink: 0; } @@ -968,10 +964,6 @@ video { grid-template-columns: repeat(7, minmax(0, 1fr)); } -.grid-cols-\[150px_1fr\] { - grid-template-columns: 150px 1fr; -} - .flex-col { flex-direction: column; } @@ -984,10 +976,6 @@ video { align-items: flex-start; } -.items-end { - align-items: flex-end; -} - .items-center { align-items: center; } @@ -1020,10 +1008,6 @@ video { gap: 0.5rem; } -.gap-3 { - gap: 0.75rem; -} - .space-x-1 > :not([hidden]) ~ :not([hidden]) { --tw-space-x-reverse: 0; margin-right: calc(0.25rem * var(--tw-space-x-reverse)); @@ -1365,11 +1349,6 @@ video { background-color: rgb(234 179 8 / var(--tw-bg-opacity)); } -.bg-blue-700 { - --tw-bg-opacity: 1; - background-color: rgb(29 78 216 / var(--tw-bg-opacity)); -} - .bg-opacity-50 { --tw-bg-opacity: 0.5; } @@ -1483,14 +1462,26 @@ video { padding-bottom: 2rem; } +.pb-2 { + padding-bottom: 0.5rem; +} + .pl-2 { padding-left: 0.5rem; } +.pl-3 { + padding-left: 0.75rem; +} + .pr-2 { padding-right: 0.5rem; } +.ps-10 { + padding-inline-start: 2.5rem; +} + .ps-2 { padding-inline-start: 0.5rem; } @@ -1507,30 +1498,6 @@ video { padding-top: 2rem; } -.pb-2 { - padding-bottom: 0.5rem; -} - -.pl-10 { - padding-left: 2.5rem; -} - -.pl-3 { - padding-left: 0.75rem; -} - -.ps-8 { - padding-inline-start: 2rem; -} - -.ps-12 { - padding-inline-start: 3rem; -} - -.ps-10 { - padding-inline-start: 2.5rem; -} - .text-left { text-align: left; } @@ -1807,11 +1774,6 @@ video { background-color: rgb(153 27 27 / var(--tw-bg-opacity)); } -.hover\:bg-blue-800:hover { - --tw-bg-opacity: 1; - background-color: rgb(30 64 175 / var(--tw-bg-opacity)); -} - .hover\:text-gray-700:hover { --tw-text-opacity: 1; color: rgb(55 65 81 / var(--tw-text-opacity)); @@ -1908,11 +1870,6 @@ video { --tw-ring-color: rgb(252 165 165 / var(--tw-ring-opacity)); } -.focus\:ring-blue-300:focus { - --tw-ring-opacity: 1; - --tw-ring-color: rgb(147 197 253 / var(--tw-ring-opacity)); -} - .dark\:divide-gray-600:is(.dark *) > :not([hidden]) ~ :not([hidden]) { --tw-divide-opacity: 1; border-color: rgb(75 85 99 / var(--tw-divide-opacity)); @@ -2053,11 +2010,6 @@ video { background-color: rgb(113 63 18 / var(--tw-bg-opacity)); } -.dark\:bg-blue-600:is(.dark *) { - --tw-bg-opacity: 1; - background-color: rgb(37 99 235 / var(--tw-bg-opacity)); -} - .dark\:bg-opacity-80:is(.dark *) { --tw-bg-opacity: 0.8; } @@ -2142,11 +2094,6 @@ video { color: rgb(254 240 138 / var(--tw-text-opacity)); } -.dark\:text-blue-500:is(.dark *) { - --tw-text-opacity: 1; - color: rgb(59 130 246 / var(--tw-text-opacity)); -} - .dark\:placeholder-gray-400:is(.dark *)::-moz-placeholder { --tw-placeholder-opacity: 1; color: rgb(156 163 175 / var(--tw-placeholder-opacity)); @@ -2186,11 +2133,6 @@ video { background-color: rgb(185 28 28 / var(--tw-bg-opacity)); } -.dark\:hover\:bg-blue-700:hover:is(.dark *) { - --tw-bg-opacity: 1; - background-color: rgb(29 78 216 / var(--tw-bg-opacity)); -} - .dark\:hover\:text-gray-300:hover:is(.dark *) { --tw-text-opacity: 1; color: rgb(209 213 219 / var(--tw-text-opacity)); @@ -2266,11 +2208,6 @@ video { --tw-ring-color: rgb(153 27 27 / 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)); -} - @media (min-width: 640px) { .sm\:mb-5 { margin-bottom: 1.25rem;