Style login/2FA pages with tailwind CSS (#113)

This commit is contained in:
Leendert de Borst
2024-07-22 00:24:39 +02:00
parent c73769750c
commit 5a2353fb11
48 changed files with 746 additions and 516 deletions

View File

@@ -1,65 +0,0 @@
@page "/Account/ForgotPassword"
@using System.ComponentModel.DataAnnotations
@using System.Text
@using System.Text.Encodings.Web
@using Microsoft.AspNetCore.Identity
@using Microsoft.AspNetCore.WebUtilities
@inject UserManager<AdminUser> UserManager
@inject IEmailSender<AdminUser> EmailSender
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
<PageTitleLayout>Forgot your password?</PageTitleLayout>
<h1>Forgot your password?</h1>
<h2>Enter your email.</h2>
<hr/>
<div class="row">
<div class="col-md-4">
<EditForm Model="Input" FormName="forgot-password" OnValidSubmit="OnValidSubmitAsync" method="post">
<DataAnnotationsValidator/>
<ValidationSummary class="text-danger" role="alert"/>
<div class="form-floating mb-3">
<InputText @bind-Value="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com"/>
<label for="email" class="form-label">Email</label>
<ValidationMessage For="() => Input.Email" class="text-danger"/>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Reset password</button>
</EditForm>
</div>
</div>
@code {
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
private async Task OnValidSubmitAsync()
{
var user = await UserManager.FindByEmailAsync(Input.Email);
if (user is null || !(await UserManager.IsEmailConfirmedAsync(user)))
{
// Don't reveal that the user does not exist or is not confirmed
RedirectManager.RedirectTo("Account/ForgotPasswordConfirmation");
}
// For more information on how to enable account confirmation and password reset please
// visit https://go.microsoft.com/fwlink/?LinkID=532713
var code = await UserManager.GeneratePasswordResetTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = NavigationManager.GetUriWithQueryParameters(
NavigationManager.ToAbsoluteUri("Account/ResetPassword").AbsoluteUri,
new Dictionary<string, object?> { ["code"] = code });
await EmailSender.SendPasswordResetLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));
RedirectManager.RedirectTo("Account/ForgotPasswordConfirmation");
}
private sealed class InputModel
{
[Required] [EmailAddress] public string Email { get; set; } = "";
}
}

View File

@@ -1,8 +0,0 @@
@page "/Account/ForgotPasswordConfirmation"
<PageTitleLayout>Forgot password confirmation</PageTitleLayout>
<h1>Forgot password confirmation</h1>
<p>
Please check your email to reset your password.
</p>

View File

@@ -1,114 +0,0 @@
@page "/user/login"
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Identity
@inject SignInManager<AdminUser> SignInManager
@inject ILogger<Login> Logger
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
<PageTitleLayout>Log in</PageTitleLayout>
<h1>Log in</h1>
<div class="row">
<div class="col">
<section>
<StatusMessage Message="@errorMessage"/>
<EditForm Model="Input" method="post" OnValidSubmit="LoginUser" FormName="login">
<DataAnnotationsValidator/>
<hr/>
<ValidationSummary class="text-danger" role="alert"/>
<div class="form-floating mb-3">
<InputText @bind-Value="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com"/>
<label for="email" class="form-label">Email</label>
<ValidationMessage For="() => Input.Email" class="text-danger"/>
</div>
<div class="form-floating mb-3">
<InputText type="password" @bind-Value="Input.Password" class="form-control" autocomplete="current-password" aria-required="true" placeholder="password"/>
<label for="password" class="form-label">Password</label>
<ValidationMessage For="() => Input.Password" class="text-danger"/>
</div>
<div class="checkbox mb-3">
<label class="form-label">
<InputCheckbox @bind-Value="Input.RememberMe" class="darker-border-checkbox form-check-input"/>
Remember me
</label>
</div>
<div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
</div>
<div>
<p>
<a href="Account/ForgotPassword">Forgot your password?</a>
</p>
<p>
<a href="@(NavigationManager.GetUriWithQueryParameters("user/register", new Dictionary<string, object?> { ["ReturnUrl"] = ReturnUrl }))">Register as a new user</a>
</p>
<p>
<a href="Account/ResendEmailConfirmation">Resend email confirmation</a>
</p>
</div>
</EditForm>
</section>
</div>
</div>
@code {
private string? errorMessage;
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
[SupplyParameterFromQuery] private string? ReturnUrl { get; set; }
protected override async Task OnInitializedAsync()
{
if (HttpMethods.IsGet(HttpContext.Request.Method))
{
// Clear the existing external cookie to ensure a clean login process
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
}
}
public async Task LoginUser()
{
// This doesn't count login failures towards account lockout
// To enable password failures to trigger account lockout, set lockoutOnFailure: true
var result = await SignInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false);
if (result.Succeeded)
{
Logger.LogInformation("User logged in.");
RedirectManager.RedirectTo(ReturnUrl);
}
else if (result.RequiresTwoFactor)
{
RedirectManager.RedirectTo(
"user/loginWith2fa",
new() { ["returnUrl"] = ReturnUrl, ["rememberMe"] = Input.RememberMe });
}
else if (result.IsLockedOut)
{
Logger.LogWarning("User account locked out.");
RedirectManager.RedirectTo("Account/Lockout");
}
else
{
errorMessage = "Error: Invalid login attempt.";
}
}
private sealed class InputModel
{
[Required] [EmailAddress] public string Email { get; set; } = "";
[Required]
[DataType(DataType.Password)]
public string Password { get; set; } = "";
[Display(Name = "Remember me?")] public bool RememberMe { get; set; }
}
}

View File

