mirror of
https://github.com/lanedirt/AliasVault.git
synced 2025-12-05 19:07:26 -06:00
Add item type selector scaffolding (#1404)
This commit is contained in:
@@ -21,6 +21,7 @@ import CredentialDetails from '@/entrypoints/popup/pages/credentials/CredentialD
|
||||
import CredentialsList from '@/entrypoints/popup/pages/credentials/CredentialsList';
|
||||
import ItemAddEdit from '@/entrypoints/popup/pages/credentials/ItemAddEdit';
|
||||
import ItemDetails from '@/entrypoints/popup/pages/credentials/ItemDetails';
|
||||
import ItemTypeSelector from '@/entrypoints/popup/pages/credentials/ItemTypeSelector';
|
||||
import EmailDetails from '@/entrypoints/popup/pages/emails/EmailDetails';
|
||||
import EmailsList from '@/entrypoints/popup/pages/emails/EmailsList';
|
||||
import Index from '@/entrypoints/popup/pages/Index';
|
||||
@@ -188,6 +189,7 @@ const App: React.FC = () => {
|
||||
{ path: '/credentials/add', element: <CredentialAddEdit />, showBackButton: true, title: t('credentials.addCredential') },
|
||||
{ path: '/credentials/:id', element: <CredentialDetails />, showBackButton: true, title: t('credentials.credentialDetails') },
|
||||
{ path: '/credentials/:id/edit', element: <CredentialAddEdit />, showBackButton: true, title: t('credentials.editCredential') },
|
||||
{ path: '/items/select-type', element: <ItemTypeSelector />, showBackButton: true, title: t('itemTypes.selectType') },
|
||||
{ path: '/items/:id', element: <ItemDetails />, showBackButton: true, title: 'Item Details' },
|
||||
{ path: '/items/:id/edit', element: <ItemAddEdit />, showBackButton: true, title: 'Edit Item' },
|
||||
{ path: '/items/add', element: <ItemAddEdit />, showBackButton: true, title: 'Add Item' },
|
||||
|
||||
@@ -85,9 +85,10 @@ const CredentialsList: React.FC = () => {
|
||||
|
||||
/**
|
||||
* Handle add new credential.
|
||||
* Navigate to item type selector for new item-based flow.
|
||||
*/
|
||||
const handleAddCredential = useCallback(() : void => {
|
||||
navigate('/credentials/add');
|
||||
navigate('/items/select-type');
|
||||
}, [navigate]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import Modal from '@/entrypoints/popup/components/Dialogs/Modal';
|
||||
import EditableFieldLabel from '@/entrypoints/popup/components/Forms/EditableFieldLabel';
|
||||
@@ -20,15 +18,8 @@ import { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate';
|
||||
import type { Item, ItemField, ItemType, FieldType } from '@/utils/dist/shared/models/vault';
|
||||
import { getSystemFieldsForItemType } from '@/utils/dist/shared/models/vault';
|
||||
|
||||
/**
|
||||
* Form data structure matching the Item model
|
||||
*/
|
||||
type ItemFormData = {
|
||||
Id: string;
|
||||
Name: string;
|
||||
ItemType: ItemType;
|
||||
Fields: Record<string, string | string[]>; // FieldKey -> Value mapping
|
||||
};
|
||||
// Valid item types from the shared model
|
||||
const VALID_ITEM_TYPES: ItemType[] = ['Login', 'CreditCard', 'Identity', 'Note'];
|
||||
|
||||
/**
|
||||
* Temporary custom field definition (before persisting to database)
|
||||
@@ -48,10 +39,15 @@ type CustomFieldDefinition = {
|
||||
const ItemAddEdit: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const dbContext = useDb();
|
||||
const isEditMode = id !== undefined && id.length > 0;
|
||||
|
||||
// Get item type and name from URL parameters (for create mode)
|
||||
const itemTypeParam = searchParams.get('type') as ItemType | null;
|
||||
const itemNameParam = searchParams.get('name');
|
||||
|
||||
const { executeVaultMutation, isLoading, syncStatus } = useVaultMutate();
|
||||
const { setHeaderButtons } = useHeaderButtons();
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
@@ -75,9 +71,11 @@ const ItemAddEdit: React.FC = () => {
|
||||
* These are sorted by DefaultDisplayOrder.
|
||||
*/
|
||||
const applicableSystemFields = useMemo(() => {
|
||||
if (!item) return [];
|
||||
if (!item) {
|
||||
return [];
|
||||
}
|
||||
return getSystemFieldsForItemType(item.ItemType);
|
||||
}, [item?.ItemType]);
|
||||
}, [item]);
|
||||
|
||||
/**
|
||||
* Group system fields by category for organized rendering.
|
||||
@@ -101,11 +99,20 @@ const ItemAddEdit: React.FC = () => {
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!dbContext?.sqliteClient || !id || !isEditMode) {
|
||||
// Create mode - initialize with defaults
|
||||
/*
|
||||
* Create mode - initialize with defaults
|
||||
* Validate item type parameter
|
||||
*/
|
||||
if (!itemTypeParam || !VALID_ITEM_TYPES.includes(itemTypeParam)) {
|
||||
/* Redirect to type selector if no valid type specified */
|
||||
navigate('/items/select-type');
|
||||
return;
|
||||
}
|
||||
|
||||
setItem({
|
||||
Id: crypto.randomUUID().toUpperCase(),
|
||||
Name: '',
|
||||
ItemType: 'Login',
|
||||
Name: itemNameParam || '',
|
||||
ItemType: itemTypeParam,
|
||||
Fields: [],
|
||||
CreatedAt: new Date().toISOString(),
|
||||
UpdatedAt: new Date().toISOString()
|
||||
@@ -153,7 +160,7 @@ const ItemAddEdit: React.FC = () => {
|
||||
setLocalLoading(false);
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
}, [dbContext?.sqliteClient, id, isEditMode, navigate, setIsInitialLoading]);
|
||||
}, [dbContext?.sqliteClient, id, isEditMode, itemTypeParam, itemNameParam, navigate, setIsInitialLoading]);
|
||||
|
||||
/**
|
||||
* Handle field value change.
|
||||
@@ -169,17 +176,19 @@ const ItemAddEdit: React.FC = () => {
|
||||
* Handle form submission.
|
||||
*/
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!item) return;
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Build the fields array from fieldValues
|
||||
const fields: ItemField[] = [];
|
||||
|
||||
// Add system fields
|
||||
/* Add system fields */
|
||||
applicableSystemFields.forEach(systemField => {
|
||||
const value = fieldValues[systemField.FieldKey];
|
||||
|
||||
// Only include fields with non-empty values
|
||||
/* Only include fields with non-empty values */
|
||||
if (value && (Array.isArray(value) ? value.length > 0 : value.trim() !== '')) {
|
||||
fields.push({
|
||||
FieldKey: systemField.FieldKey,
|
||||
@@ -192,11 +201,11 @@ const ItemAddEdit: React.FC = () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Add custom fields
|
||||
/* Add custom fields */
|
||||
customFields.forEach(customField => {
|
||||
const value = fieldValues[customField.tempId];
|
||||
|
||||
// Only include fields with non-empty values
|
||||
/* Only include fields with non-empty values */
|
||||
if (value && (Array.isArray(value) ? value.length > 0 : value.trim() !== '')) {
|
||||
fields.push({
|
||||
FieldKey: customField.tempId,
|
||||
@@ -215,7 +224,7 @@ const ItemAddEdit: React.FC = () => {
|
||||
UpdatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Save to database and sync vault
|
||||
/* Save to database and sync vault */
|
||||
if (!dbContext?.sqliteClient) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
@@ -223,34 +232,33 @@ const ItemAddEdit: React.FC = () => {
|
||||
await executeVaultMutation(async () => {
|
||||
if (isEditMode) {
|
||||
await dbContext.sqliteClient!.updateItem(updatedItem);
|
||||
console.log('Item updated:', updatedItem);
|
||||
} else {
|
||||
await dbContext.sqliteClient!.createItem(updatedItem);
|
||||
console.log('Item created:', updatedItem);
|
||||
}
|
||||
});
|
||||
|
||||
// Navigate back to details page
|
||||
/* Navigate back to details page */
|
||||
navigate(`/items/${updatedItem.Id}`);
|
||||
} catch (err) {
|
||||
console.error('Error saving item:', err);
|
||||
}
|
||||
}, [item, fieldValues, applicableSystemFields, dbContext, isEditMode, executeVaultMutation, navigate]);
|
||||
}, [item, fieldValues, applicableSystemFields, customFields, dbContext, isEditMode, executeVaultMutation, navigate]);
|
||||
|
||||
/**
|
||||
* Handle delete action.
|
||||
*/
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!item || !isEditMode || !dbContext?.sqliteClient) return;
|
||||
if (!item || !isEditMode || !dbContext?.sqliteClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete from database and sync vault
|
||||
/* Delete from database and sync vault */
|
||||
await executeVaultMutation(async () => {
|
||||
await dbContext.sqliteClient!.deleteItemById(item.Id);
|
||||
console.log('Item deleted:', item.Id);
|
||||
});
|
||||
|
||||
// Navigate back to credentials list
|
||||
/* Navigate back to credentials list */
|
||||
navigate('/credentials');
|
||||
} catch (err) {
|
||||
console.error('Error deleting item:', err);
|
||||
@@ -274,7 +282,9 @@ const ItemAddEdit: React.FC = () => {
|
||||
* Add custom field handler.
|
||||
*/
|
||||
const handleAddCustomField = useCallback(() => {
|
||||
if (!newCustomFieldLabel.trim()) return;
|
||||
if (!newCustomFieldLabel.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tempId = `custom_${crypto.randomUUID()}`;
|
||||
const newField: CustomFieldDefinition = {
|
||||
@@ -324,13 +334,13 @@ const ItemAddEdit: React.FC = () => {
|
||||
|
||||
setHeaderButtons(headerButtonsJSX);
|
||||
|
||||
return () => setHeaderButtons(null);
|
||||
return (): void => setHeaderButtons(null);
|
||||
}, [setHeaderButtons, isEditMode, t]);
|
||||
|
||||
/**
|
||||
* Render a field input based on field type.
|
||||
*/
|
||||
const renderFieldInput = useCallback((fieldKey: string, label: string, fieldType: FieldType, isHidden: boolean, isMultiValue: boolean) => {
|
||||
const renderFieldInput = useCallback((fieldKey: string, label: string, fieldType: FieldType, isHidden: boolean, isMultiValue: boolean): React.ReactNode => {
|
||||
const value = fieldValues[fieldKey] || '';
|
||||
|
||||
// Handle multi-value fields
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { FormInput } from '@/entrypoints/popup/components/Forms/FormInput';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
|
||||
import type { ItemType } from '@/utils/dist/shared/models/vault';
|
||||
|
||||
/**
|
||||
* Item type option configuration.
|
||||
*/
|
||||
type ItemTypeOption = {
|
||||
type: ItemType;
|
||||
titleKey: string;
|
||||
iconSvg: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Available item type options.
|
||||
*/
|
||||
const ITEM_TYPE_OPTIONS: ItemTypeOption[] = [
|
||||
{
|
||||
type: 'Login',
|
||||
titleKey: 'itemTypes.login.title',
|
||||
iconSvg: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={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>
|
||||
)
|
||||
},
|
||||
{
|
||||
type: 'CreditCard',
|
||||
titleKey: 'itemTypes.creditCard.title',
|
||||
iconSvg: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={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>
|
||||
)
|
||||
},
|
||||
{
|
||||
type: 'Identity',
|
||||
titleKey: 'itemTypes.identity.title',
|
||||
iconSvg: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={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>
|
||||
)
|
||||
},
|
||||
{
|
||||
type: 'Note',
|
||||
titleKey: 'itemTypes.note.title',
|
||||
iconSvg: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={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>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Item type selection page.
|
||||
* Allows users to enter item name and choose which type of item to create.
|
||||
*/
|
||||
const ItemTypeSelector: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
const [itemName, setItemName] = useState('');
|
||||
const [selectedType, setSelectedType] = useState<ItemType>('Login');
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
|
||||
/**
|
||||
* Mark page as loaded on mount.
|
||||
*/
|
||||
useEffect(() => {
|
||||
setIsInitialLoading(false);
|
||||
}, [setIsInitialLoading]);
|
||||
|
||||
/**
|
||||
* Handle continue button click.
|
||||
*/
|
||||
const handleContinue = useCallback((): void => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('type', selectedType);
|
||||
if (itemName.trim()) {
|
||||
params.set('name', itemName.trim());
|
||||
}
|
||||
navigate(`/items/add?${params.toString()}`);
|
||||
}, [selectedType, itemName, navigate]);
|
||||
|
||||
/**
|
||||
* Handle item type selection from dropdown.
|
||||
*/
|
||||
const handleSelectType = useCallback((type: ItemType): void => {
|
||||
setSelectedType(type);
|
||||
setShowDropdown(false);
|
||||
}, []);
|
||||
|
||||
const selectedOption = ITEM_TYPE_OPTIONS.find(opt => opt.type === selectedType);
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Service Name Input */}
|
||||
<div>
|
||||
<FormInput
|
||||
id="itemName"
|
||||
label={t('credentials.serviceName')}
|
||||
value={itemName}
|
||||
onChange={setItemName}
|
||||
type="text"
|
||||
placeholder={t('credentials.serviceName')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Item Type Selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('itemTypes.typeLabel')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-primary-600 dark:text-primary-400">
|
||||
{selectedOption?.iconSvg}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{selectedOption ? t(selectedOption.titleKey) : ''}
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-400 transition-transform ${showDropdown ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{showDropdown && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setShowDropdown(false)}
|
||||
/>
|
||||
<div className="absolute z-20 mt-1 w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg">
|
||||
{ITEM_TYPE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.type}
|
||||
type="button"
|
||||
onClick={() => handleSelectType(option.type)}
|
||||
className={`w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-3 border-b border-gray-100 dark:border-gray-700 last:border-b-0 ${
|
||||
selectedType === option.type
|
||||
? 'bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300'
|
||||
: 'text-gray-900 dark:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className={selectedType === option.type ? 'text-primary-600 dark:text-primary-400' : 'text-gray-500 dark:text-gray-400'}>
|
||||
{option.iconSvg}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{t(option.titleKey)}
|
||||
</span>
|
||||
{selectedType === option.type && (
|
||||
<svg className="w-5 h-5 ml-auto text-primary-600 dark:text-primary-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Continue Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleContinue}
|
||||
className="w-full px-4 py-3 bg-primary-600 text-white rounded-md hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 font-medium"
|
||||
>
|
||||
{t('common.next')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemTypeSelector;
|
||||
@@ -58,6 +58,7 @@
|
||||
"use": "Use",
|
||||
"delete": "Delete",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"or": "Or",
|
||||
"close": "Close",
|
||||
"copied": "Copied!",
|
||||
@@ -194,6 +195,7 @@
|
||||
"saveCredential": "Save credential",
|
||||
"deleteCredentialTitle": "Delete Credential",
|
||||
"deleteCredentialConfirm": "Are you sure you want to delete this credential? This action cannot be undone.",
|
||||
"deleteCredentialConfirmation": "Are you sure you want to delete this item? This action cannot be undone.",
|
||||
"filters": {
|
||||
"all": "(All) Credentials",
|
||||
"passkeys": "Passkeys",
|
||||
@@ -225,6 +227,8 @@
|
||||
"gender": "Gender",
|
||||
"birthDate": "Birth Date",
|
||||
"birthDatePlaceholder": "YYYY-MM-DD",
|
||||
"cardInformation": "Card Information",
|
||||
"identityInformation": "Identity Information",
|
||||
"metadata": "Metadata",
|
||||
"validation": {
|
||||
"required": "This field is required",
|
||||
@@ -242,6 +246,27 @@
|
||||
"enterFullEmail": "Enter full email address",
|
||||
"enterEmailPrefix": "Enter email prefix"
|
||||
},
|
||||
"itemTypes": {
|
||||
"selectType": "Add New Item",
|
||||
"selectTypeDescription": "Choose the type of item you want to create",
|
||||
"typeLabel": "Item Type",
|
||||
"login": {
|
||||
"title": "Login",
|
||||
"description": "Username, password, and website credentials"
|
||||
},
|
||||
"creditCard": {
|
||||
"title": "Credit Card",
|
||||
"description": "Credit card information and payment details"
|
||||
},
|
||||
"identity": {
|
||||
"title": "Identity",
|
||||
"description": "Personal information and contact details"
|
||||
},
|
||||
"note": {
|
||||
"title": "Secure Note",
|
||||
"description": "Encrypted notes and private information"
|
||||
}
|
||||
},
|
||||
"totp": {
|
||||
"addCode": "Add 2FA Code",
|
||||
"instructions": "Enter the secret key shown by the website where you want to add two-factor authentication.",
|
||||
|
||||
Reference in New Issue
Block a user