diff --git a/src/AliasVault.Admin/Main/Pages/Account/ManageNavMenu.razor b/src/AliasVault.Admin/Main/Pages/Account/ManageNavMenu.razor
index c3c39e2bd..e444039ed 100644
--- a/src/AliasVault.Admin/Main/Pages/Account/ManageNavMenu.razor
+++ b/src/AliasVault.Admin/Main/Pages/Account/ManageNavMenu.razor
@@ -4,12 +4,9 @@
-
- Profile
+ Password
-
- Password
-
- -
- Two-factor authentication
+ Two-factor authentication
diff --git a/src/AliasVault.Admin/Main/Pages/Dashboard/Components/ActiveUsersCard.razor b/src/AliasVault.Admin/Main/Pages/Dashboard/Components/ActiveUsersCard.razor
new file mode 100644
index 000000000..d051e0aec
--- /dev/null
+++ b/src/AliasVault.Admin/Main/Pages/Dashboard/Components/ActiveUsersCard.razor
@@ -0,0 +1,137 @@
+
+
+
Active users
+
+
+ @if (IsLoading)
+ {
+
+ }
+ else
+ {
+
+
+
Last 24 hours
+
@UserStats.Last24Hours
+ @if (ShowUserNames)
+ {
+
+
+ @foreach (var user in UserStats.Last24HourUsers)
+ {
+ - @user
+ }
+
+
+ }
+
+
+
Last 7 days
+
@UserStats.Last7Days
+ @if (ShowUserNames)
+ {
+
+
+ @foreach (var user in UserStats.Last7DayUsers)
+ {
+ - @user
+ }
+
+
+ }
+
+
+
Last 14 days
+
@UserStats.Last14Days
+ @if (ShowUserNames)
+ {
+
+
+ @foreach (var user in UserStats.Last14DayUsers)
+ {
+ - @user
+ }
+
+
+ }
+
+
+ }
+
+
+@code {
+ private bool IsLoading { get; set; } = true;
+ private UserStatistics UserStats { get; set; } = new();
+ private bool ShowUserNames { get; set; }
+
+ ///
+ /// Refreshes the data displayed on the card.
+ ///
+ public async Task RefreshData()
+ {
+ IsLoading = true;
+ StateHasChanged();
+
+ var now = DateTime.UtcNow;
+ var last24Hours = now.AddHours(-24);
+ var last7Days = now.AddDays(-7);
+ var last14Days = now.AddDays(-14);
+
+ // Get user statistics
+ var (count24h, users24h) = await GetActiveUserCount(last24Hours);
+ var (count7d, users7d) = await GetActiveUserCount(last7Days);
+ var (count14d, users14d) = await GetActiveUserCount(last14Days);
+
+ UserStats = new UserStatistics
+ {
+ Last24Hours = count24h,
+ Last7Days = count7d,
+ Last14Days = count14d,
+ Last24HourUsers = users24h,
+ Last7DayUsers = users7d,
+ Last14DayUsers = users14d
+ };
+
+ IsLoading = false;
+ StateHasChanged();
+ }
+
+ private async Task<(int count, List
users)> GetActiveUserCount(DateTime since)
+ {
+ // Get unique users who either:
+ // 1. Have successful auth logs
+ // 2. Have updated their vault
+ var activeUsers = await DbContext.AuthLogs
+ .Where(l => l.Timestamp >= since && l.IsSuccess)
+ .Select(l => l.Username)
+ .Union(
+ DbContext.Vaults
+ .Where(v => v.UpdatedAt >= since)
+ .Select(v => v.User.UserName!)
+ )
+ .Distinct()
+ .ToListAsync();
+
+ return (activeUsers.Count, activeUsers);
+ }
+
+ private void ToggleUserNames()
+ {
+ ShowUserNames = !ShowUserNames;
+ StateHasChanged();
+ }
+
+ private sealed class UserStatistics
+ {
+ public int Last24Hours { get; set; }
+ public int Last7Days { get; set; }
+ public int Last14Days { get; set; }
+ public List Last24HourUsers { get; set; } = new();
+ public List Last7DayUsers { get; set; } = new();
+ public List Last14DayUsers { get; set; } = new();
+ }
+}
diff --git a/src/AliasVault.Admin/Main/Pages/Dashboard/Components/EmailStatisticsCard.razor b/src/AliasVault.Admin/Main/Pages/Dashboard/Components/EmailStatisticsCard.razor
new file mode 100644
index 000000000..51402fdd1
--- /dev/null
+++ b/src/AliasVault.Admin/Main/Pages/Dashboard/Components/EmailStatisticsCard.razor
@@ -0,0 +1,64 @@
+
+
+
Recent emails received
+
+ @if (IsLoading)
+ {
+
+ }
+ else
+ {
+
+
+
Last 24 hours
+
@EmailStats.Last24Hours
+
+
+
Last 7 days
+
@EmailStats.Last7Days
+
+
+
Last 14 days
+
@EmailStats.Last14Days
+
+
+ }
+
+
+@code {
+ private bool IsLoading { get; set; } = true;
+ private EmailStatistics EmailStats { get; set; } = new();
+
+ ///
+ /// Refreshes the data displayed on the card.
+ ///
+ public async Task RefreshData()
+ {
+ IsLoading = true;
+ StateHasChanged();
+
+ var now = DateTime.UtcNow;
+ var last24Hours = now.AddHours(-24);
+ var last7Days = now.AddDays(-7);
+ var last14Days = now.AddDays(-14);
+
+ // Get email statistics
+ var emailQuery = DbContext.Emails.AsQueryable();
+ EmailStats = new EmailStatistics
+ {
+ Last24Hours = await emailQuery.CountAsync(e => e.DateSystem >= last24Hours),
+ Last7Days = await emailQuery.CountAsync(e => e.DateSystem >= last7Days),
+ Last14Days = await emailQuery.CountAsync(e => e.DateSystem >= last14Days)
+ };
+
+ IsLoading = false;
+ StateHasChanged();
+ }
+
+ private sealed class EmailStatistics
+ {
+ public int Last24Hours { get; set; }
+ public int Last7Days { get; set; }
+ public int Last14Days { get; set; }
+ }
+}
diff --git a/src/AliasVault.Admin/Main/Pages/Dashboard/Components/RegistrationStatisticsCard.razor b/src/AliasVault.Admin/Main/Pages/Dashboard/Components/RegistrationStatisticsCard.razor
new file mode 100644
index 000000000..85be0dd3b
--- /dev/null
+++ b/src/AliasVault.Admin/Main/Pages/Dashboard/Components/RegistrationStatisticsCard.razor
@@ -0,0 +1,64 @@
+
+
+
User registrations
+
+ @if (IsLoading)
+ {
+
+ }
+ else
+ {
+
+
+
Last 24 hours
+
@RegistrationStats.Last24Hours
+
+
+
Last 7 days
+
@RegistrationStats.Last7Days
+
+
+
Last 14 days
+
@RegistrationStats.Last14Days
+
+
+ }
+
+
+@code {
+ private bool IsLoading { get; set; } = true;
+ private RegistrationStatistics RegistrationStats { get; set; } = new();
+
+ ///
+ /// Refreshes the data displayed on the card.
+ ///
+ public async Task RefreshData()
+ {
+ IsLoading = true;
+ StateHasChanged();
+
+ var now = DateTime.UtcNow;
+ var last24Hours = now.AddHours(-24);
+ var last7Days = now.AddDays(-7);
+ var last14Days = now.AddDays(-14);
+
+ // Get registration statistics
+ var registrationQuery = DbContext.AliasVaultUsers.AsQueryable();
+ RegistrationStats = new RegistrationStatistics
+ {
+ Last24Hours = await registrationQuery.CountAsync(u => u.CreatedAt >= last24Hours),
+ Last7Days = await registrationQuery.CountAsync(u => u.CreatedAt >= last7Days),
+ Last14Days = await registrationQuery.CountAsync(u => u.CreatedAt >= last14Days)
+ };
+
+ IsLoading = false;
+ StateHasChanged();
+ }
+
+ private sealed class RegistrationStatistics
+ {
+ public int Last24Hours { get; set; }
+ public int Last7Days { get; set; }
+ public int Last14Days { get; set; }
+ }
+}
diff --git a/src/AliasVault.Admin/Main/Pages/Dashboard/Index.razor b/src/AliasVault.Admin/Main/Pages/Dashboard/Index.razor
new file mode 100644
index 000000000..9b2459abe
--- /dev/null
+++ b/src/AliasVault.Admin/Main/Pages/Dashboard/Index.razor
@@ -0,0 +1,54 @@
+@page "/"
+@using AliasVault.Admin.Main.Pages.Dashboard.Components
+@inherits MainBase
+
+Home
+
+
+
+
+
+
+
+
+
+@code {
+ private ActiveUsersCard? _activeUsersCard;
+ private RegistrationStatisticsCard? _registrationStatisticsCard;
+ private EmailStatisticsCard? _emailStatisticsCard;
+
+ ///
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (firstRender)
+ {
+ await RefreshData();
+ }
+ }
+
+ ///
+ /// Refreshes the data displayed on the cards.
+ ///
+ private async Task RefreshData()
+ {
+ if (_activeUsersCard != null &&
+ _registrationStatisticsCard != null &&
+ _emailStatisticsCard != null)
+ {
+ await Task.WhenAll(
+ _activeUsersCard.RefreshData(),
+ _registrationStatisticsCard.RefreshData(),
+ _emailStatisticsCard.RefreshData()
+ );
+ }
+ }
+}
diff --git a/src/AliasVault.Admin/Main/Pages/Home.razor b/src/AliasVault.Admin/Main/Pages/Home.razor
deleted file mode 100644
index c0be7fc3f..000000000
--- a/src/AliasVault.Admin/Main/Pages/Home.razor
+++ /dev/null
@@ -1,21 +0,0 @@
-@page "/"
-@inherits MainBase
-
-Home
-
-
-
-
-@code {
- ///
- protected override void OnInitialized()
- {
- base.OnInitialized();
-
- // Redirect to users page.
- NavigationService.RedirectTo("users");
- }
-}
diff --git a/src/AliasVault.Admin/Main/Pages/MainBase.cs b/src/AliasVault.Admin/Main/Pages/MainBase.cs
index 9b3ff31f7..dd4ef5de9 100644
--- a/src/AliasVault.Admin/Main/Pages/MainBase.cs
+++ b/src/AliasVault.Admin/Main/Pages/MainBase.cs
@@ -23,7 +23,7 @@ using Microsoft.JSInterop;
/// Also, a default set of breadcrumbs is added in the parent OnInitialized method.
///
[Authorize]
-public class MainBase : OwningComponentBase
+public abstract class MainBase : OwningComponentBase
{
///
/// Gets or sets the NavigationService instance responsible for handling navigation, replaces the default NavigationManager.
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 14b79d16a..4061b2941 100644
--- a/src/AliasVault.Admin/Main/Pages/Users/View/Components/EmailClaimTable.razor
+++ b/src/AliasVault.Admin/Main/Pages/Users/View/Components/EmailClaimTable.razor
@@ -7,6 +7,7 @@
@entry.Id
@entry.CreatedAt.ToString("yyyy-MM-dd HH:mm")
@entry.Address
+ @entry.EmailCount
}
@@ -16,7 +17,7 @@
/// Gets or sets the list of email claims to display.
///
[Parameter]
- public List EmailClaimList { get; set; } = [];
+ public List EmailClaimList { get; set; } = [];
private string SortColumn { get; set; } = "CreatedAt";
private SortDirection SortDirection { get; set; } = SortDirection.Descending;
@@ -25,9 +26,10 @@
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" },
];
- private IEnumerable SortedEmailClaimList => SortList(EmailClaimList, SortColumn, SortDirection);
+ private IEnumerable SortedEmailClaimList => SortList(EmailClaimList, SortColumn, SortDirection);
private void HandleSortChanged((string column, SortDirection direction) sort)
{
@@ -36,13 +38,14 @@
StateHasChanged();
}
- private static IEnumerable SortList(List emailClaims, string sortColumn, SortDirection sortDirection)
+ private static IEnumerable SortList(List emailClaims, string sortColumn, SortDirection sortDirection)
{
return sortColumn switch
{
"Id" => SortableTable.SortListByProperty(emailClaims, e => e.Id, sortDirection),
"CreatedAt" => SortableTable.SortListByProperty(emailClaims, e => e.CreatedAt, sortDirection),
"Address" => SortableTable.SortListByProperty(emailClaims, e => e.Address, sortDirection),
+ "EmailCount" => SortableTable.SortListByProperty(emailClaims, e => e.EmailCount, 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 e5a08113f..ff85a41db 100644
--- a/src/AliasVault.Admin/Main/Pages/Users/View/Index.razor
+++ b/src/AliasVault.Admin/Main/Pages/Users/View/Index.razor
@@ -97,7 +97,7 @@ else
private int TwoFactorKeysCount { get; set; }
private List RefreshTokenList { get; set; } = [];
private List VaultList { get; set; } = [];
- private List EmailClaimList { get; set; } = [];
+ private List EmailClaimList { get; set; } = [];
///
protected override async Task OnInitializedAsync()
@@ -171,7 +171,18 @@ else
.ToListAsync();
// Load all email claims for this user.
- EmailClaimList = await DbContext.UserEmailClaims.Where(x => x.UserId == User.Id)
+ 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();
diff --git a/src/AliasVault.Admin/wwwroot/css/tailwind.css b/src/AliasVault.Admin/wwwroot/css/tailwind.css
index d6a08f5c6..af3cea8fb 100644
--- a/src/AliasVault.Admin/wwwroot/css/tailwind.css
+++ b/src/AliasVault.Admin/wwwroot/css/tailwind.css
@@ -988,6 +988,14 @@ video {
justify-content: space-between;
}
+.gap-4 {
+ gap: 1rem;
+}
+
+.gap-8 {
+ gap: 2rem;
+}
+
.space-x-1 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.25rem * var(--tw-space-x-reverse));
@@ -1258,6 +1266,11 @@ video {
background-color: rgb(251 203 116 / var(--tw-bg-opacity));
}
+.bg-primary-50 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(255 224 150 / var(--tw-bg-opacity));
+}
+
.bg-primary-500 {
--tw-bg-opacity: 1;
background-color: rgb(244 149 65 / var(--tw-bg-opacity));
@@ -1757,6 +1770,11 @@ video {
color: rgb(154 93 38 / var(--tw-text-opacity));
}
+.hover\:text-gray-700:hover {
+ --tw-text-opacity: 1;
+ color: rgb(55 65 81 / var(--tw-text-opacity));
+}
+
.hover\:underline:hover {
text-decoration-line: underline;
}
@@ -1952,6 +1970,30 @@ video {
background-color: rgb(113 63 18 / var(--tw-bg-opacity));
}
+.dark\:bg-blue-900\/30:is(.dark *) {
+ background-color: rgb(30 58 138 / 0.3);
+}
+
+.dark\:bg-gray-700\/50:is(.dark *) {
+ background-color: rgb(55 65 81 / 0.5);
+}
+
+.dark\:bg-green-900\/30:is(.dark *) {
+ background-color: rgb(20 83 45 / 0.3);
+}
+
+.dark\:bg-blue-950\/80:is(.dark *) {
+ background-color: rgb(23 37 84 / 0.8);
+}
+
+.dark\:bg-gray-800\/80:is(.dark *) {
+ background-color: rgb(31 41 55 / 0.8);
+}
+
+.dark\:bg-green-950\/80:is(.dark *) {
+ background-color: rgb(5 46 22 / 0.8);
+}
+
.dark\:bg-opacity-80:is(.dark *) {
--tw-bg-opacity: 0.8;
}
@@ -2085,6 +2127,11 @@ video {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
+.dark\:hover\:text-gray-300:hover:is(.dark *) {
+ --tw-text-opacity: 1;
+ color: rgb(209 213 219 / var(--tw-text-opacity));
+}
+
.dark\:focus\:border-blue-500:focus:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(59 130 246 / var(--tw-border-opacity));
@@ -2227,6 +2274,10 @@ video {
width: 75%;
}
+ .md\:grid-cols-2 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
.md\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@@ -2268,6 +2319,10 @@ video {
order: 2;
}
+ .lg\:mb-0 {
+ margin-bottom: 0px;
+ }
+
.lg\:mt-0 {
margin-top: 0px;
}
diff --git a/src/AliasVault.Client/Main/Pages/MainBase.cs b/src/AliasVault.Client/Main/Pages/MainBase.cs
index b99dae4de..072ba5986 100644
--- a/src/AliasVault.Client/Main/Pages/MainBase.cs
+++ b/src/AliasVault.Client/Main/Pages/MainBase.cs
@@ -19,7 +19,7 @@ using Microsoft.AspNetCore.Components.Authorization;
/// All pages that inherit from this class will receive default injected components that are used globally.
/// Also, a default set of breadcrumbs is added in the parent OnInitialized method.
///
-public class MainBase : OwningComponentBase
+public abstract class MainBase : OwningComponentBase
{
private const string ReturnUrlKey = "returnUrl";
private bool _parametersInitialSet;
diff --git a/src/Databases/AliasServerDb/AliasServerDbContext.cs b/src/Databases/AliasServerDb/AliasServerDbContext.cs
index dc2db1723..cca5d0616 100644
--- a/src/Databases/AliasServerDb/AliasServerDbContext.cs
+++ b/src/Databases/AliasServerDb/AliasServerDbContext.cs
@@ -245,10 +245,14 @@ public class AliasServerDbContext : WorkerStatusDbContext, IDataProtectionKeyCon
.Build();
// Add SQLite connection with enhanced settings
+ var connectionString = configuration.GetConnectionString("AliasServerDbContext") +
+ ";Mode=ReadWriteCreate;Cache=Shared" +
+ ";Journal Mode=WAL" +
+ ";Synchronous=Normal" +
+ ";Busy Timeout=30000";
+
optionsBuilder
- .UseSqlite(
- configuration.GetConnectionString("AliasServerDbContext") + ";Mode=ReadWriteCreate;Cache=Shared",
- options => options.CommandTimeout(60))
+ .UseSqlite(connectionString, options => options.CommandTimeout(60))
.UseLazyLoadingProxies();
// Set additional PRAGMA settings
@@ -260,11 +264,7 @@ public class AliasServerDbContext : WorkerStatusDbContext, IDataProtectionKeyCon
using (var command = connection.CreateCommand())
{
- // Increase busy timeout
command.CommandText = @"
- PRAGMA busy_timeout = 30000;
- PRAGMA journal_mode = WAL;
- PRAGMA synchronous = FULL;
PRAGMA temp_store = MEMORY;
PRAGMA mmap_size = 1073741824;";
command.ExecuteNonQuery();
diff --git a/src/Tests/AliasVault.E2ETests/Common/AdminPlaywrightTest.cs b/src/Tests/AliasVault.E2ETests/Common/AdminPlaywrightTest.cs
index 0e60787a2..411b88af9 100644
--- a/src/Tests/AliasVault.E2ETests/Common/AdminPlaywrightTest.cs
+++ b/src/Tests/AliasVault.E2ETests/Common/AdminPlaywrightTest.cs
@@ -109,6 +109,6 @@ public class AdminPlaywrightTest : PlaywrightTest
await WaitForUrlAsync("**", "Users");
var pageContent = await Page.TextContentAsync("body");
- Assert.That(pageContent, Does.Contain("This page gives an overview of all registered users and the associated vaults"), "No entry page content visible after logging in to admin app.");
+ Assert.That(pageContent, Does.Contain("Welcome to the AliasVault admin portal"), "No entry page content visible after logging in to admin app.");
}
}