Merge pull request #159 from lanedirt/142-design-new-client-datamodel-structure-for-credentialsaliases-with-simplified-user-flow

Add quick create new identity popup
This commit is contained in:
Leendert de Borst
2024-08-07 13:39:59 -07:00
committed by GitHub
34 changed files with 894 additions and 666 deletions

View File

@@ -12,8 +12,8 @@ using AliasGenerators.Identity;
using AliasGenerators.Identity.Models;
/// <summary>
/// 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.
/// </summary>
public abstract class IdentityGenerator : IIdentityGenerator
{

View File

@@ -26,7 +26,7 @@
if (firstRender)
{
// We subscribe to the OnChange event of the PortalMessageService to update the UI when a new message is added
// We subscribe to the OnChange event of the GlobalNotificationService to update the UI when a new message is added
RefreshAddMessages();
GlobalNotificationService.OnChange += RefreshAddMessages;
_onChangeSubscribed = true;

View File

@@ -6,7 +6,7 @@
}
else
{
<input type="text" id="@Id" class="outline-0 shadow-sm bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg block w-full p-2.5 pr-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" value="@Value" @oninput="OnInputChanged">
<input type="text" id="@Id" @onfocus="OnFocusEvent" class="outline-0 shadow-sm bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg block w-full p-2.5 pr-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" value="@Value" @oninput="OnInputChanged">
}
</div>
@@ -35,6 +35,12 @@
[Parameter]
public string Value { get; set; } = string.Empty;
/// <summary>
/// Callback that is triggered when the value changes.
/// </summary>
[Parameter]
public EventCallback<FocusEventArgs> OnFocus { get; set; }
/// <summary>
/// Callback that is triggered when the value changes.
/// </summary>
@@ -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);
}
}
}

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

@@ -0,0 +1,210 @@
@using System.ComponentModel.DataAnnotations
@inherits AliasVault.Client.Main.Pages.MainBase
@inject IJSRuntime JSRuntime
@inject CredentialService CredentialService
@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
</button>
@if (IsPopupVisible)
{
<ClickOutsideHandler OnClose="ClosePopup" ContentId="quickIdentityPopup,quickIdentityButton">
<div id="quickIdentityPopup" class="absolute z-50 mt-2 p-4 bg-white rounded-lg shadow-xl border border-gray-300"
style="@PopupStyle">
<h3 class="text-lg font-semibold mb-4">Create New Identity</h3>
<EditForm Model="Model" OnValidSubmit="CreateIdentity">
<DataAnnotationsValidator />
<div class="mb-4">
<EditFormRow Id="serviceName" Label="Service Name" @bind-Value="Model.ServiceName"></EditFormRow>
<ValidationMessage For="() => Model.ServiceName"/>
</div>
<div class="mb-4">
<EditFormRow Id="serviceUrl" Label="Service URL" OnFocus="OnFocusUrlInput" @bind-Value="Model.ServiceUrl"></EditFormRow>
<ValidationMessage For="() => Model.ServiceUrl"/>
</div>
<div class="flex justify-between items-center">
<button id="quickIdentitySubmit" type="submit" class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded">
Create
</button>
</div>
<div class="pt-2">
<a href="#" @onclick="OpenAdvancedMode" @onclick:preventDefault class="text-sm text-blue-500 hover:text-blue-700">
Create via advanced mode
</a>
</div>
</EditForm>
</div>
</ClickOutsideHandler>
}
@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;
private IJSObjectReference? Module;
/// <inheritdoc />
async ValueTask IAsyncDisposable.DisposeAsync()
{
await KeyboardShortcutService.UnregisterShortcutAsync("gc");
if (Module is not null)
{
await Module.DisposeAsync();
}
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
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)
{
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()
{
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<int>("getWindowWidth");
var buttonRect = await JSRuntime.InvokeAsync<BoundingClientRect>("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();
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<Password> { new() };
await CredentialService.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 void OpenAdvancedMode()
{
NavigationManager.NavigateTo("/credentials/create");
ClosePopup();
}
/// <summary>
/// Bounding client rectangle returned from JavaScript.
/// </summary>
private sealed 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; }
}
/// <summary>
/// Local model for the form with support for validation.
/// </summary>
private sealed class CreateModel
{
/// <summary>
/// The service name.
/// </summary>
[Required]
[Display(Name = "Service Name")]
public string ServiceName { get; set; } = string.Empty;
/// <summary>
/// The service URL.
/// </summary>
[Display(Name = "Service URL")]
public string ServiceUrl { get; set; } = string.Empty;
}
}

View File

@@ -1,16 +1,18 @@
@implements IDisposable
@inject DbService DbService
@if (Loading)
{
<div class="flex items-center justify-center">
<SmallLoadingIndicator Title="@LoadingIndicatorMessage" />
</div>
}
else
{
<SmallLoadingIndicator Title="@LoadingIndicatorMessage" Spinning="false" />
}
<div class="ms-2">
@if (Loading)
{
<div class="flex items-center justify-center">
<SmallLoadingIndicator Title="@LoadingIndicatorMessage" />
</div>
}
else
{
<SmallLoadingIndicator Title="@LoadingIndicatorMessage" Spinning="false" />
}
</div>
<!--
<p>Message: @DbService.GetState().CurrentState.Message</p>

View File