@@ -1,98 +0,0 @@
@page "/user/loginWith2fa"
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Identity
@inject SignInManager<AdminUser> SignInManager
@inject UserManager<AdminUser> UserManager
@inject IdentityRedirectManager RedirectManager
@inject ILogger<LoginWith2fa> Logger
<PageTitleLayout>Two-factor authentication</PageTitleLayout>
<h1>Two-factor authentication</h1>
<hr/>
<StatusMessage Message="@message"/>
<p>Your login is protected with an authenticator app. Enter your authenticator code below.</p>
<div class="row">
<div class="col-md-4">
<EditForm Model="Input" FormName="login-with-2fa" OnValidSubmit="OnValidSubmitAsync" method="post">
<input type="hidden" name="ReturnUrl" value="@ReturnUrl"/>
<input type="hidden" name="RememberMe" value="@RememberMe"/>
<DataAnnotationsValidator/>
<ValidationSummary class="text-danger" role="alert"/>
<div class="form-floating mb-3">
<InputText @bind-Value="Input.TwoFactorCode" class="form-control" autocomplete="off"/>
<label for="two-factor-code" class="form-label">Authenticator code</label>
<ValidationMessage For="() => Input.TwoFactorCode" class="text-danger"/>
</div>
<div class="checkbox mb-3">
<label for="remember-machine" class="form-label">
<InputCheckbox @bind-Value="Input.RememberMachine"/>
Remember this machine
</label>
</div>
<div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
</div>
</EditForm>
</div>
</div>
<p>
Don't have access to your authenticator device? You can
<a href="user/loginWithRecoveryCode?ReturnUrl=@ReturnUrl">log in with a recovery code</a>.
</p>
@code {
private string? message;
private AdminUser user = default!;
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
[SupplyParameterFromQuery] private string? ReturnUrl { get; set; }
[SupplyParameterFromQuery] private bool RememberMe { get; set; }
protected override async Task OnInitializedAsync()
{
// Ensure the user has gone through the username & password screen first
user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ??
throw new InvalidOperationException("Unable to load two-factor authentication user.");
}
private async Task OnValidSubmitAsync()
{
var authenticatorCode = Input.TwoFactorCode!.Replace(" ", string.Empty).Replace("-", string.Empty);
var result = await SignInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, RememberMe, Input.RememberMachine);
var userId = await UserManager.GetUserIdAsync(user);
if (result.Succeeded)
{
Logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", userId);
RedirectManager.RedirectTo(ReturnUrl);
}
else if (result.IsLockedOut)
{
Logger.LogWarning("User with ID '{UserId}' account locked out.", userId);
RedirectManager.RedirectTo("Account/Lockout");
}
else
{
Logger.LogWarning("Invalid authenticator code entered for user with ID '{UserId}'.", userId);
message = "Error: Invalid authenticator code.";
}
}
private sealed class InputModel
{
[Required]
[StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Text)]
[Display(Name = "Authenticator code")]
public string? TwoFactorCode { get; set; }
[Display(Name = "Remember this machine")]
public bool RememberMachine { get; set; }
}
}

View File

@@ -1,20 +0,0 @@
@page "/user/logout"
@using AliasVault.Admin2.Services
@using Microsoft.AspNetCore.Identity
@inject SignInManager<IdentityUser> SignInManager
@inject NavigationManager NavigationManager
@inject GlobalNotificationService GlobalNotificationService
@code {
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
// Sign out the user
await SignInManager.SignOutAsync();
GlobalNotificationService.ClearMessages();
// Redirect to the home page with hard refresh.
NavigationManager.NavigateTo("/", true);
}
}

View File

@@ -1,3 +0,0 @@
@using AliasVault.Admin2.Account.Shared
@using AliasVault.Admin2.Main.Layout
@layout AuthLayout

View File

@@ -1,51 +0,0 @@
@inherits LayoutComponentBase
@implements IDisposable
@inject NavigationManager NavigationManager
<div class="page">
<main>
<div class="top-row px-4">
<div class="nav-item px-3">
<NavLink class="nav-link" href="user/register">
<span class="bi bi-person-nav-menu" aria-hidden="true"></span> Register
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="user/login">
<span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span> Login
</NavLink>
</div>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
@code {
private string? currentUrl;
protected override void OnInitialized()
{
currentUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
NavigationManager.LocationChanged += OnLocationChanged;
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
currentUrl = NavigationManager.ToBaseRelativePath(e.Location);
StateHasChanged();
}
public void Dispose()
{
NavigationManager.LocationChanged -= OnLocationChanged;
}
}

View File

@@ -1,26 +0,0 @@
<StatusMessage Message="@StatusMessage"/>
<h3>Recovery codes</h3>
<div class="alert alert-warning" role="alert">
<p>
<strong>Put these codes in a safe place.</strong>
</p>
<p>
If you lose your device and don't have the recovery codes you will lose access to your account.
</p>
</div>
<div class="row">
<div class="col-md-12">
@foreach (var recoveryCode in RecoveryCodes)
{
<div>
<code class="recovery-code">@recoveryCode</code>
</div>
}
</div>
</div>
@code {
[Parameter] public string[] RecoveryCodes { get; set; } = [];
[Parameter] public string? StatusMessage { get; set; }
}

View File

@@ -1,28 +0,0 @@
@if (!string.IsNullOrEmpty(DisplayMessage))
{
var statusMessageClass = DisplayMessage.StartsWith("Error") ? "danger" : "success";
<div class="alert alert-@statusMessageClass" role="alert">
@DisplayMessage
</div>
}
@code {
private string? messageFromCookie;
[Parameter] public string? Message { get; set; }
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
private string? DisplayMessage => Message ?? messageFromCookie;
protected override void OnInitialized()
{
messageFromCookie = HttpContext.Request.Cookies[IdentityRedirectManager.StatusCookieName];
if (messageFromCookie is not null)
{
HttpContext.Response.Cookies.Delete(IdentityRedirectManager.StatusCookieName);
}
}
}

View File

