From d6932f33eabbe8324b684fc8dcbac1ae6aff1406 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Wed, 19 Mar 2025 22:10:13 +0100 Subject: [PATCH] Update email list page and tweak search fields (#641) --- .../Main/Components/Icons/SearchIcon.razor | 5 + src/AliasVault.Admin/Main/Pages/Emails.razor | 153 ++++++++++++++---- .../Main/Pages/Logging/Auth.razor | 32 ++-- .../Main/Pages/Logging/General.razor | 37 +++-- .../Main/Pages/Users/Users.razor | 59 ++++--- .../Main/Pages/Users/View/Index.razor | 82 ++++++---- src/AliasVault.Admin/Main/_Imports.razor | 1 + src/AliasVault.Admin/wwwroot/css/tailwind.css | 104 ++++++++++++ src/AliasVault.Admin/wwwroot/js/utilities.js | 32 ++++ .../Paginator.razor | 4 +- 10 files changed, 402 insertions(+), 107 deletions(-) create mode 100644 src/AliasVault.Admin/Main/Components/Icons/SearchIcon.razor diff --git a/src/AliasVault.Admin/Main/Components/Icons/SearchIcon.razor b/src/AliasVault.Admin/Main/Components/Icons/SearchIcon.razor new file mode 100644 index 000000000..0e27b6a0f --- /dev/null +++ b/src/AliasVault.Admin/Main/Components/Icons/SearchIcon.razor @@ -0,0 +1,5 @@ +
+ +
diff --git a/src/AliasVault.Admin/Main/Pages/Emails.razor b/src/AliasVault.Admin/Main/Pages/Emails.razor index be72e841b..17607b559 100644 --- a/src/AliasVault.Admin/Main/Pages/Emails.razor +++ b/src/AliasVault.Admin/Main/Pages/Emails.razor @@ -7,12 +7,25 @@ + Description="This page shows an overview of recently received mails by this AliasVault server. Note: all email fields except 'To' are encrypted with the public key of the user and are unreadable by the server."> +@if (IsInitialized) +{ +
+ +
+
+ + +
+
+
+} + @if (IsLoading) { @@ -20,23 +33,25 @@ else {
- - - @foreach (var email in EmailList) + @foreach (var viewModel in EmailViewModelList) { - @email.Id - @email.DateSystem.ToString("yyyy-MM-dd HH:mm") - @(email.FromLocal.Length > 15 ? email.FromLocal.Substring(0, 15) : email.FromLocal)@@@(email.FromDomain.Length > 15 ? email.FromDomain.Substring(0, 15) : email.FromDomain) - @email.ToLocal@@@email.ToDomain - @(email.Subject.Length > 30 ? email.Subject.Substring(0, 30) : email.Subject) + @viewModel.Email.Id + @viewModel.Email.DateSystem.ToString("yyyy-MM-dd HH:mm") + @(viewModel.Email.FromLocal.Length > 10 ? viewModel.Email.FromLocal.Substring(0, 10) : viewModel.Email.FromLocal)@@@(viewModel.Email.FromDomain.Length > 10 ? viewModel.Email.FromDomain.Substring(0, 10) : viewModel.Email.FromDomain) + @viewModel.Email.ToLocal@@@viewModel.Email.ToDomain - - @(email.MessagePreview?.Length > 30 ? email.MessagePreview.Substring(0, 30) : email.MessagePreview) - + @if (viewModel.UserName.Length > 0) + { + @viewModel.UserName + } + else + { + n/a + } - @email.Attachments.Count + @viewModel.Email.Attachments.Count } @@ -49,20 +64,40 @@ else new TableColumn { Title = "Time", PropertyName = "DateSystem" }, new TableColumn { Title = "From", PropertyName = "From" }, new TableColumn { Title = "To", PropertyName = "To" }, - new TableColumn { Title = "Subject", PropertyName = "Subject" }, - new TableColumn { Title = "Preview", PropertyName = "MessagePreview" }, + new TableColumn { Title = "User", Sortable = false }, new TableColumn { Title = "Attachments", PropertyName = "Attachments" }, ]; - private List EmailList { get; set; } = []; + 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; } + private string _searchTerm = string.Empty; + + /// + /// The last search term. + /// + private string _lastSearchTerm = string.Empty; + + private string SearchTerm + { + get => _searchTerm; + set + { + if (_searchTerm != value) + { + _searchTerm = value; + _ = RefreshData(); + } + } + } private string SortColumn { get; set; } = "Id"; private SortDirection SortDirection { get; set; } = SortDirection.Descending; - private async Task HandleSortChanged((string column, SortDirection direction) sort) { SortColumn = sort.column; @@ -91,8 +126,68 @@ else StateHasChanged(); await using var dbContext = await DbContextFactory.CreateDbContextAsync(); + IQueryable query = dbContext.Emails; + query = ApplySearchFilter(query); + query = ApplySort(query); + + TotalRecords = await query.CountAsync(); + var emailList = await query + .Skip((CurrentPage - 1) * PageSize) + .Take(PageSize) + .ToListAsync(); + + // Get all usernames for the emails in the current list + var encryptionKeyIds = emailList.Select(x => x.UserEncryptionKeyId).Distinct().ToList(); + var encryptionKeyUsernames = await dbContext.UserEncryptionKeys + .Where(x => encryptionKeyIds.Contains(x.Id)) + .Join(dbContext.AliasVaultUsers, x => x.UserId, y => y.Id, (x, y) => new { EncryptionKeyId = x.Id, UserId = y.Id, y.UserName }) + .ToListAsync(); + + // Create new list of viewmodels + EmailViewModelList = new List(); + + foreach (var email in emailList) + { + var encryptionKey = encryptionKeyUsernames.FirstOrDefault(x => x.EncryptionKeyId == email.UserEncryptionKeyId); + EmailViewModelList.Add(new EmailViewModel { Email = email, UserId = encryptionKey?.UserId ?? string.Empty, UserName = encryptionKey?.UserName ?? string.Empty }); + } + + IsLoading = false; + IsInitialized = true; + StateHasChanged(); + } + + /// + /// Applies a search filter to the query based on the search term. + /// + /// The query to filter. + /// The filtered query. + private IQueryable ApplySearchFilter(IQueryable query) + { + if (SearchTerm.Length > 0) + { + // Reset page number back to 1 if the search term has changed. + if (SearchTerm != _lastSearchTerm && CurrentPage != 1) + { + CurrentPage = 1; + } + _lastSearchTerm = SearchTerm; + + query = query.Where(x => EF.Functions.Like(x.To.ToLower(), "%" + SearchTerm.Trim().ToLower() + "%")); + } + + return query; + } + + /// + /// Applies sorting to the query based on the sort column and direction. + /// + /// The query to sort. + /// The sorted query. + private IQueryable ApplySort(IQueryable query) + { // Apply sort switch (SortColumn) { @@ -116,16 +211,6 @@ else ? query.OrderBy(x => x.ToLocal + "@" + x.ToDomain) : query.OrderByDescending(x => x.ToLocal + "@" + x.ToDomain); break; - case "Subject": - query = SortDirection == SortDirection.Ascending - ? query.OrderBy(x => x.Subject) - : query.OrderByDescending(x => x.Subject); - break; - case "MessagePreview": - query = SortDirection == SortDirection.Ascending - ? query.OrderBy(x => x.MessagePreview) - : query.OrderByDescending(x => x.MessagePreview); - break; case "Attachments": query = SortDirection == SortDirection.Ascending ? query.OrderBy(x => x.Attachments.Count) @@ -136,13 +221,13 @@ else break; } - TotalRecords = await query.CountAsync(); - EmailList = await query - .Skip((CurrentPage - 1) * PageSize) - .Take(PageSize) - .ToListAsync(); + return query; + } - IsLoading = false; - StateHasChanged(); + private sealed class EmailViewModel + { + public Email Email { get; set; } = new(); + public string UserId { get; set; } = string.Empty; + public string UserName { get; set; } = string.Empty; } } diff --git a/src/AliasVault.Admin/Main/Pages/Logging/Auth.razor b/src/AliasVault.Admin/Main/Pages/Logging/Auth.razor index 655cf1c45..2cc383226 100644 --- a/src/AliasVault.Admin/Main/Pages/Logging/Auth.razor +++ b/src/AliasVault.Admin/Main/Pages/Logging/Auth.razor @@ -15,19 +15,18 @@ -@if (IsLoading) -{ - -} -else +@if (IsInitialized) {
-
+
- +
+ + +
+
+ + +
+
+
+
+} + @if (IsLoading) { @@ -20,12 +33,6 @@ else {
- - -
- -
- @foreach (var user in UserList) { @@ -70,6 +77,7 @@ else ]; private List UserList { 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; @@ -122,22 +130,13 @@ else private async Task RefreshData() { + IsLoading = true; + StateHasChanged(); + await using var dbContext = await DbContextFactory.CreateDbContextAsync(); IQueryable query = dbContext.AliasVaultUsers; - if (SearchTerm.Length > 0) - { - // Reset page number back to 1 if the search term has changed. - if (SearchTerm != _lastSearchTerm && CurrentPage != 1) - { - CurrentPage = 1; - } - _lastSearchTerm = SearchTerm; - - query = query.Where(x => EF.Functions.Like(x.UserName!.ToLower(), "%" + SearchTerm.ToLower() + "%")); - } - - // Apply sort. + query = ApplySearchFilter(query); query = ApplySort(query); TotalRecords = await query.CountAsync(); @@ -180,9 +179,31 @@ else }).ToList(); IsLoading = false; + IsInitialized = true; StateHasChanged(); } + /// + /// Apply search filter to the query. + /// + private IQueryable ApplySearchFilter(IQueryable query) + { + if (SearchTerm.Length > 0) + { + // Reset page number back to 1 if the search term has changed. + if (SearchTerm != _lastSearchTerm && CurrentPage != 1) + { + CurrentPage = 1; + } + _lastSearchTerm = SearchTerm; + + var searchTerm = SearchTerm.Trim().ToLower(); + query = query.Where(x => EF.Functions.Like(x.UserName!.ToLower(), "%" + searchTerm + "%")); + } + + return query; + } + /// /// Apply sort to the query. /// diff --git a/src/AliasVault.Admin/Main/Pages/Users/View/Index.razor b/src/AliasVault.Admin/Main/Pages/Users/View/Index.razor index 9e5660baa..3bad988dc 100644 --- a/src/AliasVault.Admin/Main/Pages/Users/View/Index.razor +++ b/src/AliasVault.Admin/Main/Pages/Users/View/Index.razor @@ -25,39 +25,57 @@ else
-

@User.UserName

+

@User.UserName

-
- -
@User.Id
-
-
- 2FA Status: - - Authenticator key(s) active: @TwoFactorKeysCount - @if (User.TwoFactorEnabled) - { - - } - else - { - if (TwoFactorKeysCount > 0) - { - - - } - } -
-
- Account Status: - - - - - Blocking a user prevents them from logging in or accessing AliasVault - +
+ + + + + + + + + + + + + + + + + + + +
Id@User.Id
Registered at@User.CreatedAt.ToString("yyyy-MM-dd HH:mm")
2FA Status +
+ + Authenticator key(s) active: @TwoFactorKeysCount + @if (User.TwoFactorEnabled) + { + + } + else + { + if (TwoFactorKeysCount > 0) + { + + + } + } +
+
Account Status +
+ + + + + Blocking a user prevents them from logging in or accessing AliasVault + +
+
diff --git a/src/AliasVault.Admin/Main/_Imports.razor b/src/AliasVault.Admin/Main/_Imports.razor index 17b2643e7..ed3f13d38 100644 --- a/src/AliasVault.Admin/Main/_Imports.razor +++ b/src/AliasVault.Admin/Main/_Imports.razor @@ -15,6 +15,7 @@ @using AliasVault.Admin.Main.Layout @using AliasVault.Admin.Main.Components @using AliasVault.Admin.Main.Components.Alerts +@using AliasVault.Admin.Main.Components.Icons @using AliasVault.Admin.Main.Components.Layout @using AliasVault.Admin.Main.Components.Loading @using AliasVault.Admin.Main.Components.WorkerStatus diff --git a/src/AliasVault.Admin/wwwroot/css/tailwind.css b/src/AliasVault.Admin/wwwroot/css/tailwind.css index 419872ae4..500a49005 100644 --- a/src/AliasVault.Admin/wwwroot/css/tailwind.css +++ b/src/AliasVault.Admin/wwwroot/css/tailwind.css @@ -566,6 +566,10 @@ video { border-width: 0; } +.pointer-events-none { + pointer-events: none; +} + .visible { visibility: visible; } @@ -594,6 +598,11 @@ video { inset: 0px; } +.inset-y-0 { + top: 0px; + bottom: 0px; +} + .right-0 { right: 0px; } @@ -602,6 +611,10 @@ video { top: 38px; } +.left-0 { + left: 0px; +} + .z-10 { z-index: 10; } @@ -730,6 +743,18 @@ 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; @@ -889,6 +914,10 @@ video { max-width: 36rem; } +.flex-1 { + flex: 1 1 0%; +} + .flex-shrink-0 { flex-shrink: 0; } @@ -939,6 +968,10 @@ video { grid-template-columns: repeat(7, minmax(0, 1fr)); } +.grid-cols-\[150px_1fr\] { + grid-template-columns: 150px 1fr; +} + .flex-col { flex-direction: column; } @@ -951,6 +984,10 @@ video { align-items: flex-start; } +.items-end { + align-items: flex-end; +} + .items-center { align-items: center; } @@ -979,6 +1016,14 @@ video { gap: 2rem; } +.gap-2 { + 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)); @@ -1320,6 +1365,11 @@ 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; } @@ -1457,6 +1507,30 @@ 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; } @@ -1733,6 +1807,11 @@ 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)); @@ -1829,6 +1908,11 @@ 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)); @@ -1969,6 +2053,11 @@ 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; } @@ -2053,6 +2142,11 @@ 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)); @@ -2092,6 +2186,11 @@ 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)); @@ -2167,6 +2266,11 @@ 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; diff --git a/src/AliasVault.Admin/wwwroot/js/utilities.js b/src/AliasVault.Admin/wwwroot/js/utilities.js index a6c44811d..a87188451 100644 --- a/src/AliasVault.Admin/wwwroot/js/utilities.js +++ b/src/AliasVault.Admin/wwwroot/js/utilities.js @@ -93,3 +93,35 @@ function generateQrCode(id) { qrcode.makeCode(dataUrl); } +// Keyboard navigation for pagination +window.enablePaginationKeyboardNavigation = (element, dotNetHelper, currentPage, maxPage) => { + if (!element) return; + + // Add tabindex and focus if not already set + if (!element.hasAttribute('tabindex')) { + element.setAttribute('tabindex', '0'); + } + + // Remove any existing event listener to prevent duplicates + if (element._paginationKeyHandler) { + element.removeEventListener('keydown', element._paginationKeyHandler); + } + + // Create keyboard event handler + element._paginationKeyHandler = (e) => { + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + e.preventDefault(); + + const newPage = e.key === 'ArrowLeft' + ? Math.max(1, currentPage - 1) + : Math.min(maxPage, currentPage + 1); + + if (newPage !== currentPage) { + dotNetHelper.invokeMethodAsync('NavigateToPage', newPage); + } + } + }; + + // Add event listener + element.addEventListener('keydown', element._paginationKeyHandler); +}; diff --git a/src/Shared/AliasVault.RazorComponents/Paginator.razor b/src/Shared/AliasVault.RazorComponents/Paginator.razor index 180692bdb..cde275271 100644 --- a/src/Shared/AliasVault.RazorComponents/Paginator.razor +++ b/src/Shared/AliasVault.RazorComponents/Paginator.razor @@ -1,7 +1,7 @@ @if (TotalRecords > PageSize) { -