mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-15 10:55:31 -04:00
Update email list page and tweak search fields (#641)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-500 dark:text-gray-400" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -7,12 +7,25 @@
|
||||
<PageHeader
|
||||
BreadcrumbItems="@BreadcrumbItems"
|
||||
Title="@(TotalRecords > 0 ? $"Emails ({TotalRecords:N0})" : "Emails")"
|
||||
Description="This page shows an overview of recently received mails by this AliasVault server. Note that all email fields except 'To' are encrypted with the public key of the user and cannot be decrypted by the server.">
|
||||
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.">
|
||||
<CustomActions>
|
||||
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
|
||||
</CustomActions>
|
||||
</PageHeader>
|
||||
|
||||
@if (IsInitialized)
|
||||
{
|
||||
<div class="px-4">
|
||||
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
|
||||
<div class="mb-3">
|
||||
<div class="relative">
|
||||
<SearchIcon />
|
||||
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search emails..." class="w-full px-4 ps-10 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
@@ -20,23 +33,25 @@
|
||||
else
|
||||
{
|
||||
<div class="overflow-x-auto px-4">
|
||||
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
|
||||
|
||||
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
|
||||
@foreach (var email in EmailList)
|
||||
@foreach (var viewModel in EmailViewModelList)
|
||||
{
|
||||
<SortableTableRow>
|
||||
<SortableTableColumn IsPrimary="true">@email.Id</SortableTableColumn>
|
||||
<SortableTableColumn>@email.DateSystem.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
|
||||
<SortableTableColumn>@(email.FromLocal.Length > 15 ? email.FromLocal.Substring(0, 15) : email.FromLocal)@@@(email.FromDomain.Length > 15 ? email.FromDomain.Substring(0, 15) : email.FromDomain)</SortableTableColumn>
|
||||
<SortableTableColumn>@email.ToLocal@@@email.ToDomain</SortableTableColumn>
|
||||
<SortableTableColumn>@(email.Subject.Length > 30 ? email.Subject.Substring(0, 30) : email.Subject)</SortableTableColumn>
|
||||
<SortableTableColumn IsPrimary="true">@viewModel.Email.Id</SortableTableColumn>
|
||||
<SortableTableColumn>@viewModel.Email.DateSystem.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
|
||||
<SortableTableColumn>@(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)</SortableTableColumn>
|
||||
<SortableTableColumn>@viewModel.Email.ToLocal@@@viewModel.Email.ToDomain</SortableTableColumn>
|
||||
<SortableTableColumn>
|
||||
<span class="line-clamp-1">
|
||||
@(email.MessagePreview?.Length > 30 ? email.MessagePreview.Substring(0, 30) : email.MessagePreview)
|
||||
</span>
|
||||
@if (viewModel.UserName.Length > 0)
|
||||
{
|
||||
<span class="line-clamp-1"><a href="/users/@viewModel.UserId">@viewModel.UserName</a></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="line-clamp-1">n/a</span>
|
||||
}
|
||||
</SortableTableColumn>
|
||||
<SortableTableColumn>@email.Attachments.Count</SortableTableColumn>
|
||||
<SortableTableColumn>@viewModel.Email.Attachments.Count</SortableTableColumn>
|
||||
</SortableTableRow>
|
||||
}
|
||||
</SortableTable>
|
||||
@@ -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<Email> EmailList { get; set; } = [];
|
||||
private List<EmailViewModel> 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;
|
||||
|
||||
/// <summary>
|
||||
/// The last search term.
|
||||
/// </summary>
|
||||
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<Email> 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<EmailViewModel>();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a search filter to the query based on the search term.
|
||||
/// </summary>
|
||||
/// <param name="query">The query to filter.</param>
|
||||
/// <returns>The filtered query.</returns>
|
||||
private IQueryable<Email> ApplySearchFilter(IQueryable<Email> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies sorting to the query based on the sort column and direction.
|
||||
/// </summary>
|
||||
/// <param name="query">The query to sort.</param>
|
||||
/// <returns>The sorted query.</returns>
|
||||
private IQueryable<Email> ApplySort(IQueryable<Email> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,19 +15,18 @@
|
||||
</CustomActions>
|
||||
</PageHeader>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
}
|
||||
else
|
||||
@if (IsInitialized)
|
||||
{
|
||||
<div class="px-4">
|
||||
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
|
||||
|
||||
<div class="mb-4 flex space-x-4">
|
||||
<div class="mb-3 flex space-x-4">
|
||||
<div class="flex w-full">
|
||||
<div class="w-2/3 pr-2">
|
||||
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search logs..." class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
|
||||
<div class="relative">
|
||||
<SearchIcon />
|
||||
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search logs..." class="w-full px-4 ps-10 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-1/3 pl-2">
|
||||
<select @bind="SelectedEventType" class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
|
||||
@@ -40,7 +39,16 @@ else
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="px-4">
|
||||
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
|
||||
@foreach (var log in LogList)
|
||||
{
|
||||
@@ -70,6 +78,7 @@ else
|
||||
];
|
||||
|
||||
private List<AuthLog> LogList { 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;
|
||||
@@ -131,6 +140,9 @@ else
|
||||
|
||||
private async Task RefreshData()
|
||||
{
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
var query = dbContext.AuthLogs.AsQueryable();
|
||||
|
||||
@@ -151,8 +163,9 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
query = query.Where(x => EF.Functions.Like((x.Username ?? string.Empty).ToLower(), "%" + SearchTerm.ToLower() + "%") ||
|
||||
EF.Functions.Like((x.IpAddress ?? string.Empty).ToLower(), "%" + SearchTerm.ToLower() + "%"));
|
||||
var searchTerm = SearchTerm.Trim().ToLower();
|
||||
query = query.Where(x => EF.Functions.Like((x.Username ?? string.Empty).ToLower(), "%" + searchTerm + "%") ||
|
||||
EF.Functions.Like((x.IpAddress ?? string.Empty).ToLower(), "%" + searchTerm + "%"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +187,7 @@ else
|
||||
.ToListAsync();
|
||||
|
||||
IsLoading = false;
|
||||
IsInitialized = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
|
||||
@@ -14,19 +14,18 @@
|
||||
</CustomActions>
|
||||
</PageHeader>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
}
|
||||
else
|
||||
@if (IsInitialized)
|
||||
{
|
||||
<div class="px-4">
|
||||
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
|
||||
|
||||
<div class="mb-4 flex space-x-4">
|
||||
<div class="mb-3 flex space-x-4">
|
||||
<div class="flex w-full">
|
||||
<div class="w-2/3 pr-2">
|
||||
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search logs..." class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
|
||||
<div class="relative">
|
||||
<SearchIcon />
|
||||
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search logs..." class="w-full px-4 ps-10 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-1/3 pl-2">
|
||||
<select @bind="SelectedServiceName" class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
|
||||
@@ -39,7 +38,16 @@ else
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="px-4">
|
||||
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
|
||||
@foreach (var log in LogList)
|
||||
{
|
||||
@@ -85,6 +93,8 @@ else
|
||||
];
|
||||
|
||||
private List<Log> LogList { 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;
|
||||
@@ -150,6 +160,9 @@ else
|
||||
|
||||
private async Task RefreshData()
|
||||
{
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
var query = dbContext.Logs.AsQueryable();
|
||||
|
||||
@@ -193,6 +206,7 @@ else
|
||||
.ToListAsync();
|
||||
|
||||
IsLoading = false;
|
||||
IsInitialized = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
@@ -211,10 +225,11 @@ else
|
||||
}
|
||||
_lastSearchTerm = SearchTerm;
|
||||
|
||||
query = query.Where(x => EF.Functions.Like(x.Application.ToLower(), "%" + SearchTerm.ToLower() + "%") ||
|
||||
EF.Functions.Like(x.Message.ToLower(), "%" + SearchTerm.ToLower() + "%") ||
|
||||
EF.Functions.Like(x.Level.ToLower(), "%" + SearchTerm.ToLower() + "%") ||
|
||||
EF.Functions.Like(x.SourceContext.ToLower(), "%" + SearchTerm.ToLower() + "%"));
|
||||
var searchTerm = SearchTerm.Trim().ToLower();
|
||||
query = query.Where(x => EF.Functions.Like(x.Application.ToLower(), "%" + searchTerm + "%") ||
|
||||
EF.Functions.Like(x.Message.ToLower(), "%" + searchTerm + "%") ||
|
||||
EF.Functions.Like(x.Level.ToLower(), "%" + searchTerm + "%") ||
|
||||
EF.Functions.Like(x.SourceContext.ToLower(), "%" + searchTerm + "%"));
|
||||
}
|
||||
|
||||
return query;
|
||||
|
||||
@@ -13,6 +13,19 @@
|
||||
</CustomActions>
|
||||
</PageHeader>
|
||||
|
||||
@if (IsInitialized)
|
||||
{
|
||||
<div class="px-4">
|
||||
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
|
||||
<div class="mb-3">
|
||||
<div class="relative">
|
||||
<SearchIcon />
|
||||
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search users..." class="w-full px-4 ps-10 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
@@ -20,12 +33,6 @@
|
||||
else
|
||||
{
|
||||
<div class="px-4">
|
||||
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
|
||||
|
||||
<div class="mb-4">
|
||||
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search users..." class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
|
||||
</div>
|
||||
|
||||
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
|
||||
@foreach (var user in UserList)
|
||||
{
|
||||
@@ -70,6 +77,7 @@ else
|
||||
];
|
||||
|
||||
private List<UserViewModel> 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<AliasVaultUser> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply search filter to the query.
|
||||
/// </summary>
|
||||
private IQueryable<AliasVaultUser> ApplySearchFilter(IQueryable<AliasVaultUser> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply sort to the query.
|
||||
/// </summary>
|
||||
|
||||
@@ -25,39 +25,57 @@ else
|
||||
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="items-center xl:block sm:space-x-4 xl:space-x-0 2xl:space-x-4">
|
||||
<div>
|
||||
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">@User.UserName</h3>
|
||||
<h3 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white border-b border-gray-200 pb-2">@User.UserName</h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Id</label>
|
||||
<div class="text-gray-700 dark:text-gray-300">@User.Id</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">2FA Status:</span>
|
||||
<StatusPill Enabled="@User.TwoFactorEnabled"/>
|
||||
<span class="text-gray-700 dark:text-gray-300">Authenticator key(s) active: @TwoFactorKeysCount</span>
|
||||
@if (User.TwoFactorEnabled)
|
||||
{
|
||||
<Button Color="danger" OnClick="DisableTwoFactor">Disable 2FA</Button>
|
||||
}
|
||||
else
|
||||
{
|
||||
if (TwoFactorKeysCount > 0)
|
||||
{
|
||||
<Button Color="success" OnClick="EnableTwoFactor">Enable 2FA</Button>
|
||||
<Button Color="danger" OnClick="ResetTwoFactor">Remove 2FA keys</Button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 mt-4">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">Account Status:</span>
|
||||
<StatusPill Enabled="@(!User.Blocked)" TextTrue="Active" TextFalse="Blocked" />
|
||||
<Button Color="@(User.Blocked ? "success" : "danger")" OnClick="ToggleBlockStatus">
|
||||
@(User.Blocked ? "Unblock User" : "Block User")
|
||||
</Button>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Blocking a user prevents them from logging in or accessing AliasVault
|
||||
</span>
|
||||
<div class="w-full mb-4 overflow-x-auto">
|
||||
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
|
||||
<tbody>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white">Id</th>
|
||||
<td class="px-4 py-3">@User.Id</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white">Registered at</th>
|
||||
<td class="px-4 py-3">@User.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row" class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white">2FA Status</th>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<StatusPill Enabled="@User.TwoFactorEnabled"/>
|
||||
<span class="text-gray-700 dark:text-gray-300">Authenticator key(s) active: @TwoFactorKeysCount</span>
|
||||
@if (User.TwoFactorEnabled)
|
||||
{
|
||||
<Button Color="danger" OnClick="DisableTwoFactor">Disable 2FA</Button>
|
||||
}
|
||||
else
|
||||
{
|
||||
if (TwoFactorKeysCount > 0)
|
||||
{
|
||||
<Button Color="success" OnClick="EnableTwoFactor">Enable 2FA</Button>
|
||||
<Button Color="danger" OnClick="ResetTwoFactor">Remove 2FA keys</Button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row" class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white">Account Status</th>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<StatusPill Enabled="@(!User.Blocked)" TextTrue="Active" TextFalse="Blocked" />
|
||||
<Button Color="@(User.Blocked ? "success" : "danger")" OnClick="ToggleBlockStatus">
|
||||
@(User.Blocked ? "Unblock User" : "Block User")
|
||||
</Button>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Blocking a user prevents them from logging in or accessing AliasVault
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@if (TotalRecords > PageSize)
|
||||
{
|
||||
<!-- Navigation component for pagination -->
|
||||
<nav aria-label="Page navigation" class="mt-4 flex justify-end mb-4">
|
||||
<nav aria-label="Page navigation" class="mt-4 flex justify-end mb-5">
|
||||
<ul class="flex space-x-2">
|
||||
<!-- First page button -->
|
||||
<li class="@(CurrentPage == 1 ? "opacity-50 cursor-not-allowed" : "")">
|
||||
@@ -14,7 +14,7 @@
|
||||
@for (var i = 1; i <= PageCount; i++)
|
||||
{
|
||||
var pageNum = i;
|
||||
if (i > 5 && i < PageCount - 5 && Math.Abs(CurrentPage - i) > 2)
|
||||
if (i > 2 && i < PageCount - 2 && Math.Abs(CurrentPage - i) > 3)
|
||||
{
|
||||
// Don't render intermediate pages when there are many pages
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user