@@ -26,28 +26,27 @@
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="Account\Pages\AccessDenied.razor" />
<AdditionalFiles Include="Account\Pages\ConfirmEmail.razor" />
<AdditionalFiles Include="Account\Pages\ConfirmEmailChange.razor" />
<AdditionalFiles Include="Account\Pages\ExternalLogin.razor" />
<AdditionalFiles Include="Account\Pages\ForgotPassword.razor" />
<AdditionalFiles Include="Account\Pages\ForgotPasswordConfirmation.razor" />
<AdditionalFiles Include="Account\Pages\InvalidPasswordReset.razor" />
<AdditionalFiles Include="Account\Pages\InvalidUser.razor" />
<AdditionalFiles Include="Account\Pages\Lockout.razor" />
<AdditionalFiles Include="Account\Pages\Login.razor" />
<AdditionalFiles Include="Account\Pages\LoginWith2fa.razor" />
<AdditionalFiles Include="Account\Pages\LoginWithRecoveryCode.razor" />
<AdditionalFiles Include="Account\Pages\Register.razor" />
<AdditionalFiles Include="Account\Pages\RegisterConfirmation.razor" />
<AdditionalFiles Include="Account\Pages\ResendEmailConfirmation.razor" />
<AdditionalFiles Include="Account\Pages\ResetPassword.razor" />
<AdditionalFiles Include="Account\Pages\ResetPasswordConfirmation.razor" />
<AdditionalFiles Include="Account\Pages\_Imports.razor" />
<AdditionalFiles Include="Account\Shared\AuthLayout.razor" />
<AdditionalFiles Include="Account\Shared\RedirectToLogin.razor" />
<AdditionalFiles Include="Account\Shared\ShowRecoveryCodes.razor" />
<AdditionalFiles Include="Account\Shared\StatusMessage.razor" />
<AdditionalFiles Include="Auth\Pages\AccessDenied.razor" />
<AdditionalFiles Include="Auth\Pages\ConfirmEmail.razor" />
<AdditionalFiles Include="Auth\Pages\ConfirmEmailChange.razor" />
<AdditionalFiles Include="Auth\Pages\ForgotPassword.razor" />
<AdditionalFiles Include="Auth\Pages\ForgotPasswordConfirmation.razor" />
<AdditionalFiles Include="Auth\Pages\InvalidPasswordReset.razor" />
<AdditionalFiles Include="Auth\Pages\InvalidUser.razor" />
<AdditionalFiles Include="Auth\Pages\Lockout.razor" />
<AdditionalFiles Include="Auth\Pages\Login.razor" />
<AdditionalFiles Include="Auth\Pages\LoginWith2fa.razor" />
<AdditionalFiles Include="Auth\Pages\LoginWithRecoveryCode.razor" />
<AdditionalFiles Include="Auth\Pages\Register.razor" />
<AdditionalFiles Include="Auth\Pages\RegisterConfirmation.razor" />
<AdditionalFiles Include="Auth\Pages\ResendEmailConfirmation.razor" />
<AdditionalFiles Include="Auth\Pages\ResetPassword.razor" />
<AdditionalFiles Include="Auth\Pages\ResetPasswordConfirmation.razor" />
<AdditionalFiles Include="Auth\Pages\_Imports.razor" />
<AdditionalFiles Include="Auth\Layout\AuthLayout.razor" />
<AdditionalFiles Include="Auth\Layout\RedirectToLogin.razor" />
<AdditionalFiles Include="Auth\Layout\ShowRecoveryCodes.razor" />
<AdditionalFiles Include="Auth\Layout\StatusMessage.razor" />
<AdditionalFiles Include="Main\Components\Alerts\AlertMessageError.razor" />
<AdditionalFiles Include="Main\Components\Alerts\AlertMessageSuccess.razor" />
<AdditionalFiles Include="Main\Components\Alerts\GlobalNotificationDisplay.razor" />

View File

