mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-04-16 12:38:56 -04:00
401 lines
16 KiB
Plaintext
401 lines
16 KiB
Plaintext
@page "/users"
|
|
@using AliasVault.RazorComponents.Tables
|
|
@using AliasVault.Shared.Server.Services
|
|
@inject ServerSettingsService SettingsService
|
|
@inherits MainBase
|
|
|
|
<LayoutPageTitle>Users</LayoutPageTitle>
|
|
|
|
<PageHeader
|
|
BreadcrumbItems="@BreadcrumbItems"
|
|
Title="@(TotalRecords > 0 ? $"Users ({TotalRecords:N0})" : "Users")"
|
|
Description="This page shows an overview of all registered users and the associated vaults.">
|
|
<CustomActions>
|
|
<a href="mobile-login-history" class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-600 bg-gray-100 border border-gray-300 rounded-md hover:bg-gray-200 hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600 dark:hover:text-gray-200 mr-3">
|
|
<svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
|
</svg>
|
|
Mobile Login History
|
|
</a>
|
|
<RefreshButton OnClick="() => RefreshData(CancellationToken.None)" ButtonText="Refresh" />
|
|
</CustomActions>
|
|
</PageHeader>
|
|
|
|
@if (IsInitialized)
|
|
{
|
|
<div class="px-4">
|
|
<ResponsivePaginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
|
|
<div class="mb-3 flex space-x-4">
|
|
<div class="w-3/4">
|
|
<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 class="w-1/4">
|
|
<select @bind="SelectedUserFilter" 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">
|
|
<option value="">All Users</option>
|
|
<option value="active">Active Users</option>
|
|
<option value="inactive">Inactive Users</option>
|
|
<option value="blocked">Blocked Users</option>
|
|
<option value="2fa">2FA Enabled</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (IsLoading)
|
|
{
|
|
<LoadingIndicator />
|
|
}
|
|
else
|
|
{
|
|
<div class="px-4">
|
|
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
|
|
@foreach (var user in UserList)
|
|
{
|
|
<SortableTableRow>
|
|
<SortableTableColumn IsPrimary="true">@user.CreatedAt.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
|
|
<SortableTableColumn>@user.UserName</SortableTableColumn>
|
|
<SortableTableColumn>@user.VaultCount</SortableTableColumn>
|
|
<SortableTableColumn>@user.CredentialCount</SortableTableColumn>
|
|
<SortableTableColumn>@user.EmailClaimCount</SortableTableColumn>
|
|
<SortableTableColumn>@Math.Round((double)user.VaultStorageInKb / 1024, 1) MB</SortableTableColumn>
|
|
<SortableTableColumn>@(user.LastActivityDate?.ToString("yyyy-MM-dd HH:mm") ?? "Never")</SortableTableColumn>
|
|
<SortableTableColumn>
|
|
<div class="flex flex-wrap gap-1">
|
|
@if (user.IsInactive)
|
|
{
|
|
<StatusPill Enabled="false" TextFalse="Inactive" />
|
|
}
|
|
@if (user.Blocked)
|
|
{
|
|
<StatusPill Enabled="false" TextFalse="Blocked" />
|
|
}
|
|
@if (user.TwoFactorEnabled)
|
|
{
|
|
<StatusPill Enabled="true" TextTrue="2FA enabled" />
|
|
}
|
|
</div>
|
|
</SortableTableColumn>
|
|
<SortableTableColumn>
|
|
<LinkButton Color="primary" Href="@($"users/{user.Id}")" Text="View" />
|
|
</SortableTableColumn>
|
|
</SortableTableRow>
|
|
}
|
|
</SortableTable>
|
|
</div>
|
|
}
|
|
|
|
@code {
|
|
private readonly List<TableColumn> _tableColumns = [
|
|
new TableColumn { Title = "Registered", PropertyName = "CreatedAt" },
|
|
new TableColumn { Title = "Username", PropertyName = "UserName" },
|
|
new TableColumn { Title = "# Vaults", PropertyName = "VaultCount" },
|
|
new TableColumn { Title = "# Credentials", PropertyName = "CredentialCount" },
|
|
new TableColumn { Title = "# Email claims", PropertyName = "EmailClaimCount" },
|
|
new TableColumn { Title = "Storage", PropertyName = "VaultStorageInKb" },
|
|
new TableColumn { Title = "Last Activity", PropertyName = "LastActivityDate" },
|
|
new TableColumn { Title = "Status", Sortable = false },
|
|
new TableColumn { Title = "Actions", Sortable = false},
|
|
];
|
|
|
|
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;
|
|
private int TotalRecords { get; set; }
|
|
|
|
private string _searchTerm = string.Empty;
|
|
private CancellationTokenSource? _searchCancellationTokenSource;
|
|
|
|
/// <summary>
|
|
/// The last search term.
|
|
/// </summary>
|
|
private string _lastSearchTerm = string.Empty;
|
|
|
|
private string SearchTerm
|
|
{
|
|
get => _searchTerm;
|
|
set
|
|
{
|
|
if (_searchTerm != value)
|
|
{
|
|
_searchTerm = value;
|
|
_searchCancellationTokenSource?.Cancel();
|
|
_searchCancellationTokenSource = new CancellationTokenSource();
|
|
_ = RefreshData(_searchCancellationTokenSource.Token);
|
|
}
|
|
}
|
|
}
|
|
|
|
private string _selectedUserFilter = string.Empty;
|
|
private string _lastSelectedUserFilter = string.Empty;
|
|
private string SelectedUserFilter
|
|
{
|
|
get => _selectedUserFilter;
|
|
set
|
|
{
|
|
if (_selectedUserFilter != value)
|
|
{
|
|
_selectedUserFilter = value;
|
|
_searchCancellationTokenSource?.Cancel();
|
|
_searchCancellationTokenSource = new CancellationTokenSource();
|
|
_ = RefreshData(_searchCancellationTokenSource.Token);
|
|
}
|
|
}
|
|
}
|
|
|
|
private string SortColumn { get; set; } = "CreatedAt";
|
|
private SortDirection SortDirection { get; set; } = SortDirection.Descending;
|
|
|
|
private async Task HandleSortChanged((string column, SortDirection direction) sort)
|
|
{
|
|
SortColumn = sort.column;
|
|
SortDirection = sort.direction;
|
|
await RefreshData(CancellationToken.None);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
await base.OnInitializedAsync();
|
|
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Users" });
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
{
|
|
if (firstRender)
|
|
{
|
|
await RefreshData(CancellationToken.None);
|
|
}
|
|
}
|
|
|
|
private void HandlePageChanged(int newPage)
|
|
{
|
|
CurrentPage = newPage;
|
|
_ = RefreshData(CancellationToken.None);
|
|
}
|
|
|
|
private async Task RefreshData(CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
IsLoading = true;
|
|
StateHasChanged();
|
|
|
|
var settings = await SettingsService.GetAllSettingsAsync();
|
|
var inactivityCutoffDate = settings.MarkUserInactiveAfterDays > 0
|
|
? DateTime.UtcNow.AddDays(-settings.MarkUserInactiveAfterDays)
|
|
: (DateTime?)null;
|
|
|
|
await using var dbContext = await DbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
IQueryable<AliasVaultUser> query = dbContext.AliasVaultUsers;
|
|
|
|
query = ApplySearchFilter(query);
|
|
query = ApplyUserFilter(query, inactivityCutoffDate);
|
|
query = ApplySort(query, dbContext);
|
|
|
|
TotalRecords = await query.CountAsync(cancellationToken);
|
|
var users = await query
|
|
.Skip((CurrentPage - 1) * PageSize)
|
|
.Take(PageSize)
|
|
.Select(u => new
|
|
{
|
|
u.Id,
|
|
u.UserName,
|
|
u.CreatedAt,
|
|
u.TwoFactorEnabled,
|
|
u.Blocked,
|
|
u.LastActivityDate,
|
|
Vaults = u.Vaults.Select(v => new
|
|
{
|
|
v.FileSize,
|
|
v.CreatedAt,
|
|
v.RevisionNumber,
|
|
CredentialCount = v.CredentialsCount,
|
|
}),
|
|
EmailClaims = u.EmailClaims.Select(ec => new
|
|
{
|
|
ec.CreatedAt,
|
|
ec.Address
|
|
}),
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
|
|
if (cancellationToken.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
|
|
UserList = users.Select(user =>
|
|
{
|
|
var lastActivity = user.LastActivityDate ?? user.CreatedAt;
|
|
var isInactive = inactivityCutoffDate.HasValue && lastActivity < inactivityCutoffDate.Value;
|
|
|
|
return new UserViewModel
|
|
{
|
|
Id = user.Id,
|
|
UserName = user.UserName?.ToLower() ?? "N/A",
|
|
TwoFactorEnabled = user.TwoFactorEnabled,
|
|
Blocked = user.Blocked,
|
|
CreatedAt = user.CreatedAt,
|
|
LastActivityDate = user.LastActivityDate,
|
|
IsInactive = isInactive,
|
|
VaultCount = user.Vaults.Count(),
|
|
CredentialCount = user.Vaults.OrderByDescending(x => x.RevisionNumber).First().CredentialCount,
|
|
EmailClaimCount = user.EmailClaims.Count(),
|
|
VaultStorageInKb = user.Vaults.Sum(x => x.FileSize),
|
|
};
|
|
}).ToList();
|
|
|
|
IsLoading = false;
|
|
IsInitialized = true;
|
|
StateHasChanged();
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Expected when cancellation is requested, do nothing
|
|
}
|
|
}
|
|
|
|
/// <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 user filter to the query.
|
|
/// </summary>
|
|
private IQueryable<AliasVaultUser> ApplyUserFilter(IQueryable<AliasVaultUser> query, DateTime? inactivityCutoffDate)
|
|
{
|
|
if (!string.IsNullOrEmpty(SelectedUserFilter))
|
|
{
|
|
// Reset page number back to 1 if the filter has changed.
|
|
if (SelectedUserFilter != _lastSelectedUserFilter && CurrentPage != 1)
|
|
{
|
|
CurrentPage = 1;
|
|
}
|
|
_lastSelectedUserFilter = SelectedUserFilter;
|
|
|
|
switch (SelectedUserFilter)
|
|
{
|
|
case "active":
|
|
if (inactivityCutoffDate.HasValue)
|
|
{
|
|
query = query.Where(u => u.LastActivityDate >= inactivityCutoffDate.Value || (u.LastActivityDate == null && u.CreatedAt >= inactivityCutoffDate.Value));
|
|
}
|
|
query = query.Where(u => !u.Blocked);
|
|
break;
|
|
case "inactive":
|
|
if (inactivityCutoffDate.HasValue)
|
|
{
|
|
query = query.Where(u => (u.LastActivityDate != null && u.LastActivityDate < inactivityCutoffDate.Value) || (u.LastActivityDate == null && u.CreatedAt < inactivityCutoffDate.Value));
|
|
}
|
|
else
|
|
{
|
|
// If MarkUserInactiveAfterDays is 0, no users are considered inactive
|
|
query = query.Where(u => false);
|
|
}
|
|
break;
|
|
case "blocked":
|
|
query = query.Where(u => u.Blocked);
|
|
break;
|
|
case "2fa":
|
|
query = query.Where(u => u.TwoFactorEnabled);
|
|
break;
|
|
}
|
|
}
|
|
|
|
return query;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Apply sort to the query.
|
|
/// </summary>
|
|
private IQueryable<AliasVaultUser> ApplySort(IQueryable<AliasVaultUser> query, AliasServerDbContext dbContext)
|
|
{
|
|
// Apply sort.
|
|
switch (SortColumn)
|
|
{
|
|
case "Id":
|
|
query = SortDirection == SortDirection.Ascending
|
|
? query.OrderBy(x => x.Id)
|
|
: query.OrderByDescending(x => x.Id);
|
|
break;
|
|
case "CreatedAt":
|
|
query = SortDirection == SortDirection.Ascending
|
|
? query.OrderBy(x => x.CreatedAt)
|
|
: query.OrderByDescending(x => x.CreatedAt);
|
|
break;
|
|
case "UserName":
|
|
query = SortDirection == SortDirection.Ascending
|
|
? query.OrderBy(x => x.UserName)
|
|
: query.OrderByDescending(x => x.UserName);
|
|
break;
|
|
case "VaultCount":
|
|
query = SortDirection == SortDirection.Ascending
|
|
? query.OrderBy(x => x.Vaults.Count)
|
|
: query.OrderByDescending(x => x.Vaults.Count);
|
|
break;
|
|
case "CredentialCount":
|
|
query = SortDirection == SortDirection.Ascending
|
|
? query.OrderBy(x => x.Vaults.OrderByDescending(x => x.RevisionNumber).First().CredentialsCount)
|
|
: query.OrderByDescending(x => x.Vaults.OrderByDescending(x => x.RevisionNumber).First().CredentialsCount);
|
|
break;
|
|
case "EmailClaimCount":
|
|
query = SortDirection == SortDirection.Ascending
|
|
? query.OrderBy(x => x.EmailClaims.Count)
|
|
: query.OrderByDescending(x => x.EmailClaims.Count);
|
|
break;
|
|
case "VaultStorageInKb":
|
|
query = SortDirection == SortDirection.Ascending
|
|
? query.OrderBy(x => x.Vaults.Sum(v => v.FileSize))
|
|
: query.OrderByDescending(x => x.Vaults.Sum(v => v.FileSize));
|
|
break;
|
|
case "LastActivityDate":
|
|
query = SortDirection == SortDirection.Ascending
|
|
? query.OrderBy(x => x.LastActivityDate ?? x.CreatedAt)
|
|
: query.OrderByDescending(x => x.LastActivityDate ?? x.CreatedAt);
|
|
break;
|
|
default:
|
|
query = SortDirection == SortDirection.Ascending
|
|
? query.OrderBy(x => x.Id)
|
|
: query.OrderByDescending(x => x.Id);
|
|
break;
|
|
}
|
|
|
|
return query;
|
|
}
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (disposing)
|
|
{
|
|
_searchCancellationTokenSource?.Cancel();
|
|
_searchCancellationTokenSource?.Dispose();
|
|
}
|
|
base.Dispose(disposing);
|
|
}
|
|
|
|
}
|