Add Vault/Item navigation, list and details pages to AliasVault.Client (#1404)

This commit is contained in:
Leendert de Borst
2025-12-19 09:59:11 +01:00
parent 75377d795e
commit 12e9c0db2a
106 changed files with 3502 additions and 866 deletions

View File

@@ -74,6 +74,6 @@
private void ShowDetails()
{
// Redirect to view page instead for now.
NavigationManager.NavigateTo($"/credentials/{Obj.Id}");
NavigationManager.NavigateTo($"/items/{Obj.Id}");
}
}

View File

@@ -44,7 +44,7 @@
<p><span class="font-medium">@Localizer["DateLabel"]</span> @Email.DateSystem</p>
@if (!string.IsNullOrEmpty(CredentialName) && CredentialId != Guid.Empty)
{
<p><span class="font-medium">@Localizer["CredentialLabel"]</span>
<p><span class="font-medium">@Localizer["ItemLabel"]</span>
<button @onclick="@(() => OnCredentialClick.InvokeAsync(CredentialId))"
class="text-blue-600 hover:underline dark:text-blue-400 cursor-pointer">
@CredentialName
@@ -53,7 +53,7 @@
}
else
{
<p><span class="font-medium">@Localizer["CredentialLabel"]</span> <span class="text-gray-400 dark:text-gray-500">@Localizer["NoneValue"]</span></p>
<p><span class="font-medium">@Localizer["ItemLabel"]</span> <span class="text-gray-400 dark:text-gray-500">@Localizer["NoneValue"]</span></p>
}
</div>
</div>

View File

@@ -0,0 +1,151 @@
@using AliasVault.Client.Main.Utilities
@using AliasClientDb.Models
@using Microsoft.Extensions.Localization
@* FieldBlock component - renders a single field based on its type *@
@switch (Field.FieldType)
{
case "Password":
case "Hidden":
<div class="col-span-6">
<CopyPastePasswordFormRow
Id="@GetFieldId()"
Label="@GetLabel()"
Value="@(Field.Value ?? string.Empty)" />
</div>
break;
case "TextArea":
<div class="col-span-6">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">@GetLabel()</label>
<div class="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white whitespace-pre-wrap text-sm">
@RenderTextWithLinks(Field.Value ?? string.Empty)
</div>
</div>
break;
case "URL":
<div class="col-span-6">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">@GetLabel()</label>
@if (!string.IsNullOrEmpty(Field.Value))
{
@if (Field.Value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || Field.Value.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
<a href="@Field.Value" target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:underline break-all">
@Field.Value
</a>
}
else
{
<span class="text-gray-700 dark:text-gray-300 break-all">@Field.Value</span>
}
}
</div>
break;
case "Date":
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow
Id="@GetFieldId()"
Label="@GetLabel()"
Value="@(Field.Value ?? string.Empty)" />
</div>
break;
default:
@* Text, Email, Phone, Number - use standard copy/paste row *@
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow
Id="@GetFieldId()"
Label="@GetLabel()"
Value="@(Field.Value ?? string.Empty)" />
</div>
break;
}
@code {
[Inject]
private IStringLocalizerFactory LocalizerFactory { get; set; } = default!;
private IStringLocalizer Localizer => LocalizerFactory.Create("Components.Fields.FieldBlock", "AliasVault.Client");
/// <summary>
/// Gets or sets the display field to render.
/// </summary>
[Parameter]
public required DisplayField Field { get; set; }
/// <summary>
/// Gets the label for the field.
/// </summary>
private string GetLabel()
{
// Try to get localized label for system field
if (!string.IsNullOrEmpty(Field.FieldKey) && !Field.FieldKey.StartsWith("custom_"))
{
// Convert field key to localization key format
// e.g., "login.username" -> "FieldLabel_login_username"
var localizationKey = "FieldLabel_" + Field.FieldKey.Replace(".", "_");
var localizedLabel = Localizer[localizationKey];
// If the localized string is different from the key, return it
if (localizedLabel.ResourceNotFound == false)
{
return localizedLabel.Value;
}
// Fallback: convert field key to readable format
// e.g., "login.username" -> "Username"
var parts = Field.FieldKey.Split('.');
if (parts.Length > 1)
{
return FormatFieldName(parts[1]);
}
}
return FormatFieldName(Field.FieldKey);
}
/// <summary>
/// Formats a field name to be human-readable.
/// </summary>
private static string FormatFieldName(string fieldName)
{
if (string.IsNullOrEmpty(fieldName))
{
return string.Empty;
}
// Convert snake_case to Title Case
var words = fieldName.Split('_');
return string.Join(" ", words.Select(w =>
string.IsNullOrEmpty(w) ? string.Empty :
char.ToUpperInvariant(w[0]) + w[1..]));
}
/// <summary>
/// Gets a unique ID for the field.
/// </summary>
private string GetFieldId()
{
return !string.IsNullOrEmpty(Field.FieldKey)
? Field.FieldKey.Replace(".", "-")
: Field.FieldDefinitionId ?? Guid.NewGuid().ToString();
}
/// <summary>
/// Renders text with clickable links.
/// </summary>
private MarkupString RenderTextWithLinks(string text)
{
if (string.IsNullOrEmpty(text))
{
return new MarkupString(string.Empty);
}
// Simple URL regex pattern
var urlPattern = @"(https?://[^\s<>""]+)";
var result = System.Text.RegularExpressions.Regex.Replace(
System.Web.HttpUtility.HtmlEncode(text),
urlPattern,
"<a href=\"$1\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-blue-600 dark:text-blue-400 hover:underline\">$1</a>");
return new MarkupString(result);
}
}

View File

@@ -0,0 +1,167 @@
@using Microsoft.Extensions.Localization
@* DeleteFolderModal component - modal with two delete options for a folder *@
@if (IsOpen)
{
<div class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
@* Background overlay *@
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity dark:bg-gray-900 dark:bg-opacity-75" @onclick="HandleClose"></div>
@* Modal panel *@
<div class="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg dark:bg-gray-800">
<div class="bg-white px-4 pb-4 pt-5 sm:p-6 dark:bg-gray-800">
<div class="sm:flex sm:items-start">
<div class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10 dark:bg-red-900/30">
<svg class="h-6 w-6 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
<div class="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left flex-1">
<h3 class="text-lg font-semibold leading-6 text-gray-900 dark:text-white">
@Localizer["DeleteFolderTitle"]
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-400">
@string.Format(Localizer["DeleteFolderDescription"].Value, FolderName)
</p>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 space-y-2 dark:bg-gray-800/50">
@* Option 1: Delete folder, keep items *@
<button
type="button"
@onclick="HandleDeleteFolderOnly"
disabled="@IsDeleting"
class="w-full flex items-center gap-3 p-3 rounded-lg border border-orange-200 bg-orange-50 hover:bg-orange-100 dark:border-orange-800 dark:bg-orange-900/20 dark:hover:bg-orange-900/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
<div class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-full bg-orange-100 dark:bg-orange-900/40">
<svg class="w-5 h-5 text-orange-600 dark:text-orange-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z"/>
</svg>
</div>
<div class="flex-1 text-left">
<div class="font-medium text-orange-700 dark:text-orange-300">@Localizer["DeleteFolderOnlyTitle"]</div>
<div class="text-sm text-orange-600/80 dark:text-orange-400/80">@Localizer["DeleteFolderOnlyDescription"]</div>
</div>
</button>
@* Option 2: Delete folder and contents - only show if folder has items *@
@if (ItemCount > 0)
{
<button
type="button"
@onclick="HandleDeleteFolderAndContents"
disabled="@IsDeleting"
class="w-full flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 dark:border-red-800 dark:bg-red-900/20 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
<div class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-full bg-red-100 dark:bg-red-900/40">
<svg class="w-5 h-5 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</div>
<div class="flex-1 text-left">
<div class="font-medium text-red-700 dark:text-red-300">@Localizer["DeleteFolderAndContentsTitle"]</div>
<div class="text-sm text-red-600/80 dark:text-red-400/80">@string.Format(Localizer["DeleteFolderAndContentsDescription"].Value, ItemCount)</div>
</div>
</button>
}
@* Cancel button *@
<button
type="button"
@onclick="HandleClose"
class="w-full mt-2 inline-flex justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-700 dark:text-white dark:ring-gray-600 dark:hover:bg-gray-600">
@Localizer["CancelButton"]
</button>
</div>
</div>
</div>
</div>
}
@code {
[Inject]
private IStringLocalizerFactory LocalizerFactory { get; set; } = default!;
private IStringLocalizer Localizer => LocalizerFactory.Create("Components.Folders.DeleteFolderModal", "AliasVault.Client");
/// <summary>
/// Gets or sets whether the modal is open.
/// </summary>
[Parameter]
public bool IsOpen { get; set; }
/// <summary>
/// Gets or sets the folder name to display.
/// </summary>
[Parameter]
public string FolderName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the number of items in the folder.
/// </summary>
[Parameter]
public int ItemCount { get; set; }
/// <summary>
/// Gets or sets the close callback.
/// </summary>
[Parameter]
public EventCallback OnClose { get; set; }
/// <summary>
/// Gets or sets the callback for deleting folder only (keeping items).
/// </summary>
[Parameter]
public EventCallback OnDeleteFolderOnly { get; set; }
/// <summary>
/// Gets or sets the callback for deleting folder and its contents.
/// </summary>
[Parameter]
public EventCallback OnDeleteFolderAndContents { get; set; }
private bool IsDeleting { get; set; }
private async Task HandleClose()
{
await OnClose.InvokeAsync();
}
private async Task HandleDeleteFolderOnly()
{
IsDeleting = true;
StateHasChanged();
try
{
await OnDeleteFolderOnly.InvokeAsync();
await OnClose.InvokeAsync();
}
finally
{
IsDeleting = false;
StateHasChanged();
}
}
private async Task HandleDeleteFolderAndContents()
{
IsDeleting = true;
StateHasChanged();
try
{
await OnDeleteFolderAndContents.InvokeAsync();
await OnClose.InvokeAsync();
}
finally
{
IsDeleting = false;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,168 @@
@using Microsoft.Extensions.Localization
@* FolderModal component - modal for creating or editing a folder *@
@if (IsOpen)
{
<div class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
@* Background overlay *@
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity dark:bg-gray-900 dark:bg-opacity-75" @onclick="HandleClose"></div>
@* Modal panel *@
<div class="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg dark:bg-gray-800">
<div class="bg-white px-4 pb-4 pt-5 sm:p-6 dark:bg-gray-800">
<div class="sm:flex sm:items-start">
<div class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-orange-100 sm:mx-0 sm:h-10 sm:w-10 dark:bg-orange-900/30">
<svg class="h-6 w-6 text-orange-600 dark:text-orange-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z"/>
</svg>
</div>
<div class="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left flex-1">
<h3 class="text-lg font-semibold leading-6 text-gray-900 dark:text-white">
@(Mode == "create" ? Localizer["CreateFolderTitle"] : Localizer["EditFolderTitle"])
</h3>
<div class="mt-4">
<label for="folder-name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
@Localizer["FolderNameLabel"]
</label>
<input
type="text"
id="folder-name"
@bind="FolderName"
@bind:event="oninput"
@onkeydown="HandleKeyDown"
placeholder="@Localizer["FolderNamePlaceholder"]"
class="block w-full rounded-md border-0 py-2 px-3 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-orange-600 sm:text-sm sm:leading-6 dark:bg-gray-700 dark:text-white dark:ring-gray-600 dark:placeholder:text-gray-500 dark:focus:ring-orange-500" />
@if (!string.IsNullOrEmpty(ErrorMessage))
{
<p class="mt-2 text-sm text-red-600 dark:text-red-400">@ErrorMessage</p>
}
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 gap-2 dark:bg-gray-800/50">
<button
type="button"
@onclick="HandleSave"
disabled="@IsSaving"
class="inline-flex w-full justify-center rounded-md bg-orange-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-orange-500 sm:w-auto disabled:opacity-50 disabled:cursor-not-allowed dark:bg-orange-700 dark:hover:bg-orange-600">
@if (IsSaving)
{
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
}
@(Mode == "create" ? Localizer["CreateButton"] : Localizer["SaveButton"])
</button>
<button
type="button"
@onclick="HandleClose"
class="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto dark:bg-gray-700 dark:text-white dark:ring-gray-600 dark:hover:bg-gray-600">
@Localizer["CancelButton"]
</button>
</div>
</div>
</div>
</div>
}
@code {
[Inject]
private IStringLocalizerFactory LocalizerFactory { get; set; } = default!;
private IStringLocalizer Localizer => LocalizerFactory.Create("Components.Folders.FolderModal", "AliasVault.Client");
/// <summary>
/// Gets or sets whether the modal is open.
/// </summary>
[Parameter]
public bool IsOpen { get; set; }
/// <summary>
/// Gets or sets the mode (create or edit).
/// </summary>
[Parameter]
public string Mode { get; set; } = "create";
/// <summary>
/// Gets or sets the initial folder name (for edit mode).
/// </summary>
[Parameter]
public string InitialName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the close callback.
/// </summary>
[Parameter]
public EventCallback OnClose { get; set; }
/// <summary>
/// Gets or sets the save callback.
/// </summary>
[Parameter]
public EventCallback<string> OnSave { get; set; }
private string FolderName { get; set; } = string.Empty;
private string ErrorMessage { get; set; } = string.Empty;
private bool IsSaving { get; set; }
/// <inheritdoc />
protected override void OnParametersSet()
{
if (IsOpen)
{
FolderName = InitialName;
ErrorMessage = string.Empty;
IsSaving = false;
}
}
private async Task HandleSave()
{
var trimmedName = FolderName?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(trimmedName))
{
ErrorMessage = Localizer["FolderNameRequired"];
return;
}
IsSaving = true;
ErrorMessage = string.Empty;
StateHasChanged();
try
{
await OnSave.InvokeAsync(trimmedName);
await OnClose.InvokeAsync();
}
catch (Exception ex)
{
ErrorMessage = ex.Message;
}
finally
{
IsSaving = false;
StateHasChanged();
}
}
private async Task HandleClose()
{
await OnClose.InvokeAsync();
}
private async Task HandleKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Enter")
{
await HandleSave();
}
else if (e.Key == "Escape")
{
await HandleClose();
}
}
}

View File

@@ -0,0 +1,24 @@
@using AliasVault.Client.Main.Models
@* FolderPill component - displays a folder as a compact clickable pill *@
<button @onclick="OnClick" class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs rounded-full bg-gray-100 dark:bg-gray-700/50 hover:bg-gray-200 dark:hover:bg-gray-600/50 transition-colors focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-1 dark:focus:ring-offset-gray-800">
<svg class="w-3.5 h-3.5 text-orange-500 flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z"/>
</svg>
<span class="text-gray-700 dark:text-gray-300 truncate max-w-[120px]" title="@Folder.Name">@Folder.Name</span>
<span class="text-gray-400 dark:text-gray-500 text-[10px]">@Folder.ItemCount</span>
</button>
@code {
/// <summary>
/// Gets or sets the folder to display.
/// </summary>
[Parameter]
public required FolderWithCount Folder { get; set; }
/// <summary>
/// Gets or sets the click callback.
/// </summary>
[Parameter]
public EventCallback OnClick { get; set; }
}

View File

@@ -0,0 +1,121 @@
@using AliasVault.Client.Main.Models
@inject NavigationManager NavigationManager
@* ItemCard component - displays an item in a card format with appropriate icon and information *@
<div @onclick="ShowDetails" class="credential-card overflow-hidden p-4 space-y-2 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700
dark:bg-gray-800 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors duration-200">
<div class="px-4 py-2 text-gray-400 rounded text-center flex flex-col items-center">
<div class="mb-2">
<ItemIcon
ItemType="@Obj.ItemType"
Logo="@Obj.Logo"
CardNumber="@Obj.CardNumber"
AltText="@GetServiceName()"
SizeClass="w-12 h-12" />
</div>
<div class="flex items-center justify-center gap-1.5 w-full">
<div class="text-gray-900 dark:text-gray-100 break-words truncate max-w-[150px]" title="@GetServiceName()">@GetServiceName()</div>
@if (Obj.HasTotp)
{
<svg class="w-3.5 h-3.5 text-gray-500 dark:text-gray-400 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-label="Has TOTP">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
}
@if (Obj.HasPasskey)
{
<svg class="w-3.5 h-3.5 text-gray-500 dark:text-gray-400 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-label="Has passkey">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
</svg>
}
@if (Obj.HasAttachment)
{
<svg class="w-3.5 h-3.5 text-gray-500 dark:text-gray-400 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-label="Has attachments">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
</svg>
}
</div>
<div class="text-gray-500 dark:text-gray-400 break-words w-full text-sm truncate" title="@GetDisplayText()">@GetDisplayText()</div>
@if (ShowFolderPath && !string.IsNullOrEmpty(Obj.FolderName))
{
<div class="text-gray-400 dark:text-gray-500 text-xs mt-1 flex items-center gap-1">
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z"/>
</svg>
<span class="truncate max-w-[100px]">@Obj.FolderName</span>
</div>
}
</div>
</div>
@code {
/// <summary>
/// Gets or sets the item list entry object to show.
/// </summary>
[Parameter]
public required ItemListEntry Obj { get; set; }
/// <summary>
/// Gets or sets whether to show the folder path (used when searching).
/// </summary>
[Parameter]
public bool ShowFolderPath { get; set; }
/// <summary>
/// Gets the display text for the item, showing username by default,
/// falling back to email. For credit cards, shows cardholder name or masked number.
/// </summary>
private string GetDisplayText()
{
if (Obj.ItemType == ItemTypes.CreditCard)
{
// For credit cards, show masked card number if available
if (!string.IsNullOrEmpty(Obj.CardNumber) && Obj.CardNumber.Length >= 4)
{
return "•••• " + Obj.CardNumber[^4..];
}
return string.Empty;
}
if (Obj.ItemType == ItemTypes.Note)
{
// For notes, no secondary text needed
return string.Empty;
}
// For Login/Alias, prioritize username then email
if (!string.IsNullOrEmpty(Obj.Username))
{
return Obj.Username;
}
if (!string.IsNullOrEmpty(Obj.Email))
{
return Obj.Email;
}
return string.Empty;
}
/// <summary>
/// Get the service name (item name) for the item.
/// </summary>
private string GetServiceName()
{
if (!string.IsNullOrEmpty(Obj.Service))
{
return Obj.Service;
}
return "Untitled";
}
/// <summary>
/// Navigate to the details page of the item.
/// </summary>
private void ShowDetails()
{
NavigationManager.NavigateTo($"/items/{Obj.Id}");
}
}

View File

@@ -0,0 +1,148 @@
@using AliasVault.Client.Main.Models
@using AliasVault.Client.Main.Utilities
@* ItemIcon component - displays contextually appropriate icons based on item type *@
@* For Login/Alias: Uses the Logo field if available, falls back to key placeholder *@
@* For CreditCard: Shows card brand icons (Visa, MC, Amex, Discover) based on card number *@
@* For Note: Shows a document/note icon *@
@if (ItemType == ItemTypes.Note)
{
@* Note icon - document style *@
<svg class="@SizeClass flex-shrink-0" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 4C6.9 4 6 4.9 6 6V26C6 27.1 6.9 28 8 28H24C25.1 28 26 27.1 26 26V11L19 4H8Z" fill="#f49541"/>
<path d="M19 4V11H26L19 4Z" fill="#d68338"/>
<rect x="10" y="14" width="12" height="1.5" rx="0.75" fill="#ffe096"/>
<rect x="10" y="18" width="10" height="1.5" rx="0.75" fill="#ffe096"/>
<rect x="10" y="22" width="8" height="1.5" rx="0.75" fill="#ffe096"/>
</svg>
}
else if (ItemType == ItemTypes.CreditCard)
{
@* Credit card icon - detect brand and show appropriate icon *@
@switch (CardBrandDetector.Detect(CardNumber))
{
case CardBrandDetector.CardBrand.Visa:
@* Visa card icon *@
<svg class="@SizeClass flex-shrink-0" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="6" width="28" height="20" rx="3" fill="#f49541"/>
<path d="M13.5 13L11.5 19H10L8.5 14.5C8.5 14.5 8.35 14 8 14C7.65 14 7 13.8 7 13.8L7.05 13.5H9.5C9.85 13.5 10.15 13.75 10.2 14.1L10.8 17L12.5 13.5H13.5V13ZM15 19H14L15 13H16L15 19ZM20 13.5C20 13.5 19.4 13.3 18.7 13.3C17.35 13.3 16.4 14 16.4 15C16.4 15.8 17.1 16.2 17.65 16.5C18.2 16.8 18.4 17 18.4 17.2C18.4 17.5 18.05 17.7 17.6 17.7C17 17.7 16.5 17.5 16.5 17.5L16.3 18.7C16.3 18.7 16.9 19 17.7 19C19.2 19 20.1 18.2 20.1 17.1C20.1 15.7 18.4 15.6 18.4 15C18.4 14.7 18.7 14.5 19.15 14.5C19.6 14.5 20.1 14.7 20.1 14.7L20.3 13.5H20V13.5ZM24 19L23.1 13.5H22C21.7 13.5 21.45 13.7 21.35 13.95L19 19H20.5L20.8 18H22.7L22.9 19H24ZM21.2 17L22 14.5L22.45 17H21.2Z" fill="#ffe096"/>
</svg>
break;
case CardBrandDetector.CardBrand.Mastercard:
@* Mastercard icon *@
<svg class="@SizeClass flex-shrink-0" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="6" width="28" height="20" rx="3" fill="#f49541"/>
<circle cx="13" cy="16" r="5" fill="#d68338"/>
<circle cx="19" cy="16" r="5" fill="#ffe096"/>
<path d="M16 12.5C17.1 13.4 17.8 14.6 17.8 16C17.8 17.4 17.1 18.6 16 19.5C14.9 18.6 14.2 17.4 14.2 16C14.2 14.6 14.9 13.4 16 12.5Z" fill="#fbcb74"/>
</svg>
break;
case CardBrandDetector.CardBrand.Amex:
@* Amex card icon *@
<svg class="@SizeClass flex-shrink-0" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="6" width="28" height="20" rx="3" fill="#f49541"/>
<text x="16" y="18" text-anchor="middle" fill="#ffe096" font-size="8" font-weight="bold" font-family="Arial, sans-serif">AMEX</text>
</svg>
break;
case CardBrandDetector.CardBrand.Discover:
@* Discover card icon *@
<svg class="@SizeClass flex-shrink-0" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="6" width="28" height="20" rx="3" fill="#f49541"/>
<circle cx="20" cy="16" r="4" fill="#ffe096"/>
<path d="M7 14H8.5C9.3 14 10 14.7 10 15.5C10 16.3 9.3 17 8.5 17H7V14Z" fill="#ffe096"/>
<rect x="11" y="14" width="1.5" height="3" fill="#ffe096"/>
<path d="M14 15C14 14.4 14.4 14 15 14C15.3 14 15.5 14.1 15.7 14.3L16.5 13.5C16.1 13.2 15.6 13 15 13C13.9 13 13 13.9 13 15C13 16.1 13.9 17 15 17C15.6 17 16.1 16.8 16.5 16.5L15.7 15.7C15.5 15.9 15.3 16 15 16C14.4 16 14 15.6 14 15Z" fill="#ffe096"/>
</svg>
break;
default:
@* Generic credit card icon *@
<svg class="@SizeClass flex-shrink-0" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="6" width="28" height="20" rx="3" fill="#f49541"/>
<rect x="2" y="11" width="28" height="4" fill="#d68338"/>
<rect x="5" y="18" width="8" height="2" rx="1" fill="#ffe096"/>
<rect x="5" y="22" width="5" height="1.5" rx="0.75" fill="#fbcb74"/>
</svg>
break;
}
}
else if (Logo != null && Logo.Length > 0)
{
@* Login/Alias with logo *@
<img src="@GetLogoSrc()" alt="@AltText" class="@SizeClass flex-shrink-0 rounded-lg" @onerror="OnImageError" />
}
else
{
@* Default placeholder - key icon for Login/Alias without logo *@
<svg class="@SizeClass flex-shrink-0" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
@* Key bow (circular head) - positioned top-left *@
<circle cx="10" cy="10" r="6.5" stroke="#f49541" stroke-width="2.5"/>
@* Key hole in bow *@
<circle cx="10" cy="10" r="2.5" stroke="#f49541" stroke-width="2"/>
@* Key shaft - diagonal *@
<path d="M15 15L27 27" stroke="#f49541" stroke-width="2.5" stroke-linecap="round"/>
@* Key teeth - perpendicular to shaft *@
<path d="M19 19L23 15" stroke="#f49541" stroke-width="2.5" stroke-linecap="round"/>
<path d="M24 24L28 20" stroke="#f49541" stroke-width="2.5" stroke-linecap="round"/>
</svg>
}
@code {
/// <summary>
/// Gets or sets the item type (Login, Alias, CreditCard, Note).
/// </summary>
[Parameter]
public string ItemType { get; set; } = ItemTypes.Login;
/// <summary>
/// Gets or sets the logo bytes for Login/Alias items.
/// </summary>
[Parameter]
public byte[]? Logo { get; set; }
/// <summary>
/// Gets or sets the card number for CreditCard items (used for brand detection).
/// </summary>
[Parameter]
public string? CardNumber { get; set; }
/// <summary>
/// Gets or sets the alt text for the image.
/// </summary>
[Parameter]
public string AltText { get; set; } = "Item";
/// <summary>
/// Gets or sets the size class for the icon (Tailwind CSS classes).
/// </summary>
[Parameter]
public string SizeClass { get; set; } = "w-10 h-10";
/// <summary>
/// Gets or sets whether to show placeholder on image error.
/// </summary>
private bool ShowPlaceholder { get; set; }
/// <summary>
/// Converts logo bytes to data URL.
/// </summary>
private string GetLogoSrc()
{
if (Logo == null || Logo.Length == 0)
{
return string.Empty;
}
var base64 = Convert.ToBase64String(Logo);
return $"data:image/png;base64,{base64}";
}
/// <summary>
/// Handle image load error by showing placeholder.
/// </summary>
private void OnImageError()
{
ShowPlaceholder = true;
StateHasChanged();
}
}

View File

@@ -153,6 +153,6 @@
/// <param name="credentialId">The ID of the credential to navigate to.</param>
private void NavigateToCredential(Guid credentialId)
{
NavigationManager.NavigateTo($"/credentials/{credentialId}");
NavigationManager.NavigateTo($"/items/{credentialId}");
}
}

