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

+ +
+

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.

+
+ + +
+
+ + +
+
+ +
+
+ } +
- @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)) + { +
+ }
-
-@if (IsAddTotpCodeModalVisible) -{ -
-
-
-
-

- Add 2FA TOTP Code -

- -
-
-

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.

-
- - -
-
- - -
- -
-
-
-
-} - @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. ///