@@ -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;
}
}

View File

@@ -1,4 +1,4 @@
<footer class="md:flex md:items-center md:justify-between px-4 2xl:px-0 py-6 md:py-10">
 <footer class="md:flex md:items-center md:justify-between px-4 2xl:px-0 py-6 md:py-10">
<p class="text-sm text-center text-gray-500 mb-4 md:mb-0">
© 2024 AliasVault. All rights reserved.
</p>
@@ -8,3 +8,9 @@
<li><a href="https://github.com/lanedirt/AliasVault" target="_blank" class="text-sm font-normal text-gray-500 hover:underline dark:text-gray-400">GitHub</a></li>
</ul>
</footer>
<div class="text-center text-gray-400 text-sm pt-4 pb-2">@RandomQuote</div>
@code {
private string RandomQuote => "Tip: You can use the 'gc' keyboard shortcut to quickly create a new identity.";
}

View File

@@ -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;
}
}

View File

@@ -19,43 +19,42 @@
</div>
</div>
<div class="flex justify-end items-center lg:order-2">
<CreateNewIdentityWidget />
<DbStatusIndicator />
<button id="theme-toggle" data-tooltip-target="tooltip-toggle" type="button" class="text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5">
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path></svg>
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path></svg>
</button>
<div id="tooltip-toggle" role="tooltip" class="inline-block absolute invisible z-10 py-2 px-3 text-sm font-medium text-white bg-gray-900 rounded-lg shadow-sm opacity-0 transition-opacity duration-300 tooltip" data-popper-placement="bottom" style="position: absolute; inset: 0px auto auto 0px; margin: 0px; transform: translate3d(1377px, 60px, 0px);">
Toggle dark mode
<div class="tooltip-arrow" data-popper-arrow="" style="position: absolute; left: 0px; transform: translate3d(68.5px, 0px, 0px);"></div>
</div>
<button @onclick="ToggleMenu" type="button" class="flex mx-3 text-sm bg-gray-800 rounded-full md:mr-0 flex-shrink-0 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600" id="userMenuDropdownButton" aria-expanded="false" data-dropdown-toggle="userMenuDropdown">
<button @onclick="ToggleMenu" type="button" class="flex ms-3 text-sm bg-gray-800 rounded-full md:mr-0 flex-shrink-0 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600" id="userMenuDropdownButton" aria-expanded="false" data-dropdown-toggle="userMenuDropdown">
<span class="sr-only">Open user menu</span>
<img class="w-8 h-8 rounded-full" src="/img/avatar.webp" alt="user photo">
</button>
@if (IsMenuOpen)
{
<div class="absolute top-10 right-0 z-50 my-4 w-56 text-base list-none bg-white rounded divide-y divide-gray-100 shadow dark:bg-gray-700 dark:divide-gray-600" id="userMenuDropdown" data-popper-placement="bottom">
<div class="py-3 px-4">
<span class="block text-sm font-semibold text-gray-900 dark:text-white">@Username</span>
</div>
<ul class="py-1 font-light text-gray-500 dark:text-gray-400" aria-labelledby="userMenuDropdownButton">
<li>
<a href="/settings/general" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white">General settings</a>
</li>
<li>
<a href="/settings/vault" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white">Vault settings</a>
</li>
</ul>
<ul class="py-1 font-light text-gray-500 dark:text-gray-400" aria-labelledby="dropdown">
<li>
<a href="/user/logout" class="block py-2 px-4 font-bold text-sm text-primary-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-primary-200 dark:hover:text-white">Sign out</a>
</li>
</ul>
<div class="absolute top-10 right-0 z-50 my-4 w-56 text-base list-none bg-white rounded divide-y divide-gray-100 shadow dark:bg-gray-700 dark:divide-gray-600 @(IsMenuOpen ? "block" : "hidden")" id="userMenuDropdown" data-popper-placement="bottom">
<div class="py-3 px-4">
<span class="block text-sm font-semibold text-gray-900 dark:text-white">@Username</span>
</div>
}
<ul class="py-1 font-light text-gray-500 dark:text-gray-400" aria-labelledby="userMenuDropdownButton">
<li>
<a href="/settings/general" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white">General settings</a>
</li>
<li>
<a href="/settings/vault" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white">Vault settings</a>
</li>
</ul>
<ul class="py-1 font-light text-gray-500 dark:text-gray-400" aria-labelledby="dropdown">
<li>
<button id="theme-toggle" data-tooltip-target="tooltip-toggle" type="button" class="w-full text-start py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white">
Toggle dark mode
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5 align-middle inline-block" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path></svg>
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5 align-middle inline-block" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path></svg>
</button>
</li>
</ul>
<ul class="py-1 font-light text-gray-500 dark:text-gray-400" aria-labelledby="dropdown">
<li>
<a href="/user/logout" class="block py-2 px-4 font-bold text-sm text-primary-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-primary-200 dark:hover:text-white">Sign out</a>
</li>
</ul>
</div>
<button @onclick="ToggleMobileMenu" type="button" id="toggleMobileMenuButton" class="items-center p-2 text-gray-500 rounded-lg md:ml-2 lg:hidden hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-700 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600">
<span class="sr-only">Open menu</span>

View File

@@ -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;
}
}

View File