View File

@@ -193,14 +193,14 @@
// Error saving.
IsCreating = false;
GlobalLoadingSpinner.Hide();
GlobalNotificationService.AddErrorMessage(Localizer["CreateCredentialErrorMessage"], true);
GlobalNotificationService.AddErrorMessage(Localizer["CreateItemErrorMessage"], true);
return;
}
// No error, add success message.
GlobalNotificationService.AddSuccessMessage(Localizer["CredentialCreatedSuccessMessage"]);
GlobalNotificationService.AddSuccessMessage(Localizer["ItemCreatedSuccessMessage"]);
NavigationManager.NavigateTo("/credentials/" + id);
NavigationManager.NavigateTo("/items/" + id);
IsCreating = false;
GlobalLoadingSpinner.Hide();
@@ -217,7 +217,7 @@
QuickCreateStateService.ServiceName = Model.ServiceName;
QuickCreateStateService.ServiceUrl = Model.ServiceUrl;
NavigationManager.NavigateTo("/credentials/create");
NavigationManager.NavigateTo("/items/create");
ClosePopup();
}

View File

@@ -278,7 +278,7 @@
private async Task SelectResult(Item item)
{
await JsInteropService.BlurElementById("searchWidget");
NavigationManager.NavigateTo($"/credentials/{item.Id}");
NavigationManager.NavigateTo($"/items/{item.Id}");
}
private void ResetSearchField(object? sender, LocationChangedEventArgs e)

View File

@@ -17,8 +17,8 @@
<div class="hidden justify-between items-center w-full lg:flex lg:w-auto lg:order-1">
<ul class="flex flex-col mt-4 space-x-6 text-sm font-medium lg:flex-row xl:space-x-8 lg:mt-0">
<NavLink href="/credentials" class="block text-gray-700 hover:text-primary-700 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
@Localizer["CredentialsNav"]
<NavLink href="/items" class="block text-gray-700 hover:text-primary-700 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
@Localizer["VaultNav"]
</NavLink>
<NavLink href="/emails" class="block text-gray-700 hover:text-primary-700 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
@Localizer["EmailsNav"]
@@ -45,8 +45,8 @@
<div class="absolute w-full md:w-64 top-[40px] md:top-[39px] right-0 z-50 my-4 text-base list-none bg-white rounded-b-lg divide-y divide-gray-100 shadow dark:bg-gray-700 dark:divide-gray-600 @(IsMobileMenuOpen ? "block" : "hidden")" id="mobileMenuDropdown" data-popper-placement="bottom">
<ul class="lg:hidden py-1 font-light text-gray-500 dark:text-gray-400" aria-labelledby="mobileMenuDropdownButton">
<li>
<NavLink href="/credentials" 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" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.Prefix">
@Localizer["CredentialsNav"]
<NavLink href="/items" 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" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.Prefix">
@Localizer["VaultNav"]
</NavLink>
</li>
<li>

View File

