Load specific JS via isolated modules, refactor CredentialService (#142)

This commit is contained in:
Leendert de Borst
2024-08-07 20:39:39 +02:00
parent 2becb3aa8f
commit 0867573f2f
17 changed files with 212 additions and 215 deletions

View File

@@ -55,7 +55,7 @@
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (Module != null)
if (Module is not null)
{
await Module.InvokeVoidAsync("unregisterClickOutsideHandler");
await Module.DisposeAsync();
@@ -69,9 +69,9 @@
/// </summary>
private async Task LoadModuleAsync()
{
if (Module == null)
if (Module is null)
{
Module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/clickOutsideHandler.js");
Module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/modules/clickOutsideHandler.js");
ObjRef = DotNetObjectReference.Create(this);
}
}

View File

@@ -1,10 +1,8 @@
@using System.ComponentModel.DataAnnotations
@using AliasGenerators.Identity.Implementations
@using AliasGenerators.Identity.Models
@inherits AliasVault.Client.Main.Pages.MainBase
@inject IJSRuntime JSRuntime
@inject CredentialService CredentialService
@implements IDisposable
@implements IAsyncDisposable
<button @ref="buttonRef" @onclick="TogglePopup" id="quickIdentityButton" class="px-4 ms-5 py-2 text-sm font-medium text-white bg-gradient-to-r from-primary-500 to-primary-600 hover:from-primary-600 hover:to-primary-700 focus:outline-none dark:from-primary-400 dark:to-primary-500 dark:hover:from-primary-500 dark:hover:to-primary-600 rounded-md shadow-sm transition duration-150 ease-in-out transform hover:scale-105 active:scale-95 focus:shadow-outline">
+ New identity
@@ -48,17 +46,16 @@
private CreateModel Model = new();
private string PopupStyle { get; set; } = "";
private ElementReference buttonRef;
private IJSObjectReference? Module;
/// <inheritdoc />
public void Dispose()
async ValueTask IAsyncDisposable.DisposeAsync()
{
KeyboardShortcutService.UnregisterShortcutAsync("gc").ConfigureAwait(false);
}
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await KeyboardShortcutService.RegisterShortcutAsync("gc", ShowPopup);
await KeyboardShortcutService.UnregisterShortcutAsync("gc");
if (Module is not null)
{
await Module.DisposeAsync();
}
}
/// <inheritdoc />
@@ -66,36 +63,14 @@
{
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;
};
");
await KeyboardShortcutService.RegisterShortcutAsync("gc", ShowPopup);
Module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/modules/newIdentityWidget.js");
}
}
/// <summary>
/// When the URL input is focused, place cursor at the end of the default URL to allow for easy typing.
/// </summary>
private void OnFocusUrlInput(FocusEventArgs e)
{
if (Model.ServiceUrl != DefaultServiceUrl)
@@ -172,7 +147,7 @@
}
credential.Passwords = new List<Password> { new() };
await GenerateRandomIdentity(credential);
await CredentialService.GenerateRandomIdentity(credential);
var id = await CredentialService.InsertEntryAsync(credential);
if (id == Guid.Empty)
@@ -195,30 +170,9 @@
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
credential.Passwords.First().Value = CredentialService.GenerateRandomPassword();
}
private void OpenAdvancedMode()
{
NavigationManager.NavigateTo("/add-credentials");
NavigationManager.NavigateTo("/credentials/create");
ClosePopup();
}

View File