@@ -0,0 +1,41 @@
@using System.Linq.Expressions
<InputText @attributes="AdditionalAttributes"
id="@Id"
Value="@Value"
ValueChanged="ValueChanged"
ValueExpression="ValueExpression"
placeholder="@Placeholder"
class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500" />
@code {
/// <summary>
/// Gets or sets the ID of the input field.
/// </summary>
[Parameter] public string Id { get; set; } = null!;
/// <summary>
/// Gets or sets the value of the input field.
/// </summary>
[Parameter] public string Value { get; set; } = null!;
/// <summary>
/// Gets or sets the event callback that is triggered when the value changes.
/// </summary>
[Parameter] public EventCallback<string?> ValueChanged { get; set; }
/// <summary>
/// Gets or sets the expression that identifies the value property.
/// </summary>
[Parameter] public Expression<Func<string>> ValueExpression { get; set; } = null!;
/// <summary>
/// Gets or sets the placeholder text for the input field.
/// </summary>
[Parameter] public string Placeholder { get; set; } = null!;
/// <summary>
/// Gets or sets additional attributes for the input field.
/// </summary>
[Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object?>? AdditionalAttributes { get; set; } = new();
}

View File

@@ -0,0 +1,5 @@
<a href="/" class="flex items-center justify-center mb-8 text-2xl font-semibold lg:mb-10 dark:text-white">
<img src="horizontal-logo-cropped.png" alt="AliasVault" class="img-fluid" style="max-width: 330px;"/>
<span class="ps-2 self-center hidden sm:flex text-lg font-bold whitespace-nowrap text-white bg-red-600 rounded-full px-2 py-1 ml-2">Admin</span>
</a>

View File

@@ -1,13 +1,9 @@
using System.Security.Claims;
using System.Text.Json;
using AliasServerDb;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;
using AliasVault.Admin2.Account.Pages;
namespace Microsoft.AspNetCore.Routing;

View File

@@ -2,7 +2,7 @@ using AliasServerDb;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
namespace AliasVault.Admin2.Account;
namespace AliasVault.Admin2.Auth;
// Remove the "else if (EmailSender is IdentityNoOpEmailSender)" block from RegisterConfirmation.razor after updating with a real implementation.
internal sealed class IdentityNoOpEmailSender : IEmailSender<AdminUser>

View File

@@ -1,7 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
namespace AliasVault.Admin2.Account;
namespace AliasVault.Admin2.Auth;
internal sealed class IdentityRedirectManager(NavigationManager navigationManager)
{

View File

@@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace AliasVault.Admin2.Account;
namespace AliasVault.Admin2.Auth;
// This is a server-side AuthenticationStateProvider that revalidates the security stamp for the connected user
// every 30 minutes an interactive circuit is connected.

View File

@@ -0,0 +1,34 @@
@inherits LayoutComponentBase
@using AliasVault.Admin2.Auth.Components
@implements IDisposable
@inject NavigationManager NavigationManager
<div class="flex flex-col items-center justify-center px-6 pt-8 mx-auto md:h-screen pt:mt-0 dark:bg-gray-900">
<Logo />
<div class="w-full max-w-xl p-6 space-y-4 sm:p-8 bg-white rounded-lg shadow dark:bg-gray-800">
@Body
</div>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
@code {
protected override void OnInitialized()
{
NavigationManager.LocationChanged += OnLocationChanged;
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
StateHasChanged();
}
public void Dispose()
{
NavigationManager.LocationChanged -= OnLocationChanged;
}
}

View File

@@ -1,6 +1,6 @@
@page "/Account/AccessDenied"
<PageTitleLayout>Access denied</PageTitleLayout>
<LayoutPageTitle>Access denied</LayoutPageTitle>
<header>
<h1 class="text-danger">Access denied</h1>

View File

@@ -8,7 +8,7 @@
@inject UserManager<AdminUser> UserManager
@inject IdentityRedirectManager RedirectManager
<PageTitleLayout>Confirm email</PageTitleLayout>
<LayoutPageTitle>Confirm email</LayoutPageTitle>
<h1>Confirm email</h1>
<StatusMessage Message="@statusMessage"/>

View File

@@ -8,7 +8,7 @@
@inject SignInManager<AdminUser> SignInManager
@inject IdentityRedirectManager RedirectManager
<PageTitleLayout>Confirm email change</PageTitleLayout>
<LayoutPageTitle>Confirm email change</LayoutPageTitle>
<h1>Confirm email change</h1>

View File

@@ -0,0 +1,67 @@
@page "/user/forgot-password"
@using System.ComponentModel.DataAnnotations
@using System.Text
@using System.Text.Encodings.Web
@using Microsoft.AspNetCore.Identity
@using Microsoft.AspNetCore.WebUtilities
@inject UserManager<AdminUser> UserManager
@inject IEmailSender<AdminUser> EmailSender
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
<LayoutPageTitle>Forgot your password?</LayoutPageTitle>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Forgot your password?
</h2>
<h3 class="text-xl text-gray-700 dark:text-gray-300 mb-4">
Enter your email.
</h3>
<hr class="mb-6"/>
<div class="w-full max-w-md">
<EditForm Model="Input" FormName="forgot-password" OnValidSubmit="OnValidSubmitAsync" method="post" class="space-y-6">
<DataAnnotationsValidator/>
<ValidationSummary class="text-red-600 dark:text-red-400" role="alert"/>
<div>
<label for="email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Email</label>
<InputText @bind-Value="Input.Email" id="email" class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" autocomplete="username" aria-required="true" placeholder="name@example.com"/>
<ValidationMessage For="() => Input.Email" class="text-red-600 dark:text-red-400 text-sm mt-1"/>
</div>
<button type="submit" class="w-full text-white bg-primary-600 hover:bg-primary-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Reset password</button>
</EditForm>
</div>
@code {
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
private async Task OnValidSubmitAsync()
{
var user = await UserManager.FindByEmailAsync(Input.Email);
if (user is null || !(await UserManager.IsEmailConfirmedAsync(user)))
{
// Don't reveal that the user does not exist or is not confirmed
RedirectManager.RedirectTo("Account/ForgotPasswordConfirmation");
}
// For more information on how to enable account confirmation and password reset please
// visit https://go.microsoft.com/fwlink/?LinkID=532713
var code = await UserManager.GeneratePasswordResetTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = NavigationManager.GetUriWithQueryParameters(
NavigationManager.ToAbsoluteUri("Account/ResetPassword").AbsoluteUri,
new Dictionary<string, object?> { ["code"] = code });
await EmailSender.SendPasswordResetLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));
RedirectManager.RedirectTo("user/forgot-password-confirm");
}
private sealed class InputModel
{
[Required] [EmailAddress] public string Email { get; set; } = "";
}
}

View File

@@ -0,0 +1,12 @@
@page "/user/forgot-password-confirm"
<LayoutPageTitle>Forgot password confirmation</LayoutPageTitle>
<div class="max-w-md mx-auto mt-8 p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Forgot password confirmation
</h2>
<p class="text-gray-700 dark:text-gray-300">
Please check your email to reset your password.
</p>
</div>

View File

@@ -1,6 +1,6 @@
@page "/Account/InvalidPasswordReset"
<PageTitleLayout>Invalid password reset</PageTitleLayout>
<LayoutPageTitle>Invalid password reset</LayoutPageTitle>
<h1>Invalid password reset</h1>
<p>

View File

@@ -1,6 +1,6 @@
@page "/Account/InvalidUser"
<PageTitleLayout>Invalid user</PageTitleLayout>
<LayoutPageTitle>Invalid user</LayoutPageTitle>
<h3>Invalid user</h3>

View File

@@ -1,6 +1,6 @@
@page "/Account/Lockout"
<PageTitleLayout>Locked out</PageTitleLayout>
<LayoutPageTitle>Locked out</LayoutPageTitle>
<header>
<h1 class="text-danger">Locked out</h1>

View File