@@ -0,0 +1,56 @@
//-----------------------------------------------------------------------
// <copyright file="DisplayField.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Main.Models;
using AliasClientDb.Models;
/// <summary>
/// Represents a field prepared for display in the UI.
/// </summary>
public class DisplayField
{
/// <summary>
/// Gets or sets the system field key (e.g., 'login.username').
/// </summary>
public string FieldKey { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the custom field definition ID (for custom fields only).
/// </summary>
public string? FieldDefinitionId { get; set; }
/// <summary>
/// Gets or sets the field type for rendering (Text, Password, Email, URL, Date, etc.).
/// </summary>
public string FieldType { get; set; } = "Text";
/// <summary>
/// Gets or sets the field value.
/// </summary>
public string? Value { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the field is hidden/masked.
/// </summary>
public bool IsHidden { get; set; }
/// <summary>
/// Gets or sets a value indicating whether history is enabled for this field.
/// </summary>
public bool EnableHistory { get; set; }
/// <summary>
/// Gets or sets the display order.
/// </summary>
public int DisplayOrder { get; set; }
/// <summary>
/// Gets or sets the field category.
/// </summary>
public FieldCategory Category { get; set; }
}

View File

@@ -0,0 +1,31 @@
//-----------------------------------------------------------------------
// <copyright file="FolderWithCount.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Main.Models;
using System;
/// <summary>
/// Folder model with item count for display in lists.
/// </summary>
public sealed class FolderWithCount
{
/// <summary>
/// Gets or sets the folder ID.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the folder name.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the number of items in this folder.
/// </summary>
public int ItemCount { get; set; }
}

View File

@@ -0,0 +1,49 @@
//-----------------------------------------------------------------------
// <copyright file="ItemFilterType.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Main.Models;
/// <summary>
/// Filter types for the items list.
/// </summary>
public enum ItemFilterType
{
/// <summary>
/// Show all items.
/// </summary>
All,
/// <summary>
/// Filter by Login item type.
/// </summary>
Login,
/// <summary>
/// Filter by Alias item type.
/// </summary>
Alias,
/// <summary>
/// Filter by CreditCard item type.
/// </summary>
CreditCard,
/// <summary>
/// Filter by Note item type.
/// </summary>
Note,
/// <summary>
/// Show only items with passkeys.
/// </summary>
Passkeys,
/// <summary>
/// Show only items with attachments.
/// </summary>
Attachments,
}

View File

@@ -19,6 +19,11 @@ public sealed class ItemListEntry
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the item type (Login, Alias, CreditCard, Note).
/// </summary>
public string ItemType { get; set; } = "Login";
/// <summary>
/// Gets or sets the Logo (favicon) bytes.
/// </summary>
@@ -39,6 +44,11 @@ public sealed class ItemListEntry
/// </summary>
public string? Email { get; set; }
/// <summary>
/// Gets or sets the card number (for CreditCard type, used for brand detection).
/// </summary>
public string? CardNumber { get; set; }
/// <summary>
/// Gets or sets the created timestamp.
/// </summary>
@@ -63,4 +73,19 @@ public sealed class ItemListEntry
/// Gets or sets a value indicating whether this item has attachments.
/// </summary>
public bool HasAttachment { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this item has TOTP codes.
/// </summary>
public bool HasTotp { get; set; }
/// <summary>
/// Gets or sets the folder ID this item belongs to.
/// </summary>
public Guid? FolderId { get; set; }
/// <summary>
/// Gets or sets the folder name this item belongs to.
/// </summary>
public string? FolderName { get; set; }
}

View File

@@ -0,0 +1,49 @@
//-----------------------------------------------------------------------
// <copyright file="ItemTypes.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Main.Models;
/// <summary>
/// Constants for item types.
/// </summary>
public static class ItemTypes
{
/// <summary>
/// Login item type - username/password credentials.
/// </summary>
public const string Login = "Login";
/// <summary>
/// Alias item type - login with auto-generated identity.
/// </summary>
public const string Alias = "Alias";
/// <summary>
/// Credit card item type - payment card information.
/// </summary>
public const string CreditCard = "CreditCard";
/// <summary>
/// Note item type - secure notes only.
/// </summary>
public const string Note = "Note";
/// <summary>
/// All available item types.
/// </summary>
public static readonly string[] All = { Login, Alias, CreditCard, Note };
/// <summary>
/// Checks if a string value is a valid item type.
/// </summary>
/// <param name="value">The value to check.</param>
/// <returns>True if the value is a valid item type.</returns>
public static bool IsValid(string? value)
{
return value == Login || value == Alias || value == CreditCard || value == Note;
}
}

View File

@@ -1,334 +0,0 @@
@page "/credentials"
@inherits MainBase
@inject ItemService ItemService
@using AliasVault.RazorComponents.Tables
@using AliasVault.Client.Main.Models
@using Microsoft.Extensions.Localization
<LayoutPageTitle>Home</LayoutPageTitle>
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="@GetFilterTitle()"
Description="@Localizer["PageDescription"]">
<TitleActions>
<div class="relative">
<button @onclick="ToggleFilterDropdown" id="filterButton" class="flex items-center gap-2 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none">
<h1 class="flex items-baseline gap-1.5 text-xl font-semibold tracking-tight text-gray-900 dark:text-white sm:text-2xl">
<span>@GetFilterTitle()</span>
<span class="text-base text-gray-500 dark:text-gray-400">(@FilteredAndSortedCredentials.Count())</span>
</h1>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
@if (ShowFilterDropdown)
{
<ClickOutsideHandler OnClose="CloseFilterDropdown" ContentId="filterDropdown,filterButton">
<div id="filterDropdown" class="absolute left-0 top-full z-10 mt-2 w-56 origin-top-left rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-gray-700">
<div class="py-1">
<button @onclick="() => SetFilter(CredentialFilterType.All)" class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 @(FilterType == CredentialFilterType.All ? "bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400" : "text-gray-700 dark:text-gray-300")">
@Localizer["FilterAllOption"]
</button>
<button @onclick="() => SetFilter(CredentialFilterType.Passkeys)" class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 @(FilterType == CredentialFilterType.Passkeys ? "bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400" : "text-gray-700 dark:text-gray-300")">
@Localizer["FilterPasskeysOption"]
</button>
<button @onclick="() => SetFilter(CredentialFilterType.Aliases)" class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 @(FilterType == CredentialFilterType.Aliases ? "bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400" : "text-gray-700 dark:text-gray-300")">
@Localizer["FilterAliasesOption"]
</button>
<button @onclick="() => SetFilter(CredentialFilterType.Userpass)" class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 @(FilterType == CredentialFilterType.Userpass ? "bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400" : "text-gray-700 dark:text-gray-300")">
@Localizer["FilterUserpassOption"]
</button>
<button @onclick="() => SetFilter(CredentialFilterType.Attachments)" class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 @(FilterType == CredentialFilterType.Attachments ? "bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400" : "text-gray-700 dark:text-gray-300")">
@Localizer["FilterAttachmentsOption"]
</button>
</div>
</div>
</ClickOutsideHandler>
}
</div>
</TitleActions>
<CustomActions>
<div class="relative">
<button @onclick="ToggleSettingsDropdown" id="settingsButton" class="p-2 text-gray-500 rounded-lg hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-700">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"></path>
</svg>
</button>
@if (ShowSettingsDropdown)
{
<ClickOutsideHandler OnClose="CloseSettingsPopup" ContentId="settingsDropdown,settingsButton">
<div id="settingsDropdown" class="absolute right-0 z-10 mt-2 min-w-[220px] origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-gray-700">
<div class="p-4">
<div class="mb-4">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">@Localizer["ViewModeLabel"]</label>
<select @bind="ViewMode" @bind:after="CloseSettingsPopup" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-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-blue-500 dark:focus:border-blue-500">
<option value="grid">@Localizer["GridViewOption"]</option>
<option value="table">@Localizer["TableViewOption"]</option>
</select>
</div>
<div class="mb-4">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">@Localizer["SortOrderLabel"]</label>
<select @bind="SortOrder" @bind:after="CloseSettingsPopup" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-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-blue-500 dark:focus:border-blue-500">
<option value="@CredentialSortOrder.OldestFirst">@Localizer["OldestFirstOption"]</option>
<option value="@CredentialSortOrder.NewestFirst">@Localizer["NewestFirstOption"]</option>
<option value="@CredentialSortOrder.Alphabetical">@Localizer["AlphabeticalOption"]</option>
</select>
</div>
</div>
</div>
</ClickOutsideHandler>
}
</div>
<RefreshButton OnClick="LoadCredentialsAsync" ButtonText="@SharedLocalizer["Refresh"]" />
</CustomActions>
</PageHeader>
@if (IsLoading)
{
<LoadingIndicator />
}
else
{
@if (DbService.Settings.CredentialsViewMode == "table")
{
<div class="px-4 min-h-[250px]">
<CredentialsTable Credentials="@FilteredAndSortedCredentials.ToList()" SortOrder="@SortOrder" />
</div>
}
else
{
<div class="grid gap-4 px-4 mb-4 md:grid-cols-4 xl:grid-cols-6">
@if (Credentials.Count == 0)
{
<div class="credential-card col-span-full p-4 space-y-2 bg-amber-50 border border-primary-500 rounded-lg shadow-sm dark:border-primary-700 dark:bg-gray-800">
<div class="px-4 py-6 text-gray-700 dark:text-gray-200 rounded text-center flex flex-col items-center">
<p class="mb-2 text-lg font-semibold text-primary-700 dark:text-primary-400">@Localizer["NoCredentialsTitle"]</p>
<div class="max-w-md mx-auto">
<div class="mb-6">
<p class="text-sm mb-2">@Localizer["CreateFirstCredentialText"] <span class="hidden md:inline">@Localizer["NewAliasButtonText"]</span><span class="md:hidden">@Localizer["NewAliasButtonTextMobile"]</span> @Localizer["ButtonLocationText"]</p>
</div>
<div class="flex items-center my-6">
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600"></div>
<span class="px-4 text-sm text-gray-500 dark:text-gray-400">@Localizer["OrText"]</span>
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600"></div>
</div>
<div>
<p class="text-sm mb-2">@Localizer["ImportCredentialsText"]</p>
<a href="/settings/import-export" class="inline-block text-sm px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors dark:bg-primary-700 dark:hover:bg-primary-600">
@Localizer["ImportButtonText"]
</a>
</div>
</div>
</div>
</div>
}
else if (!FilteredAndSortedCredentials.Any())
{
<div class="credential-card col-span-full p-4 space-y-2 bg-amber-50 border border-primary-500 rounded-lg shadow-sm dark:border-primary-700 dark:bg-gray-800">
<div class="px-4 py-6 text-gray-700 dark:text-gray-200 rounded text-center">
@if (FilterType == CredentialFilterType.Passkeys)
{
<p>@Localizer["NoPasskeysFound"]</p>
}
else if (FilterType == CredentialFilterType.Attachments)
{
<p>@Localizer["NoAttachmentsFound"]</p>
}
else
{
<p>@Localizer["NoCredentialsFound"]</p>
}
</div>
</div>
}
@foreach (var credential in FilteredAndSortedCredentials)
{
<CredentialCard Obj="@credential"/>
}
</div>
}
}
@code {
private IStringLocalizer Localizer => LocalizerFactory.Create("Pages.Main.Credentials.Home", "AliasVault.Client");
/// <summary>
/// Gets or sets whether the credentials are being loaded.
/// </summary>
private bool IsLoading { get; set; } = true;
/// <summary>
/// Gets or sets the items.
/// </summary>
private List<ItemListEntry> Credentials { get; set; } = new();
/// <summary>
/// Gets or sets whether the settings dropdown is shown.
/// </summary>
private bool ShowSettingsDropdown { get; set; }
/// <summary>
/// Gets or sets whether the filter dropdown is shown.
/// </summary>
private bool ShowFilterDropdown { get; set; }
/// <summary>
/// Gets or sets the filter type for the credentials.
/// </summary>
private CredentialFilterType FilterType { get; set; } = CredentialFilterType.All;
/// <summary>
/// Gets or sets the view mode for the credentials.
/// </summary>
private string ViewMode
{
get => DbService.Settings.CredentialsViewMode;
set => DbService.Settings.SetCredentialsViewMode(value);
}
/// <summary>
/// Gets or sets the sort order for the credentials.
/// </summary>
private CredentialSortOrder SortOrder
{
get => DbService.Settings.CredentialsSortOrder;
set => DbService.Settings.SetCredentialsSortOrder(value);
}
/// <summary>
/// Gets the items filtered and sorted according to the current filter and sort order.
/// </summary>
private IEnumerable<ItemListEntry> FilteredAndSortedCredentials
{
get
{
// First apply filter
var filtered = FilterType switch
{
CredentialFilterType.Passkeys => Credentials.Where(x => x.HasPasskey),
CredentialFilterType.Aliases => Credentials.Where(x => x.HasAlias),
CredentialFilterType.Userpass => Credentials.Where(x => x.HasUsernameOrPassword && !x.HasPasskey && !x.HasAlias),
CredentialFilterType.Attachments => Credentials.Where(x => x.HasAttachment),
_ => Credentials, // All
};
// Then apply sort
return SortOrder switch
{
CredentialSortOrder.NewestFirst => filtered.OrderByDescending(x => x.CreatedAt),
CredentialSortOrder.Alphabetical => filtered.OrderBy(x => x.Service ?? string.Empty),
_ => filtered.OrderBy(x => x.CreatedAt), // OldestFirst (default)
};
}
}
/// <summary>
/// Gets the title based on the active filter.
/// </summary>
private string GetFilterTitle()
{
return FilterType switch
{
CredentialFilterType.Passkeys => Localizer["FilterPasskeysOption"],
CredentialFilterType.Aliases => Localizer["FilterAliasesOption"],
CredentialFilterType.Userpass => Localizer["FilterUserpassOption"],
CredentialFilterType.Attachments => Localizer["FilterAttachmentsOption"],
_ => Localizer["PageTitle"],
};
}
/// <summary>
/// Toggles the settings dropdown.
/// </summary>
private void ToggleSettingsDropdown()
{
ShowSettingsDropdown = !ShowSettingsDropdown;
StateHasChanged();
}
/// <summary>
/// Toggles the filter dropdown.
/// </summary>
private void ToggleFilterDropdown()
{
ShowFilterDropdown = !ShowFilterDropdown;
StateHasChanged();
}
/// <summary>
/// Sets the filter type and closes the dropdown.
/// </summary>
private void SetFilter(CredentialFilterType filterType)
{
FilterType = filterType;
ShowFilterDropdown = false;
StateHasChanged();
}
/// <summary>
/// Closes the filter dropdown.
/// </summary>
private void CloseFilterDropdown()
{
ShowFilterDropdown = false;
StateHasChanged();
}
/// <summary>
/// Closes the settings dropdown.
/// </summary>
private async Task CloseSettingsPopup()
{
ShowSettingsDropdown = false;
// Reload the credentials in case the settings were changed.
await LoadCredentialsAsync();
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
await LoadCredentialsAsync();
}
}
/// <summary>
/// Loads and/or refreshes the items.
/// </summary>
private async Task LoadCredentialsAsync()
{
IsLoading = true;
StateHasChanged();
// Load the items from the database via ItemService.
var itemListEntries = await ItemService.GetListAsync();
if (itemListEntries is null)
{
// Error loading items.
GlobalNotificationService.AddErrorMessage(Localizer["FailedToLoadCredentialsMessage"], true);
return;
}
if (itemListEntries.Count == 0 && !DbService.Settings.TutorialDone)
{
// Redirect to the welcome page.
NavigationManager.NavigateTo("/welcome");
return;
}
// Pass unsorted list to the view - sorting will be handled by the table/grid components
Credentials = itemListEntries;
IsLoading = false;
StateHasChanged();
}
}

View File

@@ -1,349 +0,0 @@
@page "/credentials/{id:guid}"
@inherits MainBase
@inject ItemService ItemService
@implements IAsyncDisposable
@using Microsoft.Extensions.Localization
@using AliasClientDb
@using AliasClientDb.Models
<LayoutPageTitle>@Localizer["ViewCredentialsPageTitle"]</LayoutPageTitle>
@if (IsLoading || Alias == null)
{
<LoadingIndicator />
}
else
{
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="@Localizer["ViewCredentialTitle"]">
<CustomActions>
<LinkButton
SmallText="@Localizer["EditButtonMobile"]"
Text="@Localizer["EditButtonDesktop"]"
Href="@($"/credentials/{Id}/edit")"
Color="primary" />
<LinkButton
SmallText="@Localizer["DeleteButtonMobile"]"
Text="@Localizer["DeleteButtonDesktop"]"
Href="@($"/credentials/{Id}/delete")"
Color="danger" />
</CustomActions>
</PageHeader>
<div class="grid grid-cols-1 px-4 pt-6 md:grid-cols-2 lg:grid-cols-3 lg:gap-4 dark:bg-gray-900">
<div class="col-span-1 md:col-span-2 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">
<div class="items-center flex space-x-4">
<DisplayFavicon FaviconBytes="@Alias.Logo?.FileData" Padding="true" />
<div>
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">@Alias.Name</h3>
@{
var url = ItemService.GetFieldValue(Alias, FieldKey.LoginUrl);
}
@if (url is not null && url.Length > 0)
{
@if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
<a href="@url" target="_blank" class="text-blue-500 break-all dark:text-blue-400">@url</a>
}
else
{
<span class="text-gray-700 break-all dark:text-gray-300">@url</span>
}
}
</div>
</div>
</div>
<RecentEmails EmailAddress="@ItemService.GetFieldValue(Alias, FieldKey.LoginEmail)" />
@if (Alias.TotpCodes.Count > 0)
{
<TotpViewer TotpCodeList="@Alias.TotpCodes" />
}
@{
var notes = ItemService.GetFieldValue(Alias, FieldKey.NotesContent);
}
@if (notes != null && notes.Length > 0)
{
<FormattedNote Notes="@notes" />
}
@if (Alias.Attachments.Count > 0)
{
<AttachmentViewer Attachments="@Alias.Attachments" />
}
</div>
<div class="col-span-1 md:col-span-2 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-2 text-xl font-semibold dark:text-white">@Localizer["LoginCredentialsSection"]</h3>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
@{
var email = ItemService.GetFieldValue(Alias, FieldKey.LoginEmail);
}
@if (EmailService.IsAliasVaultSupportedDomain(email ?? string.Empty))
{
<span>@Localizer["GeneratedCredentialsDescription"]</span>
}
else
{
<span>@Localizer["StoredCredentialsDescription"]</span>
}
</p>
<form action="#">
<div class="grid gap-6">
@if (Alias.Passkeys != null && Alias.Passkeys.Any())
{
var passkey = Alias.Passkeys.First();
var username = ItemService.GetFieldValue(Alias, FieldKey.LoginUsername);
@* With passkey: Username, Passkey, Email, Password *@
@if (!string.IsNullOrWhiteSpace(username))
{
<div class="col-span-6">
<CopyPasteFormRow Id="username" Label="@Localizer["UsernameLabel"]" Value="@username"></CopyPasteFormRow>
</div>
}
<div class="col-span-6">
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-gray-600 dark:text-gray-400 mt-0.5 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
</svg>
<div class="flex-1">
<div class="mb-1">
<span class="text-sm font-semibold text-gray-900 dark:text-white">@Localizer["PasskeyLabel"]</span>
</div>
<div class="space-y-1 mb-2">
@if (!string.IsNullOrWhiteSpace(passkey.RpId))
{
<div>
<span class="text-xs text-gray-500 dark:text-gray-400">@Localizer["PasskeySiteLabel"]: </span>
<span class="text-sm text-gray-900 dark:text-white">@passkey.RpId</span>
</div>
}
@if (!string.IsNullOrWhiteSpace(passkey.DisplayName))
{
<div>
<span class="text-xs text-gray-500 dark:text-gray-400">@Localizer["PasskeyDisplayNameLabel"]: </span>
<span class="text-sm text-gray-900 dark:text-white">@passkey.DisplayName</span>
</div>
}
</div>
<p class="text-xs text-gray-600 dark:text-gray-400">
@Localizer["PasskeyHelpText"]
</p>
</div>
</div>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(email))
{
<div class="col-span-6">
<CopyPasteFormRow Id="email" Label="@Localizer["EmailLabel"]" Value="@email"></CopyPasteFormRow>
</div>
}
@if (!string.IsNullOrWhiteSpace(ItemService.GetFieldValue(Alias, FieldKey.LoginPassword)))
{
<div class="col-span-6">
<CopyPastePasswordFormRow Id="password" Label="@Localizer["PasswordLabel"]" Value="@ItemService.GetFieldValue(Alias, FieldKey.LoginPassword)"></CopyPastePasswordFormRow>
</div>
}
}
else
{
@* Without passkey: Email, Username, Password *@
@if (!string.IsNullOrWhiteSpace(email))
{
<div class="col-span-6">
<CopyPasteFormRow Id="email" Label="@Localizer["EmailLabel"]" Value="@email"></CopyPasteFormRow>
</div>
}
@if (!string.IsNullOrWhiteSpace(ItemService.GetFieldValue(Alias, FieldKey.LoginUsername)))
{
<div class="col-span-6">
<CopyPasteFormRow Id="username" Label="@Localizer["UsernameLabel"]" Value="@ItemService.GetFieldValue(Alias, FieldKey.LoginUsername)"></CopyPasteFormRow>
</div>
}
@if (!string.IsNullOrWhiteSpace(ItemService.GetFieldValue(Alias, FieldKey.LoginPassword)))
{
<div class="col-span-6">
<CopyPastePasswordFormRow Id="password" Label="@Localizer["PasswordLabel"]" Value="@ItemService.GetFieldValue(Alias, FieldKey.LoginPassword)"></CopyPastePasswordFormRow>
</div>
}
}
</div>
</form>
</div>
@if (HasAlias)
{
<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["AliasSection"]</h3>
<form action="#">
<div class="grid grid-cols-6 gap-6">
@{
var firstName = ItemService.GetFieldValue(Alias, FieldKey.AliasFirstName);
var lastName = ItemService.GetFieldValue(Alias, FieldKey.AliasLastName);
var birthDateStr = ItemService.GetFieldValue(Alias, FieldKey.AliasBirthdate);
var nickname = ItemService.GetFieldValue(Alias, FieldKey.LoginUsername);
}
@if (!string.IsNullOrWhiteSpace(firstName) && !string.IsNullOrWhiteSpace(lastName))
{
<div class="col-span-6">
<CopyPasteFormRow Label="@Localizer["FullNameLabel"]" Value="@(firstName + " " + lastName)"></CopyPasteFormRow>
</div>
}
@if (!string.IsNullOrWhiteSpace(firstName))
{
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Label="@Localizer["FirstNameLabel"]" Value="@firstName"></CopyPasteFormRow>
</div>
}
@if (!string.IsNullOrWhiteSpace(lastName))
{
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Label="@Localizer["LastNameLabel"]" Value="@lastName"></CopyPasteFormRow>
</div>
}
@if (!string.IsNullOrWhiteSpace(birthDateStr))
{
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Label="@Localizer["BirthdateLabel"]" Value="@birthDateStr"></CopyPasteFormRow>
</div>
}
@if (!string.IsNullOrWhiteSpace(nickname))
{
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Label="@Localizer["NicknameLabel"]" Value="@nickname"></CopyPasteFormRow>
</div>
}
</div>
</form>
</div>
}
</div>
</div>
}
@code {
private IStringLocalizer Localizer => LocalizerFactory.Create("Pages.Main.Credentials.View", "AliasVault.Client");
/// <summary>
/// Gets or sets the credentials ID.
/// </summary>
[Parameter]
public Guid Id { get; set; }
private bool IsLoading { get; set; } = true;
private Item? Alias { get; set; } = new();
private bool HasAlias { get; set; } = false;
/// <summary>
/// Checks if a date is valid and not a min value.
/// </summary>
/// <param name="date">The date to check.</param>
/// <returns>True if the date is valid and not a min value, false otherwise.</returns>
private static bool IsValidDate(DateTime date)
{
// Check if date is min value (year 1 or 0001-01-01)
if (date.Year <= 1 || date.ToString("yyyy-MM-dd") == "0001-01-01")
{
return false;
}
return true;
}
/// <summary>
/// Checks if the item has any valid alias data.
/// </summary>
/// <param name="item">The item containing alias information.</param>
/// <returns>True if the item has any valid alias data, false otherwise.</returns>
private static bool CheckHasAlias(Item item)
{
if (item == null)
{
return false;
}
return !string.IsNullOrWhiteSpace(ItemService.GetFieldValue(item, FieldKey.AliasFirstName)) ||
!string.IsNullOrWhiteSpace(ItemService.GetFieldValue(item, FieldKey.AliasLastName)) ||
!string.IsNullOrWhiteSpace(ItemService.GetFieldValue(item, FieldKey.AliasBirthdate));
}
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["ViewCredentialBreadcrumb"] });
}
/// <inheritdoc />
protected override async Task OnParametersSetAsync()
{
await base.OnParametersSetAsync();
await LoadEntryAsync();
}
/// <summary>
/// Loads the item.
/// </summary>
private async Task LoadEntryAsync()
{
IsLoading = true;
StateHasChanged();
// Load the item from the database via ItemService.
Alias = await ItemService.LoadEntryAsync(Id);
if (Alias is null)
{
// Error loading item.
GlobalNotificationService.AddErrorMessage(Localizer["CredentialNotFoundError"]);
NavigationManager.NavigateTo("/credentials", false, true);
return;
}
// Check if the item has any valid alias data
HasAlias = CheckHasAlias(Alias);
IsLoading = false;
StateHasChanged();
}
/// <inheritdoc />
async ValueTask IAsyncDisposable.DisposeAsync()
{
await KeyboardShortcutService.UnregisterShortcutAsync("ge");
await KeyboardShortcutService.UnregisterShortcutAsync("gd");
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
await KeyboardShortcutService.RegisterShortcutAsync("ge", NavigateToEdit);
await KeyboardShortcutService.RegisterShortcutAsync("gd", NavigateToDelete);
}
}
/// <summary>
/// Navigates to the edit page.
/// </summary>
private Task NavigateToEdit()
{
NavigationManager.NavigateTo($"/credentials/{Id}/edit");
return Task.CompletedTask;
}
/// <summary>
/// Navigates to the delete page.
/// </summary>
private Task NavigateToDelete()
{
NavigationManager.NavigateTo($"/credentials/{Id}/delete");
return Task.CompletedTask;
}
}

