From dc2f4dd040b5be39526e395bb9b7b08e501de68c Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 6 Aug 2024 20:29:48 +0200 Subject: [PATCH 1/7] Add quick create new identity popup (#142) --- .../Implementations/Base/IdentityGenerator.cs | 4 +- .../Main/Components/Forms/EditFormRow.razor | 16 +- .../Widgets/CreateNewIdentityWidget.razor | 269 ++++++++++++ .../Main/Layout/DbStatusIndicator.razor.css | 83 ---- .../Main/Layout/Footer.razor | 8 +- .../Main/Layout/Footer.razor.css | 83 ---- .../Main/Layout/TopMenu.razor | 57 ++- .../Main/Layout/TopMenu.razor.css | 83 ---- .../Main/Pages/Credentials/AddEdit.razor | 29 +- .../Main/Pages/Credentials/Home.razor | 3 - src/AliasVault.Client/Main/Pages/MainBase.cs | 6 + src/AliasVault.Client/Program.cs | 1 + .../Services/CredentialService.cs | 28 +- .../Services/KeyboardShortcutService.cs | 108 +++++ src/AliasVault.Client/_Imports.razor | 1 + .../wwwroot/css/tailwind.css | 408 +++++++++--------- .../wwwroot/index.template.html | 1 + .../wwwroot/js/clickOutsideHandler.js | 13 +- .../wwwroot/js/keyboardShortcuts.js | 42 ++ src/AliasVault.Client/wwwroot/js/utilities.js | 7 + 20 files changed, 719 insertions(+), 531 deletions(-) create mode 100644 src/AliasVault.Client/Main/Components/Widgets/CreateNewIdentityWidget.razor delete mode 100644 src/AliasVault.Client/Main/Layout/DbStatusIndicator.razor.css delete mode 100644 src/AliasVault.Client/Main/Layout/Footer.razor.css delete mode 100644 src/AliasVault.Client/Main/Layout/TopMenu.razor.css create mode 100644 src/AliasVault.Client/Services/KeyboardShortcutService.cs create mode 100644 src/AliasVault.Client/wwwroot/js/keyboardShortcuts.js diff --git a/src/AliasGenerators/Identity/Implementations/Base/IdentityGenerator.cs b/src/AliasGenerators/Identity/Implementations/Base/IdentityGenerator.cs index 031172731..9b6f56a44 100644 --- a/src/AliasGenerators/Identity/Implementations/Base/IdentityGenerator.cs +++ b/src/AliasGenerators/Identity/Implementations/Base/IdentityGenerator.cs @@ -12,8 +12,8 @@ using AliasGenerators.Identity; using AliasGenerators.Identity.Models; /// -/// Dutch identity generator which implements IIdentityGenerator and generates -/// random dutch identities. +/// Abstract identity generator which implements IIdentityGenerator and generates +/// random identities for a certain language. /// public abstract class IdentityGenerator : IIdentityGenerator { diff --git a/src/AliasVault.Client/Main/Components/Forms/EditFormRow.razor b/src/AliasVault.Client/Main/Components/Forms/EditFormRow.razor index ebfeccdc2..9f1ee164d 100644 --- a/src/AliasVault.Client/Main/Components/Forms/EditFormRow.razor +++ b/src/AliasVault.Client/Main/Components/Forms/EditFormRow.razor @@ -6,7 +6,7 @@ } else { - + } @@ -35,6 +35,12 @@ [Parameter] public string Value { get; set; } = string.Empty; + /// + /// Callback that is triggered when the value changes. + /// + [Parameter] + public EventCallback OnFocus { get; set; } + /// /// Callback that is triggered when the value changes. /// @@ -46,4 +52,12 @@ Value = e.Value?.ToString() ?? string.Empty; await ValueChanged.InvokeAsync(Value); } + + private async Task OnFocusEvent(FocusEventArgs e) + { + if (OnFocus.HasDelegate) + { + await OnFocus.InvokeAsync(e); + } + } } diff --git a/src/AliasVault.Client/Main/Components/Widgets/CreateNewIdentityWidget.razor b/src/AliasVault.Client/Main/Components/Widgets/CreateNewIdentityWidget.razor new file mode 100644 index 000000000..b74744c9d --- /dev/null +++ b/src/AliasVault.Client/Main/Components/Widgets/CreateNewIdentityWidget.razor @@ -0,0 +1,269 @@ +@using System.ComponentModel.DataAnnotations +@using AliasGenerators.Identity.Implementations +@using AliasGenerators.Identity.Models +@using AliasGenerators.Password +@using AliasGenerators.Password.Implementations +@inherits AliasVault.Client.Main.Pages.MainBase +@inject IJSRuntime JSRuntime +@inject CredentialService CredentialService +@implements IDisposable + + + +@if (IsPopupVisible) +{ + +
+

Create New Identity

+ + +
+ + +
+
+ + +
+
+ +
+ +
+
+
+} + +@code { + private const string DefaultServiceUrl = "https://"; + private bool IsPopupVisible = false; + private bool IsCreating = false; + private CreateModel Model = new(); + private string PopupStyle { get; set; } = ""; + private ElementReference buttonRef; + + /// + public void Dispose() + { + KeyboardShortcutService.UnregisterShortcutAsync("gc").ConfigureAwait(false); + } + + /// + protected override async Task OnInitializedAsync() + { + await KeyboardShortcutService.RegisterShortcutAsync("gc", ShowPopup); + } + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await JSRuntime.InvokeVoidAsync("eval", @" + window.getWindowWidth = function() { + return window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth; + }; + window.getElementRect = function(element) { + if (element) { + const rect = { + left: element.offsetLeft, + top: element.offsetTop, + right: element.offsetLeft + element.offsetWidth, + bottom: element.offsetTop + element.offsetHeight, + width: element.offsetWidth, + height: element.offsetHeight + }; + let parent = element.offsetParent; + while (parent) { + rect.left += parent.offsetLeft; + rect.top += parent.offsetTop; + parent = parent.offsetParent; + } + rect.right = rect.left + rect.width; + rect.bottom = rect.top + rect.height; + return rect; + } + return null; + }; + "); + } + } + + private void OnFocusUrlInput(FocusEventArgs e) + { + if (Model.ServiceUrl != DefaultServiceUrl) + { + return; + } + + // Use a small delay to ensure the focus is set after the browser's default behavior + Task.Delay(1).ContinueWith(_ => + { + JSRuntime.InvokeVoidAsync("eval", $"document.getElementById('serviceUrl').setSelectionRange({DefaultServiceUrl.Length}, {DefaultServiceUrl.Length})"); + }); + } + + private async Task TogglePopup() + { + // Use the ToggleStatus to determine if the popup should be shown or hidden + // instead of just toggling the visibility. Because the popup might be hidden + // by clicking outside of it, and we don't want to show it again when the button + // is clicked. + IsPopupVisible = !IsPopupVisible; + if (IsPopupVisible) + { + await ShowPopup(); + } + } + + private async Task ShowPopup() + { + IsPopupVisible = true; + + // Clear the input fields + Model = new(); + Model.ServiceUrl = DefaultServiceUrl; + + await UpdatePopupStyle(); + await Task.Delay(100); // Give time for the DOM to update + await JSRuntime.InvokeVoidAsync("focusElement", "serviceName"); + } + + private void ClosePopup() + { + IsPopupVisible = false; + } + + private async Task UpdatePopupStyle() + { + var windowWidth = await JSRuntime.InvokeAsync("getWindowWidth"); + var buttonRect = await JSRuntime.InvokeAsync("getElementRect", buttonRef); + + var popupWidth = Math.Min(400, windowWidth - 20); // 20px for some padding + var leftPosition = Math.Max(0, Math.Min(buttonRect.Left, windowWidth - popupWidth - 10)); + + PopupStyle = $"width: {popupWidth}px; left: {leftPosition}px; top: {buttonRect.Bottom}px;"; + StateHasChanged(); + } + + private async Task CreateIdentity() + { + if (IsCreating) + { + return; + } + + IsCreating = true; + GlobalLoadingSpinner.Show(); + StateHasChanged(); + + // Create new Obj + var credential = new Credential(); + credential.Alias = new Alias(); + credential.Alias.Email = "@" + CredentialService.GetDefaultEmailDomain(); + credential.Service = new Service(); + credential.Service.Name = Model.ServiceName; + + if (Model.ServiceUrl != DefaultServiceUrl) + { + credential.Service.Url = Model.ServiceUrl; + } + + credential.Passwords = new List { new() }; + await GenerateRandomIdentity(credential); + + var id = await CredentialService.InsertEntryAsync(credential); + if (id == Guid.Empty) + { + // Error saving. + IsCreating = false; + GlobalLoadingSpinner.Hide(); + GlobalNotificationService.AddErrorMessage("Error saving credentials. Please try again.", true); + return; + } + + // No error, add success message. + GlobalNotificationService.AddSuccessMessage("Credentials created successfully."); + + NavigationManager.NavigateTo("/credentials/" + id); + + IsCreating = false; + GlobalLoadingSpinner.Hide(); + StateHasChanged(); + ClosePopup(); + } + + private async Task GenerateRandomIdentity(Credential credential) + { + // Generate a random identity using the IIdentityGenerator implementation. + var identity = await IdentityGeneratorFactory.CreateIdentityGenerator(DbService.Settings.DefaultIdentityLanguage).GenerateRandomIdentityAsync(); + + // Generate random values for the Identity properties + credential.Username = identity.NickName; + credential.Alias.FirstName = identity.FirstName; + credential.Alias.LastName = identity.LastName; + credential.Alias.NickName = identity.NickName; + credential.Alias.Gender = identity.Gender == Gender.Male ? "Male" : "Female"; + credential.Alias.BirthDate = identity.BirthDate; + + // Set the email + var emailDomain = CredentialService.GetDefaultEmailDomain(); + credential.Alias.Email = $"{identity.EmailPrefix}@{emailDomain}"; + + // Generate password + GenerateRandomPassword(credential); + } + + private void GenerateRandomPassword(Credential credential) + { + // Generate a random password using a IPasswordGenerator implementation. + IPasswordGenerator passwordGenerator = new SpamOkPasswordGenerator(); + credential.Passwords.First().Value = passwordGenerator.GenerateRandomPassword(); + } + + private void OpenAdvancedMode() + { + // Implement the logic to open the advanced mode + Console.WriteLine("Opening advanced mode"); + NavigationManager.NavigateTo("/add-credentials"); + ClosePopup(); + } + + private class BoundingClientRect + { + public double Left { get; set; } + public double Top { get; set; } + public double Right { get; set; } + public double Bottom { get; set; } + public double Width { get; set; } + public double Height { get; set; } + } + + /// + /// Local model for the form with support for validation. + /// + private class CreateModel + { + /// + /// The service name. + /// + [Required] + [Display(Name = "Service Name")] + public string ServiceName { get; set; } = string.Empty; + + /// + /// The service URL. + /// + [Display(Name = "Service URL")] + public string ServiceUrl { get; set; } = string.Empty; + } +} diff --git a/src/AliasVault.Client/Main/Layout/DbStatusIndicator.razor.css b/src/AliasVault.Client/Main/Layout/DbStatusIndicator.razor.css deleted file mode 100644 index 881d128a5..000000000 --- a/src/AliasVault.Client/Main/Layout/DbStatusIndicator.razor.css +++ /dev/null @@ -1,83 +0,0 @@ -.navbar-toggler { - background-color: rgba(255, 255, 255, 0.1); -} - -.top-row { - height: 3.5rem; - background-color: rgba(0,0,0,0.4); -} - -.navbar-brand { - font-size: 1.1rem; -} - -.bi { - display: inline-block; - position: relative; - width: 1.25rem; - height: 1.25rem; - margin-right: 0.75rem; - top: -1px; - background-size: cover; -} - -.bi-house-door-fill-nav-menu { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E"); -} - -.bi-plus-square-fill-nav-menu { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E"); -} - -.bi-list-nested-nav-menu { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E"); -} - -.nav-item { - font-size: 0.9rem; - padding-bottom: 0.5rem; -} - - .nav-item:first-of-type { - padding-top: 1rem; - } - - .nav-item:last-of-type { - padding-bottom: 1rem; - } - - .nav-item ::deep a { - color: #d7d7d7; - border-radius: 4px; - height: 3rem; - display: flex; - align-items: center; - line-height: 3rem; - } - -.nav-item ::deep a.active { - background-color: rgba(255,255,255,0.37); - color: white; -} - -.nav-item ::deep a:hover { - background-color: rgba(255,255,255,0.1); - color: white; -} - -@media (min-width: 641px) { - .navbar-toggler { - display: none; - } - - .collapse { - /* Never collapse the sidebar for wide screens */ - display: block; - } - - .nav-scrollable { - /* Allow sidebar to scroll for tall menus */ - height: calc(100vh - 3.5rem); - overflow-y: auto; - } -} diff --git a/src/AliasVault.Client/Main/Layout/Footer.razor b/src/AliasVault.Client/Main/Layout/Footer.razor index 694980a58..d4cca12b9 100644 --- a/src/AliasVault.Client/Main/Layout/Footer.razor +++ b/src/AliasVault.Client/Main/Layout/Footer.razor @@ -1,4 +1,4 @@ -