mirror of
https://github.com/lanedirt/AliasVault.git
synced 2025-12-05 19:07:26 -06:00
Compare commits
4 Commits
14591f55a1
...
864290e619
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
864290e619 | ||
|
|
65977c1544 | ||
|
|
dbc5911257 | ||
|
|
5d7a59f3b5 |
@@ -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 },
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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} > </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;
|
||||
@@ -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')}
|
||||
|
||||
@@ -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('/');
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
675
apps/server/Databases/AliasClientDb/Migrations/20251205212831_1.7.2-AddDeletedAtToItem.Designer.cs
generated
Normal file
675
apps/server/Databases/AliasClientDb/Migrations/20251205212831_1.7.2-AddDeletedAtToItem.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;`,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user