@@ -0,0 +1,104 @@
@page "/user/login"
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Identity
@inject SignInManager<AdminUser> SignInManager
@inject ILogger<Login> Logger
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
<LayoutPageTitle>Log in</LayoutPageTitle>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
Sign in to AliasVault Admin
</h2>
<EditForm Model="Input" FormName="LoginForm" OnValidSubmit="LoginUser" class="mt-8 space-y-6">
<DataAnnotationsValidator/>
<div>
<label asp-for="Input.Email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your email</label>
<InputTextField id="email" @bind-Value="Input.Email" placeholder="name@company.com" />
<ValidationMessage For="() => Input.Email"/>
</div>
<div>
<label asp-for="Input.Password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your password</label>
<InputTextField id="password" @bind-Value="Input.Password" type="password" placeholder="••••••••" />
<ValidationMessage For="() => Input.Password"/>
</div>
<div class="flex items-start">
<div class="flex items-center h-5">
<input id="remember" aria-describedby="remember" name="remember" type="checkbox" class="w-4 h-4 border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-primary-300 dark:focus:ring-primary-600 dark:ring-offset-gray-800 dark:bg-gray-700 dark:border-gray-600">
</div>
<div class="ml-3 text-sm">
<label for="remember" class="font-medium text-gray-900 dark:text-white">Remember me</label>
</div>
<a href="/user/forgot-password" class="ml-auto text-sm text-primary-700 hover:underline dark:text-primary-500">Lost Password?</a>
</div>
<button type="submit" class="w-full px-5 py-3 text-base font-medium text-center text-white bg-primary-700 rounded-lg hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 sm:w-auto dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Login to your account</button>
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">
Not registered? <a href="/user/register" class="text-primary-700 hover:underline dark:text-primary-500">Create account</a>
</div>
</EditForm>
@code {
private string? errorMessage;
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
[SupplyParameterFromQuery] private string? ReturnUrl { get; set; }
protected override async Task OnInitializedAsync()
{
if (HttpMethods.IsGet(HttpContext.Request.Method))
{
// Clear the existing external cookie to ensure a clean login process
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
}
}
public async Task LoginUser()
{
// This doesn't count login failures towards account lockout
// To enable password failures to trigger account lockout, set lockoutOnFailure: true
var result = await SignInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false);
if (result.Succeeded)
{
Logger.LogInformation("User logged in.");
RedirectManager.RedirectTo(ReturnUrl);
}
else if (result.RequiresTwoFactor)
{
RedirectManager.RedirectTo(
"user/loginWith2fa",
new() { ["returnUrl"] = ReturnUrl, ["rememberMe"] = Input.RememberMe });
}
else if (result.IsLockedOut)
{
Logger.LogWarning("User account locked out.");
RedirectManager.RedirectTo("Account/Lockout");
}
else
{
errorMessage = "Error: Invalid login attempt.";
}
}
private sealed class InputModel
{
[Required] [EmailAddress] public string Email { get; set; } = "";
[Required]
[DataType(DataType.Password)]
public string Password { get; set; } = "";
[Display(Name = "Remember me?")] public bool RememberMe { get; set; }
}
}

View File

@@ -0,0 +1,98 @@
@page "/user/loginWith2fa"
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Identity
@inject SignInManager<AdminUser> SignInManager
@inject UserManager<AdminUser> UserManager
@inject IdentityRedirectManager RedirectManager
@inject ILogger<LoginWith2fa> Logger
<LayoutPageTitle>Two-factor authentication</LayoutPageTitle>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Two-factor authentication
</h2>
<hr class="mb-4"/>
<StatusMessage Message="@message" class="mb-4"/>
<p class="text-gray-700 dark:text-gray-300 mb-6">Your login is protected with an authenticator app. Enter your authenticator code below.</p>
<div class="w-full max-w-md">
<EditForm Model="Input" FormName="login-with-2fa" OnValidSubmit="OnValidSubmitAsync" method="post" class="space-y-6">
<input type="hidden" name="ReturnUrl" value="@ReturnUrl"/>
<input type="hidden" name="RememberMe" value="@RememberMe"/>
<DataAnnotationsValidator/>
<ValidationSummary class="text-red-600 dark:text-red-400" role="alert"/>
<div>
<label for="two-factor-code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authenticator code</label>
<InputText @bind-Value="Input.TwoFactorCode" id="two-factor-code" class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" autocomplete="off"/>
<ValidationMessage For="() => Input.TwoFactorCode" class="text-red-600 dark:text-red-400 text-sm mt-1"/>
</div>
<div class="flex items-start">
<div class="flex items-center h-5">
<InputCheckbox @bind-Value="Input.RememberMachine" id="remember-machine" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-primary-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-primary-600 dark:ring-offset-gray-800"/>
</div>
<div class="ml-3 text-sm">
<label for="remember-machine" class="font-medium text-gray-900 dark:text-white">Remember this machine</label>
</div>
</div>
<button type="submit" class="w-full text-white bg-primary-600 hover:bg-primary-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Log in</button>
</EditForm>
</div>
<p class="mt-6 text-sm text-gray-700 dark:text-gray-300">
Don't have access to your authenticator device? You can
<a href="user/loginWithRecoveryCode?ReturnUrl=@ReturnUrl" class="text-primary-600 hover:underline dark:text-primary-500">log in with a recovery code</a>.
</p>
@code {
private string? message;
private AdminUser user = default!;
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
[SupplyParameterFromQuery] private string? ReturnUrl { get; set; }
[SupplyParameterFromQuery] private bool RememberMe { get; set; }
protected override async Task OnInitializedAsync()
{
// Ensure the user has gone through the username & password screen first
user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ??
throw new InvalidOperationException("Unable to load two-factor authentication user.");
}
private async Task OnValidSubmitAsync()
{
var authenticatorCode = Input.TwoFactorCode!.Replace(" ", string.Empty).Replace("-", string.Empty);
var result = await SignInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, RememberMe, Input.RememberMachine);
var userId = await UserManager.GetUserIdAsync(user);
if (result.Succeeded)
{
Logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", userId);
RedirectManager.RedirectTo(ReturnUrl);
}
else if (result.IsLockedOut)
{
Logger.LogWarning("User with ID '{UserId}' account locked out.", userId);
RedirectManager.RedirectTo("Account/Lockout");
}
else
{
Logger.LogWarning("Invalid authenticator code entered for user with ID '{UserId}'.", userId);
message = "Error: Invalid authenticator code.";
}
}
private sealed class InputModel
{
[Required]
[StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Text)]
[Display(Name = "Authenticator code")]
public string? TwoFactorCode { get; set; }
[Display(Name = "Remember this machine")]
public bool RememberMachine { get; set; }
}
}

View File