View File

@@ -600,7 +600,7 @@ else
/// </summary>
private void NavigateToCredential(Guid credentialId)
{
NavigationManager.NavigateTo($"/credentials/{credentialId}");
NavigationManager.NavigateTo($"/items/{credentialId}");
}
/// <summary>

View File

@@ -2,7 +2,7 @@
@inherits MainBase
@code {
private const string DefaultRedirectUri = "/credentials";
private const string DefaultRedirectUri = "/items";
/// <inheritdoc />
protected override async Task OnInitializedAsync()

View File

@@ -1,5 +1,5 @@
@page "/credentials/create"
@page "/credentials/{id:guid}/edit"
@page "/items/create"
@page "/items/{id:guid}/edit"
@inherits MainBase
@inject ItemService ItemService
@inject IJSRuntime JSRuntime
@@ -12,10 +12,10 @@
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="@(EditMode ? Localizer["EditCredentialTitle"] : Localizer["AddCredentialTitle"])"
Description="@(EditMode ? Localizer["EditCredentialDescription"] : Localizer["AddCredentialDescription"])">
Title="@(EditMode ? Localizer["EditItemTitle"] : Localizer["AddItemTitle"])"
Description="@(EditMode ? Localizer["EditItemDescription"] : Localizer["AddItemDescription"])">
<CustomActions>
<ConfirmButton OnClick="TriggerFormSubmit">@Localizer["SaveCredentialButton"]</ConfirmButton>
<ConfirmButton OnClick="TriggerFormSubmit">@Localizer["SaveItemButton"]</ConfirmButton>
<CancelButton OnClick="Cancel">@SharedLocalizer["Cancel"]</CancelButton>
</CustomActions>
</PageHeader>
@@ -81,7 +81,7 @@ else
</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>
<h3 class="mb-4 text-xl font-semibold dark:text-white">@Localizer["LoginDetailsSectionHeader"]</h3>
<div class="grid gap-6">
@if (EditMode && Obj.Passkeys != null && Obj.Passkeys.Any())
{
@@ -228,12 +228,12 @@ else
</div>
</div>
</div>
<button type="submit" class="hidden">@Localizer["SaveCredentialButton"]</button>
<button type="submit" class="hidden">@Localizer["SaveItemButton"]</button>
</EditForm>
}
@code {
private IStringLocalizer Localizer => LocalizerFactory.Create("Pages.Main.Credentials.AddEdit", "AliasVault.Client");
private IStringLocalizer Localizer => LocalizerFactory.Create("Pages.Main.Items.AddEdit", "AliasVault.Client");
/// <summary>
/// Gets or sets the Credentials ID.
@@ -286,12 +286,12 @@ else
if (EditMode)
{
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["ViewCredentialBreadcrumb"], Url = $"/credentials/{Id}" });
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["EditCredentialBreadcrumb"] });
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["ViewItemBreadcrumb"], Url = $"/items/{Id}" });
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["EditItemBreadcrumb"] });
}
else
{
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["AddNewCredentialBreadcrumb"] });
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["AddNewItemBreadcrumb"] });
}
}
@@ -344,7 +344,7 @@ else
{
if (Id is null)
{
NavigateAwayWithError(Localizer["CredentialNotExistError"]);
NavigateAwayWithError(Localizer["ItemNotExistError"]);
return;
}
@@ -352,7 +352,7 @@ else
var item = await ItemService.LoadEntryAsync(Id.Value);
if (item is null)
{
NavigateAwayWithError(Localizer["CredentialNotExistError"]);
NavigateAwayWithError(Localizer["ItemNotExistError"]);
return;
}
@@ -400,7 +400,7 @@ else
private void NavigateAwayWithError(string errorMessage)
{
GlobalNotificationService.AddErrorMessage(errorMessage);
NavigationManager.NavigateTo("/credentials", false, true);
NavigationManager.NavigateTo("/items", false, true);
}
/// <summary>
@@ -563,7 +563,7 @@ else
/// </summary>
private void Cancel()
{
NavigationManager.NavigateTo("/credentials/" + Id);
NavigationManager.NavigateTo("/items/" + Id);
}
/// <summary>
@@ -634,20 +634,20 @@ else
if (Id is null || Id == Guid.Empty)
{
// Error saving.
GlobalNotificationService.AddErrorMessage(Localizer["ErrorSavingCredentials"], true);
GlobalNotificationService.AddErrorMessage(Localizer["ErrorSavingItem"], true);
return;
}
// No error, add success message.
if (EditMode)
{
GlobalNotificationService.AddSuccessMessage(Localizer["CredentialUpdatedSuccess"]);
GlobalNotificationService.AddSuccessMessage(Localizer["ItemUpdatedSuccess"]);
}
else
{
GlobalNotificationService.AddSuccessMessage(Localizer["CredentialCreatedSuccess"]);
GlobalNotificationService.AddSuccessMessage(Localizer["ItemCreatedSuccess"]);
}
NavigationManager.NavigateTo("/credentials/" + Id);
NavigationManager.NavigateTo("/items/" + Id);
}
}

View File

@@ -1,15 +1,15 @@
@page "/credentials/{id:guid}/delete"
@page "/items/{id:guid}/delete"
@inherits MainBase
@inject ItemService ItemService
@using Microsoft.Extensions.Localization
@using AliasClientDb
<LayoutPageTitle>@Localizer["DeleteCredentialPageTitle"]</LayoutPageTitle>
<LayoutPageTitle>@Localizer["DeleteItemPageTitle"]</LayoutPageTitle>
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="@Localizer["DeleteCredentialTitle"]"
Description="@Localizer["DeleteCredentialDescription"]">
Title="@Localizer["DeleteItemTitle"]"
Description="@Localizer["DeleteItemDescription"]">
</PageHeader>
@if (IsLoading)
@@ -20,7 +20,7 @@ else
{
<div class="mx-4 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">
<AlertMessageError HasTopMargin="false" Message="@Localizer["DeleteWarningMessage"]" />
<h3 class="mt-4 mb-4 text-xl font-semibold dark:text-white">@Localizer["CredentialEntrySection"]</h3>
<h3 class="mt-4 mb-4 text-xl font-semibold dark:text-white">@Localizer["ItemEntrySection"]</h3>
<div class="mb-4">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">@Localizer["IdLabel"]</label>
<div class="text-gray-900 dark:text-white">@Id</div>
@@ -40,7 +40,7 @@ else
}
@code {
private IStringLocalizer Localizer => LocalizerFactory.Create("Pages.Main.Credentials.Delete", "AliasVault.Client");
private IStringLocalizer Localizer => LocalizerFactory.Create("Pages.Main.Items.Delete", "AliasVault.Client");
/// <summary>
/// Gets or sets the login ID.
@@ -55,8 +55,8 @@ else
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
BreadcrumbItems.Add(new BreadcrumbItem { Url = "credentials/" + Id, DisplayName = Localizer["ViewCredentialBreadcrumb"] });
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["DeleteCredentialBreadcrumb"] });
BreadcrumbItems.Add(new BreadcrumbItem { Url = "items/" + Id, DisplayName = Localizer["ViewItemBreadcrumb"] });
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["DeleteItemBreadcrumb"] });
}
/// <inheritdoc />
@@ -81,11 +81,11 @@ else
{
if (Obj is null)
{
GlobalNotificationService.AddErrorMessage(Localizer["DeleteCredentialNotFoundError"], true);
GlobalNotificationService.AddErrorMessage(Localizer["DeleteItemNotFoundError"], true);
return;
}
GlobalLoadingSpinner.Show(Localizer["DeletingCredentialMessage"]);
GlobalLoadingSpinner.Show(Localizer["DeletingItemMessage"]);
if (await ItemService.TrashItemAsync(Id))
{
GlobalNotificationService.AddSuccessMessage(Localizer["DeleteSuccessMessage"]);
@@ -95,11 +95,11 @@ else
}
GlobalLoadingSpinner.Hide();
NavigationManager.NavigateTo("/credentials");
NavigationManager.NavigateTo("/items");
}
private void Cancel()
{
NavigationManager.NavigateTo("/credentials/" + Id);
NavigationManager.NavigateTo("/items/" + Id);
}
}

View File

