Compare commits

...

4 Commits

Author SHA1 Message Date
Leendert de Borst
864290e619 Add recently deleted items scaffolding (#1404) 2025-12-05 22:54:45 +01:00
Leendert de Borst
65977c1544 Add DeletedAt to Item entity (#1404) 2025-12-05 22:36:15 +01:00
Leendert de Borst
dbc5911257 Add folder support scaffolding (#1404) 2025-12-05 14:39:48 +01:00
Leendert de Borst
5d7a59f3b5 Add generic history modal component (#1404) 2025-12-04 15:58:20 +01:00
34 changed files with 2855 additions and 123 deletions

View File

@@ -22,6 +22,8 @@ import CredentialsList from '@/entrypoints/popup/pages/credentials/CredentialsLi
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 ItemsList from '@/entrypoints/popup/pages/items/ItemsList';
import RecentlyDeleted from '@/entrypoints/popup/pages/items/RecentlyDeleted';
import EmailDetails from '@/entrypoints/popup/pages/emails/EmailDetails';
import EmailsList from '@/entrypoints/popup/pages/emails/EmailsList';
import Index from '@/entrypoints/popup/pages/Index';
@@ -185,6 +187,7 @@ const App: React.FC = () => {
{ path: '/unlock-success', element: <UnlockSuccess />, showBackButton: false },
{ path: '/upgrade', element: <Upgrade />, showBackButton: false },
{ path: '/auth-settings', element: <AuthSettings />, showBackButton: true, title: t('settings.title') },
{ path: '/items', element: <ItemsList />, showBackButton: false },
{ path: '/credentials', element: <CredentialsList />, showBackButton: false },
{ path: '/credentials/add', element: <CredentialAddEdit />, showBackButton: true, title: t('credentials.addCredential') },
{ path: '/credentials/:id', element: <CredentialDetails />, showBackButton: true, title: t('credentials.credentialDetails') },
@@ -193,6 +196,7 @@ const App: React.FC = () => {
{ 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' },
{ path: '/items/deleted', element: <RecentlyDeleted />, showBackButton: true, title: t('recentlyDeleted.title') },
{ path: '/passkeys/create', element: <PasskeyCreate />, layout: LayoutType.PASSKEY },
{ path: '/passkeys/authenticate', element: <PasskeyAuthenticate />, layout: LayoutType.PASSKEY },
{ path: '/emails', element: <EmailsList />, showBackButton: false },

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { FormInputCopyToClipboard } from '@/entrypoints/popup/components/Forms/FormInputCopyToClipboard';
import PasswordHistoryModal from './PasswordHistoryModal';
import FieldHistoryModal from './FieldHistoryModal';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import type { ItemField } from '@/utils/dist/shared/models/vault';
@@ -79,6 +79,33 @@ const FieldBlock: React.FC<FieldBlockProps> = ({ field, itemId }) => {
const value = values[0];
// History button component that can be added to any field label
const HistoryButton = historyCount > 0 && itemId ? (
<button
type="button"
onClick={() => setShowHistoryModal(true)}
className="ml-2 inline-flex items-center text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 focus:outline-none"
title={t('credentials.viewHistory')}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
</button>
) : null;
// History modal component
const HistoryModal = showHistoryModal && itemId ? (
<FieldHistoryModal
isOpen={showHistoryModal}
onClose={() => setShowHistoryModal(false)}
itemId={itemId}
fieldKey={field.FieldKey}
fieldLabel={field.Label}
fieldType={field.FieldType}
isHidden={field.IsHidden}
/>
) : null;
// Render based on field type
switch (field.FieldType) {
case 'Password':
@@ -88,18 +115,7 @@ const FieldBlock: React.FC<FieldBlockProps> = ({ field, itemId }) => {
<div>
<label htmlFor={field.FieldKey} className="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{field.Label}
{historyCount > 0 && itemId && (
<button
type="button"
onClick={() => setShowHistoryModal(true)}
className="ml-2 inline-flex items-center text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 focus:outline-none"
title={t('credentials.viewHistory')}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
</button>
)}
{HistoryButton}
</label>
<div className="relative">
<input
@@ -130,15 +146,7 @@ const FieldBlock: React.FC<FieldBlockProps> = ({ field, itemId }) => {
</div>
</div>
</div>
{showHistoryModal && itemId && (
<PasswordHistoryModal
isOpen={showHistoryModal}
onClose={() => setShowHistoryModal(false)}
itemId={itemId}
fieldKey={field.FieldKey}
fieldLabel={field.Label}
/>
)}
{HistoryModal}
</>
);
@@ -167,12 +175,43 @@ const FieldBlock: React.FC<FieldBlockProps> = ({ field, itemId }) => {
case 'Text':
default:
return (
<FormInputCopyToClipboard
id={field.FieldKey}
label={field.Label}
value={value}
type="text"
/>
<>
<div>
<label htmlFor={field.FieldKey} className="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{field.Label}
{HistoryButton}
</label>
<div className="relative">
<input
type="text"
id={field.FieldKey}
readOnly
value={value}
className="w-full px-3 py-2.5 bg-white border border-gray-300 text-gray-900 text-sm rounded-lg shadow-sm focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-2">
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
} catch (err) {
console.error('Failed to copy:', err);
}
}}
className="p-1 text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors duration-200"
title={t('common.copyToClipboard')}
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</button>
</div>
</div>
</div>
{HistoryModal}
</>
);
}
};

View File

@@ -3,33 +3,42 @@ import { useTranslation } from 'react-i18next';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import type { FieldHistory } from '@/utils/dist/shared/models/vault';
import type { FieldHistory, FieldType } from '@/utils/dist/shared/models/vault';
interface PasswordHistoryModalProps {
interface FieldHistoryModalProps {
isOpen: boolean;
onClose: () => void;
itemId: string;
fieldKey: string;
fieldLabel: string;
fieldType: FieldType;
isHidden: boolean;
}
/**
* Modal component for displaying password history.
* Shows historical values with dates, passwords hidden by default.
* Modal component for displaying field value history.
* Shows historical values with dates.
* For hidden/password fields, values are masked by default.
* For other fields, values are visible by default.
*/
const PasswordHistoryModal: React.FC<PasswordHistoryModalProps> = ({
const FieldHistoryModal: React.FC<FieldHistoryModalProps> = ({
isOpen,
onClose,
itemId,
fieldKey,
fieldLabel
fieldLabel,
fieldType,
isHidden
}) => {
const { t } = useTranslation();
const dbContext = useDb();
const [history, setHistory] = useState<FieldHistory[]>([]);
const [visiblePasswords, setVisiblePasswords] = useState<Set<string>>(new Set());
const [visibleValues, setVisibleValues] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
// For non-hidden fields, show values by default
const shouldMaskByDefault = isHidden || fieldType === 'Password' || fieldType === 'Hidden';
useEffect(() => {
if (!isOpen || !dbContext?.sqliteClient) {
return;
@@ -50,8 +59,8 @@ const PasswordHistoryModal: React.FC<PasswordHistoryModalProps> = ({
return null;
}
const togglePasswordVisibility = (historyId: string): void => {
setVisiblePasswords(prev => {
const toggleValueVisibility = (historyId: string): void => {
setVisibleValues(prev => {
const newSet = new Set(prev);
if (newSet.has(historyId)) {
newSet.delete(historyId);
@@ -132,7 +141,9 @@ const PasswordHistoryModal: React.FC<PasswordHistoryModalProps> = ({
<div className="space-y-3">
{history.map((record) => {
const values = parseValueSnapshot(record.ValueSnapshot);
const isVisible = visiblePasswords.has(record.Id);
// For hidden fields, check if this record is explicitly set to visible
// For non-hidden fields, always show values (no toggle needed)
const isVisible = shouldMaskByDefault ? visibleValues.has(record.Id) : true;
return (
<div
@@ -143,13 +154,15 @@ const PasswordHistoryModal: React.FC<PasswordHistoryModalProps> = ({
<div className="text-sm text-gray-600 dark:text-gray-400">
{formatDate(record.ChangedAt)}
</div>
<button
type="button"
onClick={() => togglePasswordVisibility(record.Id)}
className="text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 focus:outline-none"
>
{isVisible ? t('common.hide') : t('common.show')}
</button>
{shouldMaskByDefault && (
<button
type="button"
onClick={() => toggleValueVisibility(record.Id)}
className="text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 focus:outline-none"
>
{isVisible ? t('common.hide') : t('common.show')}
</button>
)}
</div>
{values.map((value, idx) => (
@@ -157,8 +170,8 @@ const PasswordHistoryModal: React.FC<PasswordHistoryModalProps> = ({
key={idx}
className="flex items-center gap-2 mt-2"
>
<div className="flex-1 font-mono text-sm bg-white dark:bg-gray-800 rounded px-3 py-2 border border-gray-200 dark:border-gray-600">
{isVisible ? value : '\u2022'.repeat(12)}
<div className="flex-1 font-mono text-sm bg-white dark:bg-gray-800 rounded px-3 py-2 border border-gray-200 dark:border-gray-600 break-all">
{shouldMaskByDefault && !isVisible ? '\u2022'.repeat(12) : value}
</div>
<button
type="button"
@@ -195,4 +208,4 @@ const PasswordHistoryModal: React.FC<PasswordHistoryModalProps> = ({
);
};
export default PasswordHistoryModal;
export default FieldHistoryModal;

View File

@@ -0,0 +1,132 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
type FolderModalProps = {
isOpen: boolean;
onClose: () => void;
onSave: (folderName: string) => Promise<void>;
initialName?: string;
mode: 'create' | 'edit';
};
/**
* Modal for creating or editing a folder
*/
const FolderModal: React.FC<FolderModalProps> = ({
isOpen,
onClose,
onSave,
initialName = '',
mode
}) => {
const { t } = useTranslation();
const [folderName, setFolderName] = useState(initialName);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
setFolderName(initialName);
setError(null);
}
}, [isOpen, initialName]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmedName = folderName.trim();
if (!trimmedName) {
setError(t('items.folderNameRequired'));
return;
}
setIsSubmitting(true);
setError(null);
try {
await onSave(trimmedName);
onClose();
} catch (err) {
setError(t('items.folderSaveError'));
console.error('Error saving folder:', err);
} finally {
setIsSubmitting(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
if (!isOpen) {
return null;
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4"
onKeyDown={handleKeyDown}
>
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
{mode === 'create' ? t('items.createFolder') : t('items.editFolder')}
</h2>
</div>
{/* Body */}
<form onSubmit={handleSubmit}>
<div className="px-6 py-4">
<label
htmlFor="folderName"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{t('items.folderName')}
</label>
<input
id="folderName"
type="text"
value={folderName}
onChange={(e) => setFolderName(e.target.value)}
placeholder={t('items.folderNamePlaceholder')}
autoFocus
className="w-full p-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-blue-500 focus:border-blue-500"
/>
{error && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 text-sm font-medium text-white bg-orange-600 hover:bg-orange-700 rounded focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500 disabled:opacity-50"
>
{isSubmitting
? t('common.saving')
: mode === 'create'
? t('common.create')
: t('common.save')
}
</button>
</div>
</form>
</div>
</div>
);
};
export default FolderModal;

View File

@@ -0,0 +1,66 @@
import React from 'react';
type FolderWithCount = {
id: string;
name: string;
itemCount: number;
};
type FolderCardProps = {
folder: FolderWithCount;
onClick: () => void;
};
/**
* FolderCard component
*
* This component displays a folder card with a folder icon, name, and item count.
* It allows the user to navigate into the folder when clicked.
*/
const FolderCard: React.FC<FolderCardProps> = ({ folder, onClick }) => {
return (
<li>
<button
onClick={onClick}
className="w-full p-2 border dark:border-gray-600 rounded flex items-center bg-white dark:bg-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{/* Folder Icon */}
<div className="w-8 h-8 mr-2 flex-shrink-0 flex items-center justify-center">
<svg
className="w-7 h-7 text-orange-500 dark:text-orange-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
</div>
<div className="text-left flex-1">
<p className="font-medium text-gray-900 dark:text-white">{folder.name}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{folder.itemCount} {folder.itemCount === 1 ? 'item' : 'items'}
</p>
</div>
{/* Chevron Right Icon */}
<svg
className="w-5 h-5 text-gray-400 dark:text-gray-500 flex-shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</li>
);
};
export default FolderCard;

View File

@@ -0,0 +1,141 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import type { Item } from '@/utils/dist/shared/models/vault';
import SqliteClient from '@/utils/SqliteClient';
import { FieldKey } from '@/utils/dist/shared/models/vault';
type ItemCardProps = {
item: Item;
showFolderPath?: boolean;
};
/**
* ItemCard component
*
* This component displays an item card with a name, logo, and fields.
* It allows the user to navigate to the item details page when clicked.
*
*/
const ItemCard: React.FC<ItemCardProps> = ({ item, showFolderPath = false }) => {
const navigate = useNavigate();
/**
* Get the display text for the item (username or email)
* @param itm - The item to get the display text for
* @returns The display text for the item
*/
const getDisplayText = (itm: Item): string => {
let returnValue = '';
// Try to find username field
const usernameField = itm.Fields?.find(f => f.FieldKey === FieldKey.LoginUsername);
if (usernameField && usernameField.Value) {
returnValue = Array.isArray(usernameField.Value) ? usernameField.Value[0] : usernameField.Value;
}
// Try to find email field if no username
if (!returnValue) {
const emailField = itm.Fields?.find(f => f.FieldKey === FieldKey.AliasEmail);
if (emailField && emailField.Value) {
returnValue = Array.isArray(emailField.Value) ? emailField.Value[0] : emailField.Value;
}
}
// Trim the return value to max. 33 characters.
return returnValue.length > 33 ? returnValue.slice(0, 30) + '...' : returnValue;
};
/**
* Get the item name, trimming it to maximum length so it doesn't overflow the UI.
*/
const getItemName = (itm: Item): string => {
let returnValue = 'Untitled';
if (itm.Name) {
returnValue = itm.Name;
}
// Trim the return value to max. 33 characters.
return returnValue.length > 33 ? returnValue.slice(0, 30) + '...' : returnValue;
};
return (
<li>
<button
onClick={() => navigate(`/items/${item.Id}`)}
className="w-full p-2 border dark:border-gray-600 rounded flex items-center bg-white dark:bg-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<img
src={SqliteClient.imgSrcFromBytes(item.Logo)}
alt={item.Name || 'Item'}
className="w-8 h-8 mr-2 flex-shrink-0"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = '/assets/images/service-placeholder.webp';
}}
/>
<div className="text-left flex-1">
<div className="flex items-center gap-1.5">
<p className="font-medium text-gray-900 dark:text-white">
{showFolderPath && item.FolderPath ? (
<>
<span className="text-gray-500 dark:text-gray-400 text-sm">{item.FolderPath} &gt; </span>
{getItemName(item)}
</>
) : (
getItemName(item)
)}
</p>
{item.HasPasskey && (
<svg
className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="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>
)}
{item.HasAttachment && (
<svg
className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="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>
)}
{item.HasTotp && (
<svg
className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-label="Has 2FA"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
)}
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">{getDisplayText(item)}</p>
</div>
</button>
</li>
);
};
export default ItemCard;

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useLocation } from 'react-router-dom';
type TabName = 'credentials' | 'emails' | 'settings';
type TabName = 'items' | 'emails' | 'settings';
/**
* Bottom nav component.
@@ -11,12 +11,12 @@ const BottomNav: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const [currentTab, setCurrentTab] = useState<TabName>('credentials');
const [currentTab, setCurrentTab] = useState<TabName>('items');
// Add effect to update currentTab based on route
useEffect(() => {
const path = location.pathname.substring(1); // Remove leading slash
const tabNames: TabName[] = ['credentials', 'emails', 'settings'];
const tabNames: TabName[] = ['items', 'emails', 'settings'];
// Find the first tab name that matches the start of the path
const matchingTab = tabNames.find(tab => path === tab || path.startsWith(`${tab}/`));
@@ -54,15 +54,15 @@ const BottomNav: React.FC = () => {
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-around items-center h-14">
<button
onClick={() => handleTabChange('credentials')}
onClick={() => handleTabChange('items')}
className={`flex flex-col items-center justify-center w-1/3 h-full ${
currentTab === 'credentials' ? 'text-primary-600 dark:text-primary-500' : 'text-gray-500 dark:text-gray-400'
currentTab === 'items' ? 'text-primary-600 dark:text-primary-500' : 'text-gray-500 dark:text-gray-400'
}`}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<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>
<span className="text-sm mt-1">{t('menu.credentials')}</span>
<span className="text-sm mt-1">{t('menu.vault')}</span>
</button>
<button
onClick={() => handleTabChange('emails')}

View File

@@ -53,9 +53,9 @@ const Header: React.FC<HeaderProps> = ({
return;
}
// If logged in, navigate to credentials.
// If logged in, navigate to items.
if (app.isLoggedIn) {
navigate('/credentials');
navigate('/items');
} else {
// If not logged in, navigate to index.
navigate('/');

View File

@@ -74,8 +74,8 @@ const Reinitialize: React.FC = () => {
sendMessage('CLEAR_PERSISTED_FORM_VALUES', null, 'background'),
]);
// Navigate to the credentials page as default entry page
navigate('/credentials', { replace: true });
// Navigate to the items page as default entry page
navigate('/items', { replace: true });
}, [navigate]);
useEffect(() => {

View File

@@ -66,6 +66,9 @@ const ItemAddEdit: React.FC = () => {
const [newCustomFieldLabel, setNewCustomFieldLabel] = useState('');
const [newCustomFieldType, setNewCustomFieldType] = useState<FieldType>('Text');
// Folder selection state
const [folders, setFolders] = useState<Array<{ Id: string; Name: string }>>([]);
/**
* Get all applicable system fields for the current item type.
* These are sorted by DefaultDisplayOrder.
@@ -113,10 +116,18 @@ const ItemAddEdit: React.FC = () => {
Id: crypto.randomUUID().toUpperCase(),
Name: itemNameParam || '',
ItemType: itemTypeParam,
FolderId: null,
Fields: [],
CreatedAt: new Date().toISOString(),
UpdatedAt: new Date().toISOString()
});
// Load folders
if (dbContext?.sqliteClient) {
const allFolders = dbContext.sqliteClient.getAllFolders();
setFolders(allFolders);
}
setLocalLoading(false);
setIsInitialLoading(false);
return;
@@ -127,6 +138,10 @@ const ItemAddEdit: React.FC = () => {
if (result) {
setItem(result);
// Load folders
const allFolders = dbContext.sqliteClient.getAllFolders();
setFolders(allFolders);
// Initialize field values from existing fields
const initialValues: Record<string, string | string[]> = {};
const existingCustomFields: CustomFieldDefinition[] = [];
@@ -258,8 +273,8 @@ const ItemAddEdit: React.FC = () => {
await dbContext.sqliteClient!.deleteItemById(item.Id);
});
/* Navigate back to credentials list */
navigate('/credentials');
/* Navigate back to items list */
navigate('/items');
} catch (err) {
console.error('Error deleting item:', err);
} finally {
@@ -274,7 +289,7 @@ const ItemAddEdit: React.FC = () => {
if (isEditMode) {
navigate(`/items/${id}`);
} else {
navigate('/credentials');
navigate('/items');
}
}, [isEditMode, id, navigate]);
@@ -461,6 +476,29 @@ const ItemAddEdit: React.FC = () => {
/>
</div>
{/* Folder Selection */}
<div>
<label
htmlFor="folderSelect"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{t('items.folder')}
</label>
<select
id="folderSelect"
value={item.FolderId || ''}
onChange={(e) => setItem({ ...item, FolderId: e.target.value || null })}
className="w-full p-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-blue-500 focus:border-blue-500"
>
<option value="">{t('items.noFolder')}</option>
{folders.map(folder => (
<option key={folder.Id} value={folder.Id}>
{folder.Name}
</option>
))}
</select>
</div>
{/* Render fields grouped by category */}
{Object.keys(groupedSystemFields).map(category => (
<div key={category} className="space-y-4">

View File

@@ -0,0 +1,659 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import ItemCard from '@/entrypoints/popup/components/Items/ItemCard';
import FolderCard from '@/entrypoints/popup/components/Items/FolderCard';
import FolderModal from '@/entrypoints/popup/components/Folders/FolderModal';
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
import ReloadButton from '@/entrypoints/popup/components/ReloadButton';
import { useApp } from '@/entrypoints/popup/context/AppContext';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { useVaultSync } from '@/entrypoints/popup/hooks/useVaultSync';
import { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate';
import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility';
import type { Item } from '@/utils/dist/shared/models/vault';
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
type FilterType = 'all' | 'passkeys' | 'attachments';
const FILTER_STORAGE_KEY = 'items-filter';
const FILTER_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
/**
* Get stored filter from localStorage if not expired
*/
const getStoredFilter = (): FilterType => {
try {
const stored = localStorage.getItem(FILTER_STORAGE_KEY);
if (!stored) {
return 'all';
}
const { filter, timestamp } = JSON.parse(stored);
const now = Date.now();
// Check if expired (5 minutes)
if (now - timestamp > FILTER_EXPIRY_MS) {
localStorage.removeItem(FILTER_STORAGE_KEY);
return 'all';
}
return filter as FilterType;
} catch {
return 'all';
}
};
/**
* Store filter in localStorage with timestamp
*/
const storeFilter = (filter: FilterType): void => {
try {
localStorage.setItem(FILTER_STORAGE_KEY, JSON.stringify({
filter,
timestamp: Date.now()
}));
} catch {
// Ignore storage errors
}
};
/**
* Represents a folder with item count
*/
type FolderWithCount = {
id: string;
name: string;
itemCount: number;
};
/**
* Items list page with folder support.
*/
const ItemsList: React.FC = () => {
const { t } = useTranslation();
const dbContext = useDb();
const app = useApp();
const navigate = useNavigate();
const { syncVault } = useVaultSync();
const { executeVaultMutation, isLoading: isSaving } = useVaultMutate();
const { setHeaderButtons } = useHeaderButtons();
const [items, setItems] = useState<Item[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState<FilterType>(getStoredFilter());
const [showFilterMenu, setShowFilterMenu] = useState(false);
const [currentFolderId, setCurrentFolderId] = useState<string | null>(null);
const [folderPath, setFolderPath] = useState<string>('');
const [showFolderModal, setShowFolderModal] = useState(false);
const [recentlyDeletedCount, setRecentlyDeletedCount] = useState(0);
const { setIsInitialLoading } = useLoading();
/**
* Loading state with minimum duration for more fluid UX.
*/
const [isLoading, setIsLoading] = useMinDurationLoading(true, 100);
/**
* Handle add new item.
* Navigate to item type selector for new item-based flow.
*/
const handleAddItem = useCallback(() : void => {
navigate('/items/select-type');
}, [navigate]);
/**
* Handle add new folder.
*/
const handleAddFolder = useCallback(() : void => {
setShowFolderModal(true);
}, []);
/**
* Handle save folder.
*/
const handleSaveFolder = useCallback(async (folderName: string) : Promise<void> => {
if (!dbContext?.sqliteClient) {
console.error('[FOLDER DEBUG] No sqliteClient available');
return;
}
console.log('[FOLDER DEBUG] Creating folder:', folderName, 'in parent:', currentFolderId);
await executeVaultMutation(
async () => {
const folderId = await dbContext.sqliteClient!.createFolder(folderName, currentFolderId);
console.log('[FOLDER DEBUG] Folder created with ID:', folderId);
},
{
onSuccess: () => {
console.log('[FOLDER DEBUG] Vault mutation successful, refreshing items...');
// Refresh items to show the new folder
const results = dbContext.sqliteClient!.getAllItems();
console.log('[FOLDER DEBUG] getAllItems returned:', results.length, 'items');
console.log('[FOLDER DEBUG] Items with FolderId:', results.filter(i => i.FolderId).map(i => ({ id: i.Id, name: i.Name, folderId: i.FolderId, folderPath: i.FolderPath })));
setItems(results);
// Also try to get folders directly
const folders = dbContext.sqliteClient!.getAllFolders();
console.log('[FOLDER DEBUG] getAllFolders returned:', folders);
},
onError: (error) => {
console.error('[FOLDER DEBUG] Error creating folder:', error);
throw error;
}
}
);
}, [dbContext, currentFolderId, executeVaultMutation]);
/**
* Retrieve latest vault and refresh the items list.
*/
const onRefresh = useCallback(async () : Promise<void> => {
if (!dbContext?.sqliteClient) {
return;
}
try {
// Sync vault and load items
await syncVault({
/**
* On success.
*/
onSuccess: async (_hasNewVault) => {
// Items list is refreshed automatically when the (new) sqlite client is available via useEffect hook below.
},
/**
* On offline.
*/
_onOffline: () => {
// Not implemented for browser extension yet.
},
/**
* On error.
*/
onError: async (error) => {
console.error('Error syncing vault:', error);
},
});
} catch (err) {
console.error('Error refreshing items:', err);
await app.logout('Error while syncing vault, please re-authenticate.');
}
}, [dbContext, app, syncVault]);
/**
* Get latest vault from server and refresh the items list.
*/
const syncVaultAndRefresh = useCallback(async () : Promise<void> => {
setIsLoading(true);
await onRefresh();
setIsLoading(false);
}, [onRefresh, setIsLoading]);
// Set header buttons on mount and clear on unmount
useEffect((): (() => void) => {
const headerButtonsJSX = (
<div className="flex items-center gap-2">
{!PopoutUtility.isPopup() && (
<HeaderButton
onClick={() => PopoutUtility.openInNewPopup()}
title="Open in new window"
iconType={HeaderIconType.EXPAND}
/>
)}
<HeaderButton
onClick={handleAddItem}
title="Add new item"
iconType={HeaderIconType.PLUS}
/>
</div>
);
setHeaderButtons(headerButtonsJSX);
return () => setHeaderButtons(null);
}, [setHeaderButtons, handleAddItem]);
/**
* Load items list on mount and on sqlite client change.
*/
useEffect(() => {
/**
* Refresh items list when a (new) sqlite client is available.
*/
const refreshItems = async () : Promise<void> => {
if (dbContext?.sqliteClient) {
setIsLoading(true);
const results = dbContext.sqliteClient?.getAllItems() ?? [];
setItems(results);
// Also get recently deleted count
const deletedCount = dbContext.sqliteClient?.getRecentlyDeletedCount() ?? 0;
setRecentlyDeletedCount(deletedCount);
setIsLoading(false);
setIsInitialLoading(false);
}
};
refreshItems();
}, [dbContext?.sqliteClient, setIsLoading, setIsInitialLoading]);
/**
* Get the title based on the active filter and current folder
*/
const getFilterTitle = () : string => {
if (currentFolderId && folderPath) {
return folderPath;
}
switch (filterType) {
case 'passkeys':
return t('items.filters.passkeys');
case 'attachments':
return t('items.filters.attachments');
default:
return t('items.title');
}
};
/**
* Navigate into a folder
*/
const handleFolderClick = useCallback((folderId: string, folderName: string) => {
setCurrentFolderId(folderId);
setFolderPath(folderName);
setSearchTerm(''); // Clear search when entering folder
}, []);
/**
* Navigate back to root or parent folder
*/
const handleBackToRoot = useCallback(() => {
setCurrentFolderId(null);
setFolderPath('');
}, []);
/**
* Get folders with item counts (only for root level when not searching)
*/
const getFoldersWithCounts = (): FolderWithCount[] => {
console.log('[FOLDER DEBUG] getFoldersWithCounts called. currentFolderId:', currentFolderId, 'searchTerm:', searchTerm);
if (currentFolderId || searchTerm) {
console.log('[FOLDER DEBUG] Returning empty folders (in folder view or searching)');
return [];
}
if (!dbContext?.sqliteClient) {
return [];
}
// Get all folders directly from the database
const allFolders = dbContext.sqliteClient.getAllFolders();
console.log('[FOLDER DEBUG] Got', allFolders.length, 'folders from database:', allFolders);
// Count items per folder
const folderCounts = new Map<string, number>();
items.forEach(item => {
if (item.FolderId) {
folderCounts.set(item.FolderId, (folderCounts.get(item.FolderId) || 0) + 1);
}
});
// Build result with counts
const result = allFolders.map(folder => ({
id: folder.Id,
name: folder.Name,
itemCount: folderCounts.get(folder.Id) || 0
})).sort((a, b) => a.name.localeCompare(b.name));
console.log('[FOLDER DEBUG] Returning', result.length, 'folders with counts:', result);
return result;
};
/**
* Filter items based on current view (folder, search, filter type)
*/
const filteredItems = items.filter((item: Item) => {
// Filter by current folder (if in folder view)
if (currentFolderId !== null) {
if (item.FolderId !== currentFolderId) {
return false;
}
} else if (!searchTerm) {
// In root view without search, exclude items that are in folders
if (item.FolderId) {
return false;
}
}
// Apply type filter
let passesTypeFilter = true;
if (filterType === 'passkeys') {
passesTypeFilter = item.HasPasskey === true;
} else if (filterType === 'attachments') {
passesTypeFilter = item.HasAttachment === true;
}
if (!passesTypeFilter) {
return false;
}
// Apply search filter
const searchLower = searchTerm.toLowerCase().trim();
if (!searchLower) {
return true;
}
// Search in item name and fields
const itemName = item.Name?.toLowerCase() || '';
if (itemName.includes(searchLower)) {
return true;
}
// Search in field values
const fieldMatches = item.Fields?.some(field => {
const value = Array.isArray(field.Value)
? field.Value.join(' ').toLowerCase()
: (field.Value || '').toLowerCase();
return value.includes(searchLower) || field.Label.toLowerCase().includes(searchLower);
});
if (fieldMatches) {
return true;
}
return false;
});
const folders = getFoldersWithCounts();
console.log('[FOLDER DEBUG] Render: folders:', folders.length, 'filteredItems:', filteredItems.length, 'isLoading:', isLoading);
if (isLoading) {
return (
<div className="flex justify-center items-center p-8">
<LoadingSpinner />
</div>
);
}
return (
<div>
<div className="flex justify-between items-center mb-4">
<div className="relative flex-1">
{currentFolderId ? (
<div className="flex items-center gap-2">
<button
onClick={handleBackToRoot}
className="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
title={t('common.back')}
>
<svg
className="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</button>
<h2 className="flex items-baseline gap-1.5 text-gray-900 dark:text-white text-xl">
{getFilterTitle()}
<span className="text-sm text-gray-500 dark:text-gray-400">({filteredItems.length})</span>
</h2>
</div>
) : (
<button
onClick={() => setShowFilterMenu(!showFilterMenu)}
className="flex items-center gap-1 text-gray-900 dark:text-white text-xl hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none"
>
<h2 className="flex items-baseline gap-1.5">
{getFilterTitle()}
<span className="text-sm text-gray-500 dark:text-gray-400">
({folders.length > 0 ? `${folders.length} ${t('items.folders')}, ` : ''}{filteredItems.length} {t('items.items')})
</span>
</h2>
<svg
className="w-4 h-4 mt-1"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
)}
{showFilterMenu && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setShowFilterMenu(false)}
/>
<div className="absolute left-0 mt-2 w-56 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-20">
<div className="py-1">
<button
onClick={() => {
const newFilter = 'all';
setFilterType(newFilter);
storeFilter(newFilter);
setShowFilterMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
filterType === 'all' ? 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400' : 'text-gray-700 dark:text-gray-300'
}`}
>
{t('items.filters.all')}
</button>
<button
onClick={() => {
const newFilter = 'passkeys';
setFilterType(newFilter);
storeFilter(newFilter);
setShowFilterMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
filterType === 'passkeys' ? 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400' : 'text-gray-700 dark:text-gray-300'
}`}
>
{t('items.filters.passkeys')}
</button>
<button
onClick={() => {
const newFilter = 'attachments';
setFilterType(newFilter);
storeFilter(newFilter);
setShowFilterMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
filterType === 'attachments' ? 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400' : 'text-gray-700 dark:text-gray-300'
}`}
>
{t('items.filters.attachments')}
</button>
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
<button
onClick={() => {
setShowFilterMenu(false);
navigate('/items/deleted');
}}
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
{t('recentlyDeleted.title')}
</button>
</div>
</div>
</>
)}
</div>
<ReloadButton onClick={syncVaultAndRefresh} />
</div>
{items.length > 0 ? (
<div className="mb-4">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={`${t('content.searchVault')}`}
autoFocus
className="w-full p-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
) : (
<></>
)}
{items.length === 0 ? (
<>
<div className="text-gray-500 dark:text-gray-400 space-y-2 mb-10">
<p>
{t('items.welcomeTitle')}
</p>
<p>
{t('items.welcomeDescription')}
</p>
</div>
{/* Show Recently Deleted even when vault is empty */}
{recentlyDeletedCount > 0 && (
<button
onClick={() => navigate('/items/deleted')}
className="w-full p-3 flex items-center justify-between text-left bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
>
<div className="flex items-center gap-2">
<svg
className="w-5 h-5 text-gray-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<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 className="text-gray-700 dark:text-gray-300">{t('recentlyDeleted.title')}</span>
</div>
<span className="text-sm text-gray-500 dark:text-gray-400">
{recentlyDeletedCount}
</span>
</button>
)}
</>
) : filteredItems.length === 0 && folders.length === 0 ? (
<div className="text-gray-500 dark:text-gray-400 space-y-2 mb-10">
<p>
{filterType === 'passkeys'
? t('items.noPasskeysFound')
: filterType === 'attachments'
? t('items.noAttachmentsFound')
: t('items.noMatchingItems')
}
</p>
</div>
) : (
<>
{/* Folders section (only show at root level when not searching) */}
{!currentFolderId && !searchTerm && (
<div className="space-y-2 mb-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t('items.folders')}
</h3>
<button
onClick={handleAddFolder}
className="text-xs text-orange-600 dark:text-orange-400 hover:text-orange-700 dark:hover:text-orange-300 focus:outline-none"
>
+ {t('items.newFolder')}
</button>
</div>
{folders.length > 0 && (
<ul className="space-y-2">
{folders.map(folder => (
<FolderCard
key={folder.id}
folder={folder}
onClick={() => handleFolderClick(folder.id, folder.name)}
/>
))}
</ul>
)}
</div>
)}
{/* Items */}
{filteredItems.length > 0 && (
<div className="space-y-2">
{folders.length > 0 && (
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t('items.items')}
</h3>
)}
<ul className="space-y-2">
{filteredItems.map(item => (
<ItemCard
key={item.Id}
item={item}
showFolderPath={!!searchTerm && !!item.FolderPath}
/>
))}
</ul>
</div>
)}
{/* Recently Deleted link (only show at root level when not searching) */}
{!currentFolderId && !searchTerm && (
<button
onClick={() => navigate('/items/deleted')}
className="w-full mt-4 p-3 flex items-center justify-between text-left bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
>
<div className="flex items-center gap-2">
<svg
className="w-5 h-5 text-gray-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<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 className="text-gray-700 dark:text-gray-300">{t('recentlyDeleted.title')}</span>
</div>
{recentlyDeletedCount > 0 && (
<span className="text-sm text-gray-500 dark:text-gray-400">
{recentlyDeletedCount}
</span>
)}
</button>
)}
</>
)}
{/* Folder Modal */}
<FolderModal
isOpen={showFolderModal}
onClose={() => setShowFolderModal(false)}
onSave={handleSaveFolder}
mode="create"
/>
</div>
);
};
export default ItemsList;

View File

@@ -0,0 +1,330 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
import { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate';
import type { Item } from '@/utils/dist/shared/models/vault';
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
/**
* Calculate days remaining until permanent deletion.
* @param deletedAt - ISO timestamp when item was deleted
* @param retentionDays - Number of days to retain (default 30)
* @returns Number of days remaining, or 0 if already expired
*/
const getDaysRemaining = (deletedAt: string, retentionDays: number = 30): number => {
const deletedDate = new Date(deletedAt);
const expiryDate = new Date(deletedDate.getTime() + retentionDays * 24 * 60 * 60 * 1000);
const now = new Date();
const daysRemaining = Math.ceil((expiryDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000));
return Math.max(0, daysRemaining);
};
/**
* Recently Deleted page - shows items in trash that can be restored or permanently deleted.
*/
const RecentlyDeleted: React.FC = () => {
const { t } = useTranslation();
const dbContext = useDb();
const { executeVaultMutation } = useVaultMutate();
const { setHeaderButtons } = useHeaderButtons();
const [items, setItems] = useState<Item[]>([]);
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
const [showConfirmEmptyAll, setShowConfirmEmptyAll] = useState(false);
/**
* Loading state with minimum duration for more fluid UX.
*/
const [isLoading, setIsLoading] = useMinDurationLoading(true, 100);
/**
* Load recently deleted items.
*/
const loadItems = useCallback(() => {
if (dbContext?.sqliteClient) {
const results = dbContext.sqliteClient.getRecentlyDeletedItems();
setItems(results);
}
}, [dbContext?.sqliteClient]);
/**
* Restore an item from Recently Deleted.
*/
const handleRestore = useCallback(async (itemId: string) => {
if (!dbContext?.sqliteClient) {
return;
}
await executeVaultMutation(
async () => {
await dbContext.sqliteClient!.restoreItem(itemId);
},
{
/**
* On success callback.
*/
onSuccess: () => {
loadItems();
},
/**
* On error callback.
* @param error - The error that occurred
*/
onError: (error) => {
console.error('Error restoring item:', error);
}
}
);
}, [dbContext?.sqliteClient, executeVaultMutation, loadItems]);
/**
* Permanently delete an item.
*/
const handlePermanentDelete = useCallback(async (itemId: string) => {
if (!dbContext?.sqliteClient) {
return;
}
await executeVaultMutation(
async () => {
await dbContext.sqliteClient!.permanentlyDeleteItem(itemId);
},
{
/**
* On success callback.
*/
onSuccess: () => {
loadItems();
setShowConfirmDelete(false);
setSelectedItemId(null);
},
/**
* On error callback.
* @param error - The error that occurred
*/
onError: (error) => {
console.error('Error permanently deleting item:', error);
}
}
);
}, [dbContext?.sqliteClient, executeVaultMutation, loadItems]);
/**
* Empty all items from Recently Deleted (permanent delete all).
*/
const handleEmptyAll = useCallback(async () => {
if (!dbContext?.sqliteClient) {
return;
}
await executeVaultMutation(
async () => {
for (const item of items) {
await dbContext.sqliteClient!.permanentlyDeleteItem(item.Id);
}
},
{
/**
* On success callback.
*/
onSuccess: () => {
loadItems();
setShowConfirmEmptyAll(false);
},
/**
* On error callback.
* @param error - The error that occurred
*/
onError: (error) => {
console.error('Error emptying recently deleted:', error);
}
}
);
}, [dbContext?.sqliteClient, executeVaultMutation, items, loadItems]);
// Clear header buttons on mount
useEffect((): (() => void) => {
setHeaderButtons(null);
return () => setHeaderButtons(null);
}, [setHeaderButtons]);
// Load items on mount and when sqlite client changes
useEffect(() => {
/**
* Load items from database.
*/
const load = async (): Promise<void> => {
if (dbContext?.sqliteClient) {
setIsLoading(true);
loadItems();
setIsLoading(false);
}
};
load();
}, [dbContext?.sqliteClient, setIsLoading, loadItems]);
if (isLoading) {
return (
<div className="flex justify-center items-center p-8">
<LoadingSpinner />
</div>
);
}
return (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="flex items-baseline gap-1.5 text-gray-900 dark:text-white text-xl">
{t('recentlyDeleted.title')}
<span className="text-sm text-gray-500 dark:text-gray-400">({items.length})</span>
</h2>
{items.length > 0 && (
<button
onClick={() => setShowConfirmEmptyAll(true)}
className="text-sm text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
>
{t('recentlyDeleted.emptyAll')}
</button>
)}
</div>
{items.length === 0 ? (
<div className="text-gray-500 dark:text-gray-400 space-y-2 mb-10">
<p>{t('recentlyDeleted.noItems')}</p>
<p className="text-sm">{t('recentlyDeleted.noItemsDescription')}</p>
</div>
) : (
<>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
{t('recentlyDeleted.description')}
</p>
<ul className="space-y-2">
{items.map(item => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deletedAt = (item as any).DeletedAt;
const daysRemaining = deletedAt ? getDaysRemaining(deletedAt) : 30;
return (
<li key={item.Id} className="relative">
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3">
<div className="flex items-start gap-3">
{/* Item card content (simplified) */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{item.Logo && (
<img
src={`data:image/png;base64,${btoa(String.fromCharCode(...item.Logo))}`}
alt=""
className="w-6 h-6 rounded"
/>
)}
<span className="font-medium text-gray-900 dark:text-white truncate">
{item.Name || t('recentlyDeleted.untitledItem')}
</span>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{daysRemaining > 0 ? (
t('recentlyDeleted.daysRemaining', { count: daysRemaining })
) : (
<span className="text-red-500">{t('recentlyDeleted.expiringSoon')}</span>
)}
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2">
<button
onClick={() => handleRestore(item.Id)}
className="px-3 py-1 text-sm bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded hover:bg-green-200 dark:hover:bg-green-900/50"
>
{t('recentlyDeleted.restore')}
</button>
<button
onClick={() => {
setSelectedItemId(item.Id);
setShowConfirmDelete(true);
}}
className="px-3 py-1 text-sm bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded hover:bg-red-200 dark:hover:bg-red-900/50"
>
{t('common.delete')}
</button>
</div>
</div>
</div>
</li>
);
})}
</ul>
</>
)}
{/* Confirm Delete Modal */}
{showConfirmDelete && selectedItemId && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-sm mx-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{t('recentlyDeleted.confirmDeleteTitle')}
</h3>
<p className="text-gray-500 dark:text-gray-400 mb-4">
{t('recentlyDeleted.confirmDeleteMessage')}
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => {
setShowConfirmDelete(false);
setSelectedItemId(null);
}}
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
>
{t('common.cancel')}
</button>
<button
onClick={() => handlePermanentDelete(selectedItemId)}
className="px-4 py-2 text-sm bg-red-600 text-white rounded hover:bg-red-700"
>
{t('recentlyDeleted.deletePermanently')}
</button>
</div>
</div>
</div>
)}
{/* Confirm Empty All Modal */}
{showConfirmEmptyAll && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-sm mx-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{t('recentlyDeleted.confirmEmptyAllTitle')}
</h3>
<p className="text-gray-500 dark:text-gray-400 mb-4">
{t('recentlyDeleted.confirmEmptyAllMessage', { count: items.length })}
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowConfirmEmptyAll(false)}
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
>
{t('common.cancel')}
</button>
<button
onClick={handleEmptyAll}
className="px-4 py-2 text-sm bg-red-600 text-white rounded hover:bg-red-700"
>
{t('recentlyDeleted.emptyAll')}
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default RecentlyDeleted;

View File

@@ -43,7 +43,7 @@
}
},
"menu": {
"credentials": "Credentials",
"vault": "Vault",
"emails": "Emails",
"settings": "Settings"
},
@@ -59,6 +59,7 @@
"delete": "Delete",
"save": "Save",
"saving": "Saving...",
"create": "Create",
"or": "Or",
"close": "Close",
"copied": "Copied!",
@@ -252,6 +253,30 @@
"noHistoryAvailable": "No history available",
"tags": "Tags"
},
"items": {
"title": "Items",
"folders": "folders",
"items": "items",
"newFolder": "New Folder",
"createFolder": "Create Folder",
"editFolder": "Edit Folder",
"folderName": "Folder Name",
"folderNamePlaceholder": "Enter folder name",
"folderNameRequired": "Folder name is required",
"folderSaveError": "Failed to save folder. Please try again.",
"folder": "Folder",
"noFolder": "No Folder",
"welcomeTitle": "Welcome to AliasVault!",
"welcomeDescription": "To use the AliasVault browser extension: navigate to a website and use the AliasVault autofill popup to create a new credential.",
"noPasskeysFound": "No passkeys have been created yet. Passkeys are created by visiting a website that offers passkeys as an authentication method.",
"noAttachmentsFound": "No items with attachments found",
"noMatchingItems": "No matching items found",
"filters": {
"all": "(All) Items",
"passkeys": "Passkeys",
"attachments": "Attachments"
}
},
"itemTypes": {
"selectType": "Add New Item",
"selectTypeDescription": "Choose the type of item you want to create",
@@ -449,6 +474,23 @@
"passkeyProviderOn": "Passkey Provider on "
}
},
"recentlyDeleted": {
"title": "Recently Deleted",
"noItems": "No deleted items",
"noItemsDescription": "Items you delete will appear here for 30 days before being permanently removed.",
"description": "These items will be permanently deleted after 30 days. You can restore them or delete them immediately.",
"restore": "Restore",
"deletePermanently": "Delete Permanently",
"emptyAll": "Empty All",
"daysRemaining": "{{count}} day remaining",
"daysRemaining_plural": "{{count}} days remaining",
"expiringSoon": "Expiring soon",
"untitledItem": "Untitled Item",
"confirmDeleteTitle": "Delete Permanently?",
"confirmDeleteMessage": "This item will be permanently deleted and cannot be recovered.",
"confirmEmptyAllTitle": "Empty Recently Deleted?",
"confirmEmptyAllMessage": "All {{count}} items will be permanently deleted and cannot be recovered."
},
"upgrade": {
"title": "Upgrade Vault",
"subtitle": "AliasVault has updated and your vault needs to be upgraded. This should only take a few seconds.",

View File

@@ -225,7 +225,7 @@ export class SqliteClient {
(SELECT pk.DisplayName FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0 LIMIT 1) as PasskeyDisplayName
FROM Items i
LEFT JOIN Logos l ON i.LogoId = l.Id
WHERE i.IsDeleted = 0
WHERE i.IsDeleted = 0 AND i.DeletedAt IS NULL
AND i.Id = ?`;
const results = this.executeQuery(query, [credentialId]);
@@ -303,7 +303,7 @@ export class SqliteClient {
END as HasAttachment
FROM Items i
LEFT JOIN Logos l ON i.LogoId = l.Id
WHERE i.IsDeleted = 0
WHERE i.IsDeleted = 0 AND i.DeletedAt IS NULL
ORDER BY i.CreatedAt DESC`;
const results = this.executeQuery(query);
@@ -371,6 +371,7 @@ export class SqliteClient {
i.Name,
i.ItemType,
i.FolderId,
f.Name as FolderPath,
l.FileData as Logo,
CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey,
CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment,
@@ -379,7 +380,8 @@ export class SqliteClient {
i.UpdatedAt
FROM Items i
LEFT JOIN Logos l ON i.LogoId = l.Id
WHERE i.IsDeleted = 0
LEFT JOIN Folders f ON i.FolderId = f.Id
WHERE i.IsDeleted = 0 AND i.DeletedAt IS NULL
ORDER BY i.CreatedAt DESC`;
const items = this.executeQuery(query);
@@ -531,7 +533,7 @@ export class SqliteClient {
ItemType: row.ItemType,
Logo: row.Logo,
FolderId: row.FolderId,
FolderPath: null,
FolderPath: row.FolderPath || null,
Tags: tagsByItem[row.Id] || [],
Fields: fieldsByItem[row.Id] || [],
HasPasskey: row.HasPasskey === 1,
@@ -2400,11 +2402,12 @@ export class SqliteClient {
}
/**
* Delete an item by ID (soft delete)
* @param itemId - The ID of the item to delete
* Move an item to "Recently Deleted" (trash) by setting DeletedAt timestamp.
* Item can be restored within retention period (default 30 days).
* @param itemId - The ID of the item to trash
* @returns The number of rows updated
*/
public async deleteItemById(itemId: string): Promise<number> {
public async trashItem(itemId: string): Promise<number> {
if (!this.db) {
throw new Error('Database not initialized');
}
@@ -2414,59 +2417,438 @@ export class SqliteClient {
const currentDateTime = dateFormatter.now();
// 1. Soft delete the item
const itemQuery = `
const query = `
UPDATE Items
SET IsDeleted = 1,
SET DeletedAt = ?,
UpdatedAt = ?
WHERE Id = ?`;
WHERE Id = ? AND IsDeleted = 0`;
const result = this.executeUpdate(itemQuery, [currentDateTime, itemId]);
// 2. Soft delete all associated FieldValues
const fieldsQuery = `
UPDATE FieldValues
SET IsDeleted = 1,
UpdatedAt = ?
WHERE ItemId = ?`;
this.executeUpdate(fieldsQuery, [currentDateTime, itemId]);
// 3. Soft delete associated Passkeys
const passkeysQuery = `
UPDATE Passkeys
SET IsDeleted = 1,
UpdatedAt = ?
WHERE ItemId = ?`;
this.executeUpdate(passkeysQuery, [currentDateTime, itemId]);
// 4. Soft delete associated TotpCodes
const totpQuery = `
UPDATE TotpCodes
SET IsDeleted = 1,
UpdatedAt = ?
WHERE ItemId = ?`;
this.executeUpdate(totpQuery, [currentDateTime, itemId]);
// 5. Soft delete associated Attachments
const attachmentsQuery = `
UPDATE Attachments
SET IsDeleted = 1,
UpdatedAt = ?
WHERE ItemId = ?`;
this.executeUpdate(attachmentsQuery, [currentDateTime, itemId]);
const result = this.executeUpdate(query, [currentDateTime, currentDateTime, itemId]);
await this.commitTransaction();
return result;
} catch (error) {
this.rollbackTransaction();
console.error('Error deleting item:', error);
console.error('Error trashing item:', error);
throw error;
}
}
/**
* Restore an item from "Recently Deleted" by clearing DeletedAt timestamp.
* @param itemId - The ID of the item to restore
* @returns The number of rows updated
*/
public async restoreItem(itemId: string): Promise<number> {
if (!this.db) {
throw new Error('Database not initialized');
}
try {
this.beginTransaction();
const currentDateTime = dateFormatter.now();
const query = `
UPDATE Items
SET DeletedAt = NULL,
UpdatedAt = ?
WHERE Id = ? AND IsDeleted = 0 AND DeletedAt IS NOT NULL`;
const result = this.executeUpdate(query, [currentDateTime, itemId]);
await this.commitTransaction();
return result;
} catch (error) {
this.rollbackTransaction();
console.error('Error restoring item:', error);
throw error;
}
}
/**
* Permanently delete an item - converts to tombstone for sync.
* Hard deletes all child entities and marks item as IsDeleted=1.
* @param itemId - The ID of the item to permanently delete
* @returns The number of rows updated
*/
public async permanentlyDeleteItem(itemId: string): Promise<number> {
if (!this.db) {
throw new Error('Database not initialized');
}
try {
this.beginTransaction();
const currentDateTime = dateFormatter.now();
// 1. Hard delete all FieldValues for this item
this.executeUpdate(`DELETE FROM FieldValues WHERE ItemId = ?`, [itemId]);
// 2. Hard delete all FieldHistories for this item
this.executeUpdate(`DELETE FROM FieldHistories WHERE ItemId = ?`, [itemId]);
// 3. Hard delete all Passkeys for this item
this.executeUpdate(`DELETE FROM Passkeys WHERE ItemId = ?`, [itemId]);
// 4. Hard delete all TotpCodes for this item
this.executeUpdate(`DELETE FROM TotpCodes WHERE ItemId = ?`, [itemId]);
// 5. Hard delete all Attachments for this item
this.executeUpdate(`DELETE FROM Attachments WHERE ItemId = ?`, [itemId]);
// 6. Hard delete all ItemTags for this item
this.executeUpdate(`DELETE FROM ItemTags WHERE ItemId = ?`, [itemId]);
// 7. Convert item to tombstone (gut it, keep shell for sync)
const itemQuery = `
UPDATE Items
SET IsDeleted = 1,
Name = NULL,
LogoId = NULL,
FolderId = NULL,
UpdatedAt = ?
WHERE Id = ?`;
const result = this.executeUpdate(itemQuery, [currentDateTime, itemId]);
await this.commitTransaction();
return result;
} catch (error) {
this.rollbackTransaction();
console.error('Error permanently deleting item:', error);
throw error;
}
}
/**
* Get all items in "Recently Deleted" (where DeletedAt is set but not permanently deleted).
* @returns Array of trashed Item objects
*/
public getRecentlyDeletedItems(): Item[] {
const query = `
SELECT DISTINCT
i.Id,
i.Name,
i.ItemType,
i.FolderId,
f.Name as FolderPath,
l.FileData as Logo,
i.DeletedAt,
CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey,
CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment,
CASE WHEN EXISTS (SELECT 1 FROM TotpCodes tc WHERE tc.ItemId = i.Id AND tc.IsDeleted = 0) THEN 1 ELSE 0 END as HasTotp,
i.CreatedAt,
i.UpdatedAt
FROM Items i
LEFT JOIN Logos l ON i.LogoId = l.Id
LEFT JOIN Folders f ON i.FolderId = f.Id
WHERE i.IsDeleted = 0 AND i.DeletedAt IS NOT NULL
ORDER BY i.DeletedAt DESC`;
const items = this.executeQuery(query);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const itemIds = items.map((i: any) => i.Id);
if (itemIds.length === 0) {
return [];
}
// Get all field values
const fieldsQuery = `
SELECT
fv.ItemId,
fv.FieldKey,
fv.FieldDefinitionId,
fd.Label as CustomLabel,
fd.FieldType as CustomFieldType,
fd.IsHidden as CustomIsHidden,
fv.Value,
fv.Weight as DisplayOrder
FROM FieldValues fv
LEFT JOIN FieldDefinitions fd ON fv.FieldDefinitionId = fd.Id
WHERE fv.ItemId IN (${itemIds.map(() => '?').join(',')})
AND fv.IsDeleted = 0
ORDER BY fv.ItemId, fv.Weight`;
const fieldRows = this.executeQuery<{
ItemId: string;
FieldKey: string | null;
FieldDefinitionId: string | null;
CustomLabel: string | null;
CustomFieldType: string | null;
CustomIsHidden: number | null;
Value: string;
DisplayOrder: number;
}>(fieldsQuery, itemIds);
// Process fields
const fields = fieldRows.map(row => {
if (row.FieldKey) {
const systemField = getSystemField(row.FieldKey);
return {
ItemId: row.ItemId,
FieldKey: row.FieldKey,
Label: systemField?.Label || row.FieldKey,
FieldType: systemField?.FieldType || 'Text',
IsHidden: systemField?.IsHidden ? 1 : 0,
Value: row.Value,
DisplayOrder: row.DisplayOrder
};
} else {
return {
ItemId: row.ItemId,
FieldKey: row.FieldDefinitionId || '',
Label: row.CustomLabel || '',
FieldType: row.CustomFieldType || 'Text',
IsHidden: row.CustomIsHidden || 0,
Value: row.Value,
DisplayOrder: row.DisplayOrder
};
}
});
// Group fields by item ID
const fieldsByItem = new Map<string, typeof fields>();
fields.forEach(field => {
const existing = fieldsByItem.get(field.ItemId) || [];
existing.push(field);
fieldsByItem.set(field.ItemId, existing);
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return items.map((row: any) => {
const itemFields = fieldsByItem.get(row.Id) || [];
return {
Id: row.Id,
Name: row.Name,
ItemType: row.ItemType,
FolderId: row.FolderId,
FolderPath: row.FolderPath,
Logo: row.Logo ? new Uint8Array(row.Logo) : undefined,
DeletedAt: row.DeletedAt,
HasPasskey: row.HasPasskey === 1,
HasAttachment: row.HasAttachment === 1,
HasTotp: row.HasTotp === 1,
Fields: itemFields.map(f => ({
FieldKey: f.FieldKey,
Label: f.Label,
FieldType: f.FieldType as FieldType,
Value: f.Value,
IsHidden: f.IsHidden === 1,
DisplayOrder: f.DisplayOrder
})),
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt
};
});
}
/**
* Get count of items in "Recently Deleted".
* @returns Number of trashed items
*/
public getRecentlyDeletedCount(): number {
const query = `
SELECT COUNT(*) as count
FROM Items
WHERE IsDeleted = 0 AND DeletedAt IS NOT NULL`;
const result = this.executeQuery<{ count: number }>(query);
return result[0]?.count || 0;
}
/**
* Delete an item by ID (soft delete) - DEPRECATED, use trashItem instead.
* Kept for backwards compatibility.
* @param itemId - The ID of the item to delete
* @returns The number of rows updated
* @deprecated Use trashItem() for new code
*/
public async deleteItemById(itemId: string): Promise<number> {
// Redirect to new trash functionality
return this.trashItem(itemId);
}
/**
* ===== FOLDER OPERATIONS =====
*/
/**
* Create a new folder
* @param name - The name of the folder
* @param parentFolderId - Optional parent folder ID for nested folders
* @returns The ID of the created folder
*/
public async createFolder(name: string, parentFolderId?: string | null): Promise<string> {
if (!this.db) {
throw new Error('Database not initialized');
}
try {
this.beginTransaction();
const folderId = crypto.randomUUID();
const currentDateTime = dateFormatter.now();
const query = `
INSERT INTO Folders (Id, Name, ParentFolderId, Weight, IsDeleted, CreatedAt, UpdatedAt)
VALUES (?, ?, ?, 0, 0, ?, ?)`;
this.executeUpdate(query, [
folderId,
name,
parentFolderId || null,
currentDateTime,
currentDateTime
]);
await this.commitTransaction();
return folderId;
} catch (error) {
this.rollbackTransaction();
console.error('Error creating folder:', error);
throw error;
}
}
/**
* Get all folders
* @returns Array of folder objects
*/
public getAllFolders(): Array<{ Id: string; Name: string; ParentFolderId: string | null; Weight: number }> {
const query = `
SELECT Id, Name, ParentFolderId, Weight
FROM Folders
WHERE IsDeleted = 0
ORDER BY Weight, Name`;
return this.executeQuery(query);
}
/**
* Update a folder's name
* @param folderId - The ID of the folder to update
* @param name - The new name for the folder
* @returns The number of rows updated
*/
public async updateFolder(folderId: string, name: string): Promise<number> {
if (!this.db) {
throw new Error('Database not initialized');
}
try {
this.beginTransaction();
const currentDateTime = dateFormatter.now();
const query = `
UPDATE Folders
SET Name = ?,
UpdatedAt = ?
WHERE Id = ?`;
const result = this.executeUpdate(query, [name, currentDateTime, folderId]);
await this.commitTransaction();
return result;
} catch (error) {
this.rollbackTransaction();
console.error('Error updating folder:', error);
throw error;
}
}
/**
* Delete a folder (soft delete)
* Note: Items in the folder will have their FolderId set to NULL
* @param folderId - The ID of the folder to delete
* @returns The number of rows updated
*/
public async deleteFolder(folderId: string): Promise<number> {
if (!this.db) {
throw new Error('Database not initialized');
}
try {
this.beginTransaction();
const currentDateTime = dateFormatter.now();
// 1. Remove folder reference from all items in this folder
const itemsQuery = `
UPDATE Items
SET FolderId = NULL,
UpdatedAt = ?
WHERE FolderId = ?`;
this.executeUpdate(itemsQuery, [currentDateTime, folderId]);
// 2. Soft delete the folder
const folderQuery = `
UPDATE Folders
SET IsDeleted = 1,
UpdatedAt = ?
WHERE Id = ?`;
const result = this.executeUpdate(folderQuery, [currentDateTime, folderId]);
await this.commitTransaction();
return result;
} catch (error) {
this.rollbackTransaction();
console.error('Error deleting folder:', error);
throw error;
}
}
/**
* Move an item to a folder
* @param itemId - The ID of the item to move
* @param folderId - The ID of the destination folder (null to remove from folder)
* @returns The number of rows updated
*/
public async moveItemToFolder(itemId: string, folderId: string | null): Promise<number> {
if (!this.db) {
throw new Error('Database not initialized');
}
try {
this.beginTransaction();
const currentDateTime = dateFormatter.now();
const query = `
UPDATE Items
SET FolderId = ?,
UpdatedAt = ?
WHERE Id = ?`;
const result = this.executeUpdate(query, [folderId, currentDateTime, itemId]);
await this.commitTransaction();
return result;
} catch (error) {
this.rollbackTransaction();
console.error('Error moving item to folder:', error);
throw error;
}
}
/**
* Get folder by ID
* @param folderId - The ID of the folder to fetch
* @returns Folder object or null if not found
*/
public getFolderById(folderId: string): { Id: string; Name: string; ParentFolderId: string | null } | null {
const query = `
SELECT Id, Name, ParentFolderId
FROM Folders
WHERE Id = ? AND IsDeleted = 0`;
const results = this.executeQuery<{ Id: string; Name: string; ParentFolderId: string | null }>(query, [folderId]);
return results.length > 0 ? results[0] : null;
}
}
export default SqliteClient;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1209,6 +1209,14 @@ COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251204142538_1.7.1-MakeFieldHistoryFlexible', '9.0.4');
BEGIN TRANSACTION;
ALTER TABLE "Items" ADD "DeletedAt" TEXT NULL;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251205212831_1.7.2-AddDeletedAtToItem', '9.0.4');
COMMIT;
`;
var MIGRATION_SCRIPTS = {
1: `\uFEFFBEGIN TRANSACTION;
@@ -2317,7 +2325,14 @@ CREATE INDEX "IX_FieldHistories_ItemId" ON "FieldHistories" ("ItemId");
COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251204142538_1.7.1-MakeFieldHistoryFlexible', '9.0.4');`
VALUES ('20251204142538_1.7.1-MakeFieldHistoryFlexible', '9.0.4');`,
13: `\uFEFFBEGIN TRANSACTION;
ALTER TABLE "Items" ADD "DeletedAt" TEXT NULL;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251205212831_1.7.2-AddDeletedAtToItem', '9.0.4');
COMMIT;`
};
// src/sql/VaultVersions.ts
@@ -2412,6 +2427,13 @@ var VAULT_VERSIONS = [
description: "Make FieldHistory Flexible",
releaseVersion: "0.26.0",
compatibleUpToVersion: "0.26.0"
},
{
revision: 14,
version: "1.7.2",
description: "Add DeletedAt to Item for Recently Deleted support",
releaseVersion: "0.26.0",
compatibleUpToVersion: "0.26.0"
}
];

View File

@@ -1177,6 +1177,14 @@ COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251204142538_1.7.1-MakeFieldHistoryFlexible', '9.0.4');
BEGIN TRANSACTION;
ALTER TABLE "Items" ADD "DeletedAt" TEXT NULL;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251205212831_1.7.2-AddDeletedAtToItem', '9.0.4');
COMMIT;
`;
var MIGRATION_SCRIPTS = {
1: `\uFEFFBEGIN TRANSACTION;
@@ -2285,7 +2293,14 @@ CREATE INDEX "IX_FieldHistories_ItemId" ON "FieldHistories" ("ItemId");
COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251204142538_1.7.1-MakeFieldHistoryFlexible', '9.0.4');`
VALUES ('20251204142538_1.7.1-MakeFieldHistoryFlexible', '9.0.4');`,
13: `\uFEFFBEGIN TRANSACTION;
ALTER TABLE "Items" ADD "DeletedAt" TEXT NULL;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251205212831_1.7.2-AddDeletedAtToItem', '9.0.4');
COMMIT;`
};
// src/sql/VaultVersions.ts
@@ -2380,6 +2395,13 @@ var VAULT_VERSIONS = [
description: "Make FieldHistory Flexible",
releaseVersion: "0.26.0",
compatibleUpToVersion: "0.26.0"
},
{
revision: 14,
version: "1.7.2",
description: "Add DeletedAt to Item for Recently Deleted support",
releaseVersion: "0.26.0",
compatibleUpToVersion: "0.26.0"
}
];

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1209,6 +1209,14 @@ COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251204142538_1.7.1-MakeFieldHistoryFlexible', '9.0.4');
BEGIN TRANSACTION;
ALTER TABLE "Items" ADD "DeletedAt" TEXT NULL;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251205212831_1.7.2-AddDeletedAtToItem', '9.0.4');
COMMIT;
`;
var MIGRATION_SCRIPTS = {
1: `\uFEFFBEGIN TRANSACTION;
@@ -2317,7 +2325,14 @@ CREATE INDEX "IX_FieldHistories_ItemId" ON "FieldHistories" ("ItemId");
COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251204142538_1.7.1-MakeFieldHistoryFlexible', '9.0.4');`
VALUES ('20251204142538_1.7.1-MakeFieldHistoryFlexible', '9.0.4');`,
13: `\uFEFFBEGIN TRANSACTION;
ALTER TABLE "Items" ADD "DeletedAt" TEXT NULL;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251205212831_1.7.2-AddDeletedAtToItem', '9.0.4');
COMMIT;`
};
// src/sql/VaultVersions.ts
@@ -2412,6 +2427,13 @@ var VAULT_VERSIONS = [
description: "Make FieldHistory Flexible",
releaseVersion: "0.26.0",
compatibleUpToVersion: "0.26.0"
},
{
revision: 14,
version: "1.7.2",
description: "Add DeletedAt to Item for Recently Deleted support",
releaseVersion: "0.26.0",
compatibleUpToVersion: "0.26.0"
}
];

View File

@@ -1177,6 +1177,14 @@ COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251204142538_1.7.1-MakeFieldHistoryFlexible', '9.0.4');
BEGIN TRANSACTION;
ALTER TABLE "Items" ADD "DeletedAt" TEXT NULL;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251205212831_1.7.2-AddDeletedAtToItem', '9.0.4');
COMMIT;
`;
var MIGRATION_SCRIPTS = {
1: `\uFEFFBEGIN TRANSACTION;
@@ -2285,7 +2293,14 @@ CREATE INDEX "IX_FieldHistories_ItemId" ON "FieldHistories" ("ItemId");
COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251204142538_1.7.1-MakeFieldHistoryFlexible', '9.0.4');`
VALUES ('20251204142538_1.7.1-MakeFieldHistoryFlexible', '9.0.4');`,
13: `\uFEFFBEGIN TRANSACTION;
ALTER TABLE "Items" ADD "DeletedAt" TEXT NULL;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251205212831_1.7.2-AddDeletedAtToItem', '9.0.4');
COMMIT;`
};
// src/sql/VaultVersions.ts
@@ -2380,6 +2395,13 @@ var VAULT_VERSIONS = [
description: "Make FieldHistory Flexible",
releaseVersion: "0.26.0",
compatibleUpToVersion: "0.26.0"
},
{
revision: 14,
version: "1.7.2",
description: "Add DeletedAt to Item for Recently Deleted support",
releaseVersion: "0.26.0",
compatibleUpToVersion: "0.26.0"
}
];

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1209,6 +1209,14 @@ COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251204142538_1.7.1-MakeFieldHistoryFlexible', '9.0.4');
BEGIN TRANSACTION;
ALTER TABLE "Items" ADD "DeletedAt" TEXT NULL;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251205212831_1.7.2-AddDeletedAtToItem', '9.0.4');
COMMIT;
`;
var MIGRATION_SCRIPTS = {
1: `\uFEFFBEGIN TRANSACTION;
@@ -2317,7 +2325,14 @@ CREATE INDEX "IX_FieldHistories_ItemId" ON "FieldHistories" ("ItemId");
COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251204142538_1.7.1-MakeFieldHistoryFlexible', '9.0.4');`
VALUES ('20251204142538_1.7.1-MakeFieldHistoryFlexible', '9.0.4');`,
13: `\uFEFFBEGIN TRANSACTION;
ALTER TABLE "Items" ADD "DeletedAt" TEXT NULL;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251205212831_1.7.2-AddDeletedAtToItem', '9.0.4');
COMMIT;`
};
// src/sql/VaultVersions.ts
@@ -2412,6 +2427,13 @@ var VAULT_VERSIONS = [
description: "Make FieldHistory Flexible",
releaseVersion: "0.26.0",
compatibleUpToVersion: "0.26.0"
},
{
revision: 14,
version: "1.7.2",
description: "Add DeletedAt to Item for Recently Deleted support",
releaseVersion: "0.26.0",
compatibleUpToVersion: "0.26.0"
}
];

View File

@@ -1177,6 +1177,14 @@ COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251204142538_1.7.1-MakeFieldHistoryFlexible', '9.0.4');
BEGIN TRANSACTION;
ALTER TABLE "Items" ADD "DeletedAt" TEXT NULL;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251205212831_1.7.2-AddDeletedAtToItem', '9.0.4');
COMMIT;
`;
var MIGRATION_SCRIPTS = {
1: `\uFEFFBEGIN TRANSACTION;
@@ -2285,7 +2293,14 @@ CREATE INDEX "IX_FieldHistories_ItemId" ON "FieldHistories" ("ItemId");
COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251204142538_1.7.1-MakeFieldHistoryFlexible', '9.0.4');`
VALUES ('20251204142538_1.7.1-MakeFieldHistoryFlexible', '9.0.4');`,
13: `\uFEFFBEGIN TRANSACTION;
ALTER TABLE "Items" ADD "DeletedAt" TEXT NULL;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251205212831_1.7.2-AddDeletedAtToItem', '9.0.4');
COMMIT;`
};
// src/sql/VaultVersions.ts
@@ -2380,6 +2395,13 @@ var VAULT_VERSIONS = [
description: "Make FieldHistory Flexible",
releaseVersion: "0.26.0",
compatibleUpToVersion: "0.26.0"
},
{
revision: 14,
version: "1.7.2",
description: "Add DeletedAt to Item for Recently Deleted support",
releaseVersion: "0.26.0",
compatibleUpToVersion: "0.26.0"
}
];

View File

@@ -47,6 +47,14 @@ public class Item : SyncableEntity
[ForeignKey("LogoId")]
public virtual Logo? Logo { get; set; }
/// <summary>
/// Gets or sets the timestamp when this item was moved to the "Recently Deleted" folder.
/// When null, the item is active. When set, the item is in the trash and can be restored.
/// After a configurable retention period (default 30 days), items with DeletedAt set
/// are permanently deleted (converted to tombstones with IsDeleted = true).
/// </summary>
public DateTime? DeletedAt { get; set; }
/// <summary>
/// Gets or sets the folder ID foreign key.
/// </summary>

View File

@@ -0,0 +1,675 @@
// <auto-generated />
using System;
using AliasClientDb;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace AliasClientDb.Migrations
{
[DbContext(typeof(AliasClientDbContext))]
[Migration("20251205212831_1.7.2-AddDeletedAtToItem")]
partial class _172AddDeletedAtToItem
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.4")
.HasAnnotation("Proxies:ChangeTracking", false)
.HasAnnotation("Proxies:CheckEquality", false)
.HasAnnotation("Proxies:LazyLoading", true);
modelBuilder.Entity("AliasClientDb.Attachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<byte[]>("Blob")
.IsRequired()
.HasColumnType("BLOB");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Filename")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<Guid>("ItemId")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ItemId");
b.ToTable("Attachments");
});
modelBuilder.Entity("AliasClientDb.EncryptionKey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsPrimary")
.HasColumnType("INTEGER");
b.Property<string>("PrivateKey")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("PublicKey")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("EncryptionKeys");
});
modelBuilder.Entity("AliasClientDb.FieldDefinition", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("ApplicableToTypes")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("EnableHistory")
.HasColumnType("INTEGER");
b.Property<string>("FieldType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsHidden")
.HasColumnType("INTEGER");
b.Property<bool>("IsMultiValue")
.HasColumnType("INTEGER");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("Weight")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("FieldDefinitions");
});
modelBuilder.Entity("AliasClientDb.FieldHistory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("ChangedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid?>("FieldDefinitionId")
.HasColumnType("TEXT");
b.Property<string>("FieldKey")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<Guid>("ItemId")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("ValueSnapshot")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("FieldDefinitionId");
b.HasIndex("ItemId");
b.ToTable("FieldHistories");
});
modelBuilder.Entity("AliasClientDb.FieldValue", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid?>("FieldDefinitionId")
.HasColumnType("TEXT");
b.Property<string>("FieldKey")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<Guid>("ItemId")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.Property<int>("Weight")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("FieldDefinitionId");
b.HasIndex("FieldKey");
b.HasIndex("ItemId");
b.HasIndex("ItemId", "FieldKey");
b.HasIndex("ItemId", "FieldDefinitionId", "Weight");
b.ToTable("FieldValues");
});
modelBuilder.Entity("AliasClientDb.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<Guid?>("ParentFolderId")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("Weight")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ParentFolderId");
b.ToTable("Folders");
});
modelBuilder.Entity("AliasClientDb.Item", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<Guid?>("FolderId")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("ItemType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<Guid?>("LogoId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("LogoId");
b.ToTable("Items");
});
modelBuilder.Entity("AliasClientDb.ItemTag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<Guid>("ItemId")
.HasColumnType("TEXT");
b.Property<Guid>("TagId")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ItemId");
b.HasIndex("TagId");
b.HasIndex("ItemId", "TagId")
.IsUnique();
b.ToTable("ItemTags");
});
modelBuilder.Entity("AliasClientDb.Logo", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("FetchedAt")
.HasColumnType("TEXT");
b.Property<byte[]>("FileData")
.HasColumnType("BLOB");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("MimeType")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Source")
.IsUnique();
b.ToTable("Logos");
});
modelBuilder.Entity("AliasClientDb.Passkey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<byte[]>("AdditionalData")
.HasColumnType("BLOB");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<Guid>("ItemId")
.HasColumnType("TEXT");
b.Property<byte[]>("PrfKey")
.HasMaxLength(64)
.HasColumnType("BLOB");
b.Property<string>("PrivateKey")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PublicKey")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("RpId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<byte[]>("UserHandle")
.IsRequired()
.HasColumnType("BLOB");
b.HasKey("Id");
b.HasIndex("ItemId");
b.HasIndex("RpId");
b.ToTable("Passkeys");
});
modelBuilder.Entity("AliasClientDb.Setting", b =>
{
b.Property<string>("Key")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Settings");
});
modelBuilder.Entity("AliasClientDb.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Color")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("DisplayOrder")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name");
b.ToTable("Tags");
});
modelBuilder.Entity("AliasClientDb.TotpCode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<Guid>("ItemId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("SecretKey")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ItemId");
b.ToTable("TotpCodes");
});
modelBuilder.Entity("AliasClientDb.Attachment", b =>
{
b.HasOne("AliasClientDb.Item", "Item")
.WithMany("Attachments")
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Item");
});
modelBuilder.Entity("AliasClientDb.FieldHistory", b =>
{
b.HasOne("AliasClientDb.FieldDefinition", "FieldDefinition")
.WithMany("FieldHistories")
.HasForeignKey("FieldDefinitionId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("AliasClientDb.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("FieldDefinition");
b.Navigation("Item");
});
modelBuilder.Entity("AliasClientDb.FieldValue", b =>
{
b.HasOne("AliasClientDb.FieldDefinition", "FieldDefinition")
.WithMany("FieldValues")
.HasForeignKey("FieldDefinitionId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("AliasClientDb.Item", "Item")
.WithMany("FieldValues")
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("FieldDefinition");
b.Navigation("Item");
});
modelBuilder.Entity("AliasClientDb.Folder", b =>
{
b.HasOne("AliasClientDb.Folder", "ParentFolder")
.WithMany("ChildFolders")
.HasForeignKey("ParentFolderId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("ParentFolder");
});
modelBuilder.Entity("AliasClientDb.Item", b =>
{
b.HasOne("AliasClientDb.Folder", "Folder")
.WithMany("Items")
.HasForeignKey("FolderId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("AliasClientDb.Logo", "Logo")
.WithMany("Items")
.HasForeignKey("LogoId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Folder");
b.Navigation("Logo");
});
modelBuilder.Entity("AliasClientDb.ItemTag", b =>
{
b.HasOne("AliasClientDb.Item", "Item")
.WithMany("ItemTags")
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("AliasClientDb.Tag", "Tag")
.WithMany("ItemTags")
.HasForeignKey("TagId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Item");
b.Navigation("Tag");
});
modelBuilder.Entity("AliasClientDb.Passkey", b =>
{
b.HasOne("AliasClientDb.Item", "Item")
.WithMany("Passkeys")
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Item");
});
modelBuilder.Entity("AliasClientDb.TotpCode", b =>
{
b.HasOne("AliasClientDb.Item", "Item")
.WithMany("TotpCodes")
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Item");
});
modelBuilder.Entity("AliasClientDb.FieldDefinition", b =>
{
b.Navigation("FieldHistories");
b.Navigation("FieldValues");
});
modelBuilder.Entity("AliasClientDb.Folder", b =>
{
b.Navigation("ChildFolders");
b.Navigation("Items");
});
modelBuilder.Entity("AliasClientDb.Item", b =>
{
b.Navigation("Attachments");
b.Navigation("FieldValues");
b.Navigation("ItemTags");
b.Navigation("Passkeys");
b.Navigation("TotpCodes");
});
modelBuilder.Entity("AliasClientDb.Logo", b =>
{
b.Navigation("Items");
});
modelBuilder.Entity("AliasClientDb.Tag", b =>
{
b.Navigation("ItemTags");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,30 @@
// <auto-generated>
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AliasClientDb.Migrations
{
/// <inheritdoc />
public partial class _172AddDeletedAtToItem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "DeletedAt",
table: "Items",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DeletedAt",
table: "Items");
}
}
}

View File

@@ -262,6 +262,9 @@ namespace AliasClientDb.Migrations
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<Guid?>("FolderId")
.HasColumnType("TEXT");

View File

@@ -1172,3 +1172,11 @@ COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251204142538_1.7.1-MakeFieldHistoryFlexible', '9.0.4');
BEGIN TRANSACTION;
ALTER TABLE "Items" ADD "DeletedAt" TEXT NULL;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251205212831_1.7.2-AddDeletedAtToItem', '9.0.4');
COMMIT;

View File

@@ -0,0 +1,8 @@
BEGIN TRANSACTION;
ALTER TABLE "Items" ADD "DeletedAt" TEXT NULL;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251205212831_1.7.2-AddDeletedAtToItem', '9.0.4');
COMMIT;

View File

@@ -1178,6 +1178,14 @@ COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251204142538_1.7.1-MakeFieldHistoryFlexible', '9.0.4');
BEGIN TRANSACTION;
ALTER TABLE "Items" ADD "DeletedAt" TEXT NULL;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251205212831_1.7.2-AddDeletedAtToItem', '9.0.4');
COMMIT;
`;
/**
* Individual migration SQL scripts
@@ -2291,4 +2299,11 @@ COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251204142538_1.7.1-MakeFieldHistoryFlexible', '9.0.4');`,
13: `BEGIN TRANSACTION;
ALTER TABLE "Items" ADD "DeletedAt" TEXT NULL;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20251205212831_1.7.2-AddDeletedAtToItem', '9.0.4');
COMMIT;`,
};

View File

@@ -102,4 +102,11 @@ export const VAULT_VERSIONS: VaultVersion[] = [
releaseVersion: '0.26.0',
compatibleUpToVersion: '0.26.0',
},
{
revision: 14,
version: '1.7.2',
description: 'Add DeletedAt to Item for Recently Deleted support',
releaseVersion: '0.26.0',
compatibleUpToVersion: '0.26.0',
},
];