Add email claim enable/disable toggle to admin (#711)

This commit is contained in:
Leendert de Borst
2025-03-20 13:02:23 +01:00
committed by Leendert de Borst
parent 8c1e5a7bf8
commit 2071a7c4fe
6 changed files with 244 additions and 131 deletions

View File

@@ -32,6 +32,11 @@ public class UserEmailClaimWithCount
/// </summary>
public string AddressDomain { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether the email claim is disabled.
/// </summary>
public bool Disabled { get; set; }
/// <summary>
/// Gets or sets the created at timestamp.
/// </summary>

View File

@@ -32,7 +32,7 @@
</div>
</div>
}
@if (ShowChart && !IsLoading)
{
<div class="mt-6">
@@ -65,6 +65,7 @@
/// </summary>
private bool ShowChart { get; set; } = false;
/// <inheritdoc />
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; }

View File

@@ -59,6 +59,13 @@ else
}
@code {
/// <summary>
/// The search term from the query parameter.
/// </summary>
[Parameter]
[SupplyParameterFromQuery(Name = "search")]
public string? SearchTermFromQuery { get; set; }
private readonly List<TableColumn> _tableColumns = [
new TableColumn { Title = "ID", PropertyName = "Id" },
new TableColumn { Title = "Time", PropertyName = "DateSystem" },
@@ -70,9 +77,7 @@ else
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; }
@@ -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();
}
}

View File

@@ -1,35 +1,125 @@
@using AliasVault.RazorComponents.Tables
<SortableTable Columns="@_emailClaimTableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
@foreach (var entry in SortedEmailClaimList)
{
<SortableTableRow>
<SortableTableColumn IsPrimary="true">@entry.Id</SortableTableColumn>
<SortableTableColumn>@entry.CreatedAt.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
<SortableTableColumn>@entry.Address</SortableTableColumn>
<SortableTableColumn>@entry.EmailCount</SortableTableColumn>
</SortableTableRow>
}
</SortableTable>
<div class="d-flex justify-content-between mb-3">
<div class="flex items-center space-x-2">
<Button Color="secondary" OnClick="ToggleShowDisabled">
@(ShowDisabled ? "Hide Disabled Claims" : "Show Disabled Claims")
</Button>
@if (EmailClaimList.Any(e => !e.Disabled))
{
<Button Color="danger" OnClick="DisableAllEmailClaims">Disable All</Button>
}
</div>
</div>
@if (IsLoading)
{
<LoadingIndicator />
}
else
{
<SortableTable Columns="@_emailClaimTableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
@foreach (var entry in SortedEmailClaimList)
{
<SortableTableRow Class="@(entry.Disabled ? "bg-secondary" : "")">
<SortableTableColumn IsPrimary="true">@entry.Id</SortableTableColumn>
<SortableTableColumn>@entry.CreatedAt.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
<SortableTableColumn><a href="/emails?search=@entry.Address">@entry.Address</a></SortableTableColumn>
<SortableTableColumn>@entry.EmailCount</SortableTableColumn>
<SortableTableColumn>@(entry.Disabled ? "Disabled" : "Enabled")</SortableTableColumn>
<SortableTableColumn>
@if (entry.Disabled)
{
<Button Color="success" OnClick="() => ToggleEmailClaimStatus(entry)">Enable</Button>
}
else
{
<Button Color="danger" OnClick="() => ToggleEmailClaimStatus(entry)">Disable</Button>
}
</SortableTableColumn>
</SortableTableRow>
}
</SortableTable>
}
@code {
/// <summary>
/// Gets or sets the list of email claims to display.
/// Gets or sets the user.
/// </summary>
[Parameter]
public List<UserEmailClaimWithCount> EmailClaimList { get; set; } = [];
public AliasVaultUser User { get; set; } = new();
/// <summary>
/// Gets or sets the callback for when an email claim is enabled or disabled.
/// </summary>
[Parameter]
public EventCallback<(Guid id, bool disabled)> OnEmailClaimStatusChanged { get; set; }
/// <summary>
/// Gets or sets the list of email claims to display.
/// </summary>
private List<UserEmailClaimWithCount> 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<TableColumn> _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<UserEmailClaimWithCount> SortedEmailClaimList => SortList(EmailClaimList, SortColumn, SortDirection);
private IEnumerable<UserEmailClaimWithCount> SortedEmailClaimList =>
SortList(ShowDisabled ? EmailClaimList : EmailClaimList.Where(e => !e.Disabled).ToList(), SortColumn, SortDirection);
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
IsLoading = true;
StateHasChanged();
await RefreshData();
IsLoading = false;
StateHasChanged();
}
/// <summary>
/// This method will refresh the email claim list.
/// </summary>
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();
}
/// <summary>
/// This method will toggle the disabled status of an email claim.
/// </summary>
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<UserEmailClaimWithCount> SortList(List<UserEmailClaimWithCount> 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
};
}

View File

@@ -105,8 +105,12 @@ else
<div class="items-center">
<div>
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">Email claims</h3>
<EmailClaimTable EmailClaimList="@EmailClaimList" />
<p class="text-sm text-gray-500 dark:text-gray-400">
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.
</p>
<EmailClaimTable User="@User" />
</div>
</div>
</div>
@@ -126,7 +130,6 @@ else
private int TwoFactorKeysCount { get; set; }
private List<AliasVaultUserRefreshToken> RefreshTokenList { get; set; } = [];
private List<Vault> VaultList { get; set; } = [];
private List<UserEmailClaimWithCount> EmailClaimList { get; set; } = [];
/// <inheritdoc />
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();
}

View File

@@ -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;