@@ -1,12 +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
@using AliasGenerators.Password
@using AliasGenerators.Password.Implementations
@if (EditMode)
{
@@ -135,10 +131,6 @@ else
</div>
</div>
</div>
<div class="grid px-4 pt-6 lg:gap-4 dark:bg-gray-900">
</div>
</EditForm>
}
@@ -218,16 +210,14 @@ else
// Create new Obj
var alias = new Credential();
alias.Alias = new Alias();
alias.Alias.Email = "@" + GetDefaultEmailDomain();
alias.Alias.Email = "@" + CredentialService.GetDefaultEmailDomain();
alias.Service = new Service();
alias.Passwords = new List<Password> { new Password() };
Obj = CredentialToCredentialEdit(alias);
}
// Hide loading spinner
Loading = false;
// Force re-render invoke so the charts can be rendered
StateHasChanged();
}
}
@@ -243,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 = GetDefaultEmailDomain();
Obj.Alias.Email = $"{identity.EmailPrefix}@{emailDomain}";
// Generate password
GenerateRandomPassword();
Obj = CredentialToCredentialEdit(await CredentialService.GenerateRandomIdentity(CredentialEditToCredential(Obj)));
GlobalLoadingSpinner.Hide();
StateHasChanged();
@@ -267,9 +241,7 @@ else
private void GenerateRandomPassword()
{
// Generate a random password using a IPasswordGenerator implementation.
IPasswordGenerator passwordGenerator = new SpamOkPasswordGenerator();
Obj.Password.Value = passwordGenerator.GenerateRandomPassword();
Obj.Password.Value = CredentialService.GenerateRandomPassword();
}
private async Task SaveAlias()
@@ -277,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)
@@ -314,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
@@ -338,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()
@@ -370,29 +346,4 @@ else
return credential;
}
/// <summary>
/// Gets the default email domain based on settings and available domains.
/// </summary>
private string GetDefaultEmailDomain()
{
var defaultDomain = DbService.Settings.DefaultEmailDomain;
// Function to check if a domain is valid
bool IsValidDomain(string domain) =>
!string.IsNullOrEmpty(domain) &&
domain != "DISABLED.TLD" &&
(Config.PublicEmailDomains.Contains(domain) || Config.PrivateEmailDomains.Contains(domain));
// Get the first valid domain from private or public domains
string GetFirstValidDomain() =>
Config.PrivateEmailDomains.Find(IsValidDomain) ??
Config.PublicEmailDomains.FirstOrDefault() ??
"example.com";
// Use the default domain if it's valid, otherwise get the first valid domain
string domainToUse = IsValidDomain(defaultDomain) ? defaultDomain : GetFirstValidDomain();
return domainToUse;
}
}

View File

@@ -9,9 +9,6 @@
<Breadcrumb BreadcrumbItems="BreadcrumbItems" />
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Credentials</h1>
<a href="/add-credentials" class="px-4 py-2 text-white bg-primary-600 rounded-lg hover:bg-primary-700 focus:ring-4 focus:ring-primary-300 dark:bg-primary-500 dark:hover:bg-primary-600 dark:focus:ring-primary-800">
+ Add new credentials
</a>
</div>
<p>Find all of your credentials below.</p>
</div>

View File

@@ -116,16 +116,15 @@ else
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
protected override async Task OnParametersSetAsync()
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
await LoadEntryAsync();
}
await base.OnParametersSetAsync();
await LoadEntryAsync();
}
/// <summary>
/// Loads the credentials entry.
/// </summary>
private async Task LoadEntryAsync()
{
IsLoading = true;

View File

@@ -60,6 +60,12 @@ public class MainBase : OwningComponentBase
[Inject]
public DbService DbService { get; set; } = null!;
/// <summary>
/// Gets or sets the KeyboardShortcutService.
/// </summary>
[Inject]
public KeyboardShortcutService KeyboardShortcutService { get; set; } = null!;
/// <summary>
/// Gets or sets the AuthService.
/// </summary>

View File

@@ -72,7 +72,7 @@
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Vault settings" });
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "General settings" });
DefaultEmailDomain = DbService.Settings.DefaultEmailDomain;
AutoEmailRefresh = DbService.Settings.AutoEmailRefresh;

View File

@@ -43,6 +43,7 @@ builder.Services.AddLogging(logging =>
logging.AddFilter("Microsoft.AspNetCore.Identity.DataProtectorTokenProvider", LogLevel.Error);
logging.AddFilter("Microsoft.AspNetCore.Identity.UserManager", LogLevel.Error);
logging.AddFilter("System.Net.Http.HttpClient", LogLevel.Error);
});
builder.RootComponents.Add<App>("#app");
@@ -67,6 +68,7 @@ builder.Services.AddScoped<CredentialService>();
builder.Services.AddScoped<DbService>();
builder.Services.AddScoped<GlobalNotificationService>();
builder.Services.AddScoped<GlobalLoadingService>();
builder.Services.AddScoped<KeyboardShortcutService>();
builder.Services.AddScoped<JsInteropService>();
builder.Services.AddSingleton<ClipboardCopyService>();

View File

