Files
aliasvault/apps/server/AliasVault.Client/Main/Pages/Credentials/AddEdit.razor
2025-07-28 16:39:59 +02:00

471 lines
19 KiB
Plaintext

@page "/credentials/create"
@page "/credentials/{id:guid}/edit"
@inherits MainBase
@inject CredentialService CredentialService
@inject IJSRuntime JSRuntime
@inject AliasVault.Client.Services.QuickCreateStateService QuickCreateStateService
@using AliasVault.Client.Services.JsInterop.Models
@using Microsoft.Extensions.Localization
@implements IAsyncDisposable
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="@(EditMode ? Localizer["EditCredentialTitle"] : Localizer["AddCredentialTitle"])"
Description="@(EditMode ? Localizer["EditCredentialDescription"] : Localizer["AddCredentialDescription"])">
<CustomActions>
<ConfirmButton OnClick="TriggerFormSubmit">@Localizer["SaveCredentialButton"]</ConfirmButton>
<CancelButton OnClick="Cancel">@SharedLocalizer["Cancel"]</CancelButton>
</CustomActions>
</PageHeader>
@if (Loading)
{
<LoadingIndicator />
}
else
{
<EditForm @ref="EditFormRef" Model="Obj" OnValidSubmit="SaveAlias">
<DataAnnotationsValidator />
<div class="grid grid-cols-1 px-4 pt-6 md:grid-cols-2 lg:grid-cols-3 md:gap-4 dark:bg-gray-900">
<div class="col-span-1 md:col-span-1 lg:col-span-1">
<div class="p-4 mb-4 bg-white border-2 border-primary-600 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-xl font-semibold dark:text-white">@Localizer["ServiceSectionHeader"]</h3>
<div class="grid gap-6">
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="service-name" Label="@Localizer["ServiceNameLabel"]" Placeholder="@Localizer["ServiceNamePlaceholder"]" @bind-Value="Obj.ServiceName"></EditFormRow>
<ValidationMessage For="() => Obj.ServiceName"/>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="service-url" OnFocus="OnFocusUrlInput" Label="@Localizer["ServiceUrlLabel"]" @bind-Value="Obj.ServiceUrl"></EditFormRow>
</div>
</div>
</div>
@if (EditMode && Id.HasValue)
{
<div class="col-span-1 md:col-span-1 lg:col-span-1">
<TotpCodes TotpCodeList="@Obj.TotpCodes" TotpCodesChanged="HandleTotpCodesChanged" />
</div>
}
else
{
<div class="col-span-1 md:col-span-1 lg:col-span-1">
<TotpCodes TotpCodeList="@Obj.TotpCodes" TotpCodesChanged="HandleTotpCodesChanged" />
</div>
}
<div class="col-span-1 md:col-span-1 lg:col-span-1">
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-xl font-semibold dark:text-white">@Localizer["NotesSectionHeader"]</h3>
<div class="grid gap-6">
<div class="col-span-6 sm:col-span-3">
<EditFormRow Type="textarea" Id="notes" Label="@Localizer["NotesLabel"]" @bind-Value="Obj.Notes"></EditFormRow>
</div>
</div>
</div>
</div>
<div class="col-span-1 md:col-span-1 lg:col-span-1">
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-xl font-semibold dark:text-white">@Localizer["AttachmentsSectionHeader"]</h3>
<div class="grid gap-6">
<div class="col-span-6 sm:col-span-3">
<AttachmentUploader
Attachments="@Obj.Attachments"
AttachmentsChanged="@HandleAttachmentsChanged" />
</div>
</div>
</div>
</div>
</div>
<div class="col-span-1 md:col-span-1 lg:col-span-2">
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-xl font-semibold dark:text-white">@Localizer["LoginCredentialsSectionHeader"]</h3>
<div class="grid gap-6">
<div class="col-span-6 sm:col-span-3">
<EditEmailFormRow Id="email" Label="@Localizer["EmailLabel"]" @bind-Value="Obj.Alias.Email"></EditEmailFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditUsernameFormRow Id="username" Label="@Localizer["UsernameLabel"]" @bind-Value="Obj.Username" OnGenerateNewUsername="GenerateRandomUsername"></EditUsernameFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditPasswordFormRow Id="password" Label="@Localizer["PasswordLabel"]" @bind-Value="Obj.Password.Value" ShowPassword="IsPasswordVisible"></EditPasswordFormRow>
</div>
</div>
</div>
<div class="col-span-1 md:col-span-1 lg:col-span-2">
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-xl font-semibold dark:text-white">@Localizer["AliasSectionHeader"]</h3>
<div class="mb-4">
<Button OnClick="GenerateRandomAlias" AdditionalClasses="flex items-center justify-center gap-1">
<svg class='w-5 h-5 inline-block' viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8" cy="8" r="1"/>
<circle cx="16" cy="8" r="1"/>
<circle cx="12" cy="12" r="1"/>
<circle cx="8" cy="16" r="1"/>
<circle cx="16" cy="16" r="1"/>
</svg>
@Localizer["GenerateRandomAliasButton"]
</Button>
</div>
<div class="grid gap-6">
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="first-name" Label="@Localizer["FirstNameLabel"]" @bind-Value="Obj.Alias.FirstName"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="last-name" Label="@Localizer["LastNameLabel"]" @bind-Value="Obj.Alias.LastName"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="nickname" Label="@Localizer["NickNameLabel"]" @bind-Value="Obj.Alias.NickName"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="gender" Label="@Localizer["GenderLabel"]" @bind-Value="Obj.Alias.Gender"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="birthdate" Label="@Localizer["BirthDateLabel"]" @bind-Value="Obj.AliasBirthDate"></EditFormRow>
<ValidationMessage For="() => Obj.AliasBirthDate"/>
</div>
</div>
</div>
</div>
</div>
</div>
<button type="submit" class="hidden">@Localizer["SaveCredentialButton"]</button>
</EditForm>
}
@code {
private IStringLocalizer Localizer => LocalizerFactory.Create("Pages.Main.Credentials.AddEdit", "AliasVault.Client");
/// <summary>
/// Gets or sets the Credentials ID.
/// </summary>
[Parameter]
public Guid? Id { get; set; }
private bool EditMode { get; set; }
private EditForm EditFormRef { get; set; } = null!;
private bool Loading { get; set; } = true;
private bool IsPasswordVisible { get; set; } = false;
private CredentialEdit Obj { get; set; } = new();
private IJSObjectReference? Module;
/// <inheritdoc />
async ValueTask IAsyncDisposable.DisposeAsync()
{
await KeyboardShortcutService.UnregisterShortcutAsync("gc");
if (Module is not null)
{
await Module.DisposeAsync();
}
}
/// <inheritdoc />
protected override void OnInitialized()
{
if (Id.HasValue)
{
// Edit mode
EditMode = true;
}
else
{
// Add mode
EditMode = false;
}
}
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
if (EditMode)
{
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["ViewCredentialBreadcrumb"], Url = $"/credentials/{Id}" });
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["EditCredentialBreadcrumb"] });
}
else
{
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["AddNewCredentialBreadcrumb"] });
}
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
Module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/modules/newIdentityWidget.js");
if (EditMode)
{
await LoadExistingCredential();
}
else
{
CreateNewCredential();
// Use the state service to pre-fill form data
if (!string.IsNullOrEmpty(QuickCreateStateService.ServiceName))
{
Obj.ServiceName = QuickCreateStateService.ServiceName;
}
if (!string.IsNullOrEmpty(QuickCreateStateService.ServiceUrl))
{
Obj.ServiceUrl = QuickCreateStateService.ServiceUrl;
}
// Clear the state after using it
QuickCreateStateService.ClearState();
}
Loading = false;
StateHasChanged();
if (!EditMode)
{
// When creating a new alias: start with focus on the service name input.
await JsInteropService.FocusElementById("service-name");
}
}
}
/// <summary>
/// Loads an existing credential for editing.
/// </summary>
private async Task LoadExistingCredential()
{
if (Id is null)
{
NavigateAwayWithError(Localizer["CredentialNotExistError"]);
return;
}
// Load existing Obj, retrieve from service
var alias = await CredentialService.LoadEntryAsync(Id.Value);
if (alias is null)
{
NavigateAwayWithError(Localizer["CredentialNotExistError"]);
return;
}
Obj = CredentialEdit.FromEntity(alias);
// If BirthDate is MinValue, set AliasBirthDate to empty string
// TODO: after date field in alias data model is made optional and
// all min values have been replaced with null, we can remove this check.
if (Obj.Alias.BirthDate == DateTime.MinValue)
{
Obj.AliasBirthDate = string.Empty;
}
if (Obj.ServiceUrl is null)
{
Obj.ServiceUrl = CredentialService.DefaultServiceUrl;
}
}
/// <summary>
/// Creates a new credential object.
/// </summary>
private Credential CreateNewCredentialObject()
{
var credential = new Credential();
credential.Alias = new Alias();
credential.Alias.Email = "@" + CredentialService.GetDefaultEmailDomain();
credential.Service = new Service();
credential.Passwords = new List<Password> { new Password() };
credential.TotpCodes = new List<TotpCode>();
return credential;
}
/// <summary>
/// Creates a new credential object.
/// </summary>
private void CreateNewCredential()
{
Obj = CredentialEdit.FromEntity(CreateNewCredentialObject());
// Always set AliasBirthDate to empty for new credentials
// TODO: after date field in alias data model is made optional and
// all min values have been replaced with null, we can remove this check.
Obj.AliasBirthDate = string.Empty;
Obj.ServiceUrl = CredentialService.DefaultServiceUrl;
}
/// <summary>
/// Adds an error message and navigates to the home page.
/// </summary>
private void NavigateAwayWithError(string errorMessage)
{
GlobalNotificationService.AddErrorMessage(errorMessage);
NavigationManager.NavigateTo("/credentials", false, true);
}
/// <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 (Obj.ServiceUrl != CredentialService.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('service-url').setSelectionRange({CredentialService.DefaultServiceUrl.Length}, {CredentialService.DefaultServiceUrl.Length})");
});
}
private void HandleAttachmentsChanged(List<Attachment> updatedAttachments)
{
Obj.Attachments = updatedAttachments;
StateHasChanged();
}
private void HandleTotpCodesChanged(List<TotpCode> updatedTotpCodes)
{
Obj.TotpCodes = updatedTotpCodes;
StateHasChanged();
}
private async Task GenerateRandomAlias()
{
GlobalLoadingSpinner.Show();
StateHasChanged();
if (EditMode)
{
// Store current username and password
string currentUsername = Obj.Username;
string currentPassword = Obj.Password.Value ?? string.Empty;
// Generate random identity but preserve username and password
Obj = CredentialEdit.FromEntity(await CredentialService.GenerateRandomIdentityAsync(Obj.ToEntity()));
// Restore username and password
Obj.Username = currentUsername;
Obj.Password.Value = currentPassword;
}
else
{
// For new credentials, generate everything
Obj = CredentialEdit.FromEntity(await CredentialService.GenerateRandomIdentityAsync(Obj.ToEntity()));
IsPasswordVisible = true;
}
GlobalLoadingSpinner.Hide();
StateHasChanged();
}
/// <summary>
/// Generate a new random username based on existing identity, or if no identity is present,
/// generate a new random identity.
/// </summary>
private async Task GenerateRandomUsername()
{
// If current object is null, then we create a new random identity.
AliasVaultIdentity identity;
if (Obj.Alias.FirstName is null && Obj.Alias.LastName is null && Obj.Alias.BirthDate == DateTime.MinValue)
{
// Create new Credential object to avoid modifying the original object
var randomIdentity = await CredentialService.GenerateRandomIdentityAsync(CreateNewCredentialObject());
identity = new AliasVaultIdentity
{
FirstName = randomIdentity.Alias.FirstName ?? string.Empty,
LastName = randomIdentity.Alias.LastName ?? string.Empty,
BirthDate = randomIdentity.Alias.BirthDate.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"),
Gender = randomIdentity.Alias.Gender,
NickName = randomIdentity.Alias.NickName ?? string.Empty,
};
}
else
{
// Assemble identity model with the current values
identity = new AliasVaultIdentity
{
FirstName = Obj.Alias.FirstName ?? string.Empty,
LastName = Obj.Alias.LastName ?? string.Empty,
BirthDate = Obj.Alias.BirthDate.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"),
Gender = Obj.Alias.Gender,
NickName = Obj.Alias.NickName ?? string.Empty,
};
}
Obj.Username = await JsInteropService.GenerateRandomUsernameAsync(identity);
}
/// <summary>
/// Cancel the edit operation and navigate back to the credentials view.
/// </summary>
private void Cancel()
{
NavigationManager.NavigateTo("/credentials/" + Id);
}
/// <summary>
/// Trigger the form submit.
/// </summary>
private async Task TriggerFormSubmit()
{
if (EditFormRef.EditContext?.Validate() == false)
{
return;
}
await SaveAlias();
}
/// <summary>
/// Save the alias to the database.
/// </summary>
private async Task SaveAlias()
{
GlobalLoadingSpinner.Show(Localizer["SavingVaultMessage"]);
StateHasChanged();
if (EditMode)
{
if (Id is not null)
{
Id = await CredentialService.UpdateEntryAsync(Obj.ToEntity());
}
}
else
{
Id = await CredentialService.InsertEntryAsync(Obj.ToEntity());
}
GlobalLoadingSpinner.Hide();
StateHasChanged();
if (Id is null || Id == Guid.Empty)
{
// Error saving.
GlobalNotificationService.AddErrorMessage(Localizer["ErrorSavingCredentials"], true);
return;
}
// No error, add success message.
if (EditMode)
{
GlobalNotificationService.AddSuccessMessage(Localizer["CredentialUpdatedSuccess"]);
}
else
{
GlobalNotificationService.AddSuccessMessage(Localizer["CredentialCreatedSuccess"]);
}
NavigationManager.NavigateTo("/credentials/" + Id);
}
}