Refactor browser extension sqliteclient to use custom ORM similar to .NET EF repository pattern (#1404)

This commit is contained in:
Leendert de Borst
2025-12-15 23:10:16 +01:00
parent 23d72ef4bf
commit 21e04571b3
32 changed files with 2887 additions and 2381 deletions

View File

@@ -24,6 +24,7 @@ import type {
WebAuthnPublicKeyGetPayload
} from '@/utils/passkey/types';
import { SqliteClient } from '@/utils/SqliteClient';
import type { PasskeyWithItem } from '@/utils/db/mappers/PasskeyMapper';
import { browser, storage } from '#imports';
@@ -232,7 +233,7 @@ async function checkForMatchingPasskeys(publicKey: any, origin: string): Promise
const rpId = publicKey.rpId || new URL(origin).hostname;
// Get passkeys for this rpId
const passkeys = sqliteClient.getPasskeysByRpId(rpId);
const passkeys = sqliteClient.passkeys.getByRpId(rpId);
// If allowCredentials is specified, filter by those specific credentials
if (publicKey.allowCredentials && publicKey.allowCredentials.length > 0) {
@@ -249,7 +250,7 @@ async function checkForMatchingPasskeys(publicKey: any, origin: string): Promise
);
// Check if we have any of the allowed credentials
const matchingPasskeys = passkeys.filter(pk => allowedGuids.has(pk.Id));
const matchingPasskeys = passkeys.filter((pk: PasskeyWithItem) => allowedGuids.has(pk.Id));
return matchingPasskeys.length > 0;
}

View File