@@ -8,28 +8,28 @@
@inject IdentityRedirectManager RedirectManager
@inject ILogger<LoginWithRecoveryCode> Logger
<PageTitleLayout>Recovery code verification</PageTitleLayout>
<LayoutPageTitle>Recovery code verification</LayoutPageTitle>
<h1>Recovery code verification</h1>
<hr/>
<StatusMessage Message="@message"/>
<p>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Recovery code verification
</h2>
<hr class="mb-4"/>
<StatusMessage Message="@message" class="mb-4"/>
<p class="text-gray-700 dark:text-gray-300 mb-6">
You have requested to log in with a recovery code. This login will not be remembered until you provide
an authenticator app code at log in or disable 2FA and log in again.
</p>
<div class="row">
<div class="col-md-4">
<EditForm Model="Input" FormName="login-with-recovery-code" OnValidSubmit="OnValidSubmitAsync" method="post">
<DataAnnotationsValidator/>
<ValidationSummary class="text-danger" role="alert"/>
<div class="form-floating mb-3">
<InputText @bind-Value="Input.RecoveryCode" class="form-control" autocomplete="off" placeholder="RecoveryCode"/>
<label for="recovery-code" class="form-label">Recovery Code</label>
<ValidationMessage For="() => Input.RecoveryCode" class="text-danger"/>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
</EditForm>
</div>
<div class="w-full max-w-md">
<EditForm Model="Input" FormName="login-with-recovery-code" OnValidSubmit="OnValidSubmitAsync" method="post" class="space-y-6">
<DataAnnotationsValidator/>
<ValidationSummary class="text-red-600 dark:text-red-400" role="alert"/>
<div>
<label for="recovery-code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Recovery Code</label>
<InputText @bind-Value="Input.RecoveryCode" id="recovery-code" class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" autocomplete="off" placeholder="Enter your recovery code"/>
<ValidationMessage For="() => Input.RecoveryCode" class="text-red-600 dark:text-red-400 text-sm mt-1"/>
</div>
<button type="submit" class="w-full text-white bg-primary-600 hover:bg-primary-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Log in</button>
</EditForm>
</div>
@code {

View File

@@ -0,0 +1,38 @@
@page "/user/logout"
@using AliasVault.Admin2.Services
@using Microsoft.AspNetCore.Identity
@inject SignInManager<AdminUser> SignInManager
@inject NavigationManager NavigationManager
@inject GlobalNotificationService GlobalNotificationService
@code {
protected override async Task OnInitializedAsync()
{
// Sign out the user.
// NOTE: the try/catch below is a workaround for the issue that the sign out does not work when
// the server session is already started.
try
{
try
{
await SignInManager.SignOutAsync();
GlobalNotificationService.ClearMessages();
// Redirect to the home page with hard refresh.
NavigationManager.NavigateTo("/", true);
}
catch
{
// Hard refresh current page if sign out fails. When an interactive server session is already started
// the sign out will fail because it tries to mutate cookies which is only possible when the server
// session is not started yet.
NavigationManager.NavigateTo(NavigationManager.Uri, true);
}
}
catch
{
// Redirect to the home page with hard refresh.
NavigationManager.NavigateTo("/", true);
}
}
}

View File

@@ -14,44 +14,35 @@
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
<PageTitleLayout>Register</PageTitleLayout>
<LayoutPageTitle>Register</LayoutPageTitle>
<h1>Register</h1>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
Register admin account
</h2>
<div class="row">
<div class="col-md-4">
<StatusMessage Message="@Message"/>
<EditForm Model="Input" asp-route-returnUrl="@ReturnUrl" method="post" OnValidSubmit="RegisterUser" FormName="register">
<DataAnnotationsValidator/>
<h2>Create a new account.</h2>
<hr/>
<ValidationSummary class="text-danger" role="alert"/>
<div class="form-floating mb-3">
<InputText @bind-Value="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com"/>
<label for="email">Email</label>
<ValidationMessage For="() => Input.Email" class="text-danger"/>
</div>
<div class="form-floating mb-3">
<InputText type="password" @bind-Value="Input.Password" class="form-control" autocomplete="new-password" aria-required="true" placeholder="password"/>
<label for="password">Password</label>
<ValidationMessage For="() => Input.Password" class="text-danger"/>
</div>
<div class="form-floating mb-3">
<InputText type="password" @bind-Value="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="password"/>
<label for="confirm-password">Confirm Password</label>
<ValidationMessage For="() => Input.ConfirmPassword" class="text-danger"/>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Register</button>
</EditForm>
<EditForm Model="Input" OnValidSubmit="RegisterUser" FormName="register" class="mt-8 space-y-6">
<DataAnnotationsValidator/>
<div>
<label asp-for="Input.Email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your email</label>
<InputTextField id="email" @bind-Value="Input.Email" placeholder="name@company.com" />
<ValidationMessage For="() => Input.Email"/>
</div>
<div class="col-md-6 col-md-offset-2">
<section>
<h3>Use another service to register.</h3>
<hr/>
<ExternalLoginPicker/>
</section>
<div>
<label asp-for="Input.Password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your password</label>
<InputTextField id="password" @bind-Value="Input.Password" type="password" placeholder="••••••••" />
<ValidationMessage For="() => Input.Password"/>
</div>
</div>
<div>
<label asp-for="Input.Password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Confirm password</label>
<InputTextField id="password2" @bind-Value="Input.ConfirmPassword" type="password" placeholder="••••••••" />
<ValidationMessage For="() => Input.ConfirmPassword"/>
</div>
<button type="submit" class="w-full px-5 py-3 text-base font-medium text-center text-white bg-primary-700 rounded-lg hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 sm:w-auto dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Create account</button>
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">
Already registered? <a href="/user/login" class="text-primary-700 hover:underline dark:text-primary-500">Login here</a>
</div>
</EditForm>
@code {
private IEnumerable<IdentityError>? identityErrors;

View File

@@ -9,7 +9,7 @@
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
<PageTitleLayout>Register confirmation</PageTitleLayout>
<LayoutPageTitle>Register confirmation</LayoutPageTitle>
<h1>Register confirmation</h1>

View File

@@ -9,9 +9,8 @@
@inject UserManager<AdminUser> UserManager
@inject IEmailSender<AdminUser> EmailSender
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
<PageTitleLayout>Resend email confirmation</PageTitleLayout>
<LayoutPageTitle>Resend email confirmation</LayoutPageTitle>
<h1>Resend email confirmation</h1>
<h2>Enter your email.</h2>

View File

@@ -8,7 +8,7 @@
@inject IdentityRedirectManager RedirectManager
@inject UserManager<AdminUser> UserManager
<PageTitleLayout>Reset password</PageTitleLayout>
<LayoutPageTitle>Reset password</LayoutPageTitle>
<h1>Reset password</h1>
<h2>Reset your password.</h2>

View File

@@ -1,5 +1,5 @@
@page "/Account/ResetPasswordConfirmation"
<PageTitleLayout>Reset password confirmation</PageTitleLayout>
<LayoutPageTitle>Reset password confirmation</LayoutPageTitle>
<h1>Reset password confirmation</h1>
<p>

View File

@@ -0,0 +1,5 @@
@using AliasVault.Admin2.Auth.Components
@using AliasVault.Admin2.Auth.Layout
@using AliasVault.Admin2.Main.Components.Layout
@using AliasVault.Admin2.Main.Layout
@layout AuthLayout

View File

@@ -14,6 +14,7 @@
<body class="bg-gray-50 dark:bg-gray-800">
<Routes @rendermode="RenderModeForPage"/>
<script src="lib/qrcode.min.js?v=dev"></script>
<script src="js/dark-mode.js?v=dev"></script>
<script src="js/utilities.js?v=dev"></script>
@@ -47,6 +48,10 @@
}
};
window.isFunctionDefined = function(functionName) {
return typeof window[functionName] === 'function';
};
// Primarily used by E2E tests.
window.blazorNavigate = (url) => {
Blazor.navigateTo(url);

View File

@@ -8,7 +8,6 @@
@inject UserManager<AdminUser> UserManager
@inject UrlEncoder UrlEncoder
@inject IdentityRedirectManager RedirectManager
@inject ILogger<EnableAuthenticator> Logger
<PageTitleLayout>Configure authenticator app</PageTitleLayout>
@@ -38,7 +37,7 @@ else
<p>Scan the QR Code or enter this key <kbd>@sharedKey</kbd> into your two factor authenticator app. Spaces and casing do not matter.</p>
<div class="alert alert-info">Learn how to <a href="https://go.microsoft.com/fwlink/?Linkid=852423">enable QR code generation</a>.</div>
<div></div>
<div data-url="@authenticatorUri"></div>
<div id="authenticator-uri" data-url="@authenticatorUri"></div>
</li>
<li>
<p>
@@ -79,6 +78,8 @@ else
await base.OnInitializedAsync();
await LoadSharedKeyAndQrCodeUriAsync(UserService.User());
await JsInvokeService.RetryInvokeAsync("generateQrCode", TimeSpan.Zero, 5, "authenticator-uri");
}
private async Task OnValidSubmitAsync()
@@ -151,7 +152,7 @@ else
return string.Format(
CultureInfo.InvariantCulture,
AuthenticatorUriFormat,
UrlEncoder.Encode("Microsoft.AspNetCore.Identity.UI"),
UrlEncoder.Encode("AliasVault Admin"),
UrlEncoder.Encode(email),
unformattedKey);
}