@@ -0,0 +1,756 @@
@page "/items"
@page "/items/folder/{FolderId:guid}"
@inherits MainBase
@inject ItemService ItemService
@inject FolderService FolderService
@using AliasVault.RazorComponents.Tables
@using AliasVault.Client.Main.Models
@using AliasVault.Client.Main.Components.Items
@using AliasVault.Client.Main.Components.Folders
@using Microsoft.Extensions.Localization
@implements IAsyncDisposable
<LayoutPageTitle>Home</LayoutPageTitle>
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="@GetFilterTitle()"
Description="@Localizer["PageDescription"]">
<TitleActions>
<div class="relative">
<button @onclick="ToggleFilterDropdown" id="filterButton" class="flex items-center gap-2 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none">
<h1 class="flex items-baseline gap-1.5 text-xl font-semibold tracking-tight text-gray-900 dark:text-white sm:text-2xl">
<span>@GetFilterTitle()</span>
<span class="text-base text-gray-500 dark:text-gray-400">(@TotalFilteredItems)</span>
</h1>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
@if (ShowFilterDropdown)
{
<ClickOutsideHandler OnClose="CloseFilterDropdown" ContentId="filterDropdown,filterButton">
<div id="filterDropdown" class="absolute left-0 top-full z-10 mt-2 w-56 origin-top-left rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-gray-700">
<div class="py-1">
@* All items filter *@
<button @onclick="() => SetFilter(ItemFilterType.All)" class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 @(FilterType == ItemFilterType.All ? "bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400" : "text-gray-700 dark:text-gray-300")">
@Localizer["FilterAllOption"]
</button>
<div class="border-t border-gray-200 dark:border-gray-600 my-1"></div>
@* Item type filters with icons *@
<button @onclick="() => SetFilter(ItemFilterType.Login)" class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 flex items-center gap-2 @(FilterType == ItemFilterType.Login ? "bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400" : "text-gray-700 dark:text-gray-300")">
<svg class="w-5 h-5 @(FilterType == ItemFilterType.Login ? "text-orange-500 dark:text-orange-400" : "text-gray-400 dark:text-gray-500")" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
@Localizer["FilterLoginOption"]
</button>
<button @onclick="() => SetFilter(ItemFilterType.Alias)" class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 flex items-center gap-2 @(FilterType == ItemFilterType.Alias ? "bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400" : "text-gray-700 dark:text-gray-300")">
<svg class="w-5 h-5 @(FilterType == ItemFilterType.Alias ? "text-orange-500 dark:text-orange-400" : "text-gray-400 dark:text-gray-500")" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
@Localizer["FilterAliasOption"]
</button>
<button @onclick="() => SetFilter(ItemFilterType.CreditCard)" class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 flex items-center gap-2 @(FilterType == ItemFilterType.CreditCard ? "bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400" : "text-gray-700 dark:text-gray-300")">
<svg class="w-5 h-5 @(FilterType == ItemFilterType.CreditCard ? "text-orange-500 dark:text-orange-400" : "text-gray-400 dark:text-gray-500")" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
@Localizer["FilterCreditCardOption"]
</button>
<button @onclick="() => SetFilter(ItemFilterType.Note)" class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 flex items-center gap-2 @(FilterType == ItemFilterType.Note ? "bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400" : "text-gray-700 dark:text-gray-300")">
<svg class="w-5 h-5 @(FilterType == ItemFilterType.Note ? "text-orange-500 dark:text-orange-400" : "text-gray-400 dark:text-gray-500")" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
@Localizer["FilterNoteOption"]
</button>
<div class="border-t border-gray-200 dark:border-gray-600 my-1"></div>
@* Special filters *@
<button @onclick="() => SetFilter(ItemFilterType.Passkeys)" class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 @(FilterType == ItemFilterType.Passkeys ? "bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400" : "text-gray-700 dark:text-gray-300")">
@Localizer["FilterPasskeysOption"]
</button>
<button @onclick="() => SetFilter(ItemFilterType.Attachments)" class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 @(FilterType == ItemFilterType.Attachments ? "bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400" : "text-gray-700 dark:text-gray-300")">
@Localizer["FilterAttachmentsOption"]
</button>
</div>
</div>
</ClickOutsideHandler>
}
</div>
</TitleActions>
<CustomActions>
<div class="relative">
<button @onclick="ToggleSettingsDropdown" id="settingsButton" class="p-2 text-gray-500 rounded-lg hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-700">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"></path>
</svg>
</button>
@if (ShowSettingsDropdown)
{
<ClickOutsideHandler OnClose="CloseSettingsPopup" ContentId="settingsDropdown,settingsButton">
<div id="settingsDropdown" class="absolute right-0 z-10 mt-2 min-w-[220px] origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-gray-700">
<div class="p-4">
<div class="mb-4">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">@Localizer["ViewModeLabel"]</label>
<select @bind="ViewMode" @bind:after="CloseSettingsPopup" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-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-blue-500 dark:focus:border-blue-500">
<option value="grid">@Localizer["GridViewOption"]</option>
<option value="table">@Localizer["TableViewOption"]</option>
</select>
</div>
<div class="mb-4">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">@Localizer["SortOrderLabel"]</label>
<select @bind="SortOrder" @bind:after="CloseSettingsPopup" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-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-blue-500 dark:focus:border-blue-500">
<option value="@CredentialSortOrder.OldestFirst">@Localizer["OldestFirstOption"]</option>
<option value="@CredentialSortOrder.NewestFirst">@Localizer["NewestFirstOption"]</option>
<option value="@CredentialSortOrder.Alphabetical">@Localizer["AlphabeticalOption"]</option>
</select>
</div>
</div>
</div>
</ClickOutsideHandler>
}
</div>
<RefreshButton OnClick="LoadItemsAsync" ButtonText="@SharedLocalizer["Refresh"]" />
</CustomActions>
</PageHeader>
@if (IsLoading)
{
<LoadingIndicator />
}
else
{
@if (DbService.Settings.CredentialsViewMode == "table")
{
<div class="px-4 min-h-[250px]">
<ItemsTable Credentials="@FilteredAndSortedItems.ToList()" SortOrder="@SortOrder" />
@* Infinite scroll sentinel for table view *@
@if (HasMoreItems)
{
<div @ref="_scrollSentinel" class="flex justify-center py-4 min-h-[40px]">
@if (IsLoadingMore)
{
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>@Localizer["LoadingMore"]</span>
</div>
}
</div>
}
</div>
}
else
{
<div class="px-4 mb-4">
@* Folders section - only show at root level *@
@if (!IsInFolder && !IsSearching)
{
<div class="flex flex-wrap items-center gap-2 mb-4">
@foreach (var folder in Folders)
{
<FolderPill Folder="@folder" OnClick="() => NavigateToFolder(folder.Id)" />
}
<button @onclick="ShowCreateFolderModal" class="@GetAddFolderButtonClass()">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<svg class="w-3 h-3 -ml-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
@if (Folders.Count == 0)
{
<span>@Localizer["NewFolder"]</span>
}
</button>
</div>
}
@* Back to root button - only show when in folder *@
@if (IsInFolder)
{
<div class="mb-4">
<button @onclick="NavigateToRoot" class="inline-flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
@Localizer["BackToRoot"]
</button>
</div>
}
<div class="grid gap-4 md:grid-cols-4 xl:grid-cols-6">
@if (Items.Count == 0 && !IsInFolder)
{
<div class="credential-card col-span-full p-4 space-y-2 bg-amber-50 border border-primary-500 rounded-lg shadow-sm dark:border-primary-700 dark:bg-gray-800">
<div class="px-4 py-6 text-gray-700 dark:text-gray-200 rounded text-center flex flex-col items-center">
<p class="mb-2 text-lg font-semibold text-primary-700 dark:text-primary-400">@Localizer["NoItemsTitle"]</p>
<div class="max-w-md mx-auto">
<div class="mb-6">
<p class="text-sm mb-2">@Localizer["CreateFirstItemText"] <span class="hidden md:inline">@Localizer["NewAliasButtonText"]</span><span class="md:hidden">@Localizer["NewAliasButtonTextMobile"]</span> @Localizer["ButtonLocationText"]</p>
</div>
<div class="flex items-center my-6">
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600"></div>
<span class="px-4 text-sm text-gray-500 dark:text-gray-400">@Localizer["OrText"]</span>
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600"></div>
</div>
<div>
<p class="text-sm mb-2">@Localizer["ImportItemsText"]</p>
<a href="/settings/import-export" class="inline-block text-sm px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors dark:bg-primary-700 dark:hover:bg-primary-600">
@Localizer["ImportButtonText"]
</a>
</div>
</div>
</div>
</div>
}
else if (!FilteredAndSortedItems.Any())
{
<div class="credential-card col-span-full p-4 space-y-2 bg-amber-50 border border-primary-500 rounded-lg shadow-sm dark:border-primary-700 dark:bg-gray-800">
<div class="px-4 py-6 text-gray-700 dark:text-gray-200 rounded text-center">
@if (FilterType == ItemFilterType.Passkeys)
{
<p>@Localizer["NoPasskeysFound"]</p>
}
else if (FilterType == ItemFilterType.Attachments)
{
<p>@Localizer["NoAttachmentsFound"]</p>
}
else if (IsInFolder)
{
<p>@Localizer["EmptyFolderMessage"]</p>
}
else
{
<p>@Localizer["NoItemsFound"]</p>
}
</div>
</div>
}
@foreach (var item in FilteredAndSortedItems)
{
<ItemCard Obj="@item" ShowFolderPath="@IsSearching" />
}
</div>
@* Infinite scroll sentinel - triggers loading more items when visible *@
@if (HasMoreItems)
{
<div @ref="_scrollSentinel" class="flex justify-center py-4 min-h-[40px]">
@if (IsLoadingMore)
{
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>@Localizer["LoadingMore"]</span>
</div>
}
</div>
}
@* Delete folder button - only show when in a folder *@
@if (IsInFolder)
{
<button @onclick="ShowDeleteFolderModal" class="w-full mt-4 p-3 flex items-center gap-2 text-left text-red-600 dark:text-red-400 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 hover:border-red-300 dark:hover:border-red-700 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
<span>@Localizer["DeleteFolder"]</span>
</button>
}
</div>
}
}
@* Folder Modal *@
<FolderModal
IsOpen="@ShowFolderModal"
OnClose="CloseFolderModal"
OnSave="CreateFolderAsync"
Mode="create" />
@* Delete Folder Modal *@
<DeleteFolderModal
IsOpen="@ShowDeleteFolderModalVisible"
OnClose="CloseDeleteFolderModal"
OnDeleteFolderOnly="DeleteFolderOnlyAsync"
OnDeleteFolderAndContents="DeleteFolderAndContentsAsync"
FolderName="@CurrentFolderName"
ItemCount="@FilteredAndSortedItems.Count()" />
@code {
private IStringLocalizer Localizer => LocalizerFactory.Create("Pages.Main.Items.Home", "AliasVault.Client");
/// <summary>
/// Gets or sets the folder ID from the URL.
/// </summary>
[Parameter]
public Guid? FolderId { get; set; }
/// <summary>
/// Gets or sets whether the items are being loaded.
/// </summary>
private bool IsLoading { get; set; } = true;
/// <summary>
/// Gets or sets the items.
/// </summary>
private List<ItemListEntry> Items { get; set; } = new();
/// <summary>
/// Gets or sets the folders.
/// </summary>
private List<FolderWithCount> Folders { get; set; } = new();
/// <summary>
/// Gets or sets the current folder name (when in folder view).
/// </summary>
private string CurrentFolderName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets whether the settings dropdown is shown.
/// </summary>
private bool ShowSettingsDropdown { get; set; }
/// <summary>
/// Gets or sets whether the filter dropdown is shown.
/// </summary>
private bool ShowFilterDropdown { get; set; }
/// <summary>
/// Gets or sets whether the folder modal is shown.
/// </summary>
private bool ShowFolderModal { get; set; }
/// <summary>
/// Gets or sets whether the delete folder modal is shown.
/// </summary>
private bool ShowDeleteFolderModalVisible { get; set; }
/// <summary>
/// Gets or sets the search term.
/// </summary>
private string SearchTerm { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the filter type for the items.
/// </summary>
private ItemFilterType FilterType { get; set; } = ItemFilterType.All;
/// <summary>
/// Gets or sets the number of visible items for infinite scroll.
/// </summary>
private int VisibleItemCount { get; set; } = 200;
/// <summary>
/// Gets or sets the batch size for loading more items.
/// </summary>
private const int BatchSize = 200;
/// <summary>
/// Gets or sets whether more items are currently being loaded.
/// </summary>
private bool IsLoadingMore { get; set; }
/// <summary>
/// Reference for the infinite scroll sentinel element.
/// </summary>
private ElementReference _scrollSentinel;
/// <summary>
/// Reference to the .NET object for JS interop callbacks.
/// </summary>
private DotNetObjectReference<Home>? _dotNetRef;
/// <summary>
/// Gets or sets the view mode for the items.
/// </summary>
private string ViewMode
{
get => DbService.Settings.CredentialsViewMode;
set => DbService.Settings.SetCredentialsViewMode(value);
}
/// <summary>
/// Gets or sets the sort order for the items.
/// </summary>
private CredentialSortOrder SortOrder
{
get => DbService.Settings.CredentialsSortOrder;
set => DbService.Settings.SetCredentialsSortOrder(value);
}
/// <summary>
/// Gets whether we're currently in a folder view.
/// </summary>
private bool IsInFolder => FolderId.HasValue;
/// <summary>
/// Gets whether we're currently searching.
/// </summary>
private bool IsSearching => !string.IsNullOrEmpty(SearchTerm);
/// <summary>
/// Gets the items filtered and sorted according to the current filter and sort order (all items, not paginated).
/// </summary>
private IEnumerable<ItemListEntry> AllFilteredAndSortedItems
{
get
{
// First filter by folder
var filtered = Items.AsEnumerable();
if (IsInFolder)
{
// Show only items in this folder
filtered = filtered.Where(x => x.FolderId == FolderId);
}
else if (!IsSearching)
{
// At root level, exclude items that are in folders
filtered = filtered.Where(x => x.FolderId == null);
}
// Then apply type/feature filter
filtered = FilterType switch
{
ItemFilterType.Passkeys => filtered.Where(x => x.HasPasskey),
ItemFilterType.Attachments => filtered.Where(x => x.HasAttachment),
ItemFilterType.Login => filtered.Where(x => x.ItemType == ItemTypes.Login),
ItemFilterType.Alias => filtered.Where(x => x.ItemType == ItemTypes.Alias),
ItemFilterType.CreditCard => filtered.Where(x => x.ItemType == ItemTypes.CreditCard),
ItemFilterType.Note => filtered.Where(x => x.ItemType == ItemTypes.Note),
_ => filtered, // All
};
// Then apply sort
return SortOrder switch
{
CredentialSortOrder.NewestFirst => filtered.OrderByDescending(x => x.CreatedAt),
CredentialSortOrder.Alphabetical => filtered.OrderBy(x => x.Service ?? string.Empty),
_ => filtered.OrderBy(x => x.CreatedAt), // OldestFirst (default)
};
}
}
/// <summary>
/// Gets the total number of filtered items.
/// </summary>
private int TotalFilteredItems => AllFilteredAndSortedItems.Count();
/// <summary>
/// Gets whether there are more items to load.
/// </summary>
private bool HasMoreItems => VisibleItemCount < TotalFilteredItems;
/// <summary>
/// Gets the visible items (limited by VisibleItemCount for infinite scroll).
/// </summary>
private IEnumerable<ItemListEntry> FilteredAndSortedItems =>
AllFilteredAndSortedItems.Take(VisibleItemCount);
/// <summary>
/// Gets the title based on the active filter and folder.
/// </summary>
private string GetFilterTitle()
{
if (IsInFolder && !string.IsNullOrEmpty(CurrentFolderName))
{
return CurrentFolderName;
}
return FilterType switch
{
ItemFilterType.Passkeys => Localizer["FilterPasskeysOption"],
ItemFilterType.Attachments => Localizer["FilterAttachmentsOption"],
ItemFilterType.Login => Localizer["FilterLoginOption"],
ItemFilterType.Alias => Localizer["FilterAliasOption"],
ItemFilterType.CreditCard => Localizer["FilterCreditCardOption"],
ItemFilterType.Note => Localizer["FilterNoteOption"],
_ => Localizer["PageTitle"],
};
}
/// <summary>
/// Gets the CSS class for the add folder button.
/// </summary>
private string GetAddFolderButtonClass()
{
if (Folders.Count > 0)
{
return "inline-flex items-center gap-1 px-2.5 py-1.5 text-xs rounded-full transition-colors focus:outline-none text-gray-500 dark:text-gray-400 hover:text-orange-600 dark:hover:text-orange-400 hover:bg-gray-100 dark:hover:bg-gray-700/50";
}
return "inline-flex items-center gap-1 px-2.5 py-1.5 text-xs rounded-full transition-colors focus:outline-none text-gray-400 dark:text-gray-500 border border-dashed border-gray-300 dark:border-gray-600 hover:border-orange-400 dark:hover:border-orange-500 hover:text-orange-600 dark:hover:text-orange-400";
}
/// <summary>
/// Toggles the settings dropdown.
/// </summary>
private void ToggleSettingsDropdown()
{
ShowSettingsDropdown = !ShowSettingsDropdown;
StateHasChanged();
}
/// <summary>
/// Toggles the filter dropdown.
/// </summary>
private void ToggleFilterDropdown()
{
ShowFilterDropdown = !ShowFilterDropdown;
StateHasChanged();
}
/// <summary>
/// Sets the filter type and closes the dropdown.
/// </summary>
private void SetFilter(ItemFilterType filterType)
{
FilterType = filterType;
VisibleItemCount = BatchSize; // Reset visible items when filter changes
ShowFilterDropdown = false;
StateHasChanged();
}
/// <summary>
/// Loads more items for infinite scroll. Called from JavaScript via IntersectionObserver.
/// </summary>
[JSInvokable]
public async Task LoadMoreItems()
{
if (HasMoreItems && !IsLoadingMore)
{
IsLoadingMore = true;
StateHasChanged();
// Brief delay to show loading indicator
await Task.Delay(300);
VisibleItemCount += BatchSize;
IsLoadingMore = false;
StateHasChanged();
}
}
/// <summary>
/// Closes the filter dropdown.
/// </summary>
private void CloseFilterDropdown()
{
ShowFilterDropdown = false;
StateHasChanged();
}
/// <summary>
/// Closes the settings dropdown.
/// </summary>
private async Task CloseSettingsPopup()
{
ShowSettingsDropdown = false;
// Reload the items in case the settings were changed.
await LoadItemsAsync();
}
/// <summary>
/// Navigate to a folder.
/// </summary>
private void NavigateToFolder(Guid folderId)
{
VisibleItemCount = BatchSize; // Reset visible items when navigating to folder
NavigationManager.NavigateTo($"/items/folder/{folderId}");
}
/// <summary>
/// Navigate back to root.
/// </summary>
private void NavigateToRoot()
{
VisibleItemCount = BatchSize; // Reset visible items when navigating to root
NavigationManager.NavigateTo("/items");
}
/// <summary>
/// Show the create folder modal.
/// </summary>
private void ShowCreateFolderModal()
{
ShowFolderModal = true;
StateHasChanged();
}
/// <summary>
/// Close the folder modal.
/// </summary>
private void CloseFolderModal()
{
ShowFolderModal = false;
StateHasChanged();
}
/// <summary>
/// Create a new folder.
/// </summary>
private async Task CreateFolderAsync(string folderName)
{
var folderId = await FolderService.CreateAsync(folderName);
if (folderId != Guid.Empty)
{
// Reload folders
Folders = await FolderService.GetAllWithCountsAsync();
StateHasChanged();
}
else
{
GlobalNotificationService.AddErrorMessage(Localizer["FailedToCreateFolder"], true);
}
}
/// <summary>
/// Show the delete folder modal.
/// </summary>
private void ShowDeleteFolderModal()
{
ShowDeleteFolderModalVisible = true;
StateHasChanged();
}
/// <summary>
/// Close the delete folder modal.
/// </summary>
private void CloseDeleteFolderModal()
{
ShowDeleteFolderModalVisible = false;
StateHasChanged();
}
/// <summary>
/// Delete the current folder only (move items to root).
/// </summary>
private async Task DeleteFolderOnlyAsync()
{
if (FolderId.HasValue)
{
var success = await FolderService.DeleteAsync(FolderId.Value);
if (success)
{
NavigationManager.NavigateTo("/items");
}
else
{
GlobalNotificationService.AddErrorMessage(Localizer["FailedToDeleteFolder"], true);
}
}
}
/// <summary>
/// Delete the current folder and all its contents.
/// </summary>
private async Task DeleteFolderAndContentsAsync()
{
if (FolderId.HasValue)
{
var success = await FolderService.DeleteWithContentsAsync(FolderId.Value);
if (success)
{
NavigationManager.NavigateTo("/items");
}
else
{
GlobalNotificationService.AddErrorMessage(Localizer["FailedToDeleteFolder"], true);
}
}
}
/// <inheritdoc />
protected override async Task OnParametersSetAsync()
{
await base.OnParametersSetAsync();
// Load folder name if in folder view
if (FolderId.HasValue)
{
var folder = await FolderService.GetByIdAsync(FolderId.Value);
CurrentFolderName = folder?.Name ?? string.Empty;
}
else
{
CurrentFolderName = string.Empty;
}
await LoadItemsAsync();
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
await LoadItemsAsync();
// Set up IntersectionObserver for infinite scroll
_dotNetRef = DotNetObjectReference.Create(this);
await JsInteropService.SetupInfiniteScroll(_scrollSentinel, _dotNetRef);
}
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_dotNetRef != null)
{
await JsInteropService.TeardownInfiniteScroll(_scrollSentinel);
_dotNetRef.Dispose();
_dotNetRef = null;
}
}
/// <summary>
/// Loads and/or refreshes the items.
/// </summary>
private async Task LoadItemsAsync()
{
IsLoading = true;
VisibleItemCount = BatchSize; // Reset visible items when loading
StateHasChanged();
// Load the items from the database via ItemService.
var itemListEntries = await ItemService.GetListAsync();
if (itemListEntries is null)
{
// Error loading items.
GlobalNotificationService.AddErrorMessage(Localizer["FailedToLoadItemsMessage"], true);
return;
}
if (itemListEntries.Count == 0 && !DbService.Settings.TutorialDone && !IsInFolder)
{
// Redirect to the welcome page.
NavigationManager.NavigateTo("/welcome");
return;
}
// Load folders
Folders = await FolderService.GetAllWithCountsAsync();
// Pass unsorted list to the view - sorting will be handled by the property
Items = itemListEntries;
IsLoading = false;
StateHasChanged();
}
}