@@ -2,6 +2,7 @@
import { storage } from 'wxt/utils/storage';
import type { EncryptionKeyDerivationParams } from '@/utils/dist/core/models/metadata';
import type { Item } from '@/utils/dist/core/models/vault';
import type { Vault, VaultResponse, VaultPostResponse } from '@/utils/dist/core/models/webapi';
import { EncryptionUtility } from '@/utils/EncryptionUtility';
import { SqliteClient } from '@/utils/SqliteClient';
@@ -299,7 +300,7 @@ export async function handleCreateItem(
const sqliteClient = await createVaultSqliteClient();
// Add the new item to the vault/database.
await sqliteClient.createItem(message.item, message.attachments || [], message.totpCodes || []);
await sqliteClient.items.create(message.item, message.attachments || [], message.totpCodes || []);
// Upload the new vault to the server.
await uploadNewVaultToServer(sqliteClient);
@@ -328,7 +329,7 @@ export async function handleGetFilteredItems(
try {
const sqliteClient = await createVaultSqliteClient();
const allItems = sqliteClient.getAllItems();
const allItems = sqliteClient.items.getAll();
const { filterItems, AutofillMatchingMode } = await import('@/utils/credentialMatcher/CredentialMatcher');
@@ -370,7 +371,7 @@ export async function handleGetSearchItems(
try {
const sqliteClient = await createVaultSqliteClient();
const allItems = sqliteClient.getAllItems();
const allItems = sqliteClient.items.getAll();
// If search term is empty, return empty array
if (!message.searchTerm || message.searchTerm.trim() === '') {
@@ -381,7 +382,7 @@ export async function handleGetSearchItems(
const { FieldKey } = await import('@/utils/dist/core/models/vault');
// Filter items by search term across multiple fields
const searchResults = allItems.filter(item => {
const searchResults = allItems.filter((item: Item) => {
// Search in item name
if (item.Name?.toLowerCase().includes(searchTerm)) {
return true;
@@ -396,14 +397,14 @@ export async function handleGetSearchItems(
FieldKey.AliasLastName
];
return item.Fields?.some(field => {
if (searchableFieldKeys.includes(field.FieldKey as any)) {
return item.Fields?.some((field: { FieldKey: string; Value: string | string[] }) => {
if ((searchableFieldKeys as string[]).includes(field.FieldKey)) {
const value = Array.isArray(field.Value) ? field.Value.join(' ') : field.Value;
return value?.toLowerCase().includes(searchTerm);
}
return false;
});
}).sort((a, b) => {
}).sort((a: Item, b: Item) => {
// Sort by name
return (a.Name ?? '').localeCompare(b.Name ?? '');
});
@@ -421,7 +422,7 @@ export async function handleGetSearchItems(
export async function getEmailAddressesForVault(
sqliteClient: SqliteClient
): Promise<string[]> {
const emailAddresses = sqliteClient.getAllEmailAddresses();
const emailAddresses = sqliteClient.items.getAllEmailAddresses();
// Get metadata from local: storage
const privateEmailDomains = await getItemWithFallback<string[]>('local:privateEmailDomains') ?? [];
@@ -439,7 +440,7 @@ export function handleGetDefaultEmailDomain(): Promise<stringResponse> {
return (async (): Promise<stringResponse> => {
try {
const sqliteClient = await createVaultSqliteClient();
const defaultEmailDomain = await sqliteClient.getDefaultEmailDomain();
const defaultEmailDomain = sqliteClient.settings.getDefaultEmailDomain();
return { success: true, value: defaultEmailDomain ?? undefined };
} catch (error) {
@@ -457,8 +458,8 @@ export async function handleGetDefaultIdentitySettings(
) : Promise<IdentitySettingsResponse> {
try {
const sqliteClient = await createVaultSqliteClient();
const language = await sqliteClient.getEffectiveIdentityLanguage();
const gender = sqliteClient.getDefaultIdentityGender();
const language = sqliteClient.settings.getEffectiveIdentityLanguage();
const gender = sqliteClient.settings.getDefaultIdentityGender();
return {
success: true,
@@ -480,7 +481,7 @@ export async function handleGetPasswordSettings(
) : Promise<messagePasswordSettingsResponse> {
try {
const sqliteClient = await createVaultSqliteClient();
const passwordSettings = sqliteClient.getPasswordSettings();
const passwordSettings = sqliteClient.settings.getPasswordSettings();
return { success: true, settings: passwordSettings };
} catch (error) {
@@ -623,7 +624,7 @@ async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<Vaul
const newVault: Vault = {
blob: encryptedVault,
createdAt: new Date().toISOString(),
credentialsCount: sqliteClient.getAllItems().length,
credentialsCount: sqliteClient.items.getAll().length,
currentRevisionNumber: serverRevision,
emailAddressList: emailAddresses,
updatedAt: new Date().toISOString(),

View File

@@ -57,7 +57,7 @@ const AttachmentBlock: React.FC<AttachmentBlockProps> = ({ itemId }) => {
}
try {
const attachmentList = dbContext.sqliteClient.getAttachmentsForItem(itemId);
const attachmentList = dbContext.sqliteClient.settings.getAttachmentsForItem(itemId);
setAttachments(attachmentList);
} catch (error) {
console.error('Error loading attachments:', error);

View File

@@ -49,7 +49,7 @@ const FieldBlock: React.FC<FieldBlockProps> = ({ field, itemId }) => {
useEffect(() => {
if (hasHistoryEnabled && itemId && dbContext?.sqliteClient) {
try {
const history = dbContext.sqliteClient.getFieldHistory(itemId, field.FieldKey);
const history = dbContext.sqliteClient.items.getFieldHistory(itemId, field.FieldKey);
setHistoryCount(history.length);
} catch (error) {
console.error('[FieldBlock] Error checking history:', error);

View File

@@ -48,7 +48,7 @@ const FieldHistoryModal: React.FC<FieldHistoryModalProps> = ({
try {
setLoading(true);
const historyRecords = dbContext.sqliteClient.getFieldHistory(itemId, fieldKey);
const historyRecords = dbContext.sqliteClient.items.getFieldHistory(itemId, fieldKey);
setHistory(historyRecords);
} catch (error) {
console.error('Error loading field history:', error);

View File

@@ -43,7 +43,7 @@ const PasskeyBlock: React.FC<PasskeyBlockProps> = ({ itemId }) => {
}
try {
const itemPasskeys = dbContext.sqliteClient.getPasskeysByItemId(itemId);
const itemPasskeys = dbContext.sqliteClient.passkeys.getByItemId(itemId);
setPasskeys(itemPasskeys);
} catch (err) {
console.error('Error loading passkeys:', err);

View File

@@ -35,7 +35,7 @@ const PasskeyEditor: React.FC<PasskeyEditorProps> = ({
}
try {
const itemPasskeys = dbContext.sqliteClient.getPasskeysByItemId(itemId);
const itemPasskeys = dbContext.sqliteClient.passkeys.getByItemId(itemId);
setPasskeys(itemPasskeys);
} catch (err) {
console.error('Error loading passkeys:', err);

View File

@@ -92,7 +92,7 @@ const TotpBlock: React.FC<TotpBlockProps> = ({ itemId }) => {
}
try {
const codes = dbContext.sqliteClient.getTotpCodesForItem(itemId);
const codes = dbContext.sqliteClient.settings.getTotpCodesForItem(itemId);
setTotpCodes(codes);
} catch (error) {
console.error('Error loading TOTP codes:', error);

View File

@@ -140,7 +140,7 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
// Loop through all emails and decrypt them locally
const decryptedEmails: MailboxEmail[] = await EncryptionUtility.decryptEmailList(
allMails,
dbContext.sqliteClient!.getAllEncryptionKeys()
dbContext.sqliteClient!.settings.getAllEncryptionKeys()
);
if (loading && decryptedEmails.length > 0) {

View File

@@ -60,7 +60,7 @@ const PasswordField: React.FC<IPasswordFieldProps> = ({
const loadSettings = async (): Promise<void> => {
try {
if (dbContext.sqliteClient) {
const settings = dbContext.sqliteClient.getPasswordSettings();
const settings = dbContext.sqliteClient.settings.getPasswordSettings();
setCurrentSettings(settings);
setIsLoaded(true);
}

View File

@@ -53,13 +53,13 @@ const useAliasGenerator = (): {
}
// Get effective identity language (smart default based on UI language if no explicit override)
const identityLanguage = await dbContext.sqliteClient.getEffectiveIdentityLanguage();
const identityLanguage = dbContext.sqliteClient.settings.getEffectiveIdentityLanguage();
// Initialize identity generator based on language
const identityGenerator = CreateIdentityGenerator(identityLanguage);
// Initialize password generator with settings from vault
const passwordSettings = dbContext.sqliteClient.getPasswordSettings();
const passwordSettings = dbContext.sqliteClient.settings.getPasswordSettings();
const passwordGenerator = CreatePasswordGenerator(passwordSettings);
return { identityGenerator, passwordGenerator };
@@ -78,17 +78,17 @@ const useAliasGenerator = (): {
const { identityGenerator, passwordGenerator } = await initializeGenerators();
// Get gender preference from database
const genderPreference = dbContext.sqliteClient.getDefaultIdentityGender();
const genderPreference = dbContext.sqliteClient.settings.getDefaultIdentityGender();
// Get age range preference and convert to birthdate options
const ageRange = dbContext.sqliteClient.getDefaultIdentityAgeRange();
const ageRange = dbContext.sqliteClient.settings.getDefaultIdentityAgeRange();
const birthdateOptions = convertAgeRangeToBirthdateOptions(ageRange);
// Generate identity with gender preference and birthdate options
const identity = identityGenerator.generateRandomIdentity(genderPreference, birthdateOptions);
const password = passwordGenerator.generateRandomPassword();
const defaultEmailDomain = await dbContext.sqliteClient.getDefaultEmailDomain();
const defaultEmailDomain = dbContext.sqliteClient.settings.getDefaultEmailDomain();
const email = defaultEmailDomain ? `${identity.emailPrefix}@${defaultEmailDomain}` : identity.emailPrefix;
const generatedData: GeneratedAliasData = {

View File

@@ -429,7 +429,7 @@ const ItemAddEdit: React.FC = () => {
// Load folders
if (dbContext?.sqliteClient) {
const allFolders = dbContext.sqliteClient.getAllFolders();
const allFolders = dbContext.sqliteClient.folders.getAll();
setFolders(allFolders);
}
@@ -453,12 +453,12 @@ const ItemAddEdit: React.FC = () => {
}
try {
const result = dbContext.sqliteClient.getItemById(id);
const result = dbContext.sqliteClient.items.getById(id);
if (result) {
setItem(result);
// Load folders
const allFolders = dbContext.sqliteClient.getAllFolders();
const allFolders = dbContext.sqliteClient.folders.getAll();
setFolders(allFolders);
// Initialize field values from existing fields
@@ -466,7 +466,7 @@ const ItemAddEdit: React.FC = () => {
const existingCustomFields: CustomFieldDefinition[] = [];
const fieldsWithValues = new Set<string>();
result.Fields.forEach(field => {
result.Fields.forEach((field) => {
initialValues[field.FieldKey] = field.Value;
// Track fields that have values so they stay visible even if cleared
fieldsWithValues.add(field.FieldKey);
@@ -488,17 +488,17 @@ const ItemAddEdit: React.FC = () => {
setInitiallyVisibleFields(fieldsWithValues);
// Load TOTP codes for this item
const itemTotpCodes = dbContext.sqliteClient.getTotpCodesForItem(id);
const itemTotpCodes = dbContext.sqliteClient.settings.getTotpCodesForItem(id);
setTotpCodes(itemTotpCodes);
setOriginalTotpCodeIds(itemTotpCodes.map(tc => tc.Id));
setOriginalTotpCodeIds(itemTotpCodes.map((tc) => tc.Id));
if (itemTotpCodes.length > 0) {
setShow2FA(true);
}
// Load attachments for this item
const itemAttachments = dbContext.sqliteClient.getAttachmentsForItem(id);
const itemAttachments = dbContext.sqliteClient.settings.getAttachmentsForItem(id);
setAttachments(itemAttachments);
setOriginalAttachmentIds(itemAttachments.map(a => a.Id));
setOriginalAttachmentIds(itemAttachments.map((a) => a.Id));
if (itemAttachments.length > 0) {
setShowAttachments(true);
}
@@ -720,7 +720,7 @@ const ItemAddEdit: React.FC = () => {
setLocalLoading(false);
if (isEditMode) {
await dbContext.sqliteClient!.updateItem(
await dbContext.sqliteClient!.items.update(
updatedItem,
originalAttachmentIds,
attachments,
@@ -731,11 +731,11 @@ const ItemAddEdit: React.FC = () => {
// Delete passkeys marked for deletion
if (passkeyIdsMarkedForDeletion.length > 0) {
for (const passkeyId of passkeyIdsMarkedForDeletion) {
await dbContext.sqliteClient!.deletePasskeyById(passkeyId);
await dbContext.sqliteClient!.passkeys.deleteById(passkeyId);
}
}
} else {
await dbContext.sqliteClient!.createItem(updatedItem, attachments, totpCodes);
await dbContext.sqliteClient!.items.create(updatedItem, attachments, totpCodes);
}
});
@@ -762,7 +762,7 @@ const ItemAddEdit: React.FC = () => {
try {
await executeVaultMutationAsync(async () => {
await dbContext.sqliteClient!.deleteItemById(item.Id);
await dbContext.sqliteClient!.items.trash(item.Id);
});
navigate('/items');

View File

@@ -59,7 +59,7 @@ const ItemDetails: React.FC = (): React.ReactElement => {
}
try {
const result = dbContext.sqliteClient.getItemById(id);
const result = dbContext.sqliteClient.items.getById(id);
if (result) {
setItem(result);
setIsInitialLoading(false);

View File

@@ -60,7 +60,7 @@ const EmailDetails: React.FC = (): React.ReactElement => {
const response = await webApi.get<Email>(`Email/${id}`);
// Decrypt email locally using public/private key pairs
const encryptionKeys = dbContext.sqliteClient.getAllEncryptionKeys();
const encryptionKeys = dbContext.sqliteClient.settings.getAllEncryptionKeys();
const decryptedEmail = await EncryptionUtility.decryptEmail(response, encryptionKeys);
setEmail(decryptedEmail);
} catch (err) {
@@ -111,7 +111,7 @@ const EmailDetails: React.FC = (): React.ReactElement => {
}
// Get encryption keys for decryption
const encryptionKeys = dbContext.sqliteClient.getAllEncryptionKeys();
const encryptionKeys = dbContext.sqliteClient.settings.getAllEncryptionKeys();
// Decrypt the attachment using raw bytes
const decryptedBytes = await EncryptionUtility.decryptAttachment(encryptedBytes, email, encryptionKeys);

View File

@@ -47,7 +47,7 @@ const EmailsList: React.FC = () => {
}
// Get unique email addresses from all credentials.
const emailAddresses = dbContext.sqliteClient.getAllEmailAddresses();
const emailAddresses = dbContext.sqliteClient.items.getAllEmailAddresses();
try {
// For now we only show the latest 50 emails. No pagination.
@@ -58,7 +58,7 @@ const EmailsList: React.FC = () => {
});
// Decrypt emails locally using private key associated with the email address.
const encryptionKeys = dbContext.sqliteClient.getAllEncryptionKeys();
const encryptionKeys = dbContext.sqliteClient.settings.getAllEncryptionKeys();
// Decrypt emails locally using public/private key pairs.
const decryptedEmails = await EncryptionUtility.decryptEmailList(data.mails, encryptionKeys);

View File

@@ -105,8 +105,8 @@ const ItemsList: React.FC = () => {
if (!currentFolderId || !dbContext?.sqliteClient) {
return null;
}
const folders = dbContext.sqliteClient.getAllFolders();
const folder = folders.find(f => f.Id === currentFolderId);
const folders = dbContext.sqliteClient.folders.getAll();
const folder = folders.find((f: { Id: string; Name: string }) => f.Id === currentFolderId);
return folder?.Name ?? null;
}, [currentFolderId, dbContext?.sqliteClient]);
@@ -154,11 +154,11 @@ const ItemsList: React.FC = () => {
}
await executeVaultMutationAsync(async () => {
await dbContext.sqliteClient!.createFolder(folderName, currentFolderId);
await dbContext.sqliteClient!.folders.create(folderName, currentFolderId);
});
// Refresh items to show the new folder
const results = dbContext.sqliteClient!.getAllItems();
const results = dbContext.sqliteClient!.items.getAll();
setItems(results);
}, [dbContext, currentFolderId, executeVaultMutationAsync]);
@@ -171,13 +171,13 @@ const ItemsList: React.FC = () => {
}
await executeVaultMutationAsync(async () => {
await dbContext.sqliteClient!.deleteFolder(currentFolderId);
await dbContext.sqliteClient!.folders.delete(currentFolderId);
});
// Refresh items list to reflect changes
const results = dbContext.sqliteClient!.getAllItems();
const results = dbContext.sqliteClient!.items.getAll();
setItems(results);
const deletedCount = dbContext.sqliteClient!.getRecentlyDeletedCount();
const deletedCount = dbContext.sqliteClient!.items.getRecentlyDeletedCount();
setRecentlyDeletedCount(deletedCount);
// Navigate back to root
@@ -193,13 +193,13 @@ const ItemsList: React.FC = () => {
}
await executeVaultMutationAsync(async () => {
await dbContext.sqliteClient!.deleteFolderWithContents(currentFolderId);
await dbContext.sqliteClient!.folders.deleteWithContents(currentFolderId);
});
// Refresh items list to reflect changes
const results = dbContext.sqliteClient!.getAllItems();
const results = dbContext.sqliteClient!.items.getAll();
setItems(results);
const deletedCount = dbContext.sqliteClient!.getRecentlyDeletedCount();
const deletedCount = dbContext.sqliteClient!.items.getRecentlyDeletedCount();
setRecentlyDeletedCount(deletedCount);
// Navigate back to root
@@ -284,10 +284,10 @@ const ItemsList: React.FC = () => {
const refreshItems = async () : Promise<void> => {
if (dbContext?.sqliteClient) {
setIsLoading(true);
const results = dbContext.sqliteClient?.getAllItems() ?? [];
const results = dbContext.sqliteClient?.items.getAll() ?? [];
setItems(results);
// Also get recently deleted count
const deletedCount = dbContext.sqliteClient?.getRecentlyDeletedCount() ?? 0;
const deletedCount = dbContext.sqliteClient?.items.getRecentlyDeletedCount() ?? 0;
setRecentlyDeletedCount(deletedCount);
setIsLoading(false);
setIsInitialLoading(false);
@@ -336,22 +336,22 @@ const ItemsList: React.FC = () => {
}
// Get all folders directly from the database
const allFolders = dbContext.sqliteClient.getAllFolders();
const allFolders = dbContext.sqliteClient.folders.getAll();
// Count items per folder
const folderCounts = new Map<string, number>();
items.forEach(item => {
items.forEach((item: Item) => {
if (item.FolderId) {
folderCounts.set(item.FolderId, (folderCounts.get(item.FolderId) || 0) + 1);
}
});
// Build result with counts
const result = allFolders.map(folder => ({
const result = allFolders.map((folder: { Id: string; Name: string }) => ({
id: folder.Id,
name: folder.Name,
itemCount: folderCounts.get(folder.Id) || 0
})).sort((a, b) => a.name.localeCompare(b.name));
})).sort((a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name));
return result;
};

View File

@@ -48,7 +48,7 @@ const RecentlyDeleted: React.FC = () => {
*/
const loadItems = useCallback(() => {
if (dbContext?.sqliteClient) {
const results = dbContext.sqliteClient.getRecentlyDeletedItems();
const results = dbContext.sqliteClient.items.getRecentlyDeleted();
setItems(results);
}
}, [dbContext?.sqliteClient]);
@@ -62,7 +62,7 @@ const RecentlyDeleted: React.FC = () => {
}
await executeVaultMutationAsync(async () => {
await dbContext.sqliteClient!.restoreItem(itemId);
await dbContext.sqliteClient!.items.restore(itemId);
});
loadItems();
@@ -77,7 +77,7 @@ const RecentlyDeleted: React.FC = () => {
}
await executeVaultMutationAsync(async () => {
await dbContext.sqliteClient!.permanentlyDeleteItem(selectedItemId);
await dbContext.sqliteClient!.items.permanentlyDelete(selectedItemId);
});
loadItems();
@@ -95,7 +95,7 @@ const RecentlyDeleted: React.FC = () => {
await executeVaultMutationAsync(async () => {
for (const item of items) {
await dbContext.sqliteClient!.permanentlyDeleteItem(item.Id);
await dbContext.sqliteClient!.items.permanentlyDelete(item.Id);
}
});

View File

@@ -63,27 +63,27 @@ const PasskeyAuthenticate: React.FC = () => {
// Get passkeys for this rpId from the vault
const rpId = data.publicKey.rpId || new URL(data.origin).hostname;
const passkeys = dbContext.sqliteClient!.getPasskeysByRpId(rpId);
const passkeys = dbContext.sqliteClient!.passkeys.getByRpId(rpId);
// Filter by allowCredentials if specified
let filteredPasskeys = passkeys;
if (data.publicKey.allowCredentials && data.publicKey.allowCredentials.length > 0) {
// Convert the RP's base64url credential IDs to GUIDs for comparison
const allowedGuids = new Set(
data.publicKey.allowCredentials.map(c => {
data.publicKey.allowCredentials.map((c: { id: string }) => {
try {
return PasskeyHelper.base64urlToGuid(c.id);
} catch (e) {
console.warn('Failed to convert credential ID to GUID:', c.id, e);
return null;
}
}).filter((id): id is string => id !== null)
}).filter((id: string | null): id is string => id !== null)
);
filteredPasskeys = passkeys.filter(pk => allowedGuids.has(pk.Id));
filteredPasskeys = passkeys.filter((pk: { Id: string }) => allowedGuids.has(pk.Id));
}
// Map to display format
setAvailablePasskeys(filteredPasskeys.map(pk => ({
setAvailablePasskeys(filteredPasskeys.map((pk: { Id: string; DisplayName: string; ServiceName?: string | null; RpId: string; Username?: string | null }) => ({
id: pk.Id,
displayName: pk.DisplayName,
serviceName: pk.ServiceName,
@@ -147,7 +147,7 @@ const PasskeyAuthenticate: React.FC = () => {
try {
// Get the stored passkey from vault
const storedPasskey = dbContext.sqliteClient.getPasskeyById(passkeyId);
const storedPasskey = dbContext.sqliteClient.passkeys.getById(passkeyId);
if (!storedPasskey) {
throw new Error(t('common.errors.unknownError'));
}

View File

@@ -84,7 +84,7 @@ const PasskeyCreate: React.FC = () => {
// Check for existing passkeys for this RP ID and user
if (dbContext.sqliteClient && data.publicKey?.rp?.id) {
const allPasskeysForRpId = dbContext.sqliteClient.getPasskeysByRpId(data.publicKey.rp.id);
const allPasskeysForRpId = dbContext.sqliteClient.passkeys.getByRpId(data.publicKey.rp.id);
/**
* Filter by user ID and/or username if provided
@@ -93,7 +93,7 @@ const PasskeyCreate: React.FC = () => {
let filtered = allPasskeysForRpId;
if (data.publicKey.user?.id || data.publicKey.user?.name) {
filtered = allPasskeysForRpId.filter(passkey => {
filtered = allPasskeysForRpId.filter((passkey) => {
/**
* Match by user handle if both are available
* The request has base64url encoded user.id, passkey has UserHandle as byte array
@@ -131,7 +131,7 @@ const PasskeyCreate: React.FC = () => {
// If no existing passkeys for this user, check for matching items
if (filtered.length === 0) {
// Get all items and filter for matches
const allItems = dbContext.sqliteClient.getAllItems();
const allItems = dbContext.sqliteClient.items.getAll();
/*
* Filter items that:
@@ -139,7 +139,7 @@ const PasskeyCreate: React.FC = () => {
* 2. Have username/password (are login items)
* 3. Don't already have a passkey
*/
const itemsWithoutPasskeys = allItems.filter(item => {
const itemsWithoutPasskeys = allItems.filter((item) => {
// Must have username or password to be a login item
const username = getFieldValue(item, FieldKey.LoginUsername);
const password = getFieldValue(item, FieldKey.LoginPassword);
@@ -313,19 +313,19 @@ const PasskeyCreate: React.FC = () => {
await executeVaultMutationAsync(async () => {
if (selectedPasskeyToReplace) {
// Replace existing passkey: update the item and passkey
const existingPasskey = dbContext.sqliteClient!.getPasskeyById(selectedPasskeyToReplace);
const existingPasskey = dbContext.sqliteClient!.passkeys.getById(selectedPasskeyToReplace);
if (existingPasskey) {
// Get existing item to preserve its data
const existingItem = dbContext.sqliteClient!.getItemById(existingPasskey.ItemId);
const existingItem = dbContext.sqliteClient!.items.getById(existingPasskey.ItemId);
if (existingItem) {
// Update the parent item with new favicon and user-provided display name
await dbContext.sqliteClient!.updateItem(
await dbContext.sqliteClient!.items.update(
{
...existingItem,
Name: displayName,
Logo: faviconLogo ?? existingItem.Logo,
Fields: [
...(existingItem.Fields || []).filter(f => f.FieldKey !== FieldKey.LoginUrl && f.FieldKey !== FieldKey.LoginUsername),
...(existingItem.Fields || []).filter((f) => f.FieldKey !== FieldKey.LoginUrl && f.FieldKey !== FieldKey.LoginUsername),
{ FieldKey: FieldKey.LoginUrl, Label: 'URL', FieldType: FieldTypes.URL, Value: request.origin, IsHidden: false, DisplayOrder: 0 },
{ FieldKey: FieldKey.LoginUsername, Label: 'Username', FieldType: FieldTypes.Text, Value: request.publicKey.user.name, IsHidden: false, DisplayOrder: 1 }
]
@@ -336,7 +336,7 @@ const PasskeyCreate: React.FC = () => {
}
// Delete the old passkey
await dbContext.sqliteClient!.deletePasskeyById(selectedPasskeyToReplace);
await dbContext.sqliteClient!.passkeys.deleteById(selectedPasskeyToReplace);
/**
* Create new passkey with same item
@@ -352,7 +352,7 @@ const PasskeyCreate: React.FC = () => {
}
}
await dbContext.sqliteClient!.createPasskey({
await dbContext.sqliteClient!.passkeys.create({
Id: newPasskeyGuid,
ItemId: existingPasskey.ItemId,
RpId: stored.rpId,
@@ -380,7 +380,7 @@ const PasskeyCreate: React.FC = () => {
}
}
await dbContext.sqliteClient!.createPasskey({
await dbContext.sqliteClient!.passkeys.create({
Id: newPasskeyGuid,
ItemId: selectedItemToAttach,
RpId: stored.rpId,
@@ -406,7 +406,7 @@ const PasskeyCreate: React.FC = () => {
UpdatedAt: new Date().toISOString()
};
const itemId = await dbContext.sqliteClient!.createItem(newItem, []);
const itemId = await dbContext.sqliteClient!.items.create(newItem, []);
/**
* Create the Passkey linked to the item
@@ -423,7 +423,7 @@ const PasskeyCreate: React.FC = () => {
}
}
await dbContext.sqliteClient!.createPasskey({
await dbContext.sqliteClient!.passkeys.create({
Id: newPasskeyGuid,
ItemId: itemId,
RpId: stored.rpId,

View File

@@ -111,7 +111,7 @@ export class FaviconService {
return false;
}
return sqliteClient.hasLogoForSource(source);
return sqliteClient.logos.hasLogoForSource(source);
}
/**
@@ -142,7 +142,7 @@ export class FaviconService {
}
// Check if logo already exists (deduplication)
if (sqliteClient.hasLogoForSource(source)) {
if (sqliteClient.logos.hasLogoForSource(source)) {
return { success: false, skipped: true };
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,137 @@
import type { Database } from 'sql.js';
import * as dateFormatter from '@/utils/dateFormatter';
export type SqliteBindValue = string | number | null | Uint8Array;
/**
* Interface for the core database operations needed by repositories.
*/
export interface IDatabaseClient {
getDb(): Database | null;
executeQuery<T>(query: string, params?: SqliteBindValue[]): T[];
executeUpdate(query: string, params?: SqliteBindValue[]): number;
beginTransaction(): void;
commitTransaction(): Promise<void>;
rollbackTransaction(): void;
}
/**
* Base repository class with common database operations.
* Provides transaction handling, soft delete, and other shared functionality.
*/
export abstract class BaseRepository {
constructor(protected client: IDatabaseClient) {}
/**
* Execute a function within a transaction.
* Automatically handles begin, commit, and rollback.
* @param fn - The function to execute within the transaction
* @returns The result of the function
*/
protected async withTransaction<T>(fn: () => T | Promise<T>): Promise<T> {
this.client.beginTransaction();
try {
const result = await fn();
await this.client.commitTransaction();
return result;
} catch (error) {
this.client.rollbackTransaction();
throw error;
}
}
/**
* Soft delete a record by setting IsDeleted = 1.
* @param table - The table name
* @param id - The record ID
* @returns Number of rows affected
*/
protected softDelete(table: string, id: string): number {
const now = dateFormatter.now();
return this.client.executeUpdate(
`UPDATE ${table} SET IsDeleted = 1, UpdatedAt = ? WHERE Id = ?`,
[now, id]
);
}
/**
* Soft delete records by a foreign key.
* @param table - The table name
* @param foreignKey - The foreign key column name
* @param foreignKeyValue - The foreign key value
* @returns Number of rows affected
*/
protected softDeleteByForeignKey(table: string, foreignKey: string, foreignKeyValue: string): number {
const now = dateFormatter.now();
return this.client.executeUpdate(
`UPDATE ${table} SET IsDeleted = 1, UpdatedAt = ? WHERE ${foreignKey} = ?`,
[now, foreignKeyValue]
);
}
/**
* Hard delete a record permanently.
* @param table - The table name
* @param id - The record ID
* @returns Number of rows affected
*/
protected hardDelete(table: string, id: string): number {
return this.client.executeUpdate(`DELETE FROM ${table} WHERE Id = ?`, [id]);
}
/**
* Hard delete records by a foreign key.
* @param table - The table name
* @param foreignKey - The foreign key column name
* @param foreignKeyValue - The foreign key value
* @returns Number of rows affected
*/
protected hardDeleteByForeignKey(table: string, foreignKey: string, foreignKeyValue: string): number {
return this.client.executeUpdate(
`DELETE FROM ${table} WHERE ${foreignKey} = ?`,
[foreignKeyValue]
);
}
/**
* Check if a table exists in the database.
* @param tableName - The name of the table to check
* @returns True if the table exists
*/
protected tableExists(tableName: string): boolean {
const results = this.client.executeQuery<{ name: string }>(
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
[tableName]
);
return results.length > 0;
}
/**
* Generate a new UUID in uppercase format.
* @returns A new UUID string
*/
protected generateId(): string {
return crypto.randomUUID().toUpperCase();
}
/**
* Get the current timestamp in the standard format.
* @returns Current timestamp string
*/
protected now(): string {
return dateFormatter.now();
}
/**
* Build a parameterized IN clause for SQL queries.
* @param values - Array of values for the IN clause
* @returns Object with placeholders string and values array
*/
protected buildInClause(values: string[]): { placeholders: string; values: string[] } {
return {
placeholders: values.map(() => '?').join(','),
values
};
}
}

View File

@@ -0,0 +1,22 @@
// Base
export { BaseRepository, type IDatabaseClient, type SqliteBindValue } from './BaseRepository';
// Mappers
export { FieldMapper, type FieldRow } from './mappers/FieldMapper';
export { ItemMapper, type ItemRow, type TagRow } from './mappers/ItemMapper';
export { PasskeyMapper, type PasskeyRow, type PasskeyWithItemRow, type PasskeyWithItem } from './mappers/PasskeyMapper';
// Queries
export {
ItemQueries,
FieldValueQueries,
FieldDefinitionQueries,
FieldHistoryQueries
} from './queries/ItemQueries';
// Repositories
export { ItemRepository } from './repositories/ItemRepository';
export { PasskeyRepository } from './repositories/PasskeyRepository';
export { FolderRepository, type Folder } from './repositories/FolderRepository';
export { SettingsRepository } from './repositories/SettingsRepository';
export { LogoRepository } from './repositories/LogoRepository';

View File

@@ -0,0 +1,191 @@
import type { ItemField, FieldType } from '@/utils/dist/core/models/vault';
import { FieldTypes, getSystemField } from '@/utils/dist/core/models/vault';
/**
* Raw field row from database query.
*/
export interface FieldRow {
ItemId: string;
FieldKey: string | null;
FieldDefinitionId: string | null;
CustomLabel: string | null;
CustomFieldType: string | null;
CustomIsHidden: number | null;
Value: string;
DisplayOrder: number;
}
/**
* Intermediate field representation before grouping.
*/
interface ProcessedField {
ItemId: string;
FieldKey: string;
Label: string;
FieldType: string;
IsHidden: number;
Value: string;
DisplayOrder: number;
}
/**
* Mapper class for processing database field rows into ItemField objects.
* Handles both system fields (with FieldKey) and custom fields (with FieldDefinitionId).
*/
export class FieldMapper {
/**
* Process raw field rows from database into a map of ItemId -> ItemField[].
* Handles system vs custom fields and multi-value field grouping.
* @param rows - Raw field rows from database
* @returns Map of ItemId to array of ItemField objects
*/
public static processFieldRows(rows: FieldRow[]): Map<string, ItemField[]> {
// First, convert rows to processed fields with proper metadata
const processedFields = rows.map(row => this.processFieldRow(row));
// Group by ItemId and FieldKey (to handle multi-value fields)
const fieldsByItem = new Map<string, ItemField[]>();
const fieldValuesByKey = new Map<string, string[]>();
for (const field of processedFields) {
const key = `${field.ItemId}_${field.FieldKey}`;
// Accumulate values for the same field
if (!fieldValuesByKey.has(key)) {
fieldValuesByKey.set(key, []);
}
fieldValuesByKey.get(key)!.push(field.Value);
// Create ItemField entry only once per unique FieldKey per item
if (!fieldsByItem.has(field.ItemId)) {
fieldsByItem.set(field.ItemId, []);
}
const itemFields = fieldsByItem.get(field.ItemId)!;
const existingField = itemFields.find(f => f.FieldKey === field.FieldKey);
if (!existingField) {
itemFields.push({
FieldKey: field.FieldKey,
Label: field.Label,
FieldType: field.FieldType as FieldType,
Value: '', // Will be set below
IsHidden: field.IsHidden === 1,
DisplayOrder: field.DisplayOrder
});
}
}
// Set Values (single value or array for multi-value fields)
for (const [itemId, fields] of fieldsByItem) {
for (const field of fields) {
const key = `${itemId}_${field.FieldKey}`;
const values = fieldValuesByKey.get(key) || [];
if (values.length === 1) {
field.Value = values[0];
} else {
field.Value = values;
}
}
}
return fieldsByItem;
}
/**
* Process a single field row to extract proper metadata.
* System fields use FieldKey and get metadata from SystemFieldRegistry.
* Custom fields use FieldDefinitionId and get metadata from the row.
* @param row - Raw field row
* @returns Processed field with proper metadata
*/
private static processFieldRow(row: FieldRow): ProcessedField {
if (row.FieldKey) {
// System field: has FieldKey, get metadata from SystemFieldRegistry
const systemField = getSystemField(row.FieldKey);
return {
ItemId: row.ItemId,
FieldKey: row.FieldKey,
Label: row.FieldKey, // Use FieldKey as label; UI layer translates via fieldLabels.*
FieldType: systemField?.FieldType || FieldTypes.Text,
IsHidden: systemField?.IsHidden ? 1 : 0,
Value: row.Value,
DisplayOrder: row.DisplayOrder
};
} else {
// Custom field: has FieldDefinitionId, get metadata from FieldDefinitions
return {
ItemId: row.ItemId,
FieldKey: row.FieldDefinitionId || '', // Use FieldDefinitionId as the key for custom fields
Label: row.CustomLabel || '',
FieldType: row.CustomFieldType || FieldTypes.Text,
IsHidden: row.CustomIsHidden || 0,
Value: row.Value,
DisplayOrder: row.DisplayOrder
};
}
}
/**
* Process field rows for a single item (without ItemId in result).
* Used when fetching a single item by ID.
* @param rows - Raw field rows for a single item
* @returns Array of ItemField objects
*/
public static processFieldRowsForSingleItem(rows: Omit<FieldRow, 'ItemId'>[]): ItemField[] {
const fieldValuesByKey = new Map<string, string[]>();
const uniqueFields = new Map<string, {
FieldKey: string;
Label: string;
FieldType: string;
IsHidden: number;
DisplayOrder: number;
}>();
for (const row of rows) {
const fieldKey = row.FieldKey || row.FieldDefinitionId || '';
// Accumulate values
if (!fieldValuesByKey.has(fieldKey)) {
fieldValuesByKey.set(fieldKey, []);
}
fieldValuesByKey.get(fieldKey)!.push(row.Value);
// Store field metadata (only once per FieldKey)
if (!uniqueFields.has(fieldKey)) {
if (row.FieldKey) {
// System field
const systemField = getSystemField(row.FieldKey);
uniqueFields.set(fieldKey, {
FieldKey: row.FieldKey,
Label: row.FieldKey, // Use FieldKey as label; UI layer translates via fieldLabels.*
FieldType: systemField?.FieldType || FieldTypes.Text,
IsHidden: systemField?.IsHidden ? 1 : 0,
DisplayOrder: row.DisplayOrder
});
} else {
// Custom field
uniqueFields.set(fieldKey, {
FieldKey: fieldKey,
Label: row.CustomLabel || '',
FieldType: row.CustomFieldType || FieldTypes.Text,
IsHidden: row.CustomIsHidden || 0,
DisplayOrder: row.DisplayOrder
});
}
}
}
// Build fields array with proper single/multi values
return Array.from(uniqueFields.entries()).map(([fieldKey, metadata]) => {
const values = fieldValuesByKey.get(fieldKey) || [];
return {
...metadata,
FieldType: metadata.FieldType as FieldType,
Value: values.length === 1 ? values[0] : values,
IsHidden: metadata.IsHidden === 1
};
});
}
}

View File

@@ -0,0 +1,149 @@
import type { Item, ItemField, ItemTagRef, ItemType } from '@/utils/dist/core/models/vault';
/**
* Item with optional DeletedAt field for recently deleted items.
*/
export type ItemWithDeletedAt = Item & { DeletedAt?: string };
/**
* Raw item row from database query.
*/
export interface ItemRow {
Id: string;
Name: string;
ItemType: string;
FolderId: string | null;
FolderPath: string | null;
Logo: Uint8Array | null;
HasPasskey: number;
HasAttachment: number;
HasTotp: number;
CreatedAt: string;
UpdatedAt: string;
DeletedAt?: string | null;
}
/**
* Raw tag row from database query.
*/
export interface TagRow {
ItemId: string;
Id: string;
Name: string;
Color: string | null;
}
/**
* Mapper class for converting database rows to Item objects.
*/
export class ItemMapper {
/**
* Map a single database row to an Item object.
* @param row - Raw item row from database
* @param fields - Processed fields for this item
* @param tags - Tags for this item
* @returns Item object
*/
public static mapRow(
row: ItemRow,
fields: ItemField[] = [],
tags: ItemTagRef[] = []
): Item {
return {
Id: row.Id,
Name: row.Name,
ItemType: row.ItemType as ItemType,
Logo: row.Logo ?? undefined,
FolderId: row.FolderId,
FolderPath: row.FolderPath || null,
Tags: tags,
Fields: fields,
HasPasskey: row.HasPasskey === 1,
HasAttachment: row.HasAttachment === 1,
HasTotp: row.HasTotp === 1,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt
};
}
/**
* Map multiple database rows to Item objects with their fields and tags.
* @param rows - Raw item rows from database
* @param fieldsByItem - Map of ItemId to array of fields
* @param tagsByItem - Map of ItemId to array of tags
* @returns Array of Item objects
*/
public static mapRows(
rows: ItemRow[],
fieldsByItem: Map<string, ItemField[]>,
tagsByItem: Map<string, ItemTagRef[]>
): Item[] {
return rows.map(row => this.mapRow(
row,
fieldsByItem.get(row.Id) || [],
tagsByItem.get(row.Id) || []
));
}
/**
* Group tag rows by ItemId into a map.
* @param tagRows - Raw tag rows from database
* @returns Map of ItemId to array of ItemTagRef
*/
public static groupTagsByItem(tagRows: TagRow[]): Map<string, ItemTagRef[]> {
const tagsByItem = new Map<string, ItemTagRef[]>();
for (const tag of tagRows) {
if (!tagsByItem.has(tag.ItemId)) {
tagsByItem.set(tag.ItemId, []);
}
tagsByItem.get(tag.ItemId)!.push({
Id: tag.Id,
Name: tag.Name,
Color: tag.Color || undefined
});
}
return tagsByItem;
}
/**
* Map tag rows to ItemTagRef array (for single item).
* @param tagRows - Raw tag rows without ItemId
* @returns Array of ItemTagRef
*/
public static mapTagRows(tagRows: Omit<TagRow, 'ItemId'>[]): ItemTagRef[] {
return tagRows.map(tag => ({
Id: tag.Id,
Name: tag.Name,
Color: tag.Color || undefined
}));
}
/**
* Map a single item row for recently deleted items (includes DeletedAt).
* @param row - Raw item row with DeletedAt
* @param fields - Processed fields for this item
* @returns Item object with DeletedAt
*/
public static mapDeletedItemRow(
row: ItemRow & { DeletedAt: string },
fields: ItemField[] = []
): ItemWithDeletedAt {
return {
Id: row.Id,
Name: row.Name,
ItemType: row.ItemType as ItemType,
Logo: row.Logo ? new Uint8Array(row.Logo) : undefined,
FolderId: row.FolderId,
FolderPath: row.FolderPath,
DeletedAt: row.DeletedAt,
HasPasskey: row.HasPasskey === 1,
HasAttachment: row.HasAttachment === 1,
HasTotp: row.HasTotp === 1,
Fields: fields,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt
};
}
}

View File

@@ -0,0 +1,102 @@
import type { Passkey } from '@/utils/dist/core/models/vault';
/**
* Raw passkey row from database query.
*/
export interface PasskeyRow {
Id: string;
ItemId: string;
RpId: string;
UserHandle: Uint8Array | null;
PublicKey: string;
PrivateKey: string;
DisplayName: string;
PrfKey: Uint8Array | null;
AdditionalData: string | null;
CreatedAt: string;
UpdatedAt: string;
IsDeleted: number;
}
/**
* Extended passkey row with item information.
*/
export interface PasskeyWithItemRow extends PasskeyRow {
Username?: string | null;
ServiceName?: string | null;
}
/**
* Passkey with optional item information.
*/
export type PasskeyWithItem = Passkey & {
Username?: string | null;
ServiceName?: string | null;
};
/**
* Convert a date string to epoch milliseconds.
* @param dateString - Date string in ISO format
* @returns Epoch milliseconds
*/
function dateToEpoch(dateString: string): number {
return new Date(dateString).getTime();
}
/**
* Mapper class for converting database rows to Passkey objects.
*/
export class PasskeyMapper {
/**
* Map a single database row to a Passkey object.
* @param row - Raw passkey row from database
* @returns Passkey object
*/
public static mapRow(row: PasskeyRow): Passkey {
return {
Id: row.Id,
ItemId: row.ItemId,
RpId: row.RpId,
UserHandle: row.UserHandle ?? undefined,
PublicKey: row.PublicKey,
PrivateKey: row.PrivateKey,
DisplayName: row.DisplayName,
PrfKey: row.PrfKey ?? undefined,
AdditionalData: row.AdditionalData,
CreatedAt: dateToEpoch(row.CreatedAt),
UpdatedAt: dateToEpoch(row.UpdatedAt),
IsDeleted: row.IsDeleted
};
}
/**
* Map a single database row to a Passkey with item information.
* @param row - Raw passkey row with item data
* @returns Passkey with Username and ServiceName
*/
public static mapRowWithItem(row: PasskeyWithItemRow): PasskeyWithItem {
return {
...this.mapRow(row),
Username: row.Username,
ServiceName: row.ServiceName
};
}
/**
* Map multiple database rows to Passkey objects.
* @param rows - Raw passkey rows from database
* @returns Array of Passkey objects
*/
public static mapRows(rows: PasskeyRow[]): Passkey[] {
return rows.map(row => this.mapRow(row));
}
/**
* Map multiple database rows to Passkey objects with item information.
* @param rows - Raw passkey rows with item data
* @returns Array of Passkey with Username and ServiceName
*/
public static mapRowsWithItem(rows: PasskeyWithItemRow[]): PasskeyWithItem[] {
return rows.map(row => this.mapRowWithItem(row));
}
}

View File

@@ -0,0 +1,352 @@
import { FieldKey } from '@/utils/dist/core/models/vault';
/**
* SQL query constants for Item operations.
* Centralizes all item-related queries to avoid duplication.
*/
export class ItemQueries {
/**
* Base SELECT for items with common fields.
* Includes LEFT JOIN to Logos and Folders, and subqueries for HasPasskey/HasAttachment/HasTotp.
*/
public static readonly BASE_SELECT = `
SELECT DISTINCT
i.Id,
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,
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`;
/**
* Get all active items (not deleted, not in trash).
*/
public static readonly GET_ALL_ACTIVE = `
${ItemQueries.BASE_SELECT}
WHERE i.IsDeleted = 0 AND i.DeletedAt IS NULL
ORDER BY i.CreatedAt DESC`;
/**
* Get a single item by ID.
*/
public static readonly GET_BY_ID = `
SELECT
i.Id,
i.Name,
i.ItemType,
i.FolderId,
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,
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
WHERE i.Id = ? AND i.IsDeleted = 0`;
/**
* Get all recently deleted items (in trash).
*/
public static readonly GET_RECENTLY_DELETED = `
${ItemQueries.BASE_SELECT},
i.DeletedAt
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`;
/**
* Count of recently deleted items.
*/
public static readonly COUNT_RECENTLY_DELETED = `
SELECT COUNT(*) as count
FROM Items
WHERE IsDeleted = 0 AND DeletedAt IS NOT NULL`;
/**
* Get field values for multiple items.
* @param itemCount - Number of items (for placeholder generation)
* @returns Query with placeholders
*/
public static getFieldValuesForItems(itemCount: number): string {
const placeholders = Array(itemCount).fill('?').join(',');
return `
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 (${placeholders})
AND fv.IsDeleted = 0
ORDER BY fv.ItemId, fv.Weight`;
}
/**
* Get field values for a single item.
*/
public static readonly GET_FIELD_VALUES_FOR_ITEM = `
SELECT
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 = ? AND fv.IsDeleted = 0
ORDER BY fv.Weight`;
/**
* Get tags for multiple items.
* @param itemCount - Number of items (for placeholder generation)
* @returns Query with placeholders
*/
public static getTagsForItems(itemCount: number): string {
const placeholders = Array(itemCount).fill('?').join(',');
return `
SELECT
it.ItemId,
t.Id,
t.Name,
t.Color
FROM ItemTags it
INNER JOIN Tags t ON it.TagId = t.Id
WHERE it.ItemId IN (${placeholders})
AND it.IsDeleted = 0
AND t.IsDeleted = 0
ORDER BY t.DisplayOrder, t.Name`;
}
/**
* Get tags for a single item.
*/
public static readonly GET_TAGS_FOR_ITEM = `
SELECT
t.Id,
t.Name,
t.Color
FROM ItemTags it
INNER JOIN Tags t ON it.TagId = t.Id
WHERE it.ItemId = ? AND it.IsDeleted = 0 AND t.IsDeleted = 0
ORDER BY t.DisplayOrder, t.Name`;
/**
* Insert a new item.
*/
public static readonly INSERT_ITEM = `
INSERT INTO Items (Id, Name, ItemType, LogoId, FolderId, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`;
/**
* Update an existing item.
*/
public static readonly UPDATE_ITEM = `
UPDATE Items
SET Name = ?,
ItemType = ?,
FolderId = ?,
LogoId = COALESCE(?, LogoId),
UpdatedAt = ?
WHERE Id = ?`;
/**
* Move item to trash (set DeletedAt).
*/
public static readonly TRASH_ITEM = `
UPDATE Items
SET DeletedAt = ?,
UpdatedAt = ?
WHERE Id = ? AND IsDeleted = 0`;
/**
* Restore item from trash (clear DeletedAt).
*/
public static readonly RESTORE_ITEM = `
UPDATE Items
SET DeletedAt = NULL,
UpdatedAt = ?
WHERE Id = ? AND IsDeleted = 0 AND DeletedAt IS NOT NULL`;
/**
* Convert item to tombstone for permanent deletion.
*/
public static readonly TOMBSTONE_ITEM = `
UPDATE Items
SET IsDeleted = 1,
Name = NULL,
LogoId = NULL,
FolderId = NULL,
UpdatedAt = ?
WHERE Id = ?`;
/**
* Get LogoId for an item.
*/
public static readonly GET_LOGO_ID = `
SELECT LogoId FROM Items WHERE Id = ?`;
/**
* Get all unique email addresses from field values.
*/
public static readonly GET_ALL_EMAIL_ADDRESSES = `
SELECT DISTINCT fv.Value as Email
FROM FieldValues fv
INNER JOIN Items i ON fv.ItemId = i.Id
WHERE fv.FieldKey = ?
AND fv.Value IS NOT NULL
AND fv.Value != ''
AND fv.IsDeleted = 0
AND i.IsDeleted = 0`;
}
/**
* SQL query constants for FieldValue operations.
*/
export class FieldValueQueries {
/**
* Get existing field values for an item.
*/
public static readonly GET_EXISTING_FOR_ITEM = `
SELECT Id, FieldKey, FieldDefinitionId, Value
FROM FieldValues
WHERE ItemId = ? AND IsDeleted = 0`;
/**
* Insert a new field value.
*/
public static readonly INSERT = `
INSERT INTO FieldValues (Id, ItemId, FieldDefinitionId, FieldKey, Value, Weight, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
/**
* Update an existing field value.
*/
public static readonly UPDATE = `
UPDATE FieldValues
SET Value = ?,
Weight = ?,
UpdatedAt = ?
WHERE Id = ?`;
/**
* Soft delete a field value.
*/
public static readonly SOFT_DELETE = `
UPDATE FieldValues
SET IsDeleted = 1,
UpdatedAt = ?
WHERE Id = ?`;
/**
* Get existing field values for history tracking.
*/
public static readonly GET_FOR_HISTORY = `
SELECT FieldKey, Value
FROM FieldValues
WHERE ItemId = ? AND IsDeleted = 0 AND FieldKey IS NOT NULL`;
}
/**
* SQL query constants for FieldDefinition operations.
*/
export class FieldDefinitionQueries {
/**
* Check if a field definition exists.
*/
public static readonly EXISTS = `
SELECT Id FROM FieldDefinitions WHERE Id = ?`;
/**
* Check if a field definition exists and is not deleted.
*/
public static readonly EXISTS_ACTIVE = `
SELECT Id FROM FieldDefinitions WHERE Id = ? AND IsDeleted = 0`;
/**
* Insert a new field definition.
*/
public static readonly INSERT = `
INSERT INTO FieldDefinitions (Id, FieldType, Label, IsMultiValue, IsHidden, EnableHistory, Weight, ApplicableToTypes, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
/**
* Update an existing field definition.
*/
public static readonly UPDATE = `
UPDATE FieldDefinitions
SET Label = ?,
FieldType = ?,
IsHidden = ?,
Weight = ?,
UpdatedAt = ?
WHERE Id = ?`;
}
/**
* SQL query constants for FieldHistory operations.
*/
export class FieldHistoryQueries {
/**
* Insert a history record.
*/
public static readonly INSERT = `
INSERT INTO FieldHistories (Id, ItemId, FieldDefinitionId, FieldKey, ValueSnapshot, ChangedAt, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
/**
* Get history records for a field.
*/
public static readonly GET_FOR_FIELD = `
SELECT
Id,
ItemId,
FieldKey,
ValueSnapshot,
ChangedAt,
CreatedAt,
UpdatedAt
FROM FieldHistories
WHERE ItemId = ? AND FieldKey = ? AND IsDeleted = 0
ORDER BY ChangedAt DESC
LIMIT ?`;
/**
* Get all history records for pruning.
*/
public static readonly GET_FOR_PRUNING = `
SELECT Id, ChangedAt
FROM FieldHistories
WHERE ItemId = ? AND FieldKey = ? AND IsDeleted = 0
ORDER BY ChangedAt DESC`;
/**
* Soft delete old history records.
* @param count - Number of records to delete
* @returns Query with placeholders
*/
public static softDeleteOld(count: number): string {
const placeholders = Array(count).fill('?').join(',');
return `
UPDATE FieldHistories
SET IsDeleted = 1, UpdatedAt = ?
WHERE Id IN (${placeholders})`;
}
}

View File

@@ -0,0 +1,228 @@
import { BaseRepository } from '../BaseRepository';
/**
* Folder entity type.
*/
export interface Folder {
Id: string;
Name: string;
ParentFolderId: string | null;
Weight: number;
}
/**
* SQL query constants for Folder operations.
*/
const FolderQueries = {
/**
* Get all active folders.
*/
GET_ALL: `
SELECT Id, Name, ParentFolderId, Weight
FROM Folders
WHERE IsDeleted = 0
ORDER BY Weight, Name`,
/**
* Get folder by ID.
*/
GET_BY_ID: `
SELECT Id, Name, ParentFolderId
FROM Folders
WHERE Id = ? AND IsDeleted = 0`,
/**
* Insert a new folder.
*/
INSERT: `
INSERT INTO Folders (Id, Name, ParentFolderId, Weight, IsDeleted, CreatedAt, UpdatedAt)
VALUES (?, ?, ?, 0, 0, ?, ?)`,
/**
* Update folder name.
*/
UPDATE_NAME: `
UPDATE Folders
SET Name = ?,
UpdatedAt = ?
WHERE Id = ?`,
/**
* Soft delete folder.
*/
SOFT_DELETE: `
UPDATE Folders
SET IsDeleted = 1,
UpdatedAt = ?
WHERE Id = ?`,
/**
* Clear folder reference from items.
*/
CLEAR_ITEMS_FOLDER: `
UPDATE Items
SET FolderId = NULL,
UpdatedAt = ?
WHERE FolderId = ?`,
/**
* Trash items in folder.
*/
TRASH_ITEMS_IN_FOLDER: `
UPDATE Items
SET DeletedAt = ?,
UpdatedAt = ?,
FolderId = NULL
WHERE FolderId = ? AND IsDeleted = 0 AND DeletedAt IS NULL`,
/**
* Move item to folder.
*/
MOVE_ITEM: `
UPDATE Items
SET FolderId = ?,
UpdatedAt = ?
WHERE Id = ?`
};
/**
* Repository for Folder CRUD operations.
*/
export class FolderRepository extends BaseRepository {
/**
* 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 create(name: string, parentFolderId?: string | null): Promise<string> {
return this.withTransaction(async () => {
const folderId = crypto.randomUUID();
const currentDateTime = this.now();
this.client.executeUpdate(FolderQueries.INSERT, [
folderId,
name,
parentFolderId || null,
currentDateTime,
currentDateTime
]);
return folderId;
});
}
/**
* Get all folders.
* @returns Array of folder objects (empty array if Folders table doesn't exist yet)
*/
public getAll(): Folder[] {
try {
return this.client.executeQuery<Folder>(FolderQueries.GET_ALL);
} catch (error) {
// Table may not exist in older vault versions - return empty array
if (error instanceof Error && error.message.includes('no such table')) {
return [];
}
throw error;
}
}
/**
* Get a folder by ID.
* @param folderId - The ID of the folder
* @returns Folder object or null if not found
*/
public getById(folderId: string): Omit<Folder, 'Weight'> | null {
const results = this.client.executeQuery<Omit<Folder, 'Weight'>>(
FolderQueries.GET_BY_ID,
[folderId]
);
return results.length > 0 ? results[0] : null;
}
/**
* 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 update(folderId: string, name: string): Promise<number> {
return this.withTransaction(async () => {
const currentDateTime = this.now();
return this.client.executeUpdate(FolderQueries.UPDATE_NAME, [
name,
currentDateTime,
folderId
]);
});
}
/**
* 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 delete(folderId: string): Promise<number> {
return this.withTransaction(async () => {
const currentDateTime = this.now();
// Remove folder reference from all items in this folder
this.client.executeUpdate(FolderQueries.CLEAR_ITEMS_FOLDER, [
currentDateTime,
folderId
]);
// Soft delete the folder
return this.client.executeUpdate(FolderQueries.SOFT_DELETE, [
currentDateTime,
folderId
]);
});
}
/**
* Delete a folder and all items within it (soft delete both folder and items).
* Items are moved to "Recently Deleted" (trash).
* @param folderId - The ID of the folder to delete
* @returns The number of items trashed
*/
public async deleteWithContents(folderId: string): Promise<number> {
return this.withTransaction(async () => {
const currentDateTime = this.now();
// Move all items in this folder to trash and clear FolderId
const itemsDeleted = this.client.executeUpdate(FolderQueries.TRASH_ITEMS_IN_FOLDER, [
currentDateTime,
currentDateTime,
folderId
]);
// Soft delete the folder
this.client.executeUpdate(FolderQueries.SOFT_DELETE, [
currentDateTime,
folderId
]);
return itemsDeleted;
});
}
/**
* 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 moveItem(itemId: string, folderId: string | null): Promise<number> {
return this.withTransaction(async () => {
const currentDateTime = this.now();
return this.client.executeUpdate(FolderQueries.MOVE_ITEM, [
folderId,
currentDateTime,
itemId
]);
});
}
}

View File

@@ -0,0 +1,808 @@
import type { Item, ItemField, Attachment, TotpCode, FieldHistory } from '@/utils/dist/core/models/vault';
import { FieldKey, getSystemField, MAX_FIELD_HISTORY_RECORDS } from '@/utils/dist/core/models/vault';
import { BaseRepository, type IDatabaseClient } from '../BaseRepository';
import { FieldMapper, type FieldRow } from '../mappers/FieldMapper';
import { ItemMapper, type ItemRow, type TagRow, type ItemWithDeletedAt } from '../mappers/ItemMapper';
import {
ItemQueries,
FieldValueQueries,
FieldDefinitionQueries,
FieldHistoryQueries
} from '../queries/ItemQueries';
import type { LogoRepository } from './LogoRepository';
/**
* Repository for Item CRUD operations.
* Handles items, field values, field definitions, and field history.
*/
export class ItemRepository extends BaseRepository {
constructor(
client: IDatabaseClient,
private logoRepository: LogoRepository
) {
super(client);
}
/**
* Fetch all active items with their dynamic fields and tags.
* @returns Array of Item objects (empty array if Items table doesn't exist yet)
*/
public getAll(): Item[] {
let itemRows: ItemRow[];
try {
itemRows = this.client.executeQuery<ItemRow>(ItemQueries.GET_ALL_ACTIVE);
} catch (error) {
// Items table may not exist in older vault versions - return empty array
if (error instanceof Error && error.message.includes('no such table')) {
return [];
}
throw error;
}
if (itemRows.length === 0) {
return [];
}
const itemIds = itemRows.map(i => i.Id);
// Get all field values
const fieldRows = this.client.executeQuery<FieldRow>(
ItemQueries.getFieldValuesForItems(itemIds.length),
itemIds
);
const fieldsByItem = FieldMapper.processFieldRows(fieldRows);
// Get all tags
const tagRows = this.client.executeQuery<TagRow>(
ItemQueries.getTagsForItems(itemIds.length),
itemIds
);
const tagsByItem = ItemMapper.groupTagsByItem(tagRows);
return ItemMapper.mapRows(itemRows, fieldsByItem, tagsByItem);
}
/**
* Fetch a single item by ID with its dynamic fields and tags.
* @param itemId - The ID of the item to fetch
* @returns Item object or null if not found
*/
public getById(itemId: string): Item | null {
const results = this.client.executeQuery<ItemRow>(ItemQueries.GET_BY_ID, [itemId]);
if (results.length === 0) {
return null;
}
// Get field values
const fieldRows = this.client.executeQuery<Omit<FieldRow, 'ItemId'>>(
ItemQueries.GET_FIELD_VALUES_FOR_ITEM,
[itemId]
);
const fields = FieldMapper.processFieldRowsForSingleItem(fieldRows);
// Get tags
const tagRows = this.client.executeQuery<Omit<TagRow, 'ItemId'>>(
ItemQueries.GET_TAGS_FOR_ITEM,
[itemId]
);
const tags = ItemMapper.mapTagRows(tagRows);
return ItemMapper.mapRow(results[0], fields, tags);
}
/**
* Fetch all unique email addresses from all items.
* @returns Array of email addresses
*/
public getAllEmailAddresses(): string[] {
const results = this.client.executeQuery<{ Email: string }>(
ItemQueries.GET_ALL_EMAIL_ADDRESSES,
[FieldKey.LoginEmail]
);
return results.map(row => row.Email);
}
/**
* Create a new item with field-based structure.
* @param item The item object to insert
* @param attachments Optional attachments to associate with the item
* @param totpCodes Optional TOTP codes to associate with the item
* @returns The ID of the created item
*/
public async create(
item: Item,
attachments: Attachment[] = [],
totpCodes: TotpCode[] = []
): Promise<string> {
return this.withTransaction(async () => {
const currentDateTime = this.now();
const itemId = item.Id || this.generateId();
// 1. Handle Logo
const logoId = this.resolveLogoId(item, currentDateTime);
// 2. Insert Item
this.client.executeUpdate(ItemQueries.INSERT_ITEM, [
itemId,
item.Name ?? null,
item.ItemType,
logoId,
item.FolderId ?? null,
currentDateTime,
currentDateTime,
0
]);
// 3. Insert FieldValues for all fields
if (item.Fields && item.Fields.length > 0) {
this.insertFieldValues(itemId, item.Fields, item.ItemType, currentDateTime);
}
// 4. Insert TOTP codes
this.insertTotpCodes(itemId, totpCodes, currentDateTime);
// 5. Insert attachments
this.insertAttachments(itemId, attachments, currentDateTime);
return itemId;
});
}
/**
* Update an existing item with field-based structure.
* @param item The item object to update
* @param originalAttachmentIds Original attachment IDs for tracking changes
* @param attachments Current attachments list
* @param originalTotpCodeIds Original TOTP code IDs for tracking changes
* @param totpCodes Current TOTP codes list
* @returns The number of rows modified
*/
public async update(
item: Item,
originalAttachmentIds: string[] = [],
attachments: Attachment[] = [],
originalTotpCodeIds: string[] = [],
totpCodes: TotpCode[] = []
): Promise<number> {
return this.withTransaction(async () => {
const currentDateTime = this.now();
// 1. Handle Logo
const logoId = this.resolveLogoId(item, currentDateTime);
// 2. Update Item
this.client.executeUpdate(ItemQueries.UPDATE_ITEM, [
item.Name ?? null,
item.ItemType,
item.FolderId ?? null,
logoId,
currentDateTime,
item.Id
]);
// 3. Track history for fields that have EnableHistory=true before updating
await this.trackFieldHistory(item.Id, item.Fields, currentDateTime);
// 4. Update field values
this.updateFieldValues(item, currentDateTime);
// 5. Handle TOTP codes
this.handleTotpCodes(item.Id, totpCodes, originalTotpCodeIds, currentDateTime);
// 6. Handle attachments
this.handleAttachments(item.Id, attachments, originalAttachmentIds, currentDateTime);
return 1;
});
}
/**
* Move an item to "Recently Deleted" (trash).
* @param itemId - The ID of the item to trash
* @returns The number of rows updated
*/
public async trash(itemId: string): Promise<number> {
return this.withTransaction(async () => {
const currentDateTime = this.now();
return this.client.executeUpdate(ItemQueries.TRASH_ITEM, [
currentDateTime,
currentDateTime,
itemId
]);
});
}
/**
* Restore an item from "Recently Deleted".
* @param itemId - The ID of the item to restore
* @returns The number of rows updated
*/
public async restore(itemId: string): Promise<number> {
return this.withTransaction(async () => {
const currentDateTime = this.now();
return this.client.executeUpdate(ItemQueries.RESTORE_ITEM, [
currentDateTime,
itemId
]);
});
}
/**
* Permanently delete an item - converts to tombstone for sync.
* @param itemId - The ID of the item to permanently delete
* @returns The number of rows updated
*/
public async permanentlyDelete(itemId: string): Promise<number> {
return this.withTransaction(async () => {
const currentDateTime = this.now();
// Get the LogoId before we clear it
const logoResult = this.client.executeQuery<{ LogoId: string | null }>(
ItemQueries.GET_LOGO_ID,
[itemId]
);
const logoId = logoResult.length > 0 ? logoResult[0].LogoId : null;
// Hard delete all related entities
this.hardDeleteByForeignKey('FieldValues', 'ItemId', itemId);
this.hardDeleteByForeignKey('FieldHistories', 'ItemId', itemId);
this.hardDeleteByForeignKey('Passkeys', 'ItemId', itemId);
this.hardDeleteByForeignKey('TotpCodes', 'ItemId', itemId);
this.hardDeleteByForeignKey('Attachments', 'ItemId', itemId);
this.hardDeleteByForeignKey('ItemTags', 'ItemId', itemId);
// Convert item to tombstone
const result = this.client.executeUpdate(ItemQueries.TOMBSTONE_ITEM, [
currentDateTime,
itemId
]);
// Clean up orphaned logo
if (logoId) {
this.logoRepository.cleanupOrphanedLogo(logoId);
}
return result;
});
}
/**
* Get all items in "Recently Deleted".
* @returns Array of trashed Item objects with DeletedAt
*/
public getRecentlyDeleted(): ItemWithDeletedAt[] {
let itemRows: (ItemRow & { DeletedAt: string })[];
try {
// Need a modified query that includes DeletedAt in the SELECT
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`;
itemRows = this.client.executeQuery(query);
} catch (error) {
if (error instanceof Error && error.message.includes('no such table')) {
return [];
}
throw error;
}
if (itemRows.length === 0) {
return [];
}
const itemIds = itemRows.map(i => i.Id);
// Get all field values
const fieldRows = this.client.executeQuery<FieldRow>(
ItemQueries.getFieldValuesForItems(itemIds.length),
itemIds
);
const fieldsByItem = FieldMapper.processFieldRows(fieldRows);
return itemRows.map(row => ItemMapper.mapDeletedItemRow(row, fieldsByItem.get(row.Id) || []));
}
/**
* Get count of items in "Recently Deleted".
* @returns Number of trashed items
*/
public getRecentlyDeletedCount(): number {
try {
const result = this.client.executeQuery<{ count: number }>(ItemQueries.COUNT_RECENTLY_DELETED);
return result[0]?.count || 0;
} catch (error) {
if (error instanceof Error && error.message.includes('no such table')) {
return 0;
}
throw error;
}
}
/**
* Get field history for a specific field.
* @param itemId - The ID of the item
* @param fieldKey - The field key to get history for
* @returns Array of field history records
*/
public getFieldHistory(itemId: string, fieldKey: string): FieldHistory[] {
const results = this.client.executeQuery<{
Id: string;
ItemId: string;
FieldKey: string;
ValueSnapshot: string;
ChangedAt: string;
CreatedAt: string;
UpdatedAt: string;
}>(FieldHistoryQueries.GET_FOR_FIELD, [itemId, fieldKey, MAX_FIELD_HISTORY_RECORDS]);
return results.map(row => ({
Id: row.Id,
ItemId: row.ItemId,
FieldKey: row.FieldKey,
ValueSnapshot: row.ValueSnapshot,
ChangedAt: row.ChangedAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt
}));
}
// ===== Private Helper Methods =====
/**
* Resolve the logo ID for an item (create new or reuse existing).
*/
private resolveLogoId(item: Item, currentDateTime: string): string | null {
const urlField = item.Fields?.find(f => f.FieldKey === 'login.url');
const urlValue = urlField?.Value;
const urlString = Array.isArray(urlValue) ? urlValue[0] : urlValue;
const source = this.logoRepository.extractSourceFromUrl(urlString);
if (item.Logo) {
const logoData = this.logoRepository.convertLogoToUint8Array(item.Logo);
if (logoData) {
return this.logoRepository.getOrCreate(source, logoData, currentDateTime);
}
} else if (source !== 'unknown') {
return this.logoRepository.getIdForSource(source);
}
return null;
}
/**
* Insert field values for a new item.
*/
private insertFieldValues(
itemId: string,
fields: ItemField[],
itemType: string,
currentDateTime: string
): void {
for (const field of fields) {
// Skip empty fields
if (!field.Value || (typeof field.Value === 'string' && field.Value.trim() === '')) {
continue;
}
const isCustomField = field.FieldKey.startsWith('custom_');
let fieldDefinitionId = null;
// For custom fields, create or get FieldDefinition
if (isCustomField) {
fieldDefinitionId = this.ensureFieldDefinition(field, itemType, currentDateTime);
}
// Handle multi-value fields
const values = Array.isArray(field.Value) ? field.Value : [field.Value];
for (const value of values) {
if (!value || (typeof value === 'string' && value.trim() === '')) {
continue;
}
this.client.executeUpdate(FieldValueQueries.INSERT, [
this.generateId(),
itemId,
fieldDefinitionId,
isCustomField ? null : field.FieldKey,
value,
field.DisplayOrder ?? 0,
currentDateTime,
currentDateTime,
0
]);
}
}
}
/**
* Ensure a field definition exists for a custom field.
*/
private ensureFieldDefinition(
field: ItemField,
itemType: string,
currentDateTime: string
): string {
const existingDef = this.client.executeQuery<{ Id: string }>(
FieldDefinitionQueries.EXISTS,
[field.FieldKey]
);
if (existingDef.length === 0) {
this.client.executeUpdate(FieldDefinitionQueries.INSERT, [
field.FieldKey,
field.FieldType,
field.Label,
0, // IsMultiValue
field.IsHidden ? 1 : 0,
0, // EnableHistory
field.DisplayOrder ?? 0,
itemType,
currentDateTime,
currentDateTime,
0
]);
}
return field.FieldKey;
}
/**
* Update field values for an existing item.
*/
private updateFieldValues(item: Item, currentDateTime: string): void {
// Get existing FieldValues
const existingFieldValues = this.client.executeQuery<{
Id: string;
FieldKey: string | null;
FieldDefinitionId: string | null;
Value: string;
}>(FieldValueQueries.GET_EXISTING_FOR_ITEM, [item.Id]);
// Build a map of existing FieldValues by key:index
const existingByKey = new Map<string, { Id: string; Value: string }>();
const fieldValueCounts = new Map<string, number>();
for (const fv of existingFieldValues) {
const key = fv.FieldKey || fv.FieldDefinitionId || '';
const count = fieldValueCounts.get(key) || 0;
existingByKey.set(`${key}:${count}`, { Id: fv.Id, Value: fv.Value });
fieldValueCounts.set(key, count + 1);
}
const processedIds = new Set<string>();
// Update existing or insert new FieldValues
if (item.Fields && item.Fields.length > 0) {
for (const field of item.Fields) {
if (!field.Value || (typeof field.Value === 'string' && field.Value.trim() === '')) {
continue;
}
const isCustomField = field.FieldKey.startsWith('custom_');
let fieldDefinitionId = null;
if (isCustomField) {
fieldDefinitionId = this.ensureOrUpdateFieldDefinition(field, item.ItemType, currentDateTime);
}
const values = Array.isArray(field.Value) ? field.Value : [field.Value];
const effectiveKey = field.FieldKey;
for (let i = 0; i < values.length; i++) {
const value = values[i];
if (!value || (typeof value === 'string' && value.trim() === '')) {
continue;
}
const lookupKey = `${effectiveKey}:${i}`;
const existing = existingByKey.get(lookupKey);
if (existing) {
processedIds.add(existing.Id);
if (existing.Value !== value) {
this.client.executeUpdate(FieldValueQueries.UPDATE, [
value,
field.DisplayOrder ?? 0,
currentDateTime,
existing.Id
]);
}
} else {
this.client.executeUpdate(FieldValueQueries.INSERT, [
this.generateId(),
item.Id,
fieldDefinitionId,
isCustomField ? null : field.FieldKey,
value,
field.DisplayOrder ?? 0,
currentDateTime,
currentDateTime,
0
]);
}
}
}
}
// Soft-delete any FieldValues that were not processed
for (const fv of existingFieldValues) {
if (!processedIds.has(fv.Id)) {
this.client.executeUpdate(FieldValueQueries.SOFT_DELETE, [currentDateTime, fv.Id]);
}
}
}
/**
* Ensure a field definition exists and is up-to-date.
*/
private ensureOrUpdateFieldDefinition(
field: ItemField,
itemType: string,
currentDateTime: string
): string {
const existingDef = this.client.executeQuery<{ Id: string }>(
FieldDefinitionQueries.EXISTS_ACTIVE,
[field.FieldKey]
);
if (existingDef.length === 0) {
this.client.executeUpdate(FieldDefinitionQueries.INSERT, [
field.FieldKey,
field.FieldType,
field.Label,
0,
field.IsHidden ? 1 : 0,
0,
field.DisplayOrder ?? 0,
itemType,
currentDateTime,
currentDateTime,
0
]);
} else {
this.client.executeUpdate(FieldDefinitionQueries.UPDATE, [
field.Label,
field.FieldType,
field.IsHidden ? 1 : 0,
field.DisplayOrder ?? 0,
currentDateTime,
field.FieldKey
]);
}
return field.FieldKey;
}
/**
* Track field history for fields with EnableHistory=true.
*/
private async trackFieldHistory(
itemId: string,
newFields: ItemField[],
currentDateTime: string
): Promise<void> {
const existingFields = this.client.executeQuery<{ FieldKey: string; Value: string }>(
FieldValueQueries.GET_FOR_HISTORY,
[itemId]
);
// Create a map of existing values by FieldKey
const existingValuesMap: { [key: string]: string[] } = {};
for (const field of existingFields) {
if (!existingValuesMap[field.FieldKey]) {
existingValuesMap[field.FieldKey] = [];
}
existingValuesMap[field.FieldKey].push(field.Value);
}
for (const newField of newFields) {
// Skip custom fields
if (newField.FieldKey.startsWith('custom_')) {
continue;
}
const systemField = getSystemField(newField.FieldKey);
if (!systemField || !systemField.EnableHistory) {
continue;
}
const oldValues = existingValuesMap[newField.FieldKey] || [];
const newValues = Array.isArray(newField.Value) ? newField.Value : [newField.Value];
const valuesChanged = oldValues.length !== newValues.length ||
!oldValues.every((val, idx) => val === newValues[idx]);
if (valuesChanged && oldValues.length > 0) {
const historyId = this.generateId();
const valueSnapshot = JSON.stringify(oldValues);
this.client.executeUpdate(FieldHistoryQueries.INSERT, [
historyId,
itemId,
null,
newField.FieldKey,
valueSnapshot,
currentDateTime,
currentDateTime,
currentDateTime,
0
]);
await this.pruneFieldHistory(itemId, newField.FieldKey, currentDateTime);
}
}
}
/**
* Prune old field history records.
*/
private async pruneFieldHistory(
itemId: string,
fieldKey: string,
currentDateTime: string
): Promise<void> {
const matchingHistory = this.client.executeQuery<{ Id: string; ChangedAt: string }>(
FieldHistoryQueries.GET_FOR_PRUNING,
[itemId, fieldKey]
);
if (matchingHistory.length > MAX_FIELD_HISTORY_RECORDS) {
const recordsToDelete = matchingHistory.slice(MAX_FIELD_HISTORY_RECORDS);
const idsToDelete = recordsToDelete.map(r => r.Id);
if (idsToDelete.length > 0) {
this.client.executeUpdate(
FieldHistoryQueries.softDeleteOld(idsToDelete.length),
[currentDateTime, ...idsToDelete]
);
}
}
}
/**
* Insert TOTP codes for a new item.
*/
private insertTotpCodes(itemId: string, totpCodes: TotpCode[], currentDateTime: string): void {
for (const totpCode of totpCodes) {
this.client.executeUpdate(
`INSERT INTO TotpCodes (Id, Name, SecretKey, ItemId, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
totpCode.Id || this.generateId(),
totpCode.Name,
totpCode.SecretKey,
itemId,
currentDateTime,
currentDateTime,
0
]
);
}
}
/**
* Handle TOTP code updates.
*/
private handleTotpCodes(
itemId: string,
totpCodes: TotpCode[],
originalIds: string[],
currentDateTime: string
): void {
for (const totpCode of totpCodes) {
const wasOriginal = originalIds.includes(totpCode.Id);
if (totpCode.IsDeleted) {
if (wasOriginal) {
this.client.executeUpdate(
`UPDATE TotpCodes SET IsDeleted = 1, UpdatedAt = ? WHERE Id = ?`,
[currentDateTime, totpCode.Id]
);
}
} else if (wasOriginal) {
this.client.executeUpdate(
`UPDATE TotpCodes SET Name = ?, SecretKey = ?, UpdatedAt = ? WHERE Id = ?`,
[totpCode.Name, totpCode.SecretKey, currentDateTime, totpCode.Id]
);
} else {
this.client.executeUpdate(
`INSERT INTO TotpCodes (Id, Name, SecretKey, ItemId, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
totpCode.Id || this.generateId(),
totpCode.Name,
totpCode.SecretKey,
itemId,
currentDateTime,
currentDateTime,
0
]
);
}
}
}
/**
* Insert attachments for a new item.
*/
private insertAttachments(itemId: string, attachments: Attachment[], currentDateTime: string): void {
for (const attachment of attachments) {
const blobData = attachment.Blob instanceof Uint8Array
? attachment.Blob
: new Uint8Array(attachment.Blob);
this.client.executeUpdate(
`INSERT INTO Attachments (Id, Filename, Blob, ItemId, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
attachment.Id || this.generateId(),
attachment.Filename,
blobData,
itemId,
currentDateTime,
currentDateTime,
0
]
);
}
}
/**
* Handle attachment updates.
*/
private handleAttachments(
itemId: string,
attachments: Attachment[],
originalIds: string[],
currentDateTime: string
): void {
for (const attachment of attachments) {
const wasOriginal = originalIds.includes(attachment.Id);
if (attachment.IsDeleted) {
if (wasOriginal) {
this.client.executeUpdate(
`UPDATE Attachments SET IsDeleted = 1, UpdatedAt = ? WHERE Id = ?`,
[currentDateTime, attachment.Id]
);
}
} else if (!wasOriginal) {
const blobData = attachment.Blob instanceof Uint8Array
? attachment.Blob
: new Uint8Array(attachment.Blob);
this.client.executeUpdate(
`INSERT INTO Attachments (Id, Filename, Blob, ItemId, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
attachment.Id || this.generateId(),
attachment.Filename,
blobData,
itemId,
currentDateTime,
currentDateTime,
0
]
);
}
}
}
}

View File

@@ -0,0 +1,159 @@
import { BaseRepository } from '../BaseRepository';
/**
* SQL query constants for Logo operations.
*/
const LogoQueries = {
/**
* Check if logo exists for source.
*/
GET_ID_FOR_SOURCE: `
SELECT Id FROM Logos
WHERE Source = ? AND IsDeleted = 0
LIMIT 1`,
/**
* Insert new logo.
*/
INSERT: `
INSERT INTO Logos (Id, Source, FileData, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?)`,
/**
* Count items using a logo.
*/
COUNT_USAGE: `
SELECT COUNT(*) as count FROM Items
WHERE LogoId = ? AND IsDeleted = 0`,
/**
* Hard delete logo.
*/
HARD_DELETE: `
DELETE FROM Logos WHERE Id = ?`
};
/**
* Repository for Logo management operations.
*/
export class LogoRepository extends BaseRepository {
/**
* Check if a logo exists for the given source domain.
* @param source The normalized source domain (e.g., 'github.com')
* @returns True if a logo exists for this source
*/
public hasLogoForSource(source: string): boolean {
const existingLogos = this.client.executeQuery<{ Id: string }>(
LogoQueries.GET_ID_FOR_SOURCE,
[source]
);
return existingLogos.length > 0;
}
/**
* Get the logo ID for a given source domain if it exists.
* @param source The normalized source domain (e.g., 'github.com')
* @returns The logo ID if found, null otherwise
*/
public getIdForSource(source: string): string | null {
const existingLogos = this.client.executeQuery<{ Id: string }>(
LogoQueries.GET_ID_FOR_SOURCE,
[source]
);
return existingLogos.length > 0 ? existingLogos[0].Id : null;
}
/**
* Get or create a logo ID for the given source domain.
* If a logo for this source already exists, returns its ID.
* Otherwise, creates a new logo entry and returns its ID.
* @param source The normalized source domain (e.g., 'github.com')
* @param logoData The logo image data as Uint8Array
* @param currentDateTime The current date/time string for timestamps
* @returns The logo ID (existing or newly created)
*/
public getOrCreate(source: string, logoData: Uint8Array, currentDateTime: string): string {
// Check if a logo for this source already exists
const existingId = this.getIdForSource(source);
if (existingId) {
return existingId;
}
// Create new logo entry
const logoId = this.generateId();
this.client.executeUpdate(LogoQueries.INSERT, [
logoId,
source,
logoData,
currentDateTime,
currentDateTime,
0
]);
return logoId;
}
/**
* Clean up orphaned logo if no items reference it.
* @param logoId - The ID of the logo to potentially clean up
*/
public cleanupOrphanedLogo(logoId: string): void {
const usageResult = this.client.executeQuery<{ count: number }>(
LogoQueries.COUNT_USAGE,
[logoId]
);
const usageCount = usageResult.length > 0 ? usageResult[0].count : 0;
if (usageCount === 0) {
this.client.executeUpdate(LogoQueries.HARD_DELETE, [logoId]);
console.debug(`[LogoRepository] Deleted orphaned logo: ${logoId}`);
}
}
/**
* Extract and normalize source domain from a URL string.
* Uses lowercase and removes www. prefix for case-insensitive matching.
* @param urlString The URL to extract the domain from
* @returns The normalized source domain (e.g., 'github.com'), or 'unknown' if extraction fails
*/
public extractSourceFromUrl(urlString: string | undefined | null): string {
if (!urlString) {
return 'unknown';
}
try {
const url = new URL(urlString.startsWith('http') ? urlString : `https://${urlString}`);
// Normalize hostname: lowercase and remove www. prefix
return url.hostname.toLowerCase().replace(/^www\./, '');
} catch {
return 'unknown';
}
}
/**
* Convert logo data from various formats to Uint8Array.
* @param logo The logo data in various possible formats
* @returns Uint8Array of logo data, or null if conversion fails
*/
public convertLogoToUint8Array(logo: unknown): Uint8Array | null {
if (!logo) {
return null;
}
try {
// Handle object-like array conversion (from JSON deserialization)
if (typeof logo === 'object' && !ArrayBuffer.isView(logo) && !Array.isArray(logo)) {
const values = Object.values(logo as Record<string, number>);
return new Uint8Array(values);
}
// Handle existing array types
if (Array.isArray(logo) || logo instanceof ArrayBuffer || logo instanceof Uint8Array) {
return new Uint8Array(logo as ArrayLike<number>);
}
} catch (error) {
console.warn('Failed to convert logo to Uint8Array:', error);
}
return null;
}
}

View File

@@ -0,0 +1,295 @@
import type { Passkey } from '@/utils/dist/core/models/vault';
import { FieldKey } from '@/utils/dist/core/models/vault';
import { BaseRepository } from '../BaseRepository';
import { PasskeyMapper, type PasskeyRow, type PasskeyWithItemRow, type PasskeyWithItem } from '../mappers/PasskeyMapper';
/**
* SQL query constants for Passkey operations.
*/
const PasskeyQueries = {
/**
* Base SELECT for passkeys with item information.
*/
BASE_SELECT_WITH_ITEM: `
SELECT
p.Id,
p.ItemId,
p.RpId,
p.UserHandle,
p.PublicKey,
p.PrivateKey,
p.DisplayName,
p.PrfKey,
p.AdditionalData,
p.CreatedAt,
p.UpdatedAt,
p.IsDeleted,
i.Name as ServiceName,
(SELECT fv.Value FROM FieldValues fv WHERE fv.ItemId = i.Id AND fv.FieldKey = '${FieldKey.LoginUsername}' AND fv.IsDeleted = 0 LIMIT 1) as Username
FROM Passkeys p
INNER JOIN Items i ON p.ItemId = i.Id`,
/**
* Base SELECT for passkeys without item information.
*/
BASE_SELECT: `
SELECT
p.Id,
p.ItemId,
p.RpId,
p.UserHandle,
p.PublicKey,
p.PrivateKey,
p.DisplayName,
p.PrfKey,
p.AdditionalData,
p.CreatedAt,
p.UpdatedAt,
p.IsDeleted
FROM Passkeys p`,
/**
* Get passkeys by relying party ID.
*/
GET_BY_RP_ID: `
SELECT
p.Id,
p.ItemId,
p.RpId,
p.UserHandle,
p.PublicKey,
p.PrivateKey,
p.DisplayName,
p.PrfKey,
p.AdditionalData,
p.CreatedAt,
p.UpdatedAt,
p.IsDeleted,
i.Name as ServiceName,
(SELECT fv.Value FROM FieldValues fv WHERE fv.ItemId = i.Id AND fv.FieldKey = '${FieldKey.LoginUsername}' AND fv.IsDeleted = 0 LIMIT 1) as Username
FROM Passkeys p
INNER JOIN Items i ON p.ItemId = i.Id
WHERE p.RpId = ? AND p.IsDeleted = 0
AND i.IsDeleted = 0 AND i.DeletedAt IS NULL
ORDER BY p.CreatedAt DESC`,
/**
* Get passkey by ID with item information.
*/
GET_BY_ID_WITH_ITEM: `
SELECT
p.Id,
p.ItemId,
p.RpId,
p.UserHandle,
p.PublicKey,
p.PrivateKey,
p.DisplayName,
p.PrfKey,
p.AdditionalData,
p.CreatedAt,
p.UpdatedAt,
p.IsDeleted,
i.Name as ServiceName,
(SELECT fv.Value FROM FieldValues fv WHERE fv.ItemId = i.Id AND fv.FieldKey = '${FieldKey.LoginUsername}' AND fv.IsDeleted = 0 LIMIT 1) as Username
FROM Passkeys p
INNER JOIN Items i ON p.ItemId = i.Id
WHERE p.Id = ? AND p.IsDeleted = 0
AND i.IsDeleted = 0 AND i.DeletedAt IS NULL`,
/**
* Get passkeys by item ID.
*/
GET_BY_ITEM_ID: `
SELECT
p.Id,
p.ItemId,
p.RpId,
p.UserHandle,
p.PublicKey,
p.PrivateKey,
p.DisplayName,
p.PrfKey,
p.AdditionalData,
p.CreatedAt,
p.UpdatedAt,
p.IsDeleted
FROM Passkeys p
WHERE p.ItemId = ? AND p.IsDeleted = 0
ORDER BY p.CreatedAt DESC`,
/**
* Insert a new passkey.
*/
INSERT: `
INSERT INTO Passkeys (
Id, ItemId, RpId, UserHandle, PublicKey, PrivateKey,
PrfKey, DisplayName, AdditionalData, CreatedAt, UpdatedAt, IsDeleted
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
/**
* Update passkey display name.
*/
UPDATE_DISPLAY_NAME: `
UPDATE Passkeys
SET DisplayName = ?,
UpdatedAt = ?
WHERE Id = ?`,
/**
* Soft delete passkey by ID.
*/
SOFT_DELETE: `
UPDATE Passkeys
SET IsDeleted = 1,
UpdatedAt = ?
WHERE Id = ?`,
/**
* Soft delete passkeys by item ID.
*/
SOFT_DELETE_BY_ITEM: `
UPDATE Passkeys
SET IsDeleted = 1,
UpdatedAt = ?
WHERE ItemId = ?`
};
/**
* Repository for Passkey CRUD operations.
*/
export class PasskeyRepository extends BaseRepository {
/**
* Get all passkeys for a specific relying party (rpId).
* @param rpId - The relying party identifier (domain)
* @returns Array of passkey objects with credential info
*/
public getByRpId(rpId: string): PasskeyWithItem[] {
const results = this.client.executeQuery<PasskeyWithItemRow>(
PasskeyQueries.GET_BY_RP_ID,
[rpId]
);
return PasskeyMapper.mapRowsWithItem(results);
}
/**
* Get a passkey by its ID.
* @param passkeyId - The passkey ID
* @returns The passkey object or null if not found
*/
public getById(passkeyId: string): PasskeyWithItem | null {
const results = this.client.executeQuery<PasskeyWithItemRow>(
PasskeyQueries.GET_BY_ID_WITH_ITEM,
[passkeyId]
);
if (results.length === 0) {
return null;
}
return PasskeyMapper.mapRowWithItem(results[0]);
}
/**
* Get all passkeys for a specific item.
* @param itemId - The item ID
* @returns Array of passkey objects
*/
public getByItemId(itemId: string): Passkey[] {
const results = this.client.executeQuery<PasskeyRow>(
PasskeyQueries.GET_BY_ITEM_ID,
[itemId]
);
return PasskeyMapper.mapRows(results);
}
/**
* Create a new passkey linked to an item.
* @param passkey - The passkey object to create
*/
public async create(passkey: Omit<Passkey, 'CreatedAt' | 'UpdatedAt' | 'IsDeleted'>): Promise<void> {
return this.withTransaction(async () => {
const currentDateTime = this.now();
// Convert PrfKey to Uint8Array if it's a number array
let prfKeyData: Uint8Array | null = null;
if (passkey.PrfKey) {
prfKeyData = passkey.PrfKey instanceof Uint8Array
? passkey.PrfKey
: new Uint8Array(passkey.PrfKey);
}
// Convert UserHandle to Uint8Array if it's a number array
let userHandleData: Uint8Array | null = null;
if (passkey.UserHandle) {
userHandleData = passkey.UserHandle instanceof Uint8Array
? passkey.UserHandle
: new Uint8Array(passkey.UserHandle);
}
this.client.executeUpdate(PasskeyQueries.INSERT, [
passkey.Id,
passkey.ItemId,
passkey.RpId,
userHandleData,
passkey.PublicKey,
passkey.PrivateKey,
prfKeyData,
passkey.DisplayName,
passkey.AdditionalData ?? null,
currentDateTime,
currentDateTime,
0
]);
});
}
/**
* Delete a passkey by its ID (soft delete).
* @param passkeyId - The ID of the passkey to delete
* @returns The number of rows updated
*/
public async deleteById(passkeyId: string): Promise<number> {
return this.withTransaction(async () => {
const currentDateTime = this.now();
return this.client.executeUpdate(PasskeyQueries.SOFT_DELETE, [
currentDateTime,
passkeyId
]);
});
}
/**
* Delete all passkeys for a specific item (soft delete).
* @param itemId - The ID of the item
* @returns The number of rows updated
*/
public async deleteByItemId(itemId: string): Promise<number> {
return this.withTransaction(async () => {
const currentDateTime = this.now();
return this.client.executeUpdate(PasskeyQueries.SOFT_DELETE_BY_ITEM, [
currentDateTime,
itemId
]);
});
}
/**
* Update a passkey's display name.
* @param passkeyId - The ID of the passkey to update
* @param displayName - The new display name
* @returns The number of rows updated
*/
public async updateDisplayName(passkeyId: string, displayName: string): Promise<number> {
return this.withTransaction(async () => {
const currentDateTime = this.now();
return this.client.executeUpdate(PasskeyQueries.UPDATE_DISPLAY_NAME, [
displayName,
currentDateTime,
passkeyId
]);
});
}
}

View File

@@ -0,0 +1,192 @@
import type { EncryptionKey, PasswordSettings, TotpCode, Attachment } from '@/utils/dist/core/models/vault';
import { BaseRepository } from '../BaseRepository';
/**
* SQL query constants for Settings and related operations.
*/
const SettingsQueries = {
/**
* Get setting by key.
*/
GET_SETTING: `
SELECT s.Value
FROM Settings s
WHERE s.Key = ?`,
/**
* Get all encryption keys.
*/
GET_ENCRYPTION_KEYS: `
SELECT
x.PublicKey,
x.PrivateKey,
x.IsPrimary
FROM EncryptionKeys x`,
/**
* Get TOTP codes for an item.
*/
GET_TOTP_FOR_ITEM: `
SELECT
Id,
Name,
SecretKey,
ItemId
FROM TotpCodes
WHERE ItemId = ? AND IsDeleted = 0`,
/**
* Get attachments for an item.
*/
GET_ATTACHMENTS_FOR_ITEM: `
SELECT
Id,
Filename,
Blob,
ItemId,
CreatedAt,
UpdatedAt,
IsDeleted
FROM Attachments
WHERE ItemId = ? AND IsDeleted = 0`
};
/**
* Repository for Settings and auxiliary data operations.
*/
export class SettingsRepository extends BaseRepository {
/**
* Get setting from database for a given key.
* Returns default value (empty string by default) if setting is not found.
* @param key - The setting key
* @param defaultValue - Default value if setting not found
* @returns The setting value
*/
public getSetting(key: string, defaultValue: string = ''): string {
const results = this.client.executeQuery<{ Value: string }>(
SettingsQueries.GET_SETTING,
[key]
);
return results.length > 0 ? results[0].Value : defaultValue;
}
/**
* Get the default identity language from the database.
* @returns The stored override value if set, otherwise empty string
*/
public getDefaultIdentityLanguage(): string {
return this.getSetting('DefaultIdentityLanguage');
}
/**
* Get the default identity gender preference from the database.
* @returns The gender preference or 'random' if not set
*/
public getDefaultIdentityGender(): string {
return this.getSetting('DefaultIdentityGender', 'random');
}
/**
* Get the default identity age range from the database.
* @returns The age range preference or 'random' if not set
*/
public getDefaultIdentityAgeRange(): string {
return this.getSetting('DefaultIdentityAgeRange', 'random');
}
/**
* Get the password settings from the database.
* @returns Password settings object
*/
public getPasswordSettings(): PasswordSettings {
const settingsJson = this.getSetting('PasswordGenerationSettings');
const defaultSettings: PasswordSettings = {
Length: 18,
UseLowercase: true,
UseUppercase: true,
UseNumbers: true,
UseSpecialChars: true,
UseNonAmbiguousChars: false
};
try {
if (settingsJson) {
return { ...defaultSettings, ...JSON.parse(settingsJson) };
}
} catch (error) {
console.warn('Failed to parse password settings:', error);
}
return defaultSettings;
}
/**
* Fetch all encryption keys.
* @returns Array of encryption keys
*/
public getAllEncryptionKeys(): EncryptionKey[] {
return this.client.executeQuery<EncryptionKey>(SettingsQueries.GET_ENCRYPTION_KEYS);
}
/**
* Get TOTP codes for an item.
* @param itemId - The ID of the item to get TOTP codes for
* @returns Array of TotpCode objects
*/
public getTotpCodesForItem(itemId: string): TotpCode[] {
try {
if (!this.tableExists('TotpCodes')) {
return [];
}
return this.client.executeQuery<TotpCode>(SettingsQueries.GET_TOTP_FOR_ITEM, [itemId]);
} catch (error) {
console.error('Error getting TOTP codes for item:', error);
return [];
}
}
/**
* Get attachments for an item.
* @param itemId - The ID of the item
* @returns Array of attachments for the item
*/
public getAttachmentsForItem(itemId: string): Attachment[] {
try {
if (!this.tableExists('Attachments')) {
return [];
}
return this.client.executeQuery<Attachment>(
SettingsQueries.GET_ATTACHMENTS_FOR_ITEM,
[itemId]
);
} catch (error) {
console.error('Error getting attachments for item:', error);
return [];
}
}
/**
* Get the default email domain for new aliases.
* @returns The default email domain or empty string if not set
*/
public getDefaultEmailDomain(): string {
return this.getSetting('DefaultEmailDomain');
}
/**
* Get the effective identity language, falling back to browser language.
* @returns The effective language code
*/
public getEffectiveIdentityLanguage(): string {
const storedLanguage = this.getDefaultIdentityLanguage();
if (storedLanguage) {
return storedLanguage;
}
// Fall back to browser language (first two characters)
return navigator.language.substring(0, 2);
}
}