@@ -14,6 +14,10 @@ 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;
using Microsoft.EntityFrameworkCore;
using Identity = AliasGenerators.Identity.Models.Identity;
@@ -21,8 +25,73 @@ using Identity = AliasGenerators.Identity.Models.Identity;
/// <summary>
/// Service class for alias operations.
/// </summary>
public class CredentialService(HttpClient httpClient, DbService dbService)
public class CredentialService(HttpClient httpClient, DbService dbService, Config config)
{
/// <summary>
/// Generates a random password for a credential.
/// </summary>
/// <returns>Random password.</returns>
public static string GenerateRandomPassword()
{
// Generate a random password using a IPasswordGenerator implementation.
var passwordGenerator = new SpamOkPasswordGenerator();
return passwordGenerator.GenerateRandomPassword();
}
/// <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>
/// <returns>Default email domain.</returns>
public string GetDefaultEmailDomain()
{
var defaultDomain = dbService.Settings.DefaultEmailDomain;
// Function to check if a domain is valid
bool IsValidDomain(string domain) =>
!string.IsNullOrEmpty(domain) &&
domain != "DISABLED.TLD" &&
(config.PublicEmailDomains.Contains(domain) || config.PrivateEmailDomains.Contains(domain));
// Get the first valid domain from private or public domains
string GetFirstValidDomain() =>
config.PrivateEmailDomains.Find(IsValidDomain) ??
config.PublicEmailDomains.FirstOrDefault() ??
"example.com";
// Use the default domain if it's valid, otherwise get the first valid domain
string domainToUse = IsValidDomain(defaultDomain) ? defaultDomain : GetFirstValidDomain();
return domainToUse;
}
/// <summary>
/// Generate random identity by calling the IdentityGenerator API.
/// </summary>

View File

@@ -20,14 +20,14 @@ public class GlobalNotificationService
public event Action? OnChange;
/// <summary>
/// Gets or sets success messages that should be displayed to the user.
/// Gets success messages that should be displayed to the user.
/// </summary>
protected List<string> SuccessMessages { get; set; } = [];
protected List<string> SuccessMessages { get; } = [];
/// <summary>
/// Gets or sets error messages that should be displayed to the user.
/// Gets error messages that should be displayed to the user.
/// </summary>
protected List<string> ErrorMessages { get; set; } = [];
protected List<string> ErrorMessages { get; } = [];
/// <summary>
/// Adds a success message to the list of messages that should be displayed to the user.

View File

@@ -0,0 +1,118 @@
//-----------------------------------------------------------------------
// <copyright file="KeyboardShortcutService.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Services;
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
/// <summary>
/// Service class for alias operations.
/// </summary>
public class KeyboardShortcutService : IAsyncDisposable
{
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.
/// </summary>
/// <param name="jsRuntime">IJSRuntime instance.</param>
/// <param name="navigationManager">NavigationManager instance.</param>
public KeyboardShortcutService(IJSRuntime jsRuntime, NavigationManager navigationManager)
{
_dotNetHelper = DotNetObjectReference.Create(new CallbackWrapper());
_navigationManager = navigationManager;
moduleTask = new Lazy<Task<IJSObjectReference>>(() => jsRuntime.InvokeAsync<IJSObjectReference>(
"import", "./js/modules/keyboardShortcuts.js").AsTask());
_ = RegisterStaticShortcuts();
}
/// <summary>
/// Registers a keyboard shortcut with the given keys and callback.
/// </summary>
/// <param name="keys">The keyboard keys.</param>
/// <param name="callback">Callback when shortcut is pressed.</param>
/// <returns>Task.</returns>
public async Task RegisterShortcutAsync(string keys, Func<Task> callback)
{
_dotNetHelper.Value.RegisterCallback(keys, callback);
var module = await moduleTask.Value;
await module.InvokeVoidAsync("registerShortcut", keys, _dotNetHelper);
}
/// <summary>
/// Unregisters a keyboard shortcut with the given keys.
/// </summary>
/// <param name="keys">The keyboard keys.</param>
/// <returns>Task.</returns>
public async Task UnregisterShortcutAsync(string keys)
{
_dotNetHelper.Value.UnregisterCallback(keys);
var module = await moduleTask.Value;
await module!.InvokeVoidAsync("unregisterShortcut", keys);
}
/// <summary>
/// Disposes the service.
/// </summary>
/// <returns>ValueTask.</returns>
public async ValueTask DisposeAsync()
{
var module = await moduleTask.Value;
await module.InvokeVoidAsync("unregisterAllShortcuts");
await module.DisposeAsync();
_dotNetHelper.Dispose();
GC.SuppressFinalize(this);
}
/// <summary>
/// Registers static shortcuts that are always available.
/// </summary>
private async Task RegisterStaticShortcuts()
{
// Global shortcut: Go home
await RegisterShortcutAsync("gh", () =>
{
_navigationManager.NavigateTo("/");
return Task.CompletedTask;
});
}
/// <summary>
/// Wrapper class for callback functions that are invoked from JavaScript.
/// </summary>
private sealed class CallbackWrapper
{
private readonly Dictionary<string, Func<Task>> _callbacks = new Dictionary<string, Func<Task>>();
public void RegisterCallback(string keys, Func<Task> callback)
{
_callbacks[keys] = callback;
}
public void UnregisterCallback(string keys)
{
_callbacks.Remove(keys);
}
[JSInvokable]
public async Task Invoke(string keys)
{
if (_callbacks.TryGetValue(keys, out var callback))
{
await callback();
}
}
}
}

View File

@@ -19,6 +19,7 @@
@using AliasVault.Client.Main.Components.Forms
@using AliasVault.Client.Main.Components.Layout
@using AliasVault.Client.Main.Components.Loading
@using AliasVault.Client.Main.Components.Widgets
@using AliasVault.Client.Main.Models
@using AliasVault.Client.Services
@using AliasVault.Client.Services.Auth

View File

@@ -566,14 +566,6 @@ video {
border-width: 0;
}
.visible {
visibility: visible;
}
.invisible {
visibility: hidden;
}
.fixed {
position: fixed;
}
@@ -612,6 +604,10 @@ video {
right: 0px;
}
.right-1 {
right: 0.25rem;
}
.right-10 {
right: 2.5rem;
}
@@ -620,26 +616,10 @@ video {
top: 2.5rem;
}
.right-4 {
right: 1rem;
}
.right-1 {
right: 0.25rem;
}
.right-5 {
right: 1.25rem;
}
.top-20 {
top: 5rem;
}
.z-10 {
z-index: 10;
}
.z-30 {
z-index: 30;
}
@@ -648,10 +628,6 @@ video {
z-index: 50;
}
.z-20 {
z-index: 20;
}
.col-span-2 {
grid-column: span 2 / span 2;
}
@@ -664,11 +640,6 @@ video {
grid-column: 1 / -1;
}
.mx-3 {
margin-left: 0.75rem;
margin-right: 0.75rem;
}
.mx-4 {
margin-left: 1rem;
margin-right: 1rem;
@@ -716,6 +687,10 @@ video {
margin-left: 0.25rem;
}
.ml-2 {
margin-left: 0.5rem;
}
.ml-3 {
margin-left: 0.75rem;
}
@@ -744,6 +719,18 @@ video {
margin-right: 1rem;
}
.ms-2 {
margin-inline-start: 0.5rem;
}
.ms-3 {
margin-inline-start: 0.75rem;
}
.ms-5 {
margin-inline-start: 1.25rem;
}
.mt-0 {
margin-top: 0px;
}
@@ -752,6 +739,10 @@ video {
margin-top: 0.5rem;
}
.mt-3 {
margin-top: 0.75rem;
}
.mt-4 {
margin-top: 1rem;
}
@@ -764,18 +755,6 @@ video {
margin-top: 2rem;
}
.mt-3 {
margin-top: 0.75rem;
}
.mr-1 {
margin-right: 0.25rem;
}
.ml-2 {
margin-left: 0.5rem;
}
.block {
display: block;
}
@@ -816,6 +795,10 @@ video {
height: 3rem;
}
.h-3 {
height: 0.75rem;
}
.h-4 {
height: 1rem;
}
@@ -840,18 +823,10 @@ video {
height: 2.25rem;
}
.h-\[700px\] {
height: 700px;
}
.h-full {
height: 100%;
}
.h-3 {
height: 0.75rem;
}
.w-1\/2 {
width: 50%;
}
@@ -868,6 +843,14 @@ video {
width: 7rem;
}
.w-3 {
width: 0.75rem;
}
.w-3\/4 {
width: 75%;
}
.w-4 {
width: 1rem;
}
@@ -896,20 +879,12 @@ video {
width: 2rem;
}
.w-full {
width: 100%;
}
.w-96 {
width: 24rem;
}
.w-3 {
width: 0.75rem;
}
.w-3\/4 {
width: 75%;
.w-full {
width: 100%;
}
.min-w-full {
@@ -936,16 +911,6 @@ video {
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));
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes pulse {
50% {
opacity: .5;
@@ -956,31 +921,14 @@ video {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes ping {
75%, 100% {
transform: scale(2);
opacity: 0;
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.animate-ping {
animation: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
}
@keyframes bounce {
0%, 100% {
transform: translateY(-25%);
animation-timing-function: cubic-bezier(0.8,0,1,1);
}
50% {
transform: none;
animation-timing-function: cubic-bezier(0,0,0.2,1);
}
}
.animate-bounce {
animation: bounce 1s infinite;
.animate-spin {
animation: spin 1s linear infinite;
}
.cursor-pointer {
@@ -1069,6 +1017,12 @@ video {
margin-left: calc(0.25rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-x-2 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.5rem * var(--tw-space-x-reverse));
margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-x-4 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(1rem * var(--tw-space-x-reverse));
@@ -1099,12 +1053,6 @@ video {
margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
}
.space-x-2 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.5rem * var(--tw-space-x-reverse));
margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
}
.divide-y > :not([hidden]) ~ :not([hidden]) {
--tw-divide-y-reverse: 0;
border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
@@ -1221,16 +1169,6 @@ video {
border-color: rgb(34 197 94 / var(--tw-border-opacity));
}
.border-red-300 {
--tw-border-opacity: 1;
border-color: rgb(252 165 165 / var(--tw-border-opacity));
}
.border-primary-300 {
--tw-border-opacity: 1;
border-color: rgb(248 185 99 / var(--tw-border-opacity));
}
.border-primary-100 {
--tw-border-opacity: 1;
border-color: rgb(253 222 133 / var(--tw-border-opacity));
@@ -1266,6 +1204,11 @@ video {
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
}
.bg-gray-600 {
--tw-bg-opacity: 1;
background-color: rgb(75 85 99 / var(--tw-bg-opacity));
}
.bg-gray-700 {
--tw-bg-opacity: 1;
background-color: rgb(55 65 81 / var(--tw-bg-opacity));
@@ -1276,21 +1219,26 @@ video {
background-color: rgb(31 41 55 / var(--tw-bg-opacity));
}
.bg-gray-900 {
--tw-bg-opacity: 1;
background-color: rgb(17 24 39 / var(--tw-bg-opacity));
}
.bg-green-50 {
--tw-bg-opacity: 1;
background-color: rgb(240 253 244 / var(--tw-bg-opacity));
}
.bg-green-500 {
--tw-bg-opacity: 1;
background-color: rgb(34 197 94 / var(--tw-bg-opacity));
}
.bg-green-600 {
--tw-bg-opacity: 1;
background-color: rgb(22 163 74 / var(--tw-bg-opacity));
}
.bg-primary-300 {
--tw-bg-opacity: 1;
background-color: rgb(248 185 99 / var(--tw-bg-opacity));
}
.bg-primary-600 {
--tw-bg-opacity: 1;
background-color: rgb(214 131 56 / var(--tw-bg-opacity));
@@ -1316,21 +1264,6 @@ video {
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
}
.bg-gray-300 {
--tw-bg-opacity: 1;
background-color: rgb(209 213 219 / var(--tw-bg-opacity));
}
.bg-gray-600 {
--tw-bg-opacity: 1;
background-color: rgb(75 85 99 / var(--tw-bg-opacity));
}
.bg-primary-300 {
--tw-bg-opacity: 1;
background-color: rgb(248 185 99 / var(--tw-bg-opacity));
}
.bg-opacity-50 {
--tw-bg-opacity: 0.5;
}
@@ -1339,6 +1272,20 @@ video {
--tw-bg-opacity: 0.75;
}
.bg-gradient-to-r {
background-image: linear-gradient(to right, var(--tw-gradient-stops));
}
.from-primary-500 {
--tw-gradient-from: #f49541 var(--tw-gradient-from-position);
--tw-gradient-to: rgb(244 149 65 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
}
.to-primary-600 {
--tw-gradient-to: #d68338 var(--tw-gradient-to-position);
}
.fill-primary-600 {
fill: #d68338;
}
@@ -1355,6 +1302,10 @@ video {
padding: 1rem;
}
.p-5 {
padding: 1.25rem;
}
.p-6 {
padding: 1.5rem;
}
@@ -1363,10 +1314,6 @@ video {
padding: 2rem;
}
.p-5 {
padding: 1.25rem;
}
.px-2 {
padding-left: 0.5rem;
padding-right: 0.5rem;
@@ -1392,6 +1339,11 @@ video {
padding-right: 1.5rem;
}
.px-7 {
padding-left: 1.75rem;
padding-right: 1.75rem;
}
.py-1 {
padding-top: 0.25rem;
padding-bottom: 0.25rem;
@@ -1422,9 +1374,8 @@ video {
padding-bottom: 1.5rem;
}
.px-7 {
padding-left: 1.75rem;
padding-right: 1.75rem;
.pb-2 {
padding-bottom: 0.5rem;
}
.pr-10 {
@@ -1443,6 +1394,14 @@ video {
padding-top: 4rem;
}
.pt-2 {
padding-top: 0.5rem;
}
.pt-4 {
padding-top: 1rem;
}
.pt-6 {
padding-top: 1.5rem;
}
@@ -1451,10 +1410,6 @@ video {
padding-top: 2rem;
}
.pt-4 {
padding-top: 1rem;
}
.text-left {
text-align: left;
}
@@ -1463,6 +1418,10 @@ video {
text-align: center;
}
.text-start {
text-align: start;
}
.align-middle {
vertical-align: middle;
}
@@ -1618,15 +1577,6 @@ video {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.text-red-600 {
--tw-text-opacity: 1;
color: rgb(220 38 38 / var(--tw-text-opacity));
}
.opacity-0 {
opacity: 0;
}
.opacity-25 {
opacity: 0.25;
}
@@ -1663,15 +1613,21 @@ video {
outline-width: 0px;
}
.transition {
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter;
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);
transition-duration: 150ms;
}
.transition-opacity {
transition-property: opacity;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
.duration-150 {
transition-duration: 150ms;
}
@@ -1679,8 +1635,14 @@ video {
transition-duration: 200ms;
}
.duration-300 {
transition-duration: 300ms;
.ease-in-out {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.hover\:scale-105:hover {
--tw-scale-x: 1.05;
--tw-scale-y: 1.05;
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));
}
.hover\:bg-blue-600:hover {
@@ -1703,6 +1665,11 @@ video {
background-color: rgb(229 231 235 / var(--tw-bg-opacity));
}
.hover\:bg-gray-300:hover {
--tw-bg-opacity: 1;
background-color: rgb(209 213 219 / var(--tw-bg-opacity));
}
.hover\:bg-gray-50:hover {
--tw-bg-opacity: 1;
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
@@ -1718,6 +1685,11 @@ video {
background-color: rgb(21 128 61 / var(--tw-bg-opacity));
}
.hover\:bg-primary-400:hover {
--tw-bg-opacity: 1;
background-color: rgb(246 167 82 / var(--tw-bg-opacity));
}
.hover\:bg-primary-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(184 112 47 / var(--tw-bg-opacity));
@@ -1738,14 +1710,24 @@ video {
background-color: rgb(153 27 27 / var(--tw-bg-opacity));
}
.hover\:bg-gray-300:hover {
--tw-bg-opacity: 1;
background-color: rgb(209 213 219 / 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);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
}
.hover\:bg-primary-400:hover {
--tw-bg-opacity: 1;
background-color: rgb(246 167 82 / var(--tw-bg-opacity));
.hover\:to-primary-700:hover {
--tw-gradient-to: #b8702f var(--tw-gradient-to-position);
}
.hover\:text-blue-700:hover {
--tw-text-opacity: 1;
color: rgb(29 78 216 / var(--tw-text-opacity));
}
.hover\:text-blue-800:hover {
--tw-text-opacity: 1;
color: rgb(30 64 175 / var(--tw-text-opacity));
}
.hover\:text-gray-500:hover {
@@ -1778,50 +1760,45 @@ video {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.hover\:text-blue-800:hover {
--tw-text-opacity: 1;
color: rgb(30 64 175 / var(--tw-text-opacity));
}
.hover\:underline:hover {
text-decoration-line: underline;
}
.focus\:border-primary-500:focus {
--tw-border-opacity: 1;
border-color: rgb(244 149 65 / var(--tw-border-opacity));
}
.focus\:border-blue-500:focus {
--tw-border-opacity: 1;
border-color: rgb(59 130 246 / var(--tw-border-opacity));
}
.focus\:border-primary-500:focus {
--tw-border-opacity: 1;
border-color: rgb(244 149 65 / var(--tw-border-opacity));
}
.focus\:outline-none:focus {
outline: 2px solid transparent;
outline-offset: 2px;
}
.focus\:ring-4:focus {
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
}
.focus\:ring-2:focus {
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
}
.focus\:ring-4:focus {
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
}
.focus\:ring-blue-300:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(147 197 253 / var(--tw-ring-opacity));
}
.focus\:ring-gray-200:focus {
.focus\:ring-blue-500:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(229 231 235 / var(--tw-ring-opacity));
--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));
}
.focus\:ring-gray-300:focus {
@@ -1829,6 +1806,11 @@ video {
--tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity));
}
.focus\:ring-gray-400:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(156 163 175 / var(--tw-ring-opacity));
}
.focus\:ring-green-300:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(134 239 172 / var(--tw-ring-opacity));
@@ -1849,25 +1831,16 @@ video {
--tw-ring-color: rgb(252 165 165 / var(--tw-ring-opacity));
}
.focus\:ring-indigo-500:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(99 102 241 / var(--tw-ring-opacity));
}
.focus\:ring-gray-400:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(156 163 175 / var(--tw-ring-opacity));
}
.focus\:ring-blue-500:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));
}
.focus\:ring-offset-2:focus {
--tw-ring-offset-width: 2px;
}
.active\:scale-95:active {
--tw-scale-x: .95;
--tw-scale-y: .95;
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));
}
.dark\:divide-gray-600:is(.dark *) > :not([hidden]) ~ :not([hidden]) {
--tw-divide-opacity: 1;
border-color: rgb(75 85 99 / var(--tw-divide-opacity));
@@ -1937,6 +1910,16 @@ video {
--tw-bg-opacity: 0.8;
}
.dark\:from-primary-400:is(.dark *) {
--tw-gradient-from: #f6a752 var(--tw-gradient-from-position);
--tw-gradient-to: rgb(246 167 82 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
}
.dark\:to-primary-500:is(.dark *) {
--tw-gradient-to: #f49541 var(--tw-gradient-to-position);
}
.dark\:text-blue-400:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(96 165 250 / var(--tw-text-opacity));
@@ -2011,6 +1994,11 @@ video {
background-color: rgb(37 99 235 / 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));
}
.dark\:hover\:bg-gray-600:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(75 85 99 / var(--tw-bg-opacity));
@@ -2041,9 +2029,19 @@ video {
background-color: rgb(220 38 38 / 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));
.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);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
}
.dark\:hover\:to-primary-600:hover:is(.dark *) {
--tw-gradient-to: #d68338 var(--tw-gradient-to-position);
}
.dark\:hover\:text-blue-200:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(191 219 254 / var(--tw-text-opacity));
}
.dark\:hover\:text-primary-500:hover:is(.dark *) {
@@ -2056,9 +2054,9 @@ video {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.dark\:hover\:text-blue-200:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(191 219 254 / 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));
}
.dark\:focus\:border-primary-500:focus:is(.dark *) {
@@ -2066,9 +2064,14 @@ video {
border-color: rgb(244 149 65 / var(--tw-border-opacity));
}
.dark\:focus\:border-blue-500:focus:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(59 130 246 / var(--tw-border-opacity));
.dark\:focus\:ring-blue-500:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));
}
.dark\:focus\:ring-blue-600:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(37 99 235 / var(--tw-ring-opacity));
}
.dark\:focus\:ring-blue-800:focus:is(.dark *) {
@@ -2121,16 +2124,6 @@ video {
--tw-ring-color: rgb(127 29 29 / var(--tw-ring-opacity));
}
.dark\:focus\:ring-blue-500:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));
}
.dark\:focus\:ring-blue-600:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(37 99 235 / var(--tw-ring-opacity));
}
@media (min-width: 640px) {
.sm\:col-span-3 {
grid-column: span 3 / span 3;
@@ -2148,10 +2141,6 @@ video {
width: auto;
}
.sm\:flex-row {
flex-direction: row;
}
.sm\:space-x-4 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(1rem * var(--tw-space-x-reverse));

View File

@@ -47,8 +47,7 @@
<a class="dismiss">🗙</a>
</div>
<script src="js/dark-mode.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="_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,16 +1,18 @@
// clickOutsideHandler.js
let currentHandler = null;
let currentDotNetHelper = null;
export function registerClickOutsideHandler(dotNetHelper, contentId, methodName) {
export function registerClickOutsideHandler(dotNetHelper, contentIds, methodName) {
unregisterClickOutsideHandler();
currentDotNetHelper = dotNetHelper;
currentHandler = (event) => {
const content = document.getElementById(contentId);
if (!content) return;
const idArray = Array.isArray(contentIds) ? contentIds : contentIds.split(',').map(id => id.trim());
currentHandler = (event) => {
const isOutside = idArray.every(id => {
const content = document.getElementById(id);
return !content?.contains(event.target);
});
const isOutside = !content.contains(event.target);
const isEscapeKey = event.type === 'keydown' && event.key === 'Escape';
if (isOutside || isEscapeKey) {

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');
@@ -43,3 +39,69 @@ window.clipboardCopy = {
window.blazorNavigate = (url) => {
Blazor.navigateTo(url);
};
window.focusElement = (elementId) => {
const element = document.getElementById(elementId);
if (element) {
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");
@@ -183,11 +183,11 @@ public class ClientPlaywrightTest : PlaywrightTest
var submitButton = Page.Locator("text=Save Credentials").First;
await submitButton.ClickAsync();
await WaitForUrlAsync("credentials/**", "Login credentials");
await WaitForUrlAsync("credentials/**", "View credentials entry");
// Check if the credential was created
var pageContent = await Page.TextContentAsync("body");
Assert.That(pageContent, Does.Contain("Login credentials"), "Credential not created.");
Assert.That(pageContent, Does.Contain("View credentials entry"), "Credential not created.");
}
/// <summary>

View File

@@ -160,7 +160,13 @@ public abstract class PlaywrightTest
await Page.WaitForURLAsync("**/" + relativeUrl, new PageWaitForURLOptions() { Timeout = TestDefaults.DefaultTimeout });
// Wait for actual content to load (web API calls, etc.)
await Page.WaitForSelectorAsync("text=" + waitForText, new PageWaitForSelectorOptions() { Timeout = TestDefaults.DefaultTimeout });
await Page.GetByText(waitForText, new PageGetByTextOptions { Exact = false })
.First
.WaitForAsync(new LocatorWaitForOptions
{
Timeout = TestDefaults.DefaultTimeout,
State = WaitForSelectorState.Attached,
});
}
/// <summary>

View File

@@ -36,7 +36,7 @@ public class AuthTests : AdminPlaywrightTest
await submitButton.ClickAsync();
// Wait for current page to refresh and confirm message shows.
await WaitForUrlAsync("account/manage/change-password", "Error");
await WaitForUrlAsync("account/manage/change-password", "Incorrect");
var pageContent = await Page.TextContentAsync("body");
Assert.That(pageContent, Does.Contain("Error: Incorrect password."), "No error shown after submitting change password field with wrong old password.");

View File

@@ -49,6 +49,39 @@ public class CredentialTest : ClientPlaywrightTest
Assert.That(pageContent, Does.Contain(serviceName), "Created credential service name does not appear on alias page.");
}
/// <summary>
/// Test if creating a new credential entry works with quick create widget.
/// </summary>
/// <returns>Async task.</returns>
[Test]
public async Task CreateCredentialWidgetTest()
{
// Navigate to homepage
await NavigateUsingBlazorRouter("credentials");
await WaitForUrlAsync("credentials", "Credentials");
// Create a new alias with service name = "Test Service".
var serviceName = "Test Service Widget";
var widgetButton = Page.Locator("button[id='quickIdentityButton']");
Assert.That(widgetButton, Is.Not.Null, "Create new identity widget button not found.");
await widgetButton.ClickAsync();
await InputHelper.FillInputFields(new Dictionary<string, string>
{
{ "serviceName", serviceName },
});
var submitButton = Page.Locator("button[id='quickIdentitySubmit']");
await submitButton.ClickAsync();
await WaitForUrlAsync("credentials/**", "View credentials entry");
// Check that the service name is present in the content.
var pageContent = await Page.TextContentAsync("body");
Assert.That(pageContent, Does.Contain(serviceName), "Created credential service name does not appear on alias page.");
}
/// <summary>
/// Test if editing a created credential entry works.
/// </summary>
@@ -97,8 +130,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

@@ -23,7 +23,7 @@ public class GeneralSettingsTest : ClientPlaywrightTest
public async Task MutateDefaultEmailDomainTest()
{
await NavigateUsingBlazorRouter("settings/general");
await WaitForUrlAsync("settings/general", "General settings");
await WaitForUrlAsync("settings/general", "Configure general");
// Check if the expected content is present.
var pageContent = await Page.TextContentAsync("body");
@@ -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.");