View File

@@ -0,0 +1,346 @@
@page "/items/{id:guid}"
@inherits MainBase
@inject ItemService ItemService
@implements IAsyncDisposable
@using Microsoft.Extensions.Localization
@using AliasClientDb
@using AliasClientDb.Models
@using AliasVault.Client.Main.Models
@using AliasVault.Client.Main.Utilities
@using AliasVault.Client.Main.Components.Items
@using AliasVault.Client.Main.Components.Fields
<LayoutPageTitle>@Localizer["ViewItemPageTitle"]</LayoutPageTitle>
@if (IsLoading || Item == null)
{
<LoadingIndicator />
}
else
{
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="@Localizer["ViewItemTitle"]">
<CustomActions>
<LinkButton
SmallText="@Localizer["EditButtonMobile"]"
Text="@Localizer["EditButtonDesktop"]"
Href="@($"/items/{Id}/edit")"
Color="primary" />
<LinkButton
SmallText="@Localizer["DeleteButtonMobile"]"
Text="@Localizer["DeleteButtonDesktop"]"
Href="@($"/items/{Id}/delete")"
Color="danger" />
</CustomActions>
</PageHeader>
<div class="grid grid-cols-1 px-4 pt-6 md:grid-cols-2 lg:grid-cols-3 lg:gap-4 dark:bg-gray-900">
@* Left column *@
<div class="col-span-1 md:col-span-2 lg:col-span-1">
@* Header block with icon and name *@
<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">
<div class="items-center flex space-x-4">
<ItemIcon
ItemType="@(Item.ItemType ?? ItemTypes.Login)"
Logo="@Item.Logo?.FileData"
CardNumber="@GetCardNumber()"
AltText="@(Item.Name ?? "Item")"
SizeClass="w-14 h-14" />
<div class="flex-1 min-w-0">
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white truncate">@(Item.Name ?? Localizer["Untitled"])</h3>
@foreach (var url in UrlValues)
{
<div class="text-sm truncate">
@if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
<a href="@url" target="_blank" rel="noopener noreferrer" class="text-blue-500 break-all dark:text-blue-400 hover:underline">@url</a>
}
else
{
<span class="text-gray-700 break-all dark:text-gray-300">@url</span>
}
</div>
}
</div>
</div>
</div>
@* Recent emails - only for Login/Alias *@
@if ((Item.ItemType == ItemTypes.Login || Item.ItemType == ItemTypes.Alias) && !string.IsNullOrEmpty(GetEmailAddress()))
{
<RecentEmails EmailAddress="@GetEmailAddress()" />
}
@* TOTP codes - only for items with TOTP *@
@if (Item.TotpCodes.Count > 0)
{
<TotpViewer TotpCodeList="@Item.TotpCodes" />
}
@* Notes - if present *@
@if (GroupedFields.TryGetValue(FieldCategory.Notes, out var notesFields) && notesFields.Count > 0)
{
<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["NotesSection"]</h3>
<div class="grid grid-cols-6 gap-6">
@foreach (var field in notesFields)
{
<FieldBlock Field="@field" />
}
</div>
</div>
}
@* Attachments *@
@if (Item.Attachments.Count > 0)
{
<AttachmentViewer Attachments="@Item.Attachments" />
}
</div>
@* Right column *@
<div class="col-span-1 md:col-span-2 lg:col-span-2">
@* Login fields - for Login/Alias *@
@if ((Item.ItemType == ItemTypes.Login || Item.ItemType == ItemTypes.Alias) &&
GroupedFields.TryGetValue(FieldCategory.Login, out var loginFields) && loginFields.Count > 0)
{
<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-2 text-xl font-semibold dark:text-white">@Localizer["LoginDetailsSection"]</h3>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
@{
var email = GetEmailAddress();
}
@if (EmailService.IsAliasVaultSupportedDomain(email ?? string.Empty))
{
<span>@Localizer["GeneratedItemDescription"]</span>
}
else
{
<span>@Localizer["StoredItemDescription"]</span>
}
</p>
@* Show passkey if available *@
@if (Item.Passkeys != null && Item.Passkeys.Any())
{
var passkey = Item.Passkeys.First();
<div class="mb-6 p-3 rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-gray-600 dark:text-gray-400 mt-0.5 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
</svg>
<div class="flex-1">
<div class="mb-1">
<span class="text-sm font-semibold text-gray-900 dark:text-white">@Localizer["PasskeyLabel"]</span>
</div>
<div class="space-y-1 mb-2">
@if (!string.IsNullOrWhiteSpace(passkey.RpId))
{
<div>
<span class="text-xs text-gray-500 dark:text-gray-400">@Localizer["PasskeySiteLabel"]: </span>
<span class="text-sm text-gray-900 dark:text-white">@passkey.RpId</span>
</div>
}
@if (!string.IsNullOrWhiteSpace(passkey.DisplayName))
{
<div>
<span class="text-xs text-gray-500 dark:text-gray-400">@Localizer["PasskeyDisplayNameLabel"]: </span>
<span class="text-sm text-gray-900 dark:text-white">@passkey.DisplayName</span>
</div>
}
</div>
<p class="text-xs text-gray-600 dark:text-gray-400">
@Localizer["PasskeyHelpText"]
</p>
</div>
</div>
</div>
}
<form action="#">
<div class="grid grid-cols-6 gap-6">
@foreach (var field in loginFields)
{
<FieldBlock Field="@field" />
}
</div>
</form>
</div>
}
@* Alias fields - for Alias type only *@
@if (Item.ItemType == ItemTypes.Alias &&
GroupedFields.TryGetValue(FieldCategory.Alias, out var aliasFields) && aliasFields.Count > 0)
{
<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["AliasSection"]</h3>
<form action="#">
<div class="grid grid-cols-6 gap-6">
@* Show full name if first and last name exist *@
@{
var firstName = aliasFields.FirstOrDefault(f => f.FieldKey == FieldKey.AliasFirstName)?.Value;
var lastName = aliasFields.FirstOrDefault(f => f.FieldKey == FieldKey.AliasLastName)?.Value;
}
@if (!string.IsNullOrWhiteSpace(firstName) && !string.IsNullOrWhiteSpace(lastName))
{
<div class="col-span-6">
<CopyPasteFormRow Label="@Localizer["FullNameLabel"]" Value="@($"{firstName} {lastName}")" />
</div>
}
@foreach (var field in aliasFields)
{
<FieldBlock Field="@field" />
}
</div>
</form>
</div>
}
@* Card fields - for CreditCard type *@
@if (Item.ItemType == ItemTypes.CreditCard &&
GroupedFields.TryGetValue(FieldCategory.Card, out var cardFields) && cardFields.Count > 0)
{
<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["CardSection"]</h3>
<form action="#">
<div class="grid grid-cols-6 gap-6">
@foreach (var field in cardFields)
{
<FieldBlock Field="@field" />
}
</div>
</form>
</div>
}
@* Custom fields *@
@if (GroupedFields.TryGetValue(FieldCategory.Custom, out var customFields) && customFields.Count > 0)
{
<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["CustomFieldsSection"]</h3>
<form action="#">
<div class="grid grid-cols-6 gap-6">
@foreach (var field in customFields)
{
<FieldBlock Field="@field" />
}
</div>
</form>
</div>
}
</div>
</div>
}
@code {
private IStringLocalizer Localizer => LocalizerFactory.Create("Pages.Main.Items.View", "AliasVault.Client");
/// <summary>
/// Gets or sets the item ID.
/// </summary>
[Parameter]
public Guid Id { get; set; }
private bool IsLoading { get; set; } = true;
private Item? Item { get; set; } = new();
private Dictionary<FieldCategory, List<DisplayField>> GroupedFields { get; set; } = new();
private List<string> UrlValues { get; set; } = new();
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["ViewItemBreadcrumb"] });
}
/// <inheritdoc />
protected override async Task OnParametersSetAsync()
{
await base.OnParametersSetAsync();
await LoadEntryAsync();
}
/// <summary>
/// Loads the item.
/// </summary>
private async Task LoadEntryAsync()
{
IsLoading = true;
StateHasChanged();
// Load the item from the database via ItemService.
Item = await ItemService.LoadEntryAsync(Id);
if (Item is null)
{
// Error loading item.
GlobalNotificationService.AddErrorMessage(Localizer["ItemNotFoundError"]);
NavigationManager.NavigateTo("/items", false, true);
return;
}
// Group fields by category for display
GroupedFields = FieldGrouper.GroupByCategory(Item);
// Get URL values for prominent display
UrlValues = FieldGrouper.GetUrlValues(Item);
IsLoading = false;
StateHasChanged();
}
/// <summary>
/// Gets the email address from the item.
/// </summary>
private string? GetEmailAddress()
{
return ItemService.GetFieldValue(Item!, FieldKey.LoginEmail);
}
/// <summary>
/// Gets the card number from the item (for icon display).
/// </summary>
private string? GetCardNumber()
{
return ItemService.GetFieldValue(Item!, FieldKey.CardNumber);
}
/// <inheritdoc />
async ValueTask IAsyncDisposable.DisposeAsync()
{
await KeyboardShortcutService.UnregisterShortcutAsync("ge");
await KeyboardShortcutService.UnregisterShortcutAsync("gd");
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
await KeyboardShortcutService.RegisterShortcutAsync("ge", NavigateToEdit);
await KeyboardShortcutService.RegisterShortcutAsync("gd", NavigateToDelete);
}
}
/// <summary>
/// Navigates to the edit page.
/// </summary>
private Task NavigateToEdit()
{
NavigationManager.NavigateTo($"/items/{Id}/edit");
return Task.CompletedTask;
}
/// <summary>
/// Navigates to the delete page.
/// </summary>
private Task NavigateToDelete()
{
NavigationManager.NavigateTo($"/items/{Id}/delete");
return Task.CompletedTask;
}
}

View File

