From a53575b4bf3d0877477f3542dfa65c8cc78e4aa1 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 10 Mar 2025 21:49:58 +0100 Subject: [PATCH] Add click to copy and form validation (#181) --- .../Main/Components/TotpCodes/TotpCodes.razor | 123 +++++++----- .../Components/TotpCodes/TotpViewer.razor | 35 +++- .../Main/Models/TotpCodeEdit.cs | 66 +++++++ .../Main/Pages/Credentials/AddEdit.razor | 4 +- .../wwwroot/css/tailwind.css | 177 ++++++++---------- 5 files changed, 253 insertions(+), 152 deletions(-) create mode 100644 src/AliasVault.Client/Main/Models/TotpCodeEdit.cs diff --git a/src/AliasVault.Client/Main/Components/TotpCodes/TotpCodes.razor b/src/AliasVault.Client/Main/Components/TotpCodes/TotpCodes.razor index e0667732b..36dd69695 100644 --- a/src/AliasVault.Client/Main/Components/TotpCodes/TotpCodes.razor +++ b/src/AliasVault.Client/Main/Components/TotpCodes/TotpCodes.razor @@ -36,29 +36,34 @@ @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.

-
- - -
-
- - -
-
- -
+ + +
+

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.

+
+ + + +
+
+ + + +
+
+ +
+
} @@ -102,12 +107,6 @@ @code { - /// - /// The service name. - /// - [Parameter] - public string ServiceName { get; set; } = string.Empty; - /// /// The list of TOTP codes. /// @@ -122,7 +121,7 @@ private bool IsLoading { get; set; } = true; private bool IsAddFormVisible { get; set; } = false; - private TotpCode NewTotpCode { get; set; } = new(); + private TotpCodeEdit NewTotpCode { get; set; } = new(); private Timer? _refreshTimer; private Dictionary _currentCodes = new(); private List OriginalTotpCodeIds { get; set; } = []; @@ -208,10 +207,7 @@ private void ShowAddForm() { - NewTotpCode = new TotpCode - { - Name = ServiceName - }; + NewTotpCode = new TotpCodeEdit(); IsAddFormVisible = true; } @@ -222,22 +218,53 @@ private async Task AddTotpCode() { - if (string.IsNullOrWhiteSpace(NewTotpCode.Name)) - { - GlobalNotificationService.AddErrorMessage("Name is required.", true); - return; - } + string secretKey = NewTotpCode.SecretKey; - if (string.IsNullOrWhiteSpace(NewTotpCode.SecretKey)) + // Sanitize the secret key (remove whitespace and hyphens) + secretKey = secretKey.Replace(" ", string.Empty).Replace("-", string.Empty); + + string? name = NewTotpCode.Name; + + // Check if the input is a TOTP URI + if (secretKey.StartsWith("otpauth://totp/")) { - GlobalNotificationService.AddErrorMessage("Secret key is required.", true); - return; + try + { + var uri = new Uri(secretKey); + var queryParams = System.Web.HttpUtility.ParseQueryString(uri.Query); + + // Extract the secret from query parameters + secretKey = queryParams["secret"] ?? throw new Exception("Secret not found in URI"); + + // If no name was provided, try to get it from the URI + if (string.IsNullOrWhiteSpace(name)) + { + // The label is everything after 'totp/' and before '?' + var label = uri.AbsolutePath.TrimStart('/'); + // If the label contains ':', take the part after it + name = label.Contains(':') ? label.Split(':')[1] : label; + + // If there's an issuer in the query params, use it as a prefix + var issuer = queryParams["issuer"]; + if (!string.IsNullOrWhiteSpace(issuer)) + { + name = $"{issuer}: {name}"; + } + NewTotpCode.Name = name; + } + NewTotpCode.SecretKey = secretKey; + } + catch (Exception) + { + GlobalNotificationService.AddErrorMessage("Invalid TOTP URI format. Please check and try again.", true); + return; + } } try { // Validate the secret key by trying to generate a code - TotpGenerator.GenerateTotpCode(NewTotpCode.SecretKey); + TotpGenerator.GenerateTotpCode(secretKey); } catch (Exception) { @@ -246,14 +273,8 @@ } // Create a new TOTP code in memory - var newTotpCode = new TotpCode - { - Id = Guid.Empty, - Name = NewTotpCode.Name, - SecretKey = NewTotpCode.SecretKey, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }; + var newTotpCode = NewTotpCode.ToEntity(); + newTotpCode.Name = name ?? "Authenticator"; // Add to the list TotpCodeList.Add(newTotpCode); diff --git a/src/AliasVault.Client/Main/Components/TotpCodes/TotpViewer.razor b/src/AliasVault.Client/Main/Components/TotpCodes/TotpViewer.razor index ee2315ed7..e5fbec2dc 100644 --- a/src/AliasVault.Client/Main/Components/TotpCodes/TotpViewer.razor +++ b/src/AliasVault.Client/Main/Components/TotpCodes/TotpViewer.razor @@ -1,5 +1,7 @@ @inherits ComponentBase @inject TotpCodeService TotpCodeService +@inject ClipboardCopyService ClipboardCopyService +@inject JsInteropService JsInteropService @implements IDisposable @using TotpGenerator @@ -32,8 +34,19 @@
-
@GetTotpCode(totpCode.SecretKey)
-
@GetRemainingSeconds()s
+
+ @GetTotpCode(totpCode.SecretKey) +
+
+ @if (IsCopied(totpCode.Id.ToString())) + { + Copied! + } + else + { + @GetRemainingSeconds()s + } +
@@ -118,4 +131,22 @@ // Invert the percentage so it counts down instead of up return (int)(((30.0 - remaining) / 30.0) * 100); } + + private async Task CopyToClipboard(TotpCode totpCode) + { + var code = GetTotpCode(totpCode.SecretKey); + await JsInteropService.CopyToClipboard(code); + ClipboardCopyService.SetCopied(totpCode.Id.ToString()); + StateHasChanged(); + + // After 2 seconds, reset the copied state + await Task.Delay(2000); + if (ClipboardCopyService.GetCopiedId() == totpCode.Id.ToString()) + { + ClipboardCopyService.SetCopied(string.Empty); + } + StateHasChanged(); + } + + private bool IsCopied(string code) => ClipboardCopyService.GetCopiedId() == code; } diff --git a/src/AliasVault.Client/Main/Models/TotpCodeEdit.cs b/src/AliasVault.Client/Main/Models/TotpCodeEdit.cs new file mode 100644 index 000000000..2ad36323c --- /dev/null +++ b/src/AliasVault.Client/Main/Models/TotpCodeEdit.cs @@ -0,0 +1,66 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Client.Main.Models; + +using System; +using System.ComponentModel.DataAnnotations; +using AliasClientDb; + +/// +/// Credential edit model. +/// +public sealed class TotpCodeEdit +{ + /// + /// Gets or sets the Id of the TOTP code. + /// + public Guid Id { get; set; } + + /// + /// Gets or sets the name of the TOTP code. + /// + public string? Name { get; set; } + + /// + /// Gets or sets the secret key of the TOTP code. + /// + [Required(ErrorMessage = "Secret key is required")] + public string SecretKey { get; set; } = string.Empty; + + /// + /// Gets or sets the created at date of the TOTP code. + /// + public DateTime CreatedAt { get; set; } + + /// + /// Gets or sets the updated at date of the TOTP code. + /// + public DateTime UpdatedAt { get; set; } + + /// + /// Gets or sets a value indicating whether the TOTP code is deleted. + /// + public bool IsDeleted { get; set; } + + /// + /// Converts the edit model to a TotpCode entity. + /// + /// The TotpCode entity. + public TotpCode ToEntity() + { + return new TotpCode + { + Id = Id, + Name = Name ?? string.Empty, + SecretKey = SecretKey, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + IsDeleted = IsDeleted, + }; + } +} diff --git a/src/AliasVault.Client/Main/Pages/Credentials/AddEdit.razor b/src/AliasVault.Client/Main/Pages/Credentials/AddEdit.razor index 256358c6a..1a61ea1dd 100644 --- a/src/AliasVault.Client/Main/Pages/Credentials/AddEdit.razor +++ b/src/AliasVault.Client/Main/Pages/Credentials/AddEdit.razor @@ -47,13 +47,13 @@ else @if (EditMode && Id.HasValue) {
- +
} else {
- +
} diff --git a/src/AliasVault.Client/wwwroot/css/tailwind.css b/src/AliasVault.Client/wwwroot/css/tailwind.css index d4548bd3a..667b35006 100644 --- a/src/AliasVault.Client/wwwroot/css/tailwind.css +++ b/src/AliasVault.Client/wwwroot/css/tailwind.css @@ -674,10 +674,6 @@ video { top: 40px; } -.top-0 { - top: 0px; -} - .-z-10 { z-index: -10; } @@ -809,6 +805,10 @@ video { margin-inline-start: 0.5rem; } +.ms-auto { + margin-inline-start: auto; +} + .mt-1 { margin-top: 0.25rem; } @@ -837,10 +837,6 @@ video { margin-top: 2rem; } -.ms-auto { - margin-inline-start: auto; -} - .line-clamp-2 { overflow: hidden; display: -webkit-box; @@ -944,10 +940,6 @@ video { max-height: 90vh; } -.max-h-full { - max-height: 100%; -} - .min-h-\[250px\] { min-height: 250px; } @@ -956,6 +948,14 @@ video { min-height: 100vh; } +.w-1 { + width: 0.25rem; +} + +.w-1\.5 { + width: 0.375rem; +} + .w-1\/2 { width: 50%; } @@ -1016,14 +1016,6 @@ video { width: 100%; } -.w-1 { - width: 0.25rem; -} - -.w-1\.5 { - width: 0.375rem; -} - .min-w-0 { min-width: 0px; } @@ -1072,11 +1064,6 @@ video { transform-origin: top right; } -.rotate-180 { - --tw-rotate: 180deg; - transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); -} - .transform { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } @@ -1346,11 +1333,6 @@ video { border-bottom-right-radius: 0.5rem; } -.rounded-t { - border-top-left-radius: 0.25rem; - border-top-right-radius: 0.25rem; -} - .border { border-width: 1px; } @@ -1458,6 +1440,16 @@ video { background-color: rgb(59 130 246 / var(--tw-bg-opacity)); } +.bg-blue-600 { + --tw-bg-opacity: 1; + background-color: rgb(37 99 235 / var(--tw-bg-opacity)); +} + +.bg-blue-700 { + --tw-bg-opacity: 1; + background-color: rgb(29 78 216 / var(--tw-bg-opacity)); +} + .bg-gray-100 { --tw-bg-opacity: 1; background-color: rgb(243 244 246 / var(--tw-bg-opacity)); @@ -1573,6 +1565,10 @@ video { background-color: rgb(185 28 28 / var(--tw-bg-opacity)); } +.bg-transparent { + background-color: transparent; +} + .bg-white { --tw-bg-opacity: 1; background-color: rgb(255 255 255 / var(--tw-bg-opacity)); @@ -1583,25 +1579,6 @@ video { background-color: rgb(254 252 232 / var(--tw-bg-opacity)); } -.bg-black { - --tw-bg-opacity: 1; - background-color: rgb(0 0 0 / var(--tw-bg-opacity)); -} - -.bg-blue-600 { - --tw-bg-opacity: 1; - background-color: rgb(37 99 235 / var(--tw-bg-opacity)); -} - -.bg-blue-700 { - --tw-bg-opacity: 1; - background-color: rgb(29 78 216 / var(--tw-bg-opacity)); -} - -.bg-transparent { - background-color: transparent; -} - .bg-opacity-50 { --tw-bg-opacity: 0.5; } @@ -2078,6 +2055,12 @@ video { transition-duration: 150ms; } +.transition-all { + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + .transition-colors { transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); @@ -2090,12 +2073,6 @@ video { transition-duration: 150ms; } -.transition-all { - transition-property: all; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - transition-duration: 150ms; -} - .duration-150 { transition-duration: 150ms; } @@ -2128,6 +2105,11 @@ video { background-color: rgb(29 78 216 / var(--tw-bg-opacity)); } +.hover\:bg-blue-800:hover { + --tw-bg-opacity: 1; + background-color: rgb(30 64 175 / var(--tw-bg-opacity)); +} + .hover\:bg-gray-100:hover { --tw-bg-opacity: 1; background-color: rgb(243 244 246 / var(--tw-bg-opacity)); @@ -2208,11 +2190,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\:from-primary-600:hover { --tw-gradient-from: #d68338 var(--tw-gradient-from-position); --tw-gradient-to: rgb(214 131 56 / 0) var(--tw-gradient-to-position); @@ -2268,16 +2245,16 @@ video { color: rgb(185 28 28 / var(--tw-text-opacity)); } -.hover\:text-white:hover { - --tw-text-opacity: 1; - color: rgb(255 255 255 / var(--tw-text-opacity)); -} - .hover\:text-red-800:hover { --tw-text-opacity: 1; color: rgb(153 27 27 / var(--tw-text-opacity)); } +.hover\:text-white:hover { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + .hover\:underline:hover { text-decoration-line: underline; } @@ -2408,6 +2385,11 @@ video { border-color: rgb(156 163 175 / var(--tw-border-opacity)); } +.dark\:border-gray-500:is(.dark *) { + --tw-border-opacity: 1; + border-color: rgb(107 114 128 / var(--tw-border-opacity)); +} + .dark\:border-gray-600:is(.dark *) { --tw-border-opacity: 1; border-color: rgb(75 85 99 / var(--tw-border-opacity)); @@ -2448,9 +2430,9 @@ video { border-color: rgb(234 179 8 / var(--tw-border-opacity)); } -.dark\:border-gray-500:is(.dark *) { - --tw-border-opacity: 1; - border-color: rgb(107 114 128 / var(--tw-border-opacity)); +.dark\:bg-blue-600:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(37 99 235 / var(--tw-bg-opacity)); } .dark\:bg-blue-800:is(.dark *) { @@ -2551,11 +2533,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; } @@ -2635,6 +2612,11 @@ video { color: rgb(248 113 113 / var(--tw-text-opacity)); } +.dark\:text-red-500:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(239 68 68 / var(--tw-text-opacity)); +} + .dark\:text-white:is(.dark *) { --tw-text-opacity: 1; color: rgb(255 255 255 / var(--tw-text-opacity)); @@ -2650,11 +2632,6 @@ video { color: rgb(250 204 21 / var(--tw-text-opacity)); } -.dark\:text-red-500:is(.dark *) { - --tw-text-opacity: 1; - color: rgb(239 68 68 / 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)); @@ -2674,6 +2651,16 @@ video { background-color: rgb(59 130 246 / var(--tw-bg-opacity)); } +.dark\:hover\:bg-blue-600:hover:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(37 99 235 / 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\:bg-gray-500:hover:is(.dark *) { --tw-bg-opacity: 1; background-color: rgb(107 114 128 / var(--tw-bg-opacity)); @@ -2714,16 +2701,6 @@ video { background-color: rgb(185 28 28 / var(--tw-bg-opacity)); } -.dark\:hover\:bg-blue-600:hover:is(.dark *) { - --tw-bg-opacity: 1; - background-color: rgb(37 99 235 / 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\:from-primary-500:hover:is(.dark *) { --tw-gradient-from: #f49541 var(--tw-gradient-from-position); --tw-gradient-to: rgb(244 149 65 / 0) var(--tw-gradient-to-position); @@ -2739,6 +2716,11 @@ video { color: rgb(191 219 254 / var(--tw-text-opacity)); } +.dark\:hover\:text-blue-400:hover:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(96 165 250 / var(--tw-text-opacity)); +} + .dark\:hover\:text-gray-200:hover:is(.dark *) { --tw-text-opacity: 1; color: rgb(229 231 235 / var(--tw-text-opacity)); @@ -2749,21 +2731,26 @@ video { color: rgb(248 185 99 / var(--tw-text-opacity)); } +.dark\:hover\:text-primary-400:hover:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(246 167 82 / var(--tw-text-opacity)); +} + .dark\:hover\:text-primary-500:hover:is(.dark *) { --tw-text-opacity: 1; color: rgb(244 149 65 / var(--tw-text-opacity)); } -.dark\:hover\:text-white:hover:is(.dark *) { - --tw-text-opacity: 1; - color: rgb(255 255 255 / var(--tw-text-opacity)); -} - .dark\:hover\:text-red-400:hover:is(.dark *) { --tw-text-opacity: 1; color: rgb(248 113 113 / var(--tw-text-opacity)); } +.dark\:hover\:text-white:hover:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(255 255 255 / 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)); @@ -2979,10 +2966,6 @@ video { margin-right: calc(0.5rem * var(--tw-space-x-reverse)); margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); } - - .md\:p-5 { - padding: 1.25rem; - } } @media (min-width: 1024px) {