View File

@@ -1,6 +1,6 @@
@layout ManageLayout
@inherits MainBase
@using AliasVault.Admin2.Account
@using AliasVault.Admin2.Auth
@using AliasVault.Admin2.Main.Pages.Account.Manage.Components
@using AliasVault.Admin2.Main.Components.Layout
@attribute [Microsoft.AspNetCore.Authorization.Authorize]

View File

@@ -1,10 +1,9 @@
using AliasServerDb;
using Microsoft.AspNetCore.Authorization;
namespace AliasVault.Admin2.Main.Pages;
namespace AliasVault.Admin2.Main.Pages;
using AliasServerDb;
using AliasVault.Admin2.Main.Models;
using AliasVault.Admin2.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;

View File

@@ -1,5 +1,4 @@
@using AliasVault.Admin2.Account.Shared
<Router AppAssembly="typeof(Program).Assembly">
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
<NotAuthorized>

View File

@@ -10,6 +10,7 @@
@using Microsoft.EntityFrameworkCore
@using Microsoft.JSInterop
@using AliasVault.Admin2
@using AliasVault.Admin2.Auth.Components
@using AliasVault.Admin2.Main
@using AliasVault.Admin2.Main.Components
@using AliasVault.Admin2.Main.Components.Alerts

View File

@@ -3,7 +3,7 @@ using AliasServerDb;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using AliasVault.Admin2.Account;
using AliasVault.Admin2.Auth;
using AliasVault.Admin2.Main;
using AliasVault.Admin2.Services;
using Microsoft.Data.Sqlite;

View File

