diff --git a/src/AliasVault.Client/Main/Components/Attachments/AttachmentUploader.razor b/src/AliasVault.Client/Main/Components/Attachments/AttachmentUploader.razor
index f1832e6c6..362b70d70 100644
--- a/src/AliasVault.Client/Main/Components/Attachments/AttachmentUploader.razor
+++ b/src/AliasVault.Client/Main/Components/Attachments/AttachmentUploader.razor
@@ -100,8 +100,12 @@
if (OriginalAttachmentsIds.Contains(attachment.Id))
{
// If it was part of the original set, we soft delete it.
- attachment.IsDeleted = true;
- attachment.UpdatedAt = DateTime.UtcNow;
+ var attachmentToDelete = Attachments.FirstOrDefault(a => a.Id == attachment.Id);
+ if (attachmentToDelete is not null)
+ {
+ attachmentToDelete.IsDeleted = true;
+ attachmentToDelete.UpdatedAt = DateTime.UtcNow;
+ }
}
else
{
diff --git a/src/AliasVault.Client/Main/Components/TotpCodes/TotpCodes.razor b/src/AliasVault.Client/Main/Components/TotpCodes/TotpCodes.razor
index 738c63e12..e0667732b 100644
--- a/src/AliasVault.Client/Main/Components/TotpCodes/TotpCodes.razor
+++ b/src/AliasVault.Client/Main/Components/TotpCodes/TotpCodes.razor
@@ -1,7 +1,9 @@
@inherits ComponentBase
@inject TotpCodeService TotpCodeService
@inject GlobalNotificationService GlobalNotificationService
-@inject DbService DbService
+@inject ConfirmModalService ConfirmModalService
+@using AliasVault.RazorComponents.Services
+@using TotpGenerator
@implements IDisposable
@@ -9,10 +11,10 @@
Two-factor authentication
- @if (TotpCodeList.Count > 0)
+ @if (TotpCodeList.Where(t => !t.IsDeleted).Any() && !IsAddFormVisible)
{
-
@@ -23,16 +25,45 @@
{
}
- else if (TotpCodeList.Count == 0)
+ else if ((TotpCodeList.Count == 0 || TotpCodeList.All(t => t.IsDeleted)) && !IsAddFormVisible)
{
}
else
{
+ @if (IsAddFormVisible)
+ {
+
+
+
Add 2FA TOTP Code
+
+
+ Close form
+
+
+
If the website offers or requires 2FA for your account (such as Google Authenticator), you can use AliasVault instead to generate the codes for you.
+
+
+
+
+
+
+
+
+
+
+ Save
+
+
+
+ }
+
- @foreach (var totpCode in TotpCodeList)
+ @foreach (var totpCode in TotpCodeList.Where(t => !t.IsDeleted))
{
@@ -41,13 +72,23 @@
-
@GetTotpCode(totpCode.SecretKey)
-
@GetRemainingSeconds()s
+ @if (OriginalTotpCodeIds.Contains(totpCode.Id))
+ {
+
@GetTotpCode(totpCode.SecretKey)
+
@GetRemainingSeconds()s
+ }
+ else
+ {
+
Save to view code
+ }
-
+ @if (OriginalTotpCodeIds.Contains(totpCode.Id))
+ {
+
+ }
-
DeleteTotpCode(totpCode.Id)" class="text-red-600 hover:text-red-800 dark:text-red-500 dark:hover:text-red-400">
+ DeleteTotpCode(totpCode)" class="text-red-600 hover:text-red-800 dark:text-red-500 dark:hover:text-red-400">
@@ -60,82 +101,66 @@
}
-@if (IsAddTotpCodeModalVisible)
-{
-
-
-
-
-
- Add 2FA TOTP Code
-
-
-
- Close modal
-
-
-
-
If the website offers or requires 2FA for your account (such as Google Authenticator), you can use AliasVault instead to generate the codes for you.
-
-
-
-
-
-
-
-
-
- Save
-
-
-
-
-
-}
-
@code {
- ///
- /// The credential ID.
- ///
- [Parameter]
- public Guid CredentialId { get; set; }
-
///
/// The service name.
///
[Parameter]
public string ServiceName { get; set; } = string.Empty;
- private List
TotpCodeList { get; set; } = new();
+ ///
+ /// The list of TOTP codes.
+ ///
+ [Parameter]
+ public List TotpCodeList { get; set; } = [];
+
+ ///
+ /// Event callback for when the TOTP codes list changes.
+ ///
+ [Parameter]
+ public EventCallback> TotpCodesChanged { get; set; }
+
private bool IsLoading { get; set; } = true;
- private bool IsAddTotpCodeModalVisible { get; set; } = false;
+ private bool IsAddFormVisible { get; set; } = false;
private TotpCode NewTotpCode { get; set; } = new();
private Timer? _refreshTimer;
private Dictionary _currentCodes = new();
+ private List OriginalTotpCodeIds { get; set; } = [];
+
+ ///
+ public void Dispose()
+ {
+ _refreshTimer?.Dispose();
+ }
///
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
- await LoadTotpCodesAsync();
+ LoadTotpCodes();
// Start a timer to refresh the TOTP codes every second
_refreshTimer = new Timer(async _ => await RefreshCodesAsync(), null, 0, 1000);
}
- private async Task LoadTotpCodesAsync()
+ ///
+ protected override void OnInitialized()
+ {
+ base.OnInitialized();
+
+ // Keep track of the original TOTP codes
+ OriginalTotpCodeIds = TotpCodeList.Where(t => !t.IsDeleted).Select(t => t.Id).ToList();
+ }
+
+ private void LoadTotpCodes()
{
IsLoading = true;
StateHasChanged();
- TotpCodeList = await TotpCodeService.GetTotpCodesAsync(CredentialId);
-
// Generate initial codes
- foreach (var code in TotpCodeList)
+ foreach (var code in TotpCodeList.Where(t => !t.IsDeleted))
{
- _currentCodes[code.SecretKey] = TotpCodeService.GenerateTotpCode(code.SecretKey);
+ _currentCodes[code.SecretKey] = TotpGenerator.GenerateTotpCode(code.SecretKey);
}
IsLoading = false;
@@ -144,9 +169,9 @@
private async Task RefreshCodesAsync()
{
- foreach (var code in TotpCodeList)
+ foreach (var code in TotpCodeList.Where(t => !t.IsDeleted))
{
- var newCode = TotpCodeService.GenerateTotpCode(code.SecretKey);
+ var newCode = TotpGenerator.GenerateTotpCode(code.SecretKey);
if (!_currentCodes.ContainsKey(code.SecretKey) || _currentCodes[code.SecretKey] != newCode)
{
_currentCodes[code.SecretKey] = newCode;
@@ -164,7 +189,7 @@
return code;
}
- var newCode = TotpCodeService.GenerateTotpCode(secretKey);
+ var newCode = TotpGenerator.GenerateTotpCode(secretKey);
_currentCodes[secretKey] = newCode;
return newCode;
}
@@ -181,19 +206,18 @@
return (int)(((30.0 - remaining) / 30.0) * 100);
}
- private void ShowAddTotpCodeModal()
+ private void ShowAddForm()
{
NewTotpCode = new TotpCode
{
- CredentialId = CredentialId,
Name = ServiceName
};
- IsAddTotpCodeModalVisible = true;
+ IsAddFormVisible = true;
}
- private void HideAddTotpCodeModal()
+ private void HideAddForm()
{
- IsAddTotpCodeModalVisible = false;
+ IsAddFormVisible = false;
}
private async Task AddTotpCode()
@@ -213,7 +237,7 @@
try
{
// Validate the secret key by trying to generate a code
- TotpCodeService.GenerateTotpCode(NewTotpCode.SecretKey);
+ TotpGenerator.GenerateTotpCode(NewTotpCode.SecretKey);
}
catch (Exception)
{
@@ -221,38 +245,57 @@
return;
}
- var result = await TotpCodeService.AddTotpCodeAsync(NewTotpCode);
- if (result != null)
+ // Create a new TOTP code in memory
+ var newTotpCode = new TotpCode
{
- HideAddTotpCodeModal();
- GlobalNotificationService.AddSuccessMessage("TOTP code added successfully.", true);
- await LoadTotpCodesAsync();
- await DbService.SaveDatabaseAsync();
+ Id = Guid.Empty,
+ Name = NewTotpCode.Name,
+ SecretKey = NewTotpCode.SecretKey,
+ CreatedAt = DateTime.UtcNow,
+ UpdatedAt = DateTime.UtcNow
+ };
+
+ // Add to the list
+ TotpCodeList.Add(newTotpCode);
+
+ // Notify parent component
+ await TotpCodesChanged.InvokeAsync(TotpCodeList);
+
+ HideAddForm();
+
+ // Refresh the codes
+ _currentCodes[newTotpCode.SecretKey] = TotpGenerator.GenerateTotpCode(newTotpCode.SecretKey);
+ StateHasChanged();
+ }
+
+ private async Task DeleteTotpCode(TotpCode totpCode)
+ {
+ // Show confirmation modal.
+ var result = await ConfirmModalService.ShowConfirmation("Delete TOTP code", "Are you sure you want to delete this TOTP code?");
+ if (!result)
+ {
+ return;
+ }
+
+ // Check if the TOTP code was part of the original set
+ if (OriginalTotpCodeIds.Contains(totpCode.Id))
+ {
+ // If it was part of the original set, we soft delete it
+ var totpCodeToDelete = TotpCodeList.FirstOrDefault(t => t.Id == totpCode.Id);
+ if (totpCodeToDelete is not null)
+ {
+ totpCodeToDelete.IsDeleted = true;
+ totpCodeToDelete.UpdatedAt = DateTime.UtcNow;
+ }
}
else
{
- GlobalNotificationService.AddErrorMessage("Failed to add TOTP code. Please try again.", true);
+ // If it was not part of the original set, we hard delete it
+ TotpCodeList.Remove(totpCode);
}
- }
- private async Task DeleteTotpCode(Guid totpCodeId)
- {
- var result = await TotpCodeService.DeleteTotpCodeAsync(totpCodeId);
- if (result)
- {
- GlobalNotificationService.AddSuccessMessage("TOTP code deleted successfully.", true);
- await LoadTotpCodesAsync();
- await DbService.SaveDatabaseAsync();
- }
- else
- {
- GlobalNotificationService.AddErrorMessage("Failed to delete TOTP code. Please try again.", true);
- }
- }
-
- ///
- public void Dispose()
- {
- _refreshTimer?.Dispose();
+ // Notify parent component
+ await TotpCodesChanged.InvokeAsync(TotpCodeList);
+ StateHasChanged();
}
}
diff --git a/src/AliasVault.Client/Main/Components/TotpCodes/TotpViewer.razor b/src/AliasVault.Client/Main/Components/TotpCodes/TotpViewer.razor
new file mode 100644
index 000000000..ee2315ed7
--- /dev/null
+++ b/src/AliasVault.Client/Main/Components/TotpCodes/TotpViewer.razor
@@ -0,0 +1,121 @@
+@inherits ComponentBase
+@inject TotpCodeService TotpCodeService
+@implements IDisposable
+@using TotpGenerator
+
+
+
+
+
Two-factor authentication
+
+
+
+ @if (IsLoading)
+ {
+
+ }
+ else if (TotpCodeList.Count == 0)
+ {
+
+
No two-factor authenticator codes available
+
+ }
+ else
+ {
+
+ @foreach (var totpCode in TotpCodeList)
+ {
+
+
+
+
@totpCode.Name
+
+
+
+
@GetTotpCode(totpCode.SecretKey)
+
@GetRemainingSeconds()s
+
+
+
+
+
+ }
+
+ }
+
+
+@code {
+ ///
+ /// The list of TOTP codes to display.
+ ///
+ [Parameter]
+ public required ICollection TotpCodeList { get; set; }
+
+ private bool IsLoading { get; set; } = true;
+ private Timer? _refreshTimer;
+ private Dictionary _currentCodes = new();
+
+ ///
+ public void Dispose()
+ {
+ _refreshTimer?.Dispose();
+ }
+
+ ///
+ protected override async Task OnInitializedAsync()
+ {
+ await base.OnInitializedAsync();
+
+ // Generate initial codes
+ foreach (var code in TotpCodeList)
+ {
+ _currentCodes[code.SecretKey] = TotpGenerator.GenerateTotpCode(code.SecretKey);
+ }
+
+ // Start a timer to refresh the TOTP codes every second
+ _refreshTimer = new Timer(async _ => await RefreshCodesAsync(), null, 0, 1000);
+
+ IsLoading = false;
+ }
+
+ private async Task RefreshCodesAsync()
+ {
+ foreach (var code in TotpCodeList)
+ {
+ var newCode = TotpGenerator.GenerateTotpCode(code.SecretKey);
+ if (!_currentCodes.ContainsKey(code.SecretKey) || _currentCodes[code.SecretKey] != newCode)
+ {
+ _currentCodes[code.SecretKey] = newCode;
+ }
+ }
+
+ // Always update the UI to refresh the progress bar
+ await InvokeAsync(StateHasChanged);
+ }
+
+ private string GetTotpCode(string secretKey)
+ {
+ if (_currentCodes.TryGetValue(secretKey, out var code))
+ {
+ return code;
+ }
+
+ var newCode = TotpGenerator.GenerateTotpCode(secretKey);
+ _currentCodes[secretKey] = newCode;
+ return newCode;
+ }
+
+ private int GetRemainingSeconds()
+ {
+ return TotpCodeService.GetRemainingSeconds();
+ }
+
+ private int GetRemainingPercentage()
+ {
+ var remaining = GetRemainingSeconds();
+ // Invert the percentage so it counts down instead of up
+ return (int)(((30.0 - remaining) / 30.0) * 100);
+ }
+}
diff --git a/src/AliasVault.Client/Main/Models/CredentialEdit.cs b/src/AliasVault.Client/Main/Models/CredentialEdit.cs
index fcbbe5f2e..3981003fc 100644
--- a/src/AliasVault.Client/Main/Models/CredentialEdit.cs
+++ b/src/AliasVault.Client/Main/Models/CredentialEdit.cs
@@ -80,4 +80,9 @@ public sealed class CredentialEdit
/// Gets or sets the Attachment list.
///
public List Attachments { get; set; } = [];
+
+ ///
+ /// Gets or sets the TOTP codes list.
+ ///
+ public List TotpCodes { get; set; } = [];
}
diff --git a/src/AliasVault.Client/Main/Pages/Credentials/AddEdit.razor b/src/AliasVault.Client/Main/Pages/Credentials/AddEdit.razor
index 890019d0f..256358c6a 100644
--- a/src/AliasVault.Client/Main/Pages/Credentials/AddEdit.razor
+++ b/src/AliasVault.Client/Main/Pages/Credentials/AddEdit.razor
@@ -44,6 +44,19 @@ else
+ @if (EditMode && Id.HasValue)
+ {
+
+
+
+ }
+ else
+ {
+
+
+
+ }
+
Notes
@@ -218,6 +231,7 @@ else
alias.Alias.Email = "@" + CredentialService.GetDefaultEmailDomain();
alias.Service = new Service();
alias.Passwords = new List
{ new Password() };
+ alias.TotpCodes = new List();
Obj = CredentialToCredentialEdit(alias);
Obj.ServiceUrl = CredentialService.DefaultServiceUrl;
@@ -257,6 +271,12 @@ else
StateHasChanged();
}
+ private void HandleTotpCodesChanged(List updatedTotpCodes)
+ {
+ Obj.TotpCodes = updatedTotpCodes;
+ StateHasChanged();
+ }
+
private async Task GenerateRandomAlias()
{
GlobalLoadingSpinner.Show();
@@ -340,6 +360,7 @@ else
Alias = aliasCopy.Alias,
AliasBirthDate = aliasCopy.Alias.BirthDate.ToString("yyyy-MM-dd"),
Attachments = aliasCopy.Attachments.ToList(),
+ TotpCodes = aliasCopy.TotpCodes.ToList(),
CreateDate = aliasCopy.CreatedAt,
LastUpdate = aliasCopy.UpdatedAt
};
@@ -367,6 +388,7 @@ else
},
Alias = alias.Alias,
Attachments = alias.Attachments,
+ TotpCodes = alias.TotpCodes,
};
if (string.IsNullOrWhiteSpace(alias.AliasBirthDate))
diff --git a/src/AliasVault.Client/Main/Pages/Credentials/View.razor b/src/AliasVault.Client/Main/Pages/Credentials/View.razor
index bdbc65a56..ca2ccdf0e 100644
--- a/src/AliasVault.Client/Main/Pages/Credentials/View.razor
+++ b/src/AliasVault.Client/Main/Pages/Credentials/View.razor
@@ -44,7 +44,12 @@ else
-
+
+ @if (Alias.TotpCodes.Count > 0)
+ {
+
+ }
+
@if (Alias.Notes != null && Alias.Notes.Length > 0)
{
diff --git a/src/AliasVault.Client/Services/CredentialService.cs b/src/AliasVault.Client/Services/CredentialService.cs
index 362170f51..2a382846d 100644
--- a/src/AliasVault.Client/Services/CredentialService.cs
+++ b/src/AliasVault.Client/Services/CredentialService.cs
@@ -182,6 +182,12 @@ public sealed class CredentialService(HttpClient httpClient, DbService dbService
login.Attachments.Add(attachment);
}
+ // Add TOTP codes
+ foreach (var totpCode in loginObject.TotpCodes)
+ {
+ login.TotpCodes.Add(totpCode);
+ }
+
context.Credentials.Add(login);
// Add password.
@@ -253,8 +259,7 @@ public sealed class CredentialService(HttpClient httpClient, DbService dbService
login.Service.UpdatedAt = DateTime.UtcNow;
// Remove attachments that are no longer in the list
- var attachmentsToRemove = login.Attachments.Where(existingAttachment =>
- !loginObject.Attachments.Any(a => a.Id == existingAttachment.Id)).ToList();
+ var attachmentsToRemove = login.Attachments.Where(existingAttachment => !loginObject.Attachments.Any(a => a.Id == existingAttachment.Id)).ToList();
foreach (var attachmentToRemove in attachmentsToRemove)
{
login.Attachments.Remove(attachmentToRemove);
@@ -280,6 +285,33 @@ public sealed class CredentialService(HttpClient httpClient, DbService dbService
}
}
+ // Remove TOTP codes that are no longer in the list
+ var totpCodesToRemove = login.TotpCodes.Where(existingTotp => !loginObject.TotpCodes.Any(t => t.Id == existingTotp.Id)).ToList();
+ foreach (var totpToRemove in totpCodesToRemove)
+ {
+ login.TotpCodes.Remove(totpToRemove);
+ context.Entry(totpToRemove).State = EntityState.Deleted;
+ }
+
+ // Update existing TOTP codes and add new ones
+ foreach (var totpCode in loginObject.TotpCodes)
+ {
+ if (totpCode.Id != Guid.Empty)
+ {
+ var existingTotpCode = login.TotpCodes.FirstOrDefault(t => t.Id == totpCode.Id);
+ if (existingTotpCode != null)
+ {
+ // Update existing TOTP code
+ context.Entry(existingTotpCode).CurrentValues.SetValues(totpCode);
+ }
+ }
+ else
+ {
+ // Add new TOTP code
+ login.TotpCodes.Add(totpCode);
+ }
+ }
+
// Save the database to the server.
if (!await dbService.SaveDatabaseAsync())
{
@@ -300,14 +332,23 @@ public sealed class CredentialService(HttpClient httpClient, DbService dbService
var context = await dbService.GetDbContextAsync();
var loginObject = await context.Credentials
- .Include(x => x.Passwords)
- .Include(x => x.Alias)
- .Include(x => x.Service)
- .Include(x => x.Attachments)
- .AsSplitQuery()
- .Where(x => x.Id == loginId)
- .Where(x => !x.IsDeleted)
- .FirstOrDefaultAsync();
+ .Include(x => x.Passwords)
+ .Include(x => x.Alias)
+ .Include(x => x.Service)
+ .Include(x => x.Attachments)
+ .Include(x => x.TotpCodes)
+ .AsSplitQuery()
+ .Where(x => x.Id == loginId)
+ .Where(x => !x.IsDeleted)
+ .FirstOrDefaultAsync();
+
+ if (loginObject != null)
+ {
+ // Filter out deleted items from collections after loading
+ loginObject.Passwords = loginObject.Passwords.Where(p => !p.IsDeleted).ToList();
+ loginObject.Attachments = loginObject.Attachments.Where(a => !a.IsDeleted).ToList();
+ loginObject.TotpCodes = loginObject.TotpCodes.Where(t => !t.IsDeleted).ToList();
+ }
return loginObject;
}
@@ -321,13 +362,14 @@ public sealed class CredentialService(HttpClient httpClient, DbService dbService
var context = await dbService.GetDbContextAsync();
var loginObject = await context.Credentials
- .Include(x => x.Passwords)
- .Include(x => x.Alias)
- .Include(x => x.Service)
- .Include(x => x.Attachments)
- .AsSplitQuery()
- .Where(x => !x.IsDeleted)
- .ToListAsync();
+ .Include(x => x.Passwords.Where(p => !p.IsDeleted))
+ .Include(x => x.Alias)
+ .Include(x => x.Service)
+ .Include(x => x.Attachments.Where(a => !a.IsDeleted))
+ .Include(x => x.TotpCodes.Where(t => !t.IsDeleted))
+ .AsSplitQuery()
+ .Where(x => !x.IsDeleted)
+ .ToListAsync();
return loginObject;
}
diff --git a/src/AliasVault.Client/Services/TotpCodeService.cs b/src/AliasVault.Client/Services/TotpCodeService.cs
index c7c4c901c..3a3cd703e 100644
--- a/src/AliasVault.Client/Services/TotpCodeService.cs
+++ b/src/AliasVault.Client/Services/TotpCodeService.cs
@@ -31,85 +31,6 @@ public class TotpCodeService
_logger = logger;
}
- ///
- /// Gets all TOTP codes for a credential.
- ///
- ///
The credential ID.
- ///
A list of TOTP codes.
- public async Task
> GetTotpCodesAsync(Guid credentialId)
- {
- try
- {
- var dbContext = await _dbService.GetDbContextAsync();
- return await dbContext.TotpCodes
- .Where(t => t.CredentialId == credentialId)
- .ToListAsync();
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error getting TOTP codes for credential {CredentialId}", credentialId);
- return new List();
- }
- }
-
- ///
- /// Adds a new TOTP code.
- ///
- /// The TOTP code to add.
- /// The added TOTP code.
- public async Task AddTotpCodeAsync(TotpCode totpCode)
- {
- try
- {
- var dbContext = await _dbService.GetDbContextAsync();
- dbContext.TotpCodes.Add(totpCode);
- await dbContext.SaveChangesAsync();
- return totpCode;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error adding TOTP code for credential {CredentialId}", totpCode.CredentialId);
- return null;
- }
- }
-
- ///
- /// Deletes a TOTP code.
- ///
- /// The TOTP code ID.
- /// True if the TOTP code was deleted, false otherwise.
- public async Task DeleteTotpCodeAsync(Guid totpCodeId)
- {
- try
- {
- var dbContext = await _dbService.GetDbContextAsync();
- var totpCode = await dbContext.TotpCodes.FindAsync(totpCodeId);
- if (totpCode == null)
- {
- return false;
- }
-
- dbContext.TotpCodes.Remove(totpCode);
- await dbContext.SaveChangesAsync();
- return true;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error deleting TOTP code {TotpCodeId}", totpCodeId);
- return false;
- }
- }
-
- ///
- /// Generates a TOTP code for a given secret key.
- ///
- /// The secret key.
- /// The generated TOTP code.
- public string GenerateTotpCode(string secretKey)
- {
- return TotpGenerator.GenerateTotpCode(secretKey);
- }
-
///
/// Gets the remaining seconds until the TOTP code expires.
///