@@ -453,7 +453,7 @@
if (success)
{
GlobalNotificationService.AddSuccessMessage($"Successfully imported {ImportedCredentials.Count} credentials.");
NavigationManager.NavigateTo("/credentials");
NavigationManager.NavigateTo("/items");
}
else
{

View File

@@ -26,7 +26,7 @@
<div class="mb-6 text-gray-600 dark:text-gray-400">
<p class="mb-2">@Localizer["ResetVaultPleaseNote"]</p>
<ul class="list-disc list-inside space-y-2">
<li>@Localizer["ResetVaultCredentialsDeletedNote"]</li>
<li>@Localizer["ResetVaultItemsDeletedNote"]</li>
<li>@Localizer["ResetVaultEmailAliasesKeptNote"]</li>
<li>@Localizer["ResetVaultSettingsKeptNote"]</li>
<li>@Localizer["ResetVaultIrreversibleNote"]</li>
@@ -176,8 +176,8 @@
GlobalNotificationService.AddSuccessMessage(Localizer["ResetVaultSuccessMessage"]);
// Redirect to credentials overview page to show the empty vault
NavigationManager.NavigateTo("/credentials");
// Redirect to items overview page to show the empty vault
NavigationManager.NavigateTo("/items");
}
catch (Exception ex)
{

View File

@@ -0,0 +1,111 @@
//-----------------------------------------------------------------------
// <copyright file="CardBrandDetector.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Main.Utilities;
using System.Text.RegularExpressions;
/// <summary>
/// Utility for detecting credit card brand from card number.
/// Uses industry-standard BIN (Bank Identification Number) prefixes.
/// </summary>
public static partial class CardBrandDetector
{
/// <summary>
/// Credit card brand types.
/// </summary>
public enum CardBrand
{
/// <summary>
/// Generic/unknown card brand.
/// </summary>
Generic,
/// <summary>
/// Visa card (starts with 4).
/// </summary>
Visa,
/// <summary>
/// Mastercard (starts with 51-55 or 2221-2720).
/// </summary>
Mastercard,
/// <summary>
/// American Express (starts with 34 or 37).
/// </summary>
Amex,
/// <summary>
/// Discover card (starts with 6011, 622, 644-649, 65).
/// </summary>
Discover,
}
/// <summary>
/// Detect the card brand from a card number.
/// </summary>
/// <param name="cardNumber">The card number (may contain spaces or dashes).</param>
/// <returns>The detected card brand.</returns>
public static CardBrand Detect(string? cardNumber)
{
if (string.IsNullOrWhiteSpace(cardNumber))
{
return CardBrand.Generic;
}
// Remove spaces and dashes
var cleaned = cardNumber.Replace(" ", string.Empty).Replace("-", string.Empty);
// Must be mostly numeric (at least 4 digits)
if (!NumericPrefixRegex().IsMatch(cleaned))
{
return CardBrand.Generic;
}
// Visa: starts with 4
if (VisaRegex().IsMatch(cleaned))
{
return CardBrand.Visa;
}
// Mastercard: starts with 51-55 or 2221-2720
if (MastercardRegex().IsMatch(cleaned))
{
return CardBrand.Mastercard;
}
// Amex: starts with 34 or 37
if (AmexRegex().IsMatch(cleaned))
{
return CardBrand.Amex;
}
// Discover: starts with 6011, 622, 644-649, 65
if (DiscoverRegex().IsMatch(cleaned))
{
return CardBrand.Discover;
}
return CardBrand.Generic;
}
[GeneratedRegex(@"^\d{4,}")]
private static partial Regex NumericPrefixRegex();
[GeneratedRegex(@"^4")]
private static partial Regex VisaRegex();
[GeneratedRegex(@"^(5[1-5]|2[2-7])")]
private static partial Regex MastercardRegex();
[GeneratedRegex(@"^3[47]")]
private static partial Regex AmexRegex();
[GeneratedRegex(@"^6(011|22|4[4-9]|5)")]
private static partial Regex DiscoverRegex();
}

View File

@@ -0,0 +1,166 @@
//-----------------------------------------------------------------------
// <copyright file="FieldGrouper.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Main.Utilities;
using System.Collections.Generic;
using System.Linq;
using AliasClientDb;
using AliasClientDb.Models;
using AliasVault.Client.Main.Models;
/// <summary>
/// Utility for grouping item fields by category for display.
/// </summary>
public static class FieldGrouper
{
/// <summary>
/// Groups the fields of an item by category.
/// </summary>
/// <param name="item">The item containing field values.</param>
/// <returns>Dictionary of field category to display fields.</returns>
public static Dictionary<FieldCategory, List<DisplayField>> GroupByCategory(Item item)
{
var result = new Dictionary<FieldCategory, List<DisplayField>>();
foreach (FieldCategory category in Enum.GetValues<FieldCategory>())
{
result[category] = new List<DisplayField>();
}
if (item?.FieldValues == null)
{
return result;
}
foreach (var fieldValue in item.FieldValues.Where(fv => !fv.IsDeleted && !string.IsNullOrEmpty(fv.Value)))
{
var displayField = CreateDisplayField(fieldValue);
if (displayField != null)
{
result[displayField.Category].Add(displayField);
}
}
// Sort each category by display order
foreach (var category in result.Keys)
{
result[category] = result[category].OrderBy(f => f.DisplayOrder).ToList();
}
// Sort Login category: email -> username -> password -> others
if (result.TryGetValue(FieldCategory.Login, out var loginFields))
{
result[FieldCategory.Login] = loginFields
.OrderBy(f => f.FieldKey switch
{
FieldKey.LoginEmail => 1,
FieldKey.LoginUsername => 2,
FieldKey.LoginPassword => 3,
_ => 4 + f.DisplayOrder,
})
.ToList();
}
return result;
}
/// <summary>
/// Gets all URL field values from an item for prominent display.
/// </summary>
/// <param name="item">The item containing field values.</param>
/// <returns>List of URL values.</returns>
public static List<string> GetUrlValues(Item item)
{
if (item?.FieldValues == null)
{
return new List<string>();
}
return item.FieldValues
.Where(fv => !fv.IsDeleted && fv.FieldKey == FieldKey.LoginUrl && !string.IsNullOrEmpty(fv.Value))
.OrderBy(fv => fv.Weight)
.Select(fv => fv.Value ?? string.Empty)
.ToList();
}
/// <summary>
/// Creates a display field from a field value.
/// </summary>
private static DisplayField? CreateDisplayField(FieldValue fieldValue)
{
if (string.IsNullOrEmpty(fieldValue.FieldKey) && fieldValue.FieldDefinitionId == null)
{
return null;
}
// Determine category from field key prefix
var category = GetCategoryFromFieldKey(fieldValue.FieldKey);
// Get system field definition if available
var systemField = !string.IsNullOrEmpty(fieldValue.FieldKey)
? SystemFieldRegistry.GetSystemField(fieldValue.FieldKey)
: null;
return new DisplayField
{
FieldKey = fieldValue.FieldKey ?? string.Empty,
FieldDefinitionId = fieldValue.FieldDefinitionId?.ToString(),
FieldType = systemField?.FieldType ?? "Text",
Value = fieldValue.Value,
IsHidden = systemField?.IsHidden ?? false,
EnableHistory = systemField?.EnableHistory ?? false,
DisplayOrder = systemField?.DefaultDisplayOrder ?? fieldValue.Weight,
Category = category,
};
}
/// <summary>
/// Gets the field category from a field key based on its prefix.
/// </summary>
private static FieldCategory GetCategoryFromFieldKey(string? fieldKey)
{
if (string.IsNullOrEmpty(fieldKey))
{
return FieldCategory.Custom;
}
if (fieldKey.StartsWith("login."))
{
// URL is in Primary category
if (fieldKey == FieldKey.LoginUrl)
{
return FieldCategory.Primary;
}
return FieldCategory.Login;
}
if (fieldKey.StartsWith("alias."))
{
return FieldCategory.Alias;
}
if (fieldKey.StartsWith("card."))
{
return FieldCategory.Card;
}
if (fieldKey.StartsWith("notes."))
{
return FieldCategory.Notes;
}
if (fieldKey.StartsWith("metadata."))
{
return FieldCategory.Metadata;
}
// Custom fields
return FieldCategory.Custom;
}
}

View File

@@ -89,6 +89,7 @@ builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<UserRegistrationService>();
builder.Services.AddScoped<AuthenticationStateProvider, AuthStateProvider>();
builder.Services.AddScoped<ItemService>();
builder.Services.AddScoped<FolderService>();
builder.Services.AddScoped<DbService>();
builder.Services.AddScoped<GlobalNotificationService>();
builder.Services.AddScoped<GlobalLoadingService>();

View File

@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<!-- Login Field Labels -->
<data name="FieldLabel_login_username" xml:space="preserve">
<value>Username</value>
<comment>Label for username field</comment>
</data>
<data name="FieldLabel_login_password" xml:space="preserve">
<value>Password</value>
<comment>Label for password field</comment>
</data>
<data name="FieldLabel_login_email" xml:space="preserve">
<value>Email</value>
<comment>Label for email field</comment>
</data>
<data name="FieldLabel_login_url" xml:space="preserve">
<value>Website</value>
<comment>Label for URL field</comment>
</data>
<!-- Alias Field Labels -->
<data name="FieldLabel_alias_first_name" xml:space="preserve">
<value>First Name</value>
<comment>Label for first name field</comment>
</data>
<data name="FieldLabel_alias_last_name" xml:space="preserve">
<value>Last Name</value>
<comment>Label for last name field</comment>
</data>
<data name="FieldLabel_alias_gender" xml:space="preserve">
<value>Gender</value>
<comment>Label for gender field</comment>
</data>
<data name="FieldLabel_alias_birthdate" xml:space="preserve">
<value>Birth Date</value>
<comment>Label for birthdate field</comment>
</data>
<!-- Card Field Labels -->
<data name="FieldLabel_card_number" xml:space="preserve">
<value>Card Number</value>
<comment>Label for card number field</comment>
</data>
<data name="FieldLabel_card_cardholder_name" xml:space="preserve">
<value>Cardholder Name</value>
<comment>Label for cardholder name field</comment>
</data>
<data name="FieldLabel_card_expiry_month" xml:space="preserve">
<value>Expiry Month</value>
<comment>Label for expiry month field</comment>
</data>
<data name="FieldLabel_card_expiry_year" xml:space="preserve">
<value>Expiry Year</value>
<comment>Label for expiry year field</comment>
</data>
<data name="FieldLabel_card_cvv" xml:space="preserve">
<value>CVV</value>
<comment>Label for CVV field</comment>
</data>
<data name="FieldLabel_card_pin" xml:space="preserve">
<value>PIN</value>
<comment>Label for PIN field</comment>
</data>
<!-- Notes Field Labels -->
<data name="FieldLabel_notes_content" xml:space="preserve">
<value>Notes</value>
<comment>Label for notes content field</comment>
</data>
</root>

View File

@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="DeleteFolderTitle" xml:space="preserve">
<value>Delete Folder</value>
<comment>Title for delete folder modal</comment>
</data>
<data name="DeleteFolderDescription" xml:space="preserve">
<value>How would you like to delete the folder "{0}"?</value>
<comment>Description for delete folder modal. {0} is the folder name.</comment>
</data>
<data name="DeleteFolderOnlyTitle" xml:space="preserve">
<value>Delete folder only</value>
<comment>Title for delete folder only option</comment>
</data>
<data name="DeleteFolderOnlyDescription" xml:space="preserve">
<value>Items in this folder will be moved to root</value>
<comment>Description for delete folder only option</comment>
</data>
<data name="DeleteFolderAndContentsTitle" xml:space="preserve">
<value>Delete folder and contents</value>
<comment>Title for delete folder and contents option</comment>
</data>
<data name="DeleteFolderAndContentsDescription" xml:space="preserve">
<value>Move {0} item(s) to trash</value>
<comment>Description for delete folder and contents option. {0} is item count.</comment>
</data>
<data name="CancelButton" xml:space="preserve">
<value>Cancel</value>
<comment>Cancel button text</comment>
</data>
</root>

View File

@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="CreateFolderTitle" xml:space="preserve">
<value>Create Folder</value>
<comment>Title for create folder modal</comment>
</data>
<data name="EditFolderTitle" xml:space="preserve">
<value>Edit Folder</value>
<comment>Title for edit folder modal</comment>
</data>
<data name="FolderNameLabel" xml:space="preserve">
<value>Folder Name</value>
<comment>Label for folder name input</comment>
</data>
<data name="FolderNamePlaceholder" xml:space="preserve">
<value>Enter folder name</value>
<comment>Placeholder for folder name input</comment>
</data>
<data name="FolderNameRequired" xml:space="preserve">
<value>Folder name is required</value>
<comment>Error message when folder name is empty</comment>
</data>
<data name="CreateButton" xml:space="preserve">
<value>Create</value>
<comment>Create button text</comment>
</data>
<data name="SaveButton" xml:space="preserve">
<value>Save</value>
<comment>Save button text</comment>
</data>
<data name="CancelButton" xml:space="preserve">
<value>Cancel</value>
<comment>Cancel button text</comment>
</data>
</root>

View File

@@ -70,13 +70,13 @@
<value>Date:</value>
<comment>Email date field label</comment>
</data>
<data name="CredentialLabel" xml:space="preserve">
<value>Credential:</value>
<comment>Email credential field label</comment>
<data name="ItemLabel" xml:space="preserve">
<value>Item:</value>
<comment>Email item field label</comment>
</data>
<data name="NoneValue" xml:space="preserve">
<value>None</value>
<comment>No credential assigned value</comment>
<comment>No item assigned value</comment>
</data>
<data name="AttachmentsLabel" xml:space="preserve">
<value>Attachments:</value>

View File

@@ -74,9 +74,9 @@
<value>Please note:</value>
<comment>Reset vault please note prefix</comment>
</data>
<data name="ResetVaultCredentialsDeletedNote" xml:space="preserve">
<value>All encrypted credentials in your vault will be permanently deleted</value>
<comment>Reset vault note about credentials being deleted</comment>
<data name="ResetVaultItemsDeletedNote" xml:space="preserve">
<value>All encrypted items in your vault will be permanently deleted</value>
<comment>Reset vault note about items being deleted</comment>
</data>
<data name="ResetVaultEmailAliasesKeptNote" xml:space="preserve">
<value>Your email aliases will be preserved and can be re-used after resetting your vault</value>
@@ -99,7 +99,7 @@
<comment>Reset vault continue button</comment>
</data>
<data name="ResetVaultFinalWarning" xml:space="preserve">
<value>Final warning: You are about to permanently delete all your credentials!</value>
<value>Final warning: You are about to permanently delete all your items!</value>
<comment>Reset vault final warning message</comment>
</data>
<data name="ResetVaultDeletionIrreversibleNote" xml:space="preserve">
@@ -131,7 +131,7 @@
<comment>Reset vault progress message</comment>
</data>
<data name="ResetVaultSuccessMessage" xml:space="preserve">
<value>Your vault has been successfully reset. All credentials have been deleted and you can now start fresh.</value>
<value>Your vault has been successfully reset. All items have been deleted and you can now start fresh.</value>
<comment>Reset vault success message</comment>
</data>
<data name="ResetVaultErrorMessage" xml:space="preserve">

View File

@@ -94,12 +94,12 @@
<value>Creating new alias...</value>
<comment>Loading message while creating alias</comment>
</data>
<data name="CreateCredentialErrorMessage" xml:space="preserve">
<value>Error creating a new credential. Please try again (later) or log-out and in again.</value>
<comment>Error message when credential creation fails</comment>
<data name="CreateItemErrorMessage" xml:space="preserve">
<value>Error creating a new item. Please try again (later) or log-out and in again.</value>
<comment>Error message when item creation fails</comment>
</data>
<data name="CredentialCreatedSuccessMessage" xml:space="preserve">
<value>Credential created successfully.</value>
<comment>Success message when credential is created</comment>
<data name="ItemCreatedSuccessMessage" xml:space="preserve">
<value>Item created successfully.</value>
<comment>Success message when item is created</comment>
</data>
</root>

View File

@@ -21,9 +21,9 @@
</xsd:schema>
<!-- Main navigation links -->
<data name="CredentialsNav">
<value>Credentials</value>
<comment>Main navigation link for credentials section</comment>
<data name="VaultNav">
<value>Vault</value>
<comment>Main navigation link for vault section</comment>
</data>
<data name="EmailsNav">
<value>Emails</value>

View File

@@ -21,35 +21,35 @@
</xsd:schema>
<!-- Page titles and descriptions -->
<data name="AddCredentialTitle">
<value>Add credential</value>
<comment>Title for adding a new credential</comment>
<data name="AddItemTitle">
<value>Add item</value>
<comment>Title for adding a new item</comment>
</data>
<data name="EditCredentialTitle">
<value>Edit credential</value>
<comment>Title for editing an existing credential</comment>
<data name="EditItemTitle">
<value>Edit item</value>
<comment>Title for editing an existing item</comment>
</data>
<data name="AddCredentialDescription">
<value>Create a new credential below.</value>
<comment>Description for adding a new credential</comment>
<data name="AddItemDescription">
<value>Create a new item below.</value>
<comment>Description for adding a new item</comment>
</data>
<data name="EditCredentialDescription">
<value>Edit the existing credential below.</value>
<comment>Description for editing an existing credential</comment>
<data name="EditItemDescription">
<value>Edit the existing item below.</value>
<comment>Description for editing an existing item</comment>
</data>
<!-- Breadcrumb items -->
<data name="ViewCredentialBreadcrumb">
<value>View credential</value>
<comment>Breadcrumb text for viewing a credential</comment>
<data name="ViewItemBreadcrumb">
<value>View item</value>
<comment>Breadcrumb text for viewing an item</comment>
</data>
<data name="EditCredentialBreadcrumb">
<value>Edit credential</value>
<comment>Breadcrumb text for editing a credential</comment>
<data name="EditItemBreadcrumb">
<value>Edit item</value>
<comment>Breadcrumb text for editing an item</comment>
</data>
<data name="AddNewCredentialBreadcrumb">
<value>Add new credential</value>
<comment>Breadcrumb text for adding a new credential</comment>
<data name="AddNewItemBreadcrumb">
<value>Add new item</value>
<comment>Breadcrumb text for adding a new item</comment>
</data>
<!-- Section headers -->
@@ -57,9 +57,9 @@
<value>Service</value>
<comment>Header for the service information section</comment>
</data>
<data name="LoginCredentialsSectionHeader">
<value>Login credentials</value>
<comment>Header for the login credentials section</comment>
<data name="LoginDetailsSectionHeader">
<value>Login details</value>
<comment>Header for the login details section</comment>
</data>
<data name="AliasSectionHeader">
<value>Alias</value>
@@ -135,9 +135,9 @@
<value>Clear Alias Fields</value>
<comment>Button text for clearing alias fields</comment>
</data>
<data name="SaveCredentialButton">
<value>Save Credential</value>
<comment>Button text for saving a credential</comment>
<data name="SaveItemButton">
<value>Save Item</value>
<comment>Button text for saving an item</comment>
</data>
<data name="CancelButton">
<value>Cancel</value>
@@ -151,23 +151,23 @@
</data>
<!-- Error messages -->
<data name="CredentialNotExistError">
<value>This credential does not exist (anymore). Please try again.</value>
<comment>Error message when credential doesn't exist</comment>
<data name="ItemNotExistError">
<value>This item does not exist (anymore). Please try again.</value>
<comment>Error message when item doesn't exist</comment>
</data>
<data name="ErrorSavingCredentials">
<value>Error saving credentials. Please try again.</value>
<comment>Error message when saving credentials fails</comment>
<data name="ErrorSavingItem">
<value>Error saving item. Please try again.</value>
<comment>Error message when saving item fails</comment>
</data>
<!-- Success messages -->
<data name="CredentialUpdatedSuccess">
<value>Credential updated successfully.</value>
<comment>Success message when credential is updated</comment>
<data name="ItemUpdatedSuccess">
<value>Item updated successfully.</value>
<comment>Success message when item is updated</comment>
</data>
<data name="CredentialCreatedSuccess">
<value>Credential created successfully.</value>
<comment>Success message when credential is created</comment>
<data name="ItemCreatedSuccess">
<value>Item created successfully.</value>
<comment>Success message when item is created</comment>
</data>
<!-- Passkey labels -->
@@ -188,7 +188,7 @@
<comment>Label for passkey display name</comment>
</data>
<data name="PasskeyHelpText">
<value>Passkeys are created through the browser extension or mobile apps when prompted by a website. They cannot be manually edited or created through the web app. To remove this passkey, you can delete it from this credential. To replace or create a new passkey, visit the website and follow its prompts.</value>
<value>Passkeys are created through the browser extension or mobile apps when prompted by a website. They cannot be manually edited or created through the web app. To remove this passkey, use the delete button below. To replace or create a new passkey, visit the website and follow its prompts.</value>
<comment>Help text explaining how passkeys work</comment>
</data>
<data name="DeletePasskeyButton">
@@ -200,7 +200,7 @@
<comment>Header when passkey is marked for deletion</comment>
</data>
<data name="PasskeyWillBeDeleted">
<value>This passkey will be deleted when you save this credential.</value>
<value>This passkey will be deleted when you save this item.</value>
<comment>Message explaining passkey will be deleted on save</comment>
</data>
<data name="UndoButton">

View File

@@ -20,29 +20,29 @@
</xsd:element>
</xsd:schema>
<data name="DeleteCredentialPageTitle">
<value>Delete credential</value>
<comment>Page title for deleting credentials</comment>
<data name="DeleteItemPageTitle">
<value>Delete item</value>
<comment>Page title for deleting items</comment>
</data>
<data name="DeleteCredentialTitle">
<value>Delete credential</value>
<comment>Page header title for deleting credential</comment>
<data name="DeleteItemTitle">
<value>Delete item</value>
<comment>Page header title for deleting item</comment>
</data>
<data name="DeleteCredentialDescription">
<value>You can delete the credential below.</value>
<comment>Page description for deleting credential</comment>
<data name="DeleteItemDescription">
<value>You can delete the item below.</value>
<comment>Page description for deleting item</comment>
</data>
<data name="DeleteWarningMessage">
<value>Note: removing this login entry is permanent and cannot be undone.</value>
<value>Note: removing this item is permanent and cannot be undone.</value>
<comment>Warning message about permanent deletion</comment>
</data>
<data name="CredentialEntrySection">
<value>Credential entry</value>
<comment>Section header for credential details</comment>
<data name="ItemEntrySection">
<value>Item entry</value>
<comment>Section header for item details</comment>
</data>
<data name="IdLabel">
<value>Id</value>
<comment>Label for credential ID field</comment>
<comment>Label for item ID field</comment>
</data>
<data name="ServiceNameLabel">
<value>Service name</value>
@@ -56,25 +56,25 @@
<value>No, cancel</value>
<comment>Cancel button text</comment>
</data>
<data name="ViewCredentialBreadcrumb">
<value>View credential</value>
<comment>Breadcrumb text for view credential page</comment>
<data name="ViewItemBreadcrumb">
<value>View item</value>
<comment>Breadcrumb text for view item page</comment>
</data>
<data name="DeleteCredentialBreadcrumb">
<value>Delete credential</value>
<comment>Breadcrumb text for delete credential page</comment>
<data name="DeleteItemBreadcrumb">
<value>Delete item</value>
<comment>Breadcrumb text for delete item page</comment>
</data>
<data name="DeleteCredentialNotFoundError">
<value>Error deleting. Credential not found.</value>
<comment>Error message when credential is not found during deletion</comment>
<data name="DeleteItemNotFoundError">
<value>Error deleting. Item not found.</value>
<comment>Error message when item is not found during deletion</comment>
</data>
<data name="DeletingCredentialMessage">
<value>Deleting credential...</value>
<comment>Loading message while deleting credential</comment>
<data name="DeletingItemMessage">
<value>Deleting item...</value>
<comment>Loading message while deleting item</comment>
</data>
<data name="DeleteSuccessMessage">
<value>Credential successfully deleted.</value>
<comment>Success message after credential deletion</comment>
<value>Item successfully deleted.</value>
<comment>Success message after item deletion</comment>
</data>
<data name="DeleteDatabaseError">
<value>Error saving database.</value>

View File

@@ -61,11 +61,11 @@
<!-- Page Header -->
<data name="PageTitle" xml:space="preserve">
<value>Credentials</value>
<comment>Main credentials page title</comment>
<value>Vault</value>
<comment>Main vault page title</comment>
</data>
<data name="PageDescription" xml:space="preserve">
<value>Find all of your credentials below.</value>
<value>Find all of your items below.</value>
<comment>Page description text</comment>
</data>
@@ -100,13 +100,13 @@
</data>
<!-- Empty State -->
<data name="NoCredentialsTitle" xml:space="preserve">
<value>No credentials yet</value>
<comment>Title when no credentials exist</comment>
<data name="NoItemsTitle" xml:space="preserve">
<value>No items yet</value>
<comment>Title when no items exist</comment>
</data>
<data name="CreateFirstCredentialText" xml:space="preserve">
<value>Create your first credential using the</value>
<comment>Text explaining how to create first credential</comment>
<data name="CreateFirstItemText" xml:space="preserve">
<value>Create your first item using the</value>
<comment>Text explaining how to create first item</comment>
</data>
<data name="NewAliasButtonText" xml:space="preserve">
<value>"+ New Alias"</value>
@@ -124,8 +124,8 @@
<value>or</value>
<comment>Separator text between options</comment>
</data>
<data name="ImportCredentialsText" xml:space="preserve">
<value>If you previously used a different password manager, you can import your credentials from it.</value>
<data name="ImportItemsText" xml:space="preserve">
<value>If you previously used a different password manager, you can import your items from it.</value>
<comment>Text explaining import option</comment>
</data>
<data name="ImportButtonText" xml:space="preserve">
@@ -134,9 +134,9 @@
</data>
<!-- Error Messages -->
<data name="FailedToLoadCredentialsMessage" xml:space="preserve">
<value>Failed to load credentials.</value>
<comment>Error message when credentials fail to load</comment>
<data name="FailedToLoadItemsMessage" xml:space="preserve">
<value>Failed to load items.</value>
<comment>Error message when items fail to load</comment>
</data>
<!-- Filter Options -->
@@ -145,8 +145,8 @@
<comment>Label for filter dropdown</comment>
</data>
<data name="FilterAllOption" xml:space="preserve">
<value>(All) Credentials</value>
<comment>Filter option to show all credentials</comment>
<value>(All) Items</value>
<comment>Filter option to show all items</comment>
</data>
<data name="FilterPasskeysOption" xml:space="preserve">
<value>Passkeys</value>
@@ -162,7 +162,7 @@
</data>
<data name="FilterAttachmentsOption" xml:space="preserve">
<value>Attachments</value>
<comment>Filter option to show only credentials with attachments</comment>
<comment>Filter option to show only items with attachments</comment>
</data>
<!-- Filtered Empty States -->
@@ -171,11 +171,61 @@
<comment>Empty state message when no passkeys are found</comment>
</data>
<data name="NoAttachmentsFound" xml:space="preserve">
<value>No credentials with attachments found.</value>
<comment>Empty state message when no credentials with attachments are found</comment>
<value>No items with attachments found.</value>
<comment>Empty state message when no items with attachments are found</comment>
</data>
<data name="NoCredentialsFound" xml:space="preserve">
<value>No credentials match the selected filter.</value>
<comment>Empty state message when no credentials match the filter</comment>
<data name="NoItemsFound" xml:space="preserve">
<value>No items match the selected filter.</value>
<comment>Empty state message when no items match the filter</comment>
</data>
<data name="EmptyFolderMessage" xml:space="preserve">
<value>This folder is empty.</value>
<comment>Empty state message when folder has no items</comment>
</data>
<!-- Item Type Filters -->
<data name="FilterLoginOption" xml:space="preserve">
<value>Logins</value>
<comment>Filter option to show only Login items</comment>
</data>
<data name="FilterAliasOption" xml:space="preserve">
<value>Aliases</value>
<comment>Filter option to show only Alias items</comment>
</data>
<data name="FilterCreditCardOption" xml:space="preserve">
<value>Credit Cards</value>
<comment>Filter option to show only Credit Card items</comment>
</data>
<data name="FilterNoteOption" xml:space="preserve">
<value>Notes</value>
<comment>Filter option to show only Note items</comment>
</data>
<!-- Folder Management -->
<data name="NewFolder" xml:space="preserve">
<value>New Folder</value>
<comment>Button text for creating a new folder</comment>
</data>
<data name="DeleteFolder" xml:space="preserve">
<value>Delete Folder</value>
<comment>Button text for deleting a folder</comment>
</data>
<data name="BackToRoot" xml:space="preserve">
<value>Back</value>
<comment>Button text for navigating back to root</comment>
</data>
<data name="FailedToCreateFolder" xml:space="preserve">
<value>Failed to create folder.</value>
<comment>Error message when folder creation fails</comment>
</data>
<data name="FailedToDeleteFolder" xml:space="preserve">
<value>Failed to delete folder.</value>
<comment>Error message when folder deletion fails</comment>
</data>
<!-- Infinite Scroll -->
<data name="LoadingMore" xml:space="preserve">
<value>Loading more...</value>
<comment>Text shown when loading more items during infinite scroll</comment>
</data>
</root>

View File

@@ -20,20 +20,20 @@
</xsd:element>
</xsd:schema>
<data name="ViewCredentialsPageTitle">
<value>View credentials</value>
<comment>Page title for viewing credentials</comment>
<data name="ViewItemPageTitle">
<value>View item</value>
<comment>Page title for viewing an item</comment>
</data>
<data name="ViewCredentialTitle">
<value>View credential</value>
<comment>Page header title for viewing a credential</comment>
<data name="ViewItemTitle">
<value>View item</value>
<comment>Page header title for viewing an item</comment>
</data>
<data name="EditButtonMobile">
<value>Edit</value>
<comment>Text for edit button on mobile</comment>
</data>
<data name="EditButtonDesktop">
<value>Edit credential</value>
<value>Edit item</value>
<comment>Text for edit button on desktop</comment>
</data>
<data name="DeleteButtonMobile">
@@ -41,20 +41,20 @@
<comment>Text for delete button on mobile</comment>
</data>
<data name="DeleteButtonDesktop">
<value>Delete credential</value>
<value>Delete item</value>
<comment>Text for delete button on desktop</comment>
</data>
<data name="LoginCredentialsSection">
<value>Login credentials</value>
<comment>Section header for login credentials</comment>
<data name="LoginDetailsSection">
<value>Login details</value>
<comment>Section header for login details</comment>
</data>
<data name="GeneratedCredentialsDescription">
<value>Below you can view and copy the generated credentials for this account. Any emails sent to the shown address will automatically appear on this page.</value>
<comment>Description for generated credentials with email support</comment>
<data name="GeneratedItemDescription">
<value>Below you can view and copy the generated details for this item. Any emails sent to the shown address will automatically appear on this page.</value>
<comment>Description for generated item with email support</comment>
</data>
<data name="StoredCredentialsDescription">
<value>Below you can view and copy the stored login credentials for this account.</value>
<comment>Description for stored credentials without email support</comment>
<data name="StoredItemDescription">
<value>Below you can view and copy the stored login details for this item.</value>
<comment>Description for stored item without email support</comment>
</data>
<data name="EmailLabel">
<value>Email</value>
@@ -92,13 +92,13 @@
<value>Nickname</value>
<comment>Label for nickname field</comment>
</data>
<data name="ViewCredentialBreadcrumb">
<value>View credential</value>
<comment>Breadcrumb text for view credential page</comment>
<data name="ViewItemBreadcrumb">
<value>View item</value>
<comment>Breadcrumb text for view item page</comment>
</data>
<data name="CredentialNotFoundError">
<value>This credential does not exist (anymore). Please try again.</value>
<comment>Error message when credential is not found</comment>
<data name="ItemNotFoundError">
<value>This item does not exist (anymore). Please try again.</value>
<comment>Error message when item is not found</comment>
</data>
<data name="PasskeySectionHeader">
<value>Passkey</value>
@@ -117,7 +117,23 @@
<comment>Label for passkey display name</comment>
</data>
<data name="PasskeyHelpText">
<value>Passkeys are created through the browser extension or mobile apps when prompted by a website. They cannot be manually edited or created through the web app. To remove this passkey, you can delete it from this credential. To replace or create a new passkey, visit the website and follow its prompts.</value>
<value>Passkeys are created through the browser extension or mobile apps when prompted by a website. They cannot be manually edited or created through the web app. To remove this passkey, edit this item and delete the passkey. To replace or create a new passkey, visit the website and follow its prompts.</value>
<comment>Help text explaining how passkeys work</comment>
</data>
<data name="Untitled">
<value>Untitled</value>
<comment>Placeholder for items without a name</comment>
</data>
<data name="NotesSection">
<value>Notes</value>
<comment>Section header for notes</comment>
</data>
<data name="CardSection">
<value>Card Details</value>
<comment>Section header for credit card details</comment>
</data>
<data name="CustomFieldsSection">
<value>Custom Fields</value>
<comment>Section header for custom fields</comment>
</data>
</root>

Some files were not shown because too many files have changed in this diff Show More