@@ -1,10 +1,8 @@
@page "/add-credentials"
@page "/credentials/create"
@page "/credentials/{id:guid}/edit"
@inherits MainBase
@inject CredentialService CredentialService
@using System.Globalization
@using AliasGenerators.Identity.Implementations
@using AliasGenerators.Identity.Models
@if (EditMode)
{
@@ -133,10 +131,6 @@ else
</div>
</div>
</div>
<div class="grid px-4 pt-6 lg:gap-4 dark:bg-gray-900">
</div>
</EditForm>
}
@@ -223,9 +217,7 @@ else
Obj = CredentialToCredentialEdit(alias);
}
// Hide loading spinner
Loading = false;
// Force re-render invoke so the charts can be rendered
StateHasChanged();
}
}
@@ -241,23 +233,7 @@ else
GlobalLoadingSpinner.Show();
StateHasChanged();
// Generate a random identity using the IIdentityGenerator implementation.
var identity = await IdentityGeneratorFactory.CreateIdentityGenerator(DbService.Settings.DefaultIdentityLanguage).GenerateRandomIdentityAsync();
// Generate random values for the Identity properties
Obj.Username = identity.NickName;
Obj.Alias.FirstName = identity.FirstName;
Obj.Alias.LastName = identity.LastName;
Obj.Alias.NickName = identity.NickName;
Obj.Alias.Gender = identity.Gender == Gender.Male ? "Male" : "Female";
Obj.AliasBirthDate = identity.BirthDate.ToString("yyyy-MM-dd");
// Set the email
var emailDomain = CredentialService.GetDefaultEmailDomain();
Obj.Alias.Email = $"{identity.EmailPrefix}@{emailDomain}";
// Generate password
GenerateRandomPassword();
Obj = CredentialToCredentialEdit(await CredentialService.GenerateRandomIdentity(CredentialEditToCredential(Obj)));
GlobalLoadingSpinner.Hide();
StateHasChanged();
@@ -273,8 +249,6 @@ else
GlobalLoadingSpinner.Show();
StateHasChanged();
// Sanity check for unittest. Delete later if not needed.
// Try to parse birthdate as datetime. if it fails, set it to empty.
if (EditMode)
{
if (Id is not null)
@@ -310,6 +284,9 @@ else
NavigationManager.NavigateTo("/credentials/" + Id);
}
/// <summary>
/// Helper method to convert a Credential object to a CredentialEdit object.
/// </summary>
private CredentialEdit CredentialToCredentialEdit(Credential alias)
{
return new CredentialEdit
@@ -334,6 +311,9 @@ else
};
}
/// <summary>
/// Helper method to convert a CredentialEdit object to a Credential object.
/// </summary>
private Credential CredentialEditToCredential(CredentialEdit alias)
{
var credential = new Credential()

View File

@@ -14,6 +14,8 @@ using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
using AliasClientDb;
using AliasGenerators.Identity.Implementations;
using AliasGenerators.Identity.Models;
using AliasGenerators.Password;
using AliasGenerators.Password.Implementations;
using AliasVault.Shared.Models;
@@ -25,6 +27,34 @@ using Identity = AliasGenerators.Identity.Models.Identity;
/// </summary>
public class CredentialService(HttpClient httpClient, DbService dbService, Config config)
{
/// <summary>
/// Generates a random identity for a credential.
/// </summary>
/// <param name="credential">The credential object to update.</param>
/// <returns>Task.</returns>
public async Task<Credential> 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 = GetDefaultEmailDomain();
credential.Alias.Email = $"{identity.EmailPrefix}@{emailDomain}";
// Generate password
credential.Passwords.First().Value = GenerateRandomPassword();
return credential;
}
/// <summary>
/// Gets the default email domain based on settings and available domains.
/// </summary>

View File

@@ -20,6 +20,7 @@ public class KeyboardShortcutService : IAsyncDisposable
private readonly IJSRuntime _jsRuntime;
private readonly DotNetObjectReference<CallbackWrapper> _dotNetHelper;
private readonly NavigationManager _navigationManager;
private readonly Lazy<Task<IJSObjectReference>> moduleTask;
/// <summary>
/// Initializes a new instance of the <see cref="KeyboardShortcutService"/> class.
@@ -32,6 +33,9 @@ public class KeyboardShortcutService : IAsyncDisposable
_dotNetHelper = DotNetObjectReference.Create(new CallbackWrapper());
_navigationManager = navigationManager;
moduleTask = new Lazy<Task<IJSObjectReference>>(() => jsRuntime.InvokeAsync<IJSObjectReference>(
"import", "./js/modules/keyboardShortcuts.js").AsTask());
_ = RegisterStaticShortcuts();
}
@@ -44,7 +48,8 @@ public class KeyboardShortcutService : IAsyncDisposable
public async Task RegisterShortcutAsync(string keys, Func<Task> callback)
{
_dotNetHelper.Value.RegisterCallback(keys, callback);
await _jsRuntime.InvokeVoidAsync("keyboardShortcuts.registerShortcut", keys, _dotNetHelper);
var module = await moduleTask.Value;
await module.InvokeVoidAsync("registerShortcut", keys, _dotNetHelper);
}
/// <summary>
@@ -55,7 +60,8 @@ public class KeyboardShortcutService : IAsyncDisposable
public async Task UnregisterShortcutAsync(string keys)
{
_dotNetHelper.Value.UnregisterCallback(keys);
await _jsRuntime.InvokeVoidAsync("keyboardShortcuts.unregisterShortcut", keys);
var module = await moduleTask.Value;
await module!.InvokeVoidAsync("unregisterShortcut", keys);
}
/// <summary>
@@ -64,7 +70,10 @@ public class KeyboardShortcutService : IAsyncDisposable
/// <returns>ValueTask.</returns>
public async ValueTask DisposeAsync()
{
await _jsRuntime.InvokeVoidAsync("keyboardShortcuts.unregisterAllShortcuts");
var module = await moduleTask.Value;
await module.InvokeVoidAsync("unregisterAllShortcuts");
await module.DisposeAsync();
_dotNetHelper.Dispose();
GC.SuppressFinalize(this);
}