@@ -728,6 +728,26 @@ video {
margin-left: 0.5rem;
}
.mb-8 {
margin-bottom: 2rem;
}
.ml-3 {
margin-left: 0.75rem;
}
.ml-auto {
margin-left: auto;
}
.mt-8 {
margin-top: 2rem;
}
.mt-6 {
margin-top: 1.5rem;
}
.line-clamp-2 {
overflow: hidden;
display: -webkit-box;
@@ -790,6 +810,10 @@ video {
height: 100%;
}
.h-4 {
height: 1rem;
}
.w-1\/2 {
width: 50%;
}
@@ -818,10 +842,22 @@ video {
width: 100%;
}
.w-4 {
width: 1rem;
}
.max-w-screen-2xl {
max-width: 1536px;
}
.max-w-xl {
max-width: 36rem;
}
.max-w-md {
max-width: 28rem;
}
.flex-shrink-0 {
flex-shrink: 0;
}
@@ -858,6 +894,10 @@ video {
flex-wrap: wrap;
}
.items-start {
align-items: flex-start;
}
.items-center {
align-items: center;
}
@@ -900,6 +940,18 @@ video {
margin-bottom: calc(0.25rem * var(--tw-space-y-reverse));
}
.space-y-4 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(1rem * var(--tw-space-y-reverse));
}
.space-y-6 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
}
.divide-y > :not([hidden]) ~ :not([hidden]) {
--tw-divide-y-reverse: 0;
border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
@@ -1048,6 +1100,16 @@ video {
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
}
.bg-primary-700 {
--tw-bg-opacity: 1;
background-color: rgb(184 112 47 / var(--tw-bg-opacity));
}
.bg-primary-600 {
--tw-bg-opacity: 1;
background-color: rgb(214 131 56 / var(--tw-bg-opacity));
}
.bg-opacity-50 {
--tw-bg-opacity: 0.5;
}
@@ -1064,6 +1126,10 @@ video {
padding: 1rem;
}
.p-6 {
padding: 1.5rem;
}
.px-3 {
padding-left: 0.75rem;
padding-right: 0.75rem;
@@ -1114,6 +1180,16 @@ video {
padding-right: 0.5rem;
}
.px-5 {
padding-left: 1.25rem;
padding-right: 1.25rem;
}
.py-2\.5 {
padding-top: 0.625rem;
padding-bottom: 0.625rem;
}
.ps-2 {
padding-inline-start: 0.5rem;
}
@@ -1126,6 +1202,10 @@ video {
padding-top: 1.5rem;
}
.pt-8 {
padding-top: 2rem;
}
.text-left {
text-align: left;
}
@@ -1255,6 +1335,16 @@ video {
color: rgb(239 68 68 / var(--tw-text-opacity));
}
.text-primary-600 {
--tw-text-opacity: 1;
color: rgb(214 131 56 / var(--tw-text-opacity));
}
.text-red-600 {
--tw-text-opacity: 1;
color: rgb(220 38 38 / var(--tw-text-opacity));
}
.opacity-0 {
opacity: 0;
}
@@ -1336,6 +1426,16 @@ video {
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
}
.hover\:bg-primary-800:hover {
--tw-bg-opacity: 1;
background-color: rgb(154 93 38 / var(--tw-bg-opacity));
}
.hover\:bg-primary-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(184 112 47 / var(--tw-bg-opacity));
}
.hover\:text-gray-900:hover {
--tw-text-opacity: 1;
color: rgb(17 24 39 / var(--tw-text-opacity));
@@ -1366,6 +1466,16 @@ video {
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\:border-primary-600:focus {
--tw-border-opacity: 1;
border-color: rgb(214 131 56 / var(--tw-border-opacity));
}
.focus\:outline-none:focus {
outline: 2px solid transparent;
outline-offset: 2px;
@@ -1398,6 +1508,21 @@ video {
--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));
}
.focus\:ring-primary-500:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(244 149 65 / var(--tw-ring-opacity));
}
.focus\:ring-primary-300:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(248 185 99 / var(--tw-ring-opacity));
}
.focus\:ring-primary-600:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(214 131 56 / var(--tw-ring-opacity));
}
.dark\:divide-gray-600:is(.dark *) > :not([hidden]) ~ :not([hidden]) {
--tw-divide-opacity: 1;
border-color: rgb(75 85 99 / var(--tw-divide-opacity));
@@ -1408,6 +1533,11 @@ video {
border-color: rgb(55 65 81 / var(--tw-border-opacity));
}
.dark\:border-gray-600:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(75 85 99 / var(--tw-border-opacity));
}
.dark\:bg-gray-700:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(55 65 81 / var(--tw-bg-opacity));
@@ -1423,6 +1553,11 @@ video {
background-color: rgb(17 24 39 / var(--tw-bg-opacity));
}
.dark\:bg-primary-600:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(214 131 56 / var(--tw-bg-opacity));
}
.dark\:bg-opacity-80:is(.dark *) {
--tw-bg-opacity: 0.8;
}
@@ -1462,6 +1597,20 @@ video {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.dark\:placeholder-gray-400:is(.dark *)::-moz-placeholder {
--tw-placeholder-opacity: 1;
color: rgb(156 163 175 / var(--tw-placeholder-opacity));
}
.dark\:placeholder-gray-400:is(.dark *)::placeholder {
--tw-placeholder-opacity: 1;
color: rgb(156 163 175 / var(--tw-placeholder-opacity));
}
.dark\:ring-offset-gray-800:is(.dark *) {
--tw-ring-offset-color: #1f2937;
}
.dark\:hover\:bg-gray-600:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(75 85 99 / var(--tw-bg-opacity));
@@ -1472,6 +1621,11 @@ video {
background-color: rgb(55 65 81 / var(--tw-bg-opacity));
}
.dark\:hover\:bg-primary-700:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(184 112 47 / var(--tw-bg-opacity));
}
.dark\:hover\:text-primary-500:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(244 149 65 / var(--tw-text-opacity));
@@ -1482,6 +1636,16 @@ video {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.dark\:focus\:border-primary-500:focus:is(.dark *) {
--tw-border-opacity: 1;
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-gray-600:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(75 85 99 / var(--tw-ring-opacity));
@@ -1492,19 +1656,52 @@ video {
--tw-ring-color: rgb(55 65 81 / var(--tw-ring-opacity));
}
.dark\:focus\:ring-primary-500:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(244 149 65 / var(--tw-ring-opacity));
}
.dark\:focus\:ring-primary-600:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(214 131 56 / var(--tw-ring-opacity));
}
.dark\:focus\:ring-primary-800:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(154 93 38 / 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));
}
@media (min-width: 640px) {
.sm\:flex {
display: flex;
}
.sm\:w-auto {
width: auto;
}
.sm\:rounded-lg {
border-radius: 0.5rem;
}
.sm\:p-8 {
padding: 2rem;
}
.sm\:text-2xl {
font-size: 1.5rem;
line-height: 2rem;
}
.sm\:text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
}
@media (min-width: 768px) {
@@ -1528,6 +1725,10 @@ video {
display: flex;
}
.md\:h-screen {
height: 100vh;
}
.md\:w-1\/4 {
width: 25%;
}
@@ -1585,6 +1786,10 @@ video {
margin-top: 0px;
}
.lg\:mb-10 {
margin-bottom: 2.5rem;
}
.lg\:flex {
display: flex;
}

View File

@@ -9,3 +9,46 @@ function downloadFileFromStream(fileName, contentStreamReference) {
anchorElement.remove();
URL.revokeObjectURL(url);
}
/**
* Generate a QR code for the given id element that has a data-url attribute.
* @param id
*/
function generateQrCode(id) {
console.log(`Generating QR code for element with id "${id}".`);
// Find the element by id
const element = document.getElementById(id);
// Check if the element exists
if (!element) {
console.log(`Element with id "${id}" not found. QR code generation aborted.`);
return; // Silently fail
}
// Get the data-url attribute
const dataUrl = element.getAttribute('data-url');
// Check if data-url exists
if (!dataUrl) {
console.log(`No data-url attribute found on element with id "${id}". QR code generation aborted.`);
return; // Silently fail
}
// Create a container for the QR code
const qrContainer = document.createElement('div');
qrContainer.id = `qrcode-${id}`;
element.appendChild(qrContainer);
// Initialize QRCode object
const qrcode = new QRCode(qrContainer, {
text: dataUrl,
width: 256,
height: 256,
colorDark : "#000000",
colorLight : "#ffffff",
correctLevel : QRCode.CorrectLevel.H
});
console.log(`QR code generated successfully for element with id "${id}".`);
}

View File

File diff suppressed because one or more lines are too long