View File

@@ -640,6 +640,11 @@ video {
grid-column: 1 / -1;
}
.mx-3 {
margin-left: 0.75rem;
margin-right: 0.75rem;
}
.mx-4 {
margin-left: 1rem;
margin-right: 1rem;
@@ -719,10 +724,6 @@ video {
margin-right: 1rem;
}
.ms-5 {
margin-inline-start: 1.25rem;
}
.mt-0 {
margin-top: 0px;
}
@@ -747,18 +748,18 @@ video {
margin-top: 2rem;
}
.ms-3 {
margin-inline-start: 0.75rem;
}
.ms-1 {
margin-inline-start: 0.25rem;
}
.ms-2 {
margin-inline-start: 0.5rem;
}
.ms-5 {
margin-inline-start: 1.25rem;
}
.ms-3 {
margin-inline-start: 0.75rem;
}
.block {
display: block;
}

View File

@@ -47,10 +47,8 @@
<a class="dismiss">🗙</a>
</div>
<script src="js/darkMode.js?v=@CacheBuster"></script>
<script src="js/cryptoInterop.js?v=@CacheBuster"></script>
<script src="js/crypto.js?v=@CacheBuster"></script>
<script src="js/utilities.js?v=@CacheBuster"></script>
<script src="js/keyboardShortcuts.js?v=@CacheBuster"></script>
<script src="_framework/blazor.webassembly.js?v=@CacheBuster"></script>
</body>

View File

@@ -1,53 +0,0 @@
function initDarkModeSwitcher() {
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
if (themeToggleDarkIcon === null && themeToggleLightIcon === null) {
return;
}
if (localStorage.getItem('color-theme')) {
if (localStorage.getItem('color-theme') === 'light') {
document.documentElement.classList.remove('dark');
themeToggleLightIcon.classList.remove('hidden');
} else {
document.documentElement.classList.add('dark');
themeToggleDarkIcon.classList.remove('hidden');
}
}
else {
// Default to light mode if not set.
document.documentElement.classList.remove('dark');
themeToggleLightIcon.classList.remove('hidden');
}
const themeToggleBtn = document.getElementById('theme-toggle');
let event = new Event('dark-mode');
themeToggleBtn.addEventListener('click', function () {
// toggle icons
themeToggleDarkIcon.classList.toggle('hidden');
themeToggleLightIcon.classList.toggle('hidden');
// if set via local storage previously
if (localStorage.getItem('color-theme')) {
if (localStorage.getItem('color-theme') === 'light') {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
}
// if NOT set via local storage previously
} else if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
}
document.dispatchEvent(event);
});
}

View File

@@ -1,42 +0,0 @@
window.keyboardShortcuts = {
shortcuts: {},
lastKeyPressed: '',
lastKeyPressTime: 0,
init: function() {
document.addEventListener('keydown', this.handleKeyPress.bind(this));
},
handleKeyPress: function(event) {
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
return;
}
const currentTime = new Date().getTime();
const key = event.key.toLowerCase();
if (currentTime - this.lastKeyPressTime > 500) {
this.lastKeyPressed = '';
}
this.lastKeyPressed += key;
this.lastKeyPressTime = currentTime;
const shortcut = this.shortcuts[this.lastKeyPressed];
if (shortcut) {
event.preventDefault();
shortcut.dotNetHelper.invokeMethodAsync('Invoke', this.lastKeyPressed);
this.lastKeyPressed = '';
}
},
registerShortcut: function(keys, dotNetHelper) {
this.shortcuts[keys.toLowerCase()] = { dotNetHelper: dotNetHelper };
},
unregisterShortcut: function(keys) {
delete this.shortcuts[keys.toLowerCase()];
}
};
window.keyboardShortcuts.init();

View File

@@ -0,0 +1,40 @@
let shortcuts = {};
let lastKeyPressed = '';
let lastKeyPressTime = 0;
document.addEventListener('keydown', handleKeyPress);
export function handleKeyPress(event) {
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
return;
}
const currentTime = new Date().getTime();
const key = event.key.toLowerCase();
if (currentTime - this.lastKeyPressTime > 500) {
lastKeyPressed = '';
}
lastKeyPressed += key;
lastKeyPressTime = currentTime;
const shortcut = shortcuts[lastKeyPressed];
if (shortcut) {
event.preventDefault();
shortcut.dotNetHelper.invokeMethodAsync('Invoke', lastKeyPressed);
lastKeyPressed = '';
}
}
export function registerShortcut(keys, dotNetHelper) {
shortcuts[keys.toLowerCase()] = { dotNetHelper: dotNetHelper };
}
export function unregisterShortcut(keys) {
delete shortcuts[keys.toLowerCase()];
}
export function unregisterAllShortcuts() {
shortcuts = {};
}

View File

@@ -0,0 +1,25 @@
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;
};

View File

@@ -10,10 +10,6 @@ function downloadFileFromStream(fileName, contentStreamReference) {
URL.revokeObjectURL(url);
}
window.initTopMenu = function() {
initDarkModeSwitcher();
};
window.topMenuClickOutsideHandler = (dotNetHelper) => {
document.addEventListener('click', (event) => {
const menu = document.getElementById('userMenuDropdown');
@@ -50,3 +46,62 @@ window.focusElement = (elementId) => {
element.focus();
}
};
function initDarkModeSwitcher() {
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
if (themeToggleDarkIcon === null && themeToggleLightIcon === null) {
return;
}
if (localStorage.getItem('color-theme')) {
if (localStorage.getItem('color-theme') === 'light') {
document.documentElement.classList.remove('dark');
themeToggleLightIcon.classList.remove('hidden');
} else {
document.documentElement.classList.add('dark');
themeToggleDarkIcon.classList.remove('hidden');
}
}
else {
// Default to light mode if not set.
document.documentElement.classList.remove('dark');
themeToggleLightIcon.classList.remove('hidden');
}
const themeToggleBtn = document.getElementById('theme-toggle');
let event = new Event('dark-mode');
themeToggleBtn.addEventListener('click', function () {
// toggle icons
themeToggleDarkIcon.classList.toggle('hidden');
themeToggleLightIcon.classList.toggle('hidden');
// if set via local storage previously
if (localStorage.getItem('color-theme')) {
if (localStorage.getItem('color-theme') === 'light') {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
}
// if NOT set via local storage previously
} else if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
}
document.dispatchEvent(event);
});
}
window.initTopMenu = function() {
initDarkModeSwitcher();
};

View File

@@ -170,8 +170,8 @@ public class ClientPlaywrightTest : PlaywrightTest
/// <returns>Async task.</returns>
protected async Task CreateCredentialEntry(Dictionary<string, string>? formValues = null)
{
await NavigateUsingBlazorRouter("add-credentials");
await WaitForUrlAsync("add-credentials", "Add credentials");
await NavigateUsingBlazorRouter("credentials/create");
await WaitForUrlAsync("credentials/create", "Add credentials");
// Check if a button with text "Generate Random Identity" appears
var generateButton = Page.Locator("text=Generate Random Identity");

View File

@@ -97,8 +97,8 @@ public class CredentialTest : ClientPlaywrightTest
// Create a new alias with service name = "Test Service".
var serviceName = "Test Service";
await NavigateUsingBlazorRouter("add-credentials");
await WaitForUrlAsync("add-credentials", "Add credentials");
await NavigateUsingBlazorRouter("credentials/create");
await WaitForUrlAsync("credentials/create", "Add credentials");
await InputHelper.FillInputFields(
fieldValues: new Dictionary<string, string>

View File

@@ -34,8 +34,8 @@ public class GeneralSettingsTest : ClientPlaywrightTest
await defaultEmailDomainField.SelectOptionAsync("example2.tld");
// Go to new credential create page and assert that the default email domain is visible on the page.
await NavigateUsingBlazorRouter("add-credentials");
await WaitForUrlAsync("add-credentials", "Add credentials");
await NavigateUsingBlazorRouter("credentials/create");
await WaitForUrlAsync("credentials/create", "Add credentials");
var defaultEmailDomainText = await Page.TextContentAsync("body");
Assert.That(defaultEmailDomainText, Does.Contain("example2.tld"), "Default email domain not visible on add credentials page.");