Basic mobile app refactor scaffolding from credential to item structure (#1404)

This commit is contained in:
Leendert de Borst
2025-12-27 18:42:12 +01:00
parent 96e68b2bce
commit 2a9aecabbb
39 changed files with 2908 additions and 893 deletions

View File

@@ -99,11 +99,11 @@ export default function TabLayout() : React.ReactNode {
}),
}}>
<Tabs.Screen
name="credentials"
name="items"
options={{
title: t('navigation.credentials'),
title: t('navigation.vault'),
/**
* Icon for the credentials tab.
* Icon for the vault tab.
*/
tabBarIcon: ({ color }) => <IconSymbol size={28} name={IconSymbolName.Key} color={color} />,
}}

View File

@@ -168,11 +168,11 @@ export default function EmailDetailsScreen() : React.ReactNode {
};
/**
* Handle the open credential button press.
* Handle the open item button press.
*/
const handleOpenCredential = () : void => {
const handleOpenItem = () : void => {
if (associatedCredential) {
router.push(`/(tabs)/credentials/${associatedCredential.Id}`);
router.push(`/(tabs)/items/${associatedCredential.Id}`);
}
};
@@ -238,12 +238,12 @@ export default function EmailDetailsScreen() : React.ReactNode {
metadataContainer: {
padding: 2,
},
metadataCredential: {
metadataItem: {
alignItems: 'center',
alignSelf: 'center',
flexDirection: 'row',
},
metadataCredentialIcon: {
metadataItemIcon: {
marginRight: 4,
},
metadataHeading: {
@@ -408,10 +408,10 @@ export default function EmailDetailsScreen() : React.ReactNode {
{associatedCredential && (
<View>
<RobustPressable
onPress={handleOpenCredential}
style={styles.metadataCredential}
onPress={handleOpenItem}
style={styles.metadataItem}
>
<IconSymbol size={16} name={IconSymbolName.Key} color={colors.primary} style={styles.metadataCredentialIcon} />
<IconSymbol size={16} name={IconSymbolName.Key} color={colors.primary} style={styles.metadataItemIcon} />
<ThemedText style={[styles.metadataText, { color: colors.primary }]}>
{associatedCredential.ServiceName}
</ThemedText>

View File

@@ -4,18 +4,19 @@ import { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, View, Text, StyleSheet, Linking, Platform } from 'react-native'
import Toast from 'react-native-toast-message';
import type { Credential } from '@/utils/dist/core/models/vault';
import type { Item } from '@/utils/dist/core/models/vault';
import { FieldTypes, getFieldValue, FieldKey } from '@/utils/dist/core/models/vault';
import emitter from '@/utils/EventEmitter';
import { useColors } from '@/hooks/useColorScheme';
import { CredentialIcon } from '@/components/credentials/CredentialIcon';
import { AliasDetails } from '@/components/credentials/details/AliasDetails';
import { AttachmentSection } from '@/components/credentials/details/AttachmentSection';
import { EmailPreview } from '@/components/credentials/details/EmailPreview';
import { LoginCredentials } from '@/components/credentials/details/LoginCredentials';
import { NotesSection } from '@/components/credentials/details/NotesSection';
import { TotpSection } from '@/components/credentials/details/TotpSection';
import { AliasDetails } from '@/components/items/details/AliasDetails';
import { AttachmentSection } from '@/components/items/details/AttachmentSection';
import { EmailPreview } from '@/components/items/details/EmailPreview';
import { LoginFields } from '@/components/items/details/LoginFields';
import { NotesSection } from '@/components/items/details/NotesSection';
import { TotpSection } from '@/components/items/details/TotpSection';
import { ItemIcon } from '@/components/items/ItemIcon';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
import { ThemedScrollView } from '@/components/themed/ThemedScrollView';
import { ThemedText } from '@/components/themed/ThemedText';
@@ -24,11 +25,11 @@ import { RobustPressable } from '@/components/ui/RobustPressable';
import { useDb } from '@/context/DbContext';
/**
* Credential details screen.
* Item details screen.
*/
export default function CredentialDetailsScreen() : React.ReactNode {
export default function ItemDetailsScreen() : React.ReactNode {
const { id } = useLocalSearchParams();
const [credential, setCredential] = useState<Credential | null>(null);
const [item, setItem] = useState<Item | null>(null);
const [isLoading, setIsLoading] = useState(true);
const dbContext = useDb();
const navigation = useNavigation();
@@ -39,7 +40,7 @@ export default function CredentialDetailsScreen() : React.ReactNode {
* Handle the edit button press.
*/
const handleEdit = useCallback(() : void => {
router.push(`/(tabs)/credentials/add-edit?id=${id}`);
router.push(`/(tabs)/items/add-edit?id=${id}`);
}, [id, router]);
// Set header buttons
@@ -63,38 +64,38 @@ export default function CredentialDetailsScreen() : React.ReactNode {
</View>
),
});
}, [navigation, credential, handleEdit, colors.primary]);
}, [navigation, item, handleEdit, colors.primary]);
useEffect(() => {
/**
* Load the credential.
* Load the item.
*/
const loadCredential = async () : Promise<void> => {
const loadItem = async () : Promise<void> => {
if (!dbContext.dbAvailable || !id) {
return;
}
try {
const cred = await dbContext.sqliteClient!.getCredentialById(id as string);
setCredential(cred);
const result = await dbContext.sqliteClient!.getItemById(id as string);
setItem(result);
} catch (err) {
console.error('Error loading credential:', err);
console.error('Error loading item:', err);
} finally {
setIsLoading(false);
}
};
loadCredential();
loadItem();
// Add listener for credential changes
const credentialChangedSub = emitter.addListener('credentialChanged', async (changedId: string) => {
// Add listener for item changes
const itemChangedSub = emitter.addListener('credentialChanged', async (changedId: string) => {
if (changedId === id) {
await loadCredential();
await loadItem();
}
});
return () : void => {
credentialChangedSub.remove();
itemChangedSub.remove();
Toast.hide();
};
}, [id, dbContext.dbAvailable, dbContext.sqliteClient]);
@@ -107,42 +108,51 @@ export default function CredentialDetailsScreen() : React.ReactNode {
);
}
if (!credential) {
if (!item) {
return null;
}
// Extract URL fields for display
const urlFields = item.Fields.filter(field => field.FieldType === FieldTypes.URL && field.Value);
const firstUrl = urlFields.length > 0
? (Array.isArray(urlFields[0].Value) ? urlFields[0].Value[0] : urlFields[0].Value)
: null;
// Get email for EmailPreview
const email = getFieldValue(item, FieldKey.LoginEmail);
return (
<ThemedContainer>
<ThemedScrollView>
<ThemedView style={styles.header}>
<CredentialIcon logo={credential.Logo} style={styles.logo} />
<ItemIcon logo={item.Logo} style={styles.logo} />
<View style={styles.headerText}>
<ThemedText type="title" style={styles.serviceName}>
{credential.ServiceName}
{item.Name}
</ThemedText>
{credential.ServiceUrl && (
/^https?:\/\//i.test(credential.ServiceUrl) ? (
{firstUrl && (
/^https?:\/\//i.test(firstUrl) ? (
<RobustPressable
onPress={() => Linking.openURL(credential.ServiceUrl!)}
onPress={() => Linking.openURL(firstUrl)}
>
<Text style={[styles.serviceUrl, { color: colors.primary }]}>
{credential.ServiceUrl}
{firstUrl}
</Text>
</RobustPressable>
) : (
<Text style={styles.serviceUrl}>
{credential.ServiceUrl}
{firstUrl}
</Text>
)
)}
</View>
</ThemedView>
<EmailPreview email={credential.Alias.Email} />
<TotpSection credential={credential} />
<LoginCredentials credential={credential} />
<AliasDetails credential={credential} />
<NotesSection credential={credential} />
<AttachmentSection credential={credential} />
<EmailPreview email={email} />
<TotpSection item={item} />
<LoginFields item={item} />
<AliasDetails item={item} />
<NotesSection item={item} />
<AttachmentSection item={item} />
</ThemedScrollView>
</ThemedContainer>
);

View File

@@ -5,10 +5,10 @@ import { Platform } from 'react-native';
import { defaultHeaderOptions } from '@/components/themed/ThemedHeader';
/**
* Credentials layout.
* @returns {React.ReactNode} The credentials layout component
* Items layout.
* @returns {React.ReactNode} The items layout component
*/
export default function CredentialsLayout(): React.ReactNode {
export default function ItemsLayout(): React.ReactNode {
const { t } = useTranslation();
return (
@@ -16,7 +16,7 @@ export default function CredentialsLayout(): React.ReactNode {
<Stack.Screen
name="index"
options={{
title: t('credentials.title'),
title: t('items.title'),
headerShown: Platform.OS === 'android',
...defaultHeaderOptions,
}}
@@ -24,7 +24,7 @@ export default function CredentialsLayout(): React.ReactNode {
<Stack.Screen
name="add-edit"
options={{
title: t('credentials.addCredential'),
title: t('items.addItem'),
presentation: Platform.OS === 'ios' ? 'modal' : 'card',
...defaultHeaderOptions,
}}
@@ -32,14 +32,14 @@ export default function CredentialsLayout(): React.ReactNode {
<Stack.Screen
name="add-edit-page"
options={{
title: t('credentials.addCredential'),
title: t('items.addItem'),
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="autofill-credential-created"
name="autofill-item-created"
options={{
title: t('credentials.credentialCreated'),
title: t('items.itemCreated'),
presentation: Platform.OS === 'ios' ? 'modal' : 'card',
...defaultHeaderOptions,
}}
@@ -47,14 +47,14 @@ export default function CredentialsLayout(): React.ReactNode {
<Stack.Screen
name="[id]"
options={{
title: t('credentials.credentialDetails'),
title: t('items.itemDetails'),
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="email/[id]"
options={{
title: t('credentials.emailPreview'),
title: t('items.emailPreview'),
}}
/>
</Stack>

View File

@@ -8,7 +8,8 @@ import { StyleSheet, Text, FlatList, TouchableOpacity, TextInput, RefreshControl
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Toast from 'react-native-toast-message';
import type { Credential } from '@/utils/dist/core/models/vault';
import type { Item } from '@/utils/dist/core/models/vault';
import { getFieldValue, FieldKey } from '@/utils/dist/core/models/vault';
import emitter from '@/utils/EventEmitter';
import { VaultAuthenticationError } from '@/utils/types/errors/VaultAuthenticationError';
@@ -20,8 +21,8 @@ import { useVaultSync } from '@/hooks/useVaultSync';
type FilterType = 'all' | 'passkeys' | 'aliases' | 'userpass' | 'attachments';
import Logo from '@/assets/images/logo.svg';
import { CredentialCard } from '@/components/credentials/CredentialCard';
import { ServiceUrlNotice } from '@/components/credentials/ServiceUrlNotice';
import { ItemCard } from '@/components/items/ItemCard';
import { ServiceUrlNotice } from '@/components/items/ServiceUrlNotice';
import LoadingOverlay from '@/components/LoadingOverlay';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
import { ThemedText } from '@/components/themed/ThemedText';
@@ -34,9 +35,9 @@ import { useApp } from '@/context/AppContext';
import { useDb } from '@/context/DbContext';
/**
* Credentials screen.
* Items screen.
*/
export default function CredentialsScreen() : React.ReactNode {
export default function ItemsScreen() : React.ReactNode {
const [searchQuery, setSearchQuery] = useState('');
const { syncVault } = useVaultSync();
const colors = useColors();
@@ -47,8 +48,8 @@ export default function CredentialsScreen() : React.ReactNode {
const [isTabFocused, setIsTabFocused] = useState(false);
const router = useRouter();
const { serviceUrl: serviceUrlParam } = useLocalSearchParams<{ serviceUrl?: string }>();
const [credentialsList, setCredentialsList] = useState<Credential[]>([]);
const [isLoadingCredentials, setIsLoadingCredentials] = useMinDurationLoading(false, 200);
const [itemsList, setItemsList] = useState<Item[]>([]);
const [isLoadingItems, setIsLoadingItems] = useMinDurationLoading(false, 200);
const [refreshing, setRefreshing] = useMinDurationLoading(false, 200);
const [serviceUrl, setServiceUrl] = useState<string | null>(null);
const insets = useSafeAreaInsets();
@@ -64,23 +65,23 @@ export default function CredentialsScreen() : React.ReactNode {
const isDatabaseAvailable = dbContext.dbAvailable;
/**
* Load credentials.
* Load items (credentials).
*/
const loadCredentials = useCallback(async () : Promise<void> => {
const loadItems = useCallback(async (): Promise<void> => {
try {
const credentials = await dbContext.sqliteClient!.getAllCredentials();
setCredentialsList(credentials);
setIsLoadingCredentials(false);
const items = await dbContext.sqliteClient!.getAllItems();
setItemsList(items);
setIsLoadingItems(false);
} catch (err) {
// Error loading credentials, show error toast
// Error loading items, show error toast
Toast.show({
type: 'error',
text1: t('credentials.errorLoadingCredentials'),
text1: t('items.errorLoadingItems'),
text2: err instanceof Error ? err.message : 'Unknown error',
});
setIsLoadingCredentials(false);
setIsLoadingItems(false);
}
}, [dbContext.sqliteClient, setIsLoadingCredentials, t]);
}, [dbContext.sqliteClient, setIsLoadingItems, t]);
useEffect(() => {
const unsubscribeFocus = navigation.addListener('focus', () => {
@@ -100,18 +101,18 @@ export default function CredentialsScreen() : React.ReactNode {
}
});
// Add listener for credential changes
const credentialChangedSub = emitter.addListener('credentialChanged', async () => {
await loadCredentials();
// Add listener for item/credential changes
const itemChangedSub = emitter.addListener('credentialChanged', async () => {
await loadItems();
});
return () : void => {
return (): void => {
tabPressSub.remove();
credentialChangedSub.remove();
itemChangedSub.remove();
unsubscribeFocus();
unsubscribeBlur();
};
}, [isTabFocused, loadCredentials, navigation, setRefreshing]);
}, [isTabFocused, loadItems, navigation, setRefreshing]);
const onRefresh = useCallback(async () => {
// Trigger haptic feedback when pull-to-refresh is activated
@@ -122,13 +123,13 @@ export default function CredentialsScreen() : React.ReactNode {
}
setRefreshing(true);
setIsLoadingCredentials(true);
setIsLoadingItems(true);
// Check if we are in offline mode, if so, we don't need to refresh the credentials
const isOffline = authContext.isOffline;
if (isOffline) {
setRefreshing(false);
setIsLoadingCredentials(false);
setIsLoadingItems(false);
return;
}
@@ -140,13 +141,13 @@ export default function CredentialsScreen() : React.ReactNode {
*/
onSuccess: async (hasNewVault) => {
// Calculate remaining time needed to reach minimum duration
await loadCredentials();
setIsLoadingCredentials(false);
await loadItems();
setIsLoadingItems(false);
setRefreshing(false);
setTimeout(() => {
Toast.show({
type: 'success',
text1: hasNewVault ? t('credentials.vaultSyncedSuccessfully') : t('credentials.vaultUpToDate'),
text1: hasNewVault ? t('items.vaultSyncedSuccessfully') : t('items.vaultUpToDate'),
position: 'top',
visibilityTime: 1200,
});
@@ -157,12 +158,12 @@ export default function CredentialsScreen() : React.ReactNode {
*/
onOffline: () => {
setRefreshing(false);
setIsLoadingCredentials(false);
setIsLoadingItems(false);
authContext.setOfflineMode(true);
setTimeout(() => {
Toast.show({
type: 'error',
text1: t('credentials.offlineMessage'),
text1: t('items.offlineMessage'),
position: 'bottom',
});
}, 200);
@@ -173,7 +174,7 @@ export default function CredentialsScreen() : React.ReactNode {
onError: async (error) => {
console.error('Error syncing vault:', error);
setRefreshing(false);
setIsLoadingCredentials(false);
setIsLoadingItems(false);
/**
* Authentication errors are handled in useVaultSync
@@ -193,29 +194,29 @@ export default function CredentialsScreen() : React.ReactNode {
},
});
} catch (err) {
console.error('Error refreshing credentials:', err);
console.error('Error refreshing items:', err);
setRefreshing(false);
setIsLoadingCredentials(false);
setIsLoadingItems(false);
// Authentication errors are already handled in useVaultSync
if (!(err instanceof VaultAuthenticationError)) {
Toast.show({
type: 'error',
text1: t('credentials.vaultSyncFailed'),
text1: t('items.vaultSyncFailed'),
text2: err instanceof Error ? err.message : 'Unknown error',
});
}
}
}, [syncVault, loadCredentials, setIsLoadingCredentials, setRefreshing, authContext, router, t]);
}, [syncVault, loadItems, setIsLoadingItems, setRefreshing, authContext, router, t]);
useEffect(() => {
if (!isAuthenticated || !isDatabaseAvailable) {
return;
}
setIsLoadingCredentials(true);
loadCredentials();
}, [isAuthenticated, isDatabaseAvailable, loadCredentials, setIsLoadingCredentials]);
setIsLoadingItems(true);
loadItems();
}, [isAuthenticated, isDatabaseAvailable, loadItems, setIsLoadingItems]);
/**
* Get the title based on the active filter
@@ -223,49 +224,57 @@ export default function CredentialsScreen() : React.ReactNode {
const getFilterTitle = useCallback(() : string => {
switch (filterType) {
case 'passkeys':
return t('credentials.filters.passkeys');
return t('items.filters.passkeys');
case 'aliases':
return t('credentials.filters.aliases');
return t('items.filters.aliases');
case 'userpass':
return t('credentials.filters.userpass');
return t('items.filters.userpass');
case 'attachments':
return t('credentials.filters.attachments');
return t('items.filters.attachments');
default:
return t('credentials.title');
return t('items.title');
}
}, [filterType, t]);
const filteredCredentials = credentialsList.filter(credential => {
const filteredItems = itemsList.filter(item => {
// First apply type filter
let passesTypeFilter = true;
if (filterType === 'passkeys') {
passesTypeFilter = credential.HasPasskey === true;
passesTypeFilter = item.HasPasskey === true;
} else if (filterType === 'aliases') {
// Check for non-empty alias fields (excluding email which is used everywhere)
const firstName = getFieldValue(item, FieldKey.AliasFirstName);
const lastName = getFieldValue(item, FieldKey.AliasLastName);
const gender = getFieldValue(item, FieldKey.AliasGender);
const birthDate = getFieldValue(item, FieldKey.AliasBirthdate);
passesTypeFilter = !!(
(credential.Alias?.FirstName && credential.Alias.FirstName.trim()) ||
(credential.Alias?.LastName && credential.Alias.LastName.trim()) ||
(credential.Alias?.NickName && credential.Alias.NickName.trim()) ||
(credential.Alias?.Gender && credential.Alias.Gender.trim()) ||
(credential.Alias?.BirthDate && credential.Alias.BirthDate.trim() && credential.Alias.BirthDate.trim().startsWith('0001-01-01') !== true)
(firstName && firstName.trim()) ||
(lastName && lastName.trim()) ||
(gender && gender.trim()) ||
(birthDate && birthDate.trim() && !birthDate.trim().startsWith('0001-01-01'))
);
} else if (filterType === 'userpass') {
// Show only credentials that have username/password AND do NOT have alias fields AND do NOT have passkey
// Show only items that have username/password AND do NOT have alias fields AND do NOT have passkey
const firstName = getFieldValue(item, FieldKey.AliasFirstName);
const lastName = getFieldValue(item, FieldKey.AliasLastName);
const gender = getFieldValue(item, FieldKey.AliasGender);
const birthDate = getFieldValue(item, FieldKey.AliasBirthdate);
const hasAliasFields = !!(
(credential.Alias?.FirstName && credential.Alias.FirstName.trim()) ||
(credential.Alias?.LastName && credential.Alias.LastName.trim()) ||
(credential.Alias?.NickName && credential.Alias.NickName.trim()) ||
(credential.Alias?.Gender && credential.Alias.Gender.trim()) ||
(credential.Alias?.BirthDate && credential.Alias.BirthDate.trim() && credential.Alias.BirthDate.trim().startsWith('0001-01-01') !== true)
(firstName && firstName.trim()) ||
(lastName && lastName.trim()) ||
(gender && gender.trim()) ||
(birthDate && birthDate.trim() && !birthDate.trim().startsWith('0001-01-01'))
);
const username = getFieldValue(item, FieldKey.LoginUsername);
const password = getFieldValue(item, FieldKey.LoginPassword);
const hasUsernameOrPassword = !!(
(credential.Username && credential.Username.trim()) ||
(credential.Password && credential.Password.trim())
(username && username.trim()) ||
(password && password.trim())
);
passesTypeFilter = hasUsernameOrPassword && !credential.HasPasskey && !hasAliasFields;
passesTypeFilter = hasUsernameOrPassword && !item.HasPasskey && !hasAliasFields;
} else if (filterType === 'attachments') {
passesTypeFilter = credential.HasAttachment === true;
passesTypeFilter = item.HasAttachment === true;
}
if (!passesTypeFilter) {
@@ -280,19 +289,19 @@ export default function CredentialsScreen() : React.ReactNode {
}
/**
* We filter credentials by searching in the following fields:
* - Service name
* We filter items by searching in the following fields:
* - Item name
* - Username
* - Alias email
* - Service URL
* - Email
* - URL
* - Notes
*/
const searchableFields = [
credential.ServiceName?.toLowerCase() || '',
credential.Username?.toLowerCase() || '',
credential.Alias?.Email?.toLowerCase() || '',
credential.ServiceUrl?.toLowerCase() || '',
credential.Notes?.toLowerCase() || '',
item.Name?.toLowerCase() || '',
getFieldValue(item, FieldKey.LoginUsername)?.toLowerCase() || '',
getFieldValue(item, FieldKey.LoginEmail)?.toLowerCase() || '',
getFieldValue(item, FieldKey.LoginUrl)?.toLowerCase() || '',
getFieldValue(item, FieldKey.NotesContent)?.toLowerCase() || '',
];
// Split search term into words for AND search
@@ -431,7 +440,7 @@ export default function CredentialsScreen() : React.ReactNode {
if (Platform.OS === 'android') {
return (
<AndroidHeader
title={`${getFilterTitle()} (${filteredCredentials.length})`}
title={`${getFilterTitle()} (${filteredItems.length})`}
headerButtons={[
{
icon: showFilterMenu ? "keyboard-arrow-up" : "keyboard-arrow-down",
@@ -447,26 +456,26 @@ export default function CredentialsScreen() : React.ReactNode {
/>
);
}
return <Text>{t('credentials.title')}</Text>;
return <Text>{t('items.title')}</Text>;
},
});
}, [navigation, t, filterType, showFilterMenu, getFilterTitle, filteredCredentials.length]);
}, [navigation, t, filterType, showFilterMenu, getFilterTitle, filteredItems.length]);
/**
* Delete a credential.
* Delete an item (move to trash).
*/
const onCredentialDelete = useCallback(async (credentialId: string) : Promise<void> => {
const onItemDelete = useCallback(async (itemId: string): Promise<void> => {
setIsSyncing(true);
await executeVaultMutation(async () => {
await dbContext.sqliteClient!.deleteCredentialById(credentialId);
await dbContext.sqliteClient!.trashItem(itemId);
setIsSyncing(false);
});
// Refresh list after deletion with a small delay to ensure feedback is visible.
await new Promise(resolve => setTimeout(resolve, 250));
await loadCredentials();
}, [dbContext.sqliteClient, executeVaultMutation, loadCredentials]);
await loadItems();
}, [dbContext.sqliteClient, executeVaultMutation, loadItems]);
// Handle deep link parameters
useFocusEffect(
@@ -483,7 +492,7 @@ export default function CredentialsScreen() : React.ReactNode {
<LoadingOverlay status={syncStatus} />
)}
<CollapsibleHeader
title={t('credentials.title')}
title={t('items.title')}
scrollY={scrollY}
showNavigationHeader={true}
alwaysVisible={true}
@@ -491,7 +500,7 @@ export default function CredentialsScreen() : React.ReactNode {
<RobustPressable
style={styles.fab}
onPress={() => {
router.push('/(tabs)/credentials/add-edit');
router.push('/(tabs)/items/add-edit');
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}}
>
@@ -500,8 +509,8 @@ export default function CredentialsScreen() : React.ReactNode {
<ThemedView style={styles.stepContainer}>
<Animated.FlatList
ref={flatListRef}
data={isLoadingCredentials ? Array(4).fill(null) : filteredCredentials}
keyExtractor={(item, index) => item?.Id ?? `skeleton-${index}`}
data={isLoadingItems ? Array(4).fill(null) : filteredItems}
keyExtractor={(itm, index) => itm?.Id ?? `skeleton-${index}`}
keyboardShouldPersistTaps='handled'
onScroll={Animated.event(
[{ nativeEvent: { contentOffset: { y: scrollY } } }],
@@ -526,7 +535,7 @@ export default function CredentialsScreen() : React.ReactNode {
{getFilterTitle()}
</ThemedText>
<ThemedText style={styles.filterCount}>
({filteredCredentials.length})
({filteredItems.length})
</ThemedText>
<MaterialIcons
name={showFilterMenu ? "keyboard-arrow-up" : "keyboard-arrow-down"}
@@ -557,7 +566,7 @@ export default function CredentialsScreen() : React.ReactNode {
styles.filterMenuItemText,
filterType === 'all' && styles.filterMenuItemTextActive
]}>
{t('credentials.filters.all')}
{t('items.filters.all')}
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
@@ -574,7 +583,7 @@ export default function CredentialsScreen() : React.ReactNode {
styles.filterMenuItemText,
filterType === 'passkeys' && styles.filterMenuItemTextActive
]}>
{t('credentials.filters.passkeys')}
{t('items.filters.passkeys')}
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
@@ -591,7 +600,7 @@ export default function CredentialsScreen() : React.ReactNode {
styles.filterMenuItemText,
filterType === 'aliases' && styles.filterMenuItemTextActive
]}>
{t('credentials.filters.aliases')}
{t('items.filters.aliases')}
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
@@ -608,7 +617,7 @@ export default function CredentialsScreen() : React.ReactNode {
styles.filterMenuItemText,
filterType === 'userpass' && styles.filterMenuItemTextActive
]}>
{t('credentials.filters.userpass')}
{t('items.filters.userpass')}
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
@@ -625,7 +634,7 @@ export default function CredentialsScreen() : React.ReactNode {
styles.filterMenuItemText,
filterType === 'attachments' && styles.filterMenuItemTextActive
]}>
{t('credentials.filters.attachments')}
{t('items.filters.attachments')}
</ThemedText>
</TouchableOpacity>
</ThemedView>
@@ -639,7 +648,7 @@ export default function CredentialsScreen() : React.ReactNode {
/>
<TextInput
style={styles.searchInput}
placeholder={t('credentials.searchPlaceholder')}
placeholder={t('items.searchPlaceholder')}
placeholderTextColor={colors.textMuted}
value={searchQuery}
autoCorrect={false}
@@ -668,30 +677,30 @@ export default function CredentialsScreen() : React.ReactNode {
tintColor={colors.primary}
/>
}
renderItem={({ item }) =>
isLoadingCredentials ? (
renderItem={({ item: itm }) =>
isLoadingItems ? (
<SkeletonLoader count={1} height={60} parts={2} />
) : (
<CredentialCard credential={item} onCredentialDelete={onCredentialDelete} />
<ItemCard item={itm} onItemDelete={onItemDelete} />
)
}
ListEmptyComponent={
!isLoadingCredentials ? (
!isLoadingItems ? (
<Text style={styles.emptyText}>
{searchQuery
? t('credentials.noMatchingCredentials')
? t('items.noMatchingItems')
: filterType === 'passkeys'
? t('credentials.noPasskeysFound')
? t('items.noPasskeysFound')
: filterType === 'attachments'
? t('credentials.noAttachmentsFound')
: t('credentials.noCredentialsFound')
? t('items.noAttachmentsFound')
: t('items.noItemsFound')
}
</Text>
) : null
}
/>
</ThemedView>
{isLoading && <LoadingOverlay status={syncStatus || t('credentials.deletingCredential')} />}
{isLoading && <LoadingOverlay status={syncStatus || t('items.deletingItem')} />}
</ThemedContainer>
);
}

View File

@@ -1,10 +1,10 @@
import { Redirect } from 'expo-router';
/**
* App index which is the entry point of the app and redirects to the credentials screen.
* App index which is the entry point of the app and redirects to the items screen.
* If user is not logged in, they will automatically be redirected to the login screen instead
* by global navigation handlers.
*/
export default function AppIndex() : React.ReactNode {
return <Redirect href={'/credentials'} />;
return <Redirect href={'/items'} />;
}

View File

@@ -235,7 +235,7 @@ export default function LoginScreen() : React.ReactNode {
setPasswordHashBase64(null);
setInitiateLoginResponse(null);
setLoginStatus(null);
router.replace('/(tabs)/credentials');
router.replace('/(tabs)/items');
setIsLoading(false);
};

View File

@@ -29,8 +29,8 @@ export default function ActionHandler() : null {
const pathArray = Array.isArray(pathSegments) ? pathSegments : pathSegments ? [pathSegments] : [];
if (pathArray.length === 0) {
// No action specified, go to credentials
router.replace('/(tabs)/credentials');
// No action specified, go to items
router.replace('/(tabs)/items');
setHasNavigated(true);
return;
}
@@ -56,9 +56,9 @@ export default function ActionHandler() : null {
}
default:
// Unknown action, log and go to credentials
// Unknown action, log and go to items
console.warn('[ActionHandler] Unknown action:', action);
router.replace('/(tabs)/credentials');
router.replace('/(tabs)/items');
setHasNavigated(true);
break;
}

View File

@@ -230,24 +230,24 @@ export default function UpgradeScreen() : React.ReactNode {
*/
onStatus: (message) => setUpgradeStatus(message),
/**
* Handle successful vault sync and navigate to credentials.
* Handle successful vault sync and navigate to items.
*/
onSuccess: () => {
router.replace('/(tabs)/credentials');
router.replace('/(tabs)/items');
},
/**
* Handle sync error and still navigate to credentials.
* Handle sync error and still navigate to items.
*/
onError: (error) => {
console.error('Sync error after upgrade:', error);
// Still navigate to credentials even if sync fails
router.replace('/(tabs)/credentials');
// Still navigate to items even if sync fails
router.replace('/(tabs)/items');
}
});
} catch (error) {
console.error('Error during post-upgrade flow:', error);
// Navigate to credentials anyway
router.replace('/(tabs)/credentials');
// Navigate to items anyway
router.replace('/(tabs)/items');
}
};

View File

@@ -1,7 +1,6 @@
import { MaterialIcons } from '@expo/vector-icons';
import Slider from '@react-native-community/slider';
import React, { forwardRef, useImperativeHandle, useMemo, useRef, useState, useCallback, useEffect } from 'react';
import { Controller, Control, FieldValues, Path } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { View, TextInput, TextInputProps, StyleSheet, TouchableOpacity, Platform, Modal, ScrollView, Switch } from 'react-native';
@@ -18,20 +17,20 @@ export type AdvancedPasswordFieldRef = {
selectAll: () => void;
};
type AdvancedPasswordFieldProps<T extends FieldValues> = Omit<TextInputProps, 'value' | 'onChangeText'> & {
type AdvancedPasswordFieldProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
label: string;
name: Path<T>;
control: Control<T>;
value: string;
onChangeText: (value: string) => void;
required?: boolean;
showPassword?: boolean;
onShowPasswordChange?: (show: boolean) => void;
isNewCredential?: boolean;
}
const AdvancedPasswordFieldComponent = forwardRef<AdvancedPasswordFieldRef, AdvancedPasswordFieldProps<FieldValues>>(({
const AdvancedPasswordFieldComponent = forwardRef<AdvancedPasswordFieldRef, AdvancedPasswordFieldProps>(({
label,
name,
control,
value,
onChangeText,
required,
showPassword: controlledShowPassword,
onShowPasswordChange,
@@ -45,12 +44,10 @@ const AdvancedPasswordFieldComponent = forwardRef<AdvancedPasswordFieldRef, Adva
const [showSettingsModal, setShowSettingsModal] = useState(false);
const [currentSettings, setCurrentSettings] = useState<PasswordSettings | null>(null);
const [previewPassword, setPreviewPassword] = useState<string>('');
const [sliderValue, setSliderValue] = useState<number>(16); // Default until loaded from DB
const fieldOnChangeRef = useRef<((value: string) => void) | null>(null);
const [sliderValue, setSliderValue] = useState<number>(16);
const lastGeneratedLength = useRef<number>(0);
const isSliding = useRef(false);
const hasSetInitialLength = useRef(false);
const currentPasswordRef = useRef<string>('');
const dbContext = useDb();
const showPassword = controlledShowPassword ?? internalShowPassword;
@@ -69,7 +66,6 @@ const AdvancedPasswordFieldComponent = forwardRef<AdvancedPasswordFieldRef, Adva
if (dbContext.sqliteClient) {
const settings = await dbContext.sqliteClient.getPasswordSettings();
setCurrentSettings(settings);
// Always set slider value from loaded settings
setSliderValue(settings.Length);
hasSetInitialLength.current = true;
}
@@ -80,16 +76,27 @@ const AdvancedPasswordFieldComponent = forwardRef<AdvancedPasswordFieldRef, Adva
loadSettings();
}, [dbContext.sqliteClient]);
// Sync slider value with password length
useEffect(() => {
if (!hasSetInitialLength.current) {
if (!isNewCredential && value && value.length > 0) {
setSliderValue(value.length);
hasSetInitialLength.current = true;
} else if (isNewCredential) {
hasSetInitialLength.current = true;
}
}
}, [value, isNewCredential]);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
selectAll: () => {
const input = inputRef.current;
if (input && input.props.value) {
input.setSelection(0, String(input.props.value).length);
if (input && value) {
input.setSelection(0, value.length);
}
}
}), []);
}), [value]);
const generatePassword = useCallback((settings: PasswordSettings): string => {
try {
@@ -102,52 +109,49 @@ const AdvancedPasswordFieldComponent = forwardRef<AdvancedPasswordFieldRef, Adva
}, []);
const handleGeneratePassword = useCallback(() => {
if (fieldOnChangeRef.current && currentSettings) {
if (currentSettings) {
const password = generatePassword(currentSettings);
if (password) {
fieldOnChangeRef.current(password);
onChangeText(password);
setShowPasswordState(true);
}
}
}, [currentSettings, generatePassword, setShowPasswordState]);
}, [currentSettings, generatePassword, onChangeText, setShowPasswordState]);
const handleSliderChange = useCallback((value: number) => {
const roundedLength = Math.round(value);
const handleSliderChange = useCallback((sliderVal: number) => {
const roundedLength = Math.round(sliderVal);
setSliderValue(roundedLength);
// Only generate if value actually changed and we're actively sliding
if (roundedLength !== lastGeneratedLength.current && isSliding.current) {
lastGeneratedLength.current = roundedLength;
// Show password when sliding
if (!showPassword) {
setShowPasswordState(true);
}
const newSettings = { ...(currentSettings || {}), Length: roundedLength } as PasswordSettings;
if (fieldOnChangeRef.current && currentSettings) {
if (currentSettings) {
const password = generatePassword(newSettings);
if (password) {
fieldOnChangeRef.current(password);
onChangeText(password);
}
}
}
}, [currentSettings, generatePassword, showPassword, setShowPasswordState]);
}, [currentSettings, generatePassword, showPassword, setShowPasswordState, onChangeText]);
const handleSliderStart = useCallback(() => {
isSliding.current = true;
// Initialize lastGeneratedLength when starting to slide
lastGeneratedLength.current = sliderValue;
}, [sliderValue]);
const handleSliderComplete = useCallback((value: number) => {
const handleSliderComplete = useCallback((sliderVal: number) => {
isSliding.current = false;
const roundedLength = Math.round(value);
const roundedLength = Math.round(sliderVal);
if (currentSettings) {
const newSettings = { ...currentSettings, Length: roundedLength };
setCurrentSettings(newSettings);
}
lastGeneratedLength.current = 0; // Reset for next sliding session
lastGeneratedLength.current = 0;
}, [currentSettings]);
const handleRefreshPreview = useCallback(() => {
@@ -158,12 +162,12 @@ const AdvancedPasswordFieldComponent = forwardRef<AdvancedPasswordFieldRef, Adva
}, [currentSettings, generatePassword]);
const handleUsePassword = useCallback(() => {
if (fieldOnChangeRef.current && previewPassword) {
fieldOnChangeRef.current(previewPassword);
if (previewPassword) {
onChangeText(previewPassword);
setShowPasswordState(true);
setShowSettingsModal(false);
}
}, [previewPassword, setShowPasswordState]);
}, [previewPassword, onChangeText, setShowPasswordState]);
const handleOpenSettings = useCallback(() => {
if (currentSettings) {
@@ -173,10 +177,10 @@ const AdvancedPasswordFieldComponent = forwardRef<AdvancedPasswordFieldRef, Adva
}
}, [currentSettings, generatePassword]);
const updateSetting = useCallback((key: keyof PasswordSettings, value: boolean) => {
const updateSetting = useCallback((key: keyof PasswordSettings, settingValue: boolean) => {
setCurrentSettings(prev => {
if (!prev) return prev;
const newSettings = { ...prev, [key]: value };
const newSettings = { ...prev, [key]: settingValue };
const password = generatePassword(newSettings);
setPreviewPassword(password);
return newSettings;
@@ -217,9 +221,6 @@ const AdvancedPasswordFieldComponent = forwardRef<AdvancedPasswordFieldRef, Adva
borderWidth: 1,
flexDirection: 'row',
},
inputError: {
borderColor: 'red',
},
inputGroup: {
marginBottom: 6,
},
@@ -289,8 +290,8 @@ const AdvancedPasswordFieldComponent = forwardRef<AdvancedPasswordFieldRef, Adva
},
settingLabel: {
color: colors.text,
fontSize: 14,
flex: 1,
fontSize: 14,
},
settingsButton: {
marginLeft: 8,
@@ -343,223 +344,195 @@ const AdvancedPasswordFieldComponent = forwardRef<AdvancedPasswordFieldRef, Adva
},
}), [colors]);
const showClearButton = Platform.OS === 'android' && value && value.length > 0;
return (
<Controller
control={control}
name={name}
render={({ field: { onChange, value }, fieldState: { error } }) => {
fieldOnChangeRef.current = onChange;
currentPasswordRef.current = value as string || '';
<View style={styles.inputGroup}>
<ThemedText style={styles.inputLabel}>
{label} {required && <ThemedText style={styles.requiredIndicator}>*</ThemedText>}
</ThemedText>
// Use useEffect to update slider value when password value changes
// This avoids setState during render
useEffect(() => {
if (!hasSetInitialLength.current) {
if (!isNewCredential && value && typeof value === 'string' && value.length > 0) {
// Editing existing credential: use actual password length
setSliderValue(value.length);
hasSetInitialLength.current = true;
} else if (isNewCredential) {
// New credential: settings default is already set
hasSetInitialLength.current = true;
}
}
}, [value]);
<View style={styles.inputContainer}>
<TextInput
ref={inputRef}
style={styles.input}
value={value}
placeholderTextColor={colors.textMuted}
onChangeText={onChangeText}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
clearButtonMode={Platform.OS === 'ios' ? "while-editing" : "never"}
secureTextEntry={!showPassword}
{...props}
/>
const showClearButton = Platform.OS === 'android' && value && value.length > 0;
{showClearButton && (
<TouchableOpacity
style={styles.clearButton}
onPress={() => onChangeText('')}
activeOpacity={0.7}
>
<MaterialIcons name="close" size={16} color={colors.textMuted} />
</TouchableOpacity>
)}
return (
<View style={styles.inputGroup}>
<ThemedText style={styles.inputLabel}>
{label} {required && <ThemedText style={styles.requiredIndicator}>*</ThemedText>}
</ThemedText>
<TouchableOpacity
style={styles.button}
onPress={() => setShowPasswordState(!showPassword)}
activeOpacity={0.7}
>
<MaterialIcons
name={showPassword ? "visibility-off" : "visibility"}
size={20}
color={colors.primary}
/>
</TouchableOpacity>
<View style={[styles.inputContainer, error ? styles.inputError : null]}>
<TextInput
ref={inputRef}
style={styles.input}
value={value as string}
placeholderTextColor={colors.textMuted}
onChangeText={onChange}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
clearButtonMode={Platform.OS === 'ios' ? "while-editing" : "never"}
secureTextEntry={!showPassword}
{...props}
/>
<TouchableOpacity
style={styles.button}
onPress={handleGeneratePassword}
activeOpacity={0.7}
>
<MaterialIcons name="refresh" size={20} color={colors.primary} />
</TouchableOpacity>
</View>
{showClearButton && (
<TouchableOpacity
style={styles.clearButton}
onPress={() => onChange('')}
activeOpacity={0.7}
>
<MaterialIcons name="close" size={16} color={colors.textMuted} />
</TouchableOpacity>
)}
<View style={styles.sliderContainer}>
<View style={styles.sliderHeader}>
<ThemedText style={styles.sliderLabel}>{t('credentials.passwordLength')}</ThemedText>
<View style={styles.sliderValueContainer}>
<ThemedText style={styles.sliderValue}>{sliderValue}</ThemedText>
<TouchableOpacity
style={styles.settingsButton}
onPress={handleOpenSettings}
activeOpacity={0.7}
>
<MaterialIcons name="settings" size={20} color={colors.primary} />
</TouchableOpacity>
</View>
</View>
<Slider
style={styles.slider}
minimumValue={8}
maximumValue={64}
value={sliderValue}
onValueChange={handleSliderChange}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
step={1}
minimumTrackTintColor={colors.primary}
maximumTrackTintColor={colors.accentBorder}
thumbTintColor={colors.primary}
/>
</View>
<Modal
visible={showSettingsModal}
transparent
animationType="fade"
onRequestClose={() => setShowSettingsModal(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<ThemedText style={styles.modalTitle}>{t('credentials.changePasswordComplexity')}</ThemedText>
<TouchableOpacity
style={styles.button}
onPress={() => setShowPasswordState(!showPassword)}
style={styles.closeButton}
onPress={() => setShowSettingsModal(false)}
activeOpacity={0.7}
>
<MaterialIcons
name={showPassword ? "visibility-off" : "visibility"}
size={20}
color={colors.primary}
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.button}
onPress={handleGeneratePassword}
activeOpacity={0.7}
>
<MaterialIcons name="refresh" size={20} color={colors.primary} />
<MaterialIcons name="close" size={24} color={colors.textMuted} />
</TouchableOpacity>
</View>
<View style={styles.sliderContainer}>
<View style={styles.sliderHeader}>
<ThemedText style={styles.sliderLabel}>{t('credentials.passwordLength')}</ThemedText>
<View style={styles.sliderValueContainer}>
<ThemedText style={styles.sliderValue}>{sliderValue}</ThemedText>
<ScrollView showsVerticalScrollIndicator={false}>
<View style={styles.previewContainer}>
<View style={styles.previewInputContainer}>
<TextInput
style={styles.previewInput}
value={previewPassword}
editable={false}
/>
<TouchableOpacity
style={styles.settingsButton}
onPress={handleOpenSettings}
style={styles.refreshButton}
onPress={handleRefreshPreview}
activeOpacity={0.7}
>
<MaterialIcons name="settings" size={20} color={colors.primary} />
<MaterialIcons name="refresh" size={20} color={colors.primary} />
</TouchableOpacity>
</View>
</View>
<Slider
style={styles.slider}
minimumValue={8}
maximumValue={64}
value={sliderValue}
onValueChange={handleSliderChange}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
step={1}
minimumTrackTintColor={colors.primary}
maximumTrackTintColor={colors.accentBorder}
thumbTintColor={colors.primary}
/>
</View>
<View style={styles.settingsSection}>
<View style={styles.settingItem}>
<ThemedText style={styles.settingLabel}>{t('credentials.includeLowercase')}</ThemedText>
<Switch
value={currentSettings?.UseLowercase ?? true}
onValueChange={(settingValue) => updateSetting('UseLowercase', settingValue)}
trackColor={{ false: colors.accentBorder, true: colors.primary }}
thumbColor={Platform.OS === 'android' ? colors.background : undefined}
/>
</View>
{error && <ThemedText style={styles.errorText}>{error.message}</ThemedText>}
<View style={styles.settingItem}>
<ThemedText style={styles.settingLabel}>{t('credentials.includeUppercase')}</ThemedText>
<Switch
value={currentSettings?.UseUppercase ?? true}
onValueChange={(settingValue) => updateSetting('UseUppercase', settingValue)}
trackColor={{ false: colors.accentBorder, true: colors.primary }}
thumbColor={Platform.OS === 'android' ? colors.background : undefined}
/>
</View>
<Modal
visible={showSettingsModal}
transparent
animationType="fade"
onRequestClose={() => setShowSettingsModal(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<ThemedText style={styles.modalTitle}>{t('credentials.changePasswordComplexity')}</ThemedText>
<TouchableOpacity
style={styles.closeButton}
onPress={() => setShowSettingsModal(false)}
activeOpacity={0.7}
>
<MaterialIcons name="close" size={24} color={colors.textMuted} />
</TouchableOpacity>
</View>
<View style={styles.settingItem}>
<ThemedText style={styles.settingLabel}>{t('credentials.includeNumbers')}</ThemedText>
<Switch
value={currentSettings?.UseNumbers ?? true}
onValueChange={(settingValue) => updateSetting('UseNumbers', settingValue)}
trackColor={{ false: colors.accentBorder, true: colors.primary }}
thumbColor={Platform.OS === 'android' ? colors.background : undefined}
/>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
<View style={styles.previewContainer}>
<View style={styles.previewInputContainer}>
<TextInput
style={styles.previewInput}
value={previewPassword}
editable={false}
/>
<TouchableOpacity
style={styles.refreshButton}
onPress={handleRefreshPreview}
activeOpacity={0.7}
>
<MaterialIcons name="refresh" size={20} color={colors.primary} />
</TouchableOpacity>
</View>
</View>
<View style={styles.settingItem}>
<ThemedText style={styles.settingLabel}>{t('credentials.includeSpecialChars')}</ThemedText>
<Switch
value={currentSettings?.UseSpecialChars ?? true}
onValueChange={(settingValue) => updateSetting('UseSpecialChars', settingValue)}
trackColor={{ false: colors.accentBorder, true: colors.primary }}
thumbColor={Platform.OS === 'android' ? colors.background : undefined}
/>
</View>
<View style={styles.settingsSection}>
<View style={styles.settingItem}>
<ThemedText style={styles.settingLabel}>{t('credentials.includeLowercase')}</ThemedText>
<Switch
value={currentSettings?.UseLowercase ?? true}
onValueChange={(value) => updateSetting('UseLowercase', value)}
trackColor={{ false: colors.accentBorder, true: colors.primary }}
thumbColor={Platform.OS === 'android' ? colors.background : undefined}
/>
</View>
<View style={styles.settingItem}>
<ThemedText style={styles.settingLabel}>{t('credentials.includeUppercase')}</ThemedText>
<Switch
value={currentSettings?.UseUppercase ?? true}
onValueChange={(value) => updateSetting('UseUppercase', value)}
trackColor={{ false: colors.accentBorder, true: colors.primary }}
thumbColor={Platform.OS === 'android' ? colors.background : undefined}
/>
</View>
<View style={styles.settingItem}>
<ThemedText style={styles.settingLabel}>{t('credentials.includeNumbers')}</ThemedText>
<Switch
value={currentSettings?.UseNumbers ?? true}
onValueChange={(value) => updateSetting('UseNumbers', value)}
trackColor={{ false: colors.accentBorder, true: colors.primary }}
thumbColor={Platform.OS === 'android' ? colors.background : undefined}
/>
</View>
<View style={styles.settingItem}>
<ThemedText style={styles.settingLabel}>{t('credentials.includeSpecialChars')}</ThemedText>
<Switch
value={currentSettings?.UseSpecialChars ?? true}
onValueChange={(value) => updateSetting('UseSpecialChars', value)}
trackColor={{ false: colors.accentBorder, true: colors.primary }}
thumbColor={Platform.OS === 'android' ? colors.background : undefined}
/>
</View>
<View style={styles.settingItem}>
<ThemedText style={styles.settingLabel}>{t('credentials.avoidAmbiguousChars')}</ThemedText>
<Switch
value={currentSettings?.UseNonAmbiguousChars ?? false}
onValueChange={(value) => updateSetting('UseNonAmbiguousChars', value)}
trackColor={{ false: colors.accentBorder, true: colors.primary }}
thumbColor={Platform.OS === 'android' ? colors.background : undefined}
/>
</View>
</View>
<TouchableOpacity
style={styles.useButton}
onPress={handleUsePassword}
activeOpacity={0.7}
>
<MaterialIcons name="keyboard-arrow-down" size={20} color={colors.text} />
<ThemedText style={styles.useButtonText}>{t('common.use')}</ThemedText>
</TouchableOpacity>
</ScrollView>
<View style={styles.settingItem}>
<ThemedText style={styles.settingLabel}>{t('credentials.avoidAmbiguousChars')}</ThemedText>
<Switch
value={currentSettings?.UseNonAmbiguousChars ?? false}
onValueChange={(settingValue) => updateSetting('UseNonAmbiguousChars', settingValue)}
trackColor={{ false: colors.accentBorder, true: colors.primary }}
thumbColor={Platform.OS === 'android' ? colors.background : undefined}
/>
</View>
</View>
</Modal>
<TouchableOpacity
style={styles.useButton}
onPress={handleUsePassword}
activeOpacity={0.7}
>
<MaterialIcons name="keyboard-arrow-down" size={20} color={colors.text} />
<ThemedText style={styles.useButtonText}>{t('common.use')}</ThemedText>
</TouchableOpacity>
</ScrollView>
</View>
);
}}
/>
</View>
</Modal>
</View>
);
});
AdvancedPasswordFieldComponent.displayName = 'AdvancedPasswordField';
export const AdvancedPasswordField = AdvancedPasswordFieldComponent as <T extends FieldValues>(props: AdvancedPasswordFieldProps<T> & { ref?: React.Ref<AdvancedPasswordFieldRef> }) => JSX.Element;
export const AdvancedPasswordField = AdvancedPasswordFieldComponent;

View File

@@ -0,0 +1,152 @@
import { MaterialIcons } from '@expo/vector-icons';
import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react';
import { View, TextInput, TextInputProps, StyleSheet, TouchableHighlight, Platform } from 'react-native';
import { useColors } from '@/hooks/useColorScheme';
import { ThemedText } from '@/components/themed/ThemedText';
type FormFieldButton = {
icon: keyof typeof MaterialIcons.glyphMap;
onPress: () => void;
}
export type FormFieldRef = {
focus: () => void;
selectAll: () => void;
}
type FormFieldProps = Omit<TextInputProps, 'onChangeText'> & {
label: string;
value: string;
onChangeText: (value: string) => void;
required?: boolean;
buttons?: FormFieldButton[];
error?: string;
}
/**
* Simple form field component without react-hook-form.
*/
const FormFieldComponent = forwardRef<FormFieldRef, FormFieldProps>(({
label,
value,
onChangeText,
required,
buttons,
error,
...props
}, ref) => {
const colors = useColors();
const inputRef = useRef<TextInput>(null);
const [isFocused, setIsFocused] = useState(false);
useImperativeHandle(ref, () => ({
focus: (): void => {
inputRef.current?.focus();
},
selectAll: (): void => {
inputRef.current?.setSelection(0, (value || '').length);
}
}));
const colorRed = 'red';
const styles = StyleSheet.create({
button: {
borderLeftColor: colors.accentBorder,
borderLeftWidth: 1,
padding: 10,
},
clearButton: {
borderRadius: 6,
marginRight: 4,
padding: 6,
},
errorText: {
color: colorRed,
fontSize: 12,
marginTop: 4,
},
input: {
color: colors.text,
flex: 1,
fontSize: 16,
marginRight: 5,
padding: 10,
},
inputContainer: {
alignItems: 'center',
backgroundColor: colors.background,
borderColor: colors.accentBorder,
borderRadius: 6,
borderWidth: 1,
flexDirection: 'row',
},
inputError: {
borderColor: colorRed,
},
inputGroup: {
marginBottom: 6,
},
inputLabel: {
color: colors.textMuted,
fontSize: 12,
marginBottom: 4,
},
requiredIndicator: {
color: colorRed,
marginLeft: 4,
},
});
const showClearButton = Platform.OS === 'android' && value && value.length > 0 && isFocused;
return (
<View style={styles.inputGroup}>
<ThemedText style={styles.inputLabel}>
{label} {required && <ThemedText style={styles.requiredIndicator}>*</ThemedText>}
</ThemedText>
<View style={[styles.inputContainer, error ? styles.inputError : null]}>
<TextInput
ref={inputRef}
style={styles.input}
value={value}
placeholderTextColor={colors.textMuted}
onChangeText={onChangeText}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
clearButtonMode={Platform.OS === 'ios' ? "while-editing" : "never"}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
{...props}
/>
{showClearButton && (
<TouchableHighlight
style={styles.clearButton}
onPress={() => onChangeText('')}
underlayColor={colors.accentBackground}
>
<MaterialIcons name="close" size={16} color={colors.textMuted} />
</TouchableHighlight>
)}
{buttons?.map((button, index) => (
<TouchableHighlight
key={index}
style={styles.button}
onPress={button.onPress}
underlayColor={colors.accentBackground}
>
<MaterialIcons name={button.icon} size={20} color={colors.primary} />
</TouchableHighlight>
))}
</View>
{error && <ThemedText style={styles.errorText}>{error}</ThemedText>}
</View>
);
});
FormFieldComponent.displayName = 'FormField';
export const FormField = FormFieldComponent;

View File

@@ -2,60 +2,59 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { router } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { StyleSheet, View, Text, TouchableOpacity, Keyboard, Platform, Alert } from 'react-native';
import ContextMenu, { OnPressMenuItemEvent } from 'react-native-context-menu-view';
import ContextMenu, { ContextMenuOnPressNativeEvent } from 'react-native-context-menu-view';
import type { NativeSyntheticEvent } from 'react-native';
import Toast from 'react-native-toast-message';
import { copyToClipboardWithExpiration } from '@/utils/ClipboardUtility';
import type { Credential } from '@/utils/dist/core/models/vault';
import type { Item } from '@/utils/dist/core/models/vault';
import { getFieldValue, FieldKey } from '@/utils/dist/core/models/vault';
import { useColors } from '@/hooks/useColorScheme';
import { CredentialIcon } from '@/components/credentials/CredentialIcon';
import { ItemIcon } from '@/components/items/ItemIcon';
import { useAuth } from '@/context/AuthContext';
type CredentialCardProps = {
credential: Credential;
onCredentialDelete?: (credentialId: string) => Promise<void>;
type ItemCardProps = {
item: Item;
onItemDelete?: (itemId: string) => Promise<void>;
};
/**
* Credential card component.
* Item card component for displaying vault items in a list.
*/
export function CredentialCard({ credential, onCredentialDelete }: CredentialCardProps) : React.ReactNode {
export function ItemCard({ item, onItemDelete }: ItemCardProps): React.ReactNode {
const colors = useColors();
const { t } = useTranslation();
const { getClipboardClearTimeout } = useAuth();
/**
* Get the display text for a credential, showing username by default,
* Get the display text for an item, showing username by default,
* falling back to email only if username is null/undefined/empty
*/
const getCredentialDisplayText = (cred: Credential): string => {
let returnValue = '';
const getItemDisplayText = (itm: Item): string => {
// Show username if available
if (cred.Username) {
returnValue = cred.Username;
const username = getFieldValue(itm, FieldKey.LoginUsername);
if (username) {
// Trim the return value to max. 38 characters.
return username.length > 38 ? username.slice(0, 35) + '...' : username;
}
// Show email if username is not available
if (cred.Alias?.Email) {
returnValue = cred.Alias.Email;
const email = getFieldValue(itm, FieldKey.LoginEmail);
if (email) {
// Trim the return value to max. 38 characters.
return email.length > 38 ? email.slice(0, 35) + '...' : email;
}
// Trim the return value to max. 38 characters.
return returnValue.length > 38 ? returnValue.slice(0, 35) + '...' : returnValue;
return '';
};
/**
* Get the service name for a credential, trimming it to maximum length so it doesn't overflow the UI.
* Get the item name, trimming it to maximum length so it doesn't overflow the UI.
*/
const getCredentialServiceName = (cred: Credential): string => {
let returnValue = 'Untitled';
if (cred.ServiceName) {
returnValue = cred.ServiceName;
}
const getItemName = (itm: Item): string => {
const returnValue = itm.Name || t('items.untitled');
// Trim the return value to max. 33 characters.
return returnValue.length > 33 ? returnValue.slice(0, 30) + '...' : returnValue;
@@ -80,22 +79,22 @@ export function CredentialCard({ credential, onCredentialDelete }: CredentialCar
* Handles the context menu action when an item is selected.
* @param event - The event object containing the selected action details
*/
const handleContextMenuAction = async (event: OnPressMenuItemEvent): Promise<void> => {
const handleContextMenuAction = async (event: NativeSyntheticEvent<ContextMenuOnPressNativeEvent>): Promise<void> => {
const { name } = event.nativeEvent;
switch (name) {
case t('credentials.contextMenu.edit'):
case t('items.contextMenu.edit'):
Keyboard.dismiss();
router.push({
pathname: '/(tabs)/credentials/add-edit',
params: { id: credential.Id }
pathname: '/(tabs)/items/add-edit',
params: { id: item.Id }
});
break;
case t('credentials.contextMenu.delete'):
case t('items.contextMenu.delete'):
Keyboard.dismiss();
Alert.alert(
t('credentials.deleteCredential'),
t('credentials.deleteConfirm'),
t('items.deleteItem'),
t('items.deleteConfirm'),
[
{
text: t('common.cancel'),
@@ -105,50 +104,59 @@ export function CredentialCard({ credential, onCredentialDelete }: CredentialCar
text: t('common.delete'),
style: "destructive",
/**
* Handles the delete credential action.
* Handles the delete item action.
*/
onPress: async () : Promise<void> => {
if (onCredentialDelete) {
await onCredentialDelete(credential.Id);
onPress: async (): Promise<void> => {
if (onItemDelete) {
await onItemDelete(item.Id);
}
}
}
]
);
break;
case t('credentials.contextMenu.copyUsername'):
if (credential.Username) {
await copyToClipboard(credential.Username);
if (Platform.OS === 'ios') {
Toast.show({
type: 'success',
text1: t('credentials.toasts.usernameCopied'),
position: 'bottom',
});
case t('items.contextMenu.copyUsername'):
{
const username = getFieldValue(item, FieldKey.LoginUsername);
if (username) {
await copyToClipboard(username);
if (Platform.OS === 'ios') {
Toast.show({
type: 'success',
text1: t('items.toasts.usernameCopied'),
position: 'bottom',
});
}
}
}
break;
case t('credentials.contextMenu.copyEmail'):
if (credential.Alias?.Email) {
await copyToClipboard(credential.Alias.Email);
if (Platform.OS === 'ios') {
Toast.show({
type: 'success',
text1: t('credentials.toasts.emailCopied'),
position: 'bottom',
});
case t('items.contextMenu.copyEmail'):
{
const email = getFieldValue(item, FieldKey.LoginEmail);
if (email) {
await copyToClipboard(email);
if (Platform.OS === 'ios') {
Toast.show({
type: 'success',
text1: t('items.toasts.emailCopied'),
position: 'bottom',
});
}
}
}
break;
case t('credentials.contextMenu.copyPassword'):
if (credential.Password) {
await copyToClipboard(credential.Password);
if (Platform.OS === 'ios') {
Toast.show({
type: 'success',
text1: t('credentials.toasts.passwordCopied'),
position: 'bottom',
});
case t('items.contextMenu.copyPassword'):
{
const password = getFieldValue(item, FieldKey.LoginPassword);
if (password) {
await copyToClipboard(password);
if (Platform.OS === 'ios') {
Toast.show({
type: 'success',
text1: t('items.toasts.passwordCopied'),
position: 'bottom',
});
}
}
}
break;
@@ -156,7 +164,7 @@ export function CredentialCard({ credential, onCredentialDelete }: CredentialCar
};
/**
* Gets the menu actions for the context menu based on available credential data.
* Gets the menu actions for the context menu based on available item data.
* @returns Array of menu action objects with title and icon
*/
const getMenuActions = (): {
@@ -164,50 +172,58 @@ export function CredentialCard({ credential, onCredentialDelete }: CredentialCar
systemIcon: string;
destructive?: boolean;
}[] => {
const actions = [
const actions: { title: string; systemIcon: string; destructive?: boolean }[] = [
{
title: t('credentials.contextMenu.edit'),
title: t('items.contextMenu.edit'),
systemIcon: Platform.select({
ios: 'pencil',
android: 'baseline_edit',
default: 'pencil',
}),
},
{
title: t('credentials.contextMenu.delete'),
title: t('items.contextMenu.delete'),
systemIcon: Platform.select({
ios: 'trash',
android: 'baseline_delete',
default: 'trash',
}),
destructive: true,
},
];
if (credential.Username) {
const username = getFieldValue(item, FieldKey.LoginUsername);
if (username) {
actions.push({
title: t('credentials.contextMenu.copyUsername'),
title: t('items.contextMenu.copyUsername'),
systemIcon: Platform.select({
ios: 'person',
android: 'baseline_person',
default: 'person',
}),
});
}
if (credential.Alias?.Email) {
const email = getFieldValue(item, FieldKey.LoginEmail);
if (email) {
actions.push({
title: t('credentials.contextMenu.copyEmail'),
title: t('items.contextMenu.copyEmail'),
systemIcon: Platform.select({
ios: 'envelope',
android: 'baseline_email',
default: 'envelope',
}),
});
}
if (credential.Password) {
const password = getFieldValue(item, FieldKey.LoginPassword);
if (password) {
actions.push({
title: t('credentials.contextMenu.copyPassword'),
title: t('items.contextMenu.copyPassword'),
systemIcon: Platform.select({
ios: 'key',
android: 'baseline_key',
default: 'key',
}),
});
}
@@ -222,26 +238,26 @@ export function CredentialCard({ credential, onCredentialDelete }: CredentialCar
marginBottom: 8,
padding: 12,
},
credentialContent: {
itemContent: {
alignItems: 'center',
flexDirection: 'row',
},
credentialInfo: {
itemInfo: {
flex: 1,
},
credentialText: {
itemText: {
color: colors.textMuted,
fontSize: 14,
},
iconStyle: {
marginLeft: 6,
},
logo: {
borderRadius: 4,
height: 32,
marginRight: 12,
width: 32,
},
passkeyIcon: {
marginLeft: 6,
},
serviceName: {
color: colors.text,
fontSize: 16,
@@ -265,39 +281,47 @@ export function CredentialCard({ credential, onCredentialDelete }: CredentialCar
style={styles.credentialCard}
onPress={() => {
Keyboard.dismiss();
router.push(`/(tabs)/credentials/${credential.Id}`);
router.push(`/(tabs)/items/${item.Id}`);
}}
onLongPress={() => {
// Ignore long press to prevent context menu long press from triggering the credential card press.
// Ignore long press to prevent context menu long press from triggering the item card press.
}}
activeOpacity={0.7}
>
<View style={styles.credentialContent}>
<CredentialIcon logo={credential.Logo} style={styles.logo} />
<View style={styles.credentialInfo}>
<View style={styles.itemContent}>
<ItemIcon logo={item.Logo} style={styles.logo} />
<View style={styles.itemInfo}>
<View style={styles.serviceNameRow}>
<Text style={styles.serviceName}>
{getCredentialServiceName(credential)}
{getItemName(item)}
</Text>
{credential.HasPasskey && (
{item.HasPasskey && (
<MaterialIcons
name="vpn-key"
size={14}
color={colors.textMuted}
style={styles.passkeyIcon}
style={styles.iconStyle}
/>
)}
{credential.HasAttachment && (
{item.HasAttachment && (
<MaterialIcons
name="attach-file"
size={14}
color={colors.textMuted}
style={styles.passkeyIcon}
style={styles.iconStyle}
/>
)}
{item.HasTotp && (
<MaterialIcons
name="schedule"
size={14}
color={colors.textMuted}
style={styles.iconStyle}
/>
)}
</View>
<Text style={styles.credentialText}>
{getCredentialDisplayText(credential)}
<Text style={styles.itemText}>
{getItemDisplayText(item)}
</Text>
</View>
</View>

View File

@@ -6,17 +6,17 @@ import { SvgUri } from 'react-native-svg';
import servicePlaceholder from '@/assets/images/service-placeholder.webp';
/**
* Credential icon props.
* Item icon props.
*/
type CredentialIconProps = {
type ItemIconProps = {
logo?: Uint8Array | number[] | string | null;
style?: ImageStyle;
};
/**
* Credential icon component.
* Item icon component.
*/
export function CredentialIcon({ logo, style }: CredentialIconProps) : React.ReactNode {
export function ItemIcon({ logo, style }: ItemIconProps) : React.ReactNode {
/**
* Get the logo source.
*/

View File

@@ -30,7 +30,7 @@ export function ServiceUrlNotice({ serviceUrl, onDismiss }: IServiceUrlNoticePro
*/
const handlePress = (): void => {
router.push({
pathname: '/(tabs)/credentials/add-edit',
pathname: '/(tabs)/items/add-edit',
params: { serviceUrl }
});
};

View File

@@ -1,25 +1,30 @@
import { useTranslation } from 'react-i18next';
import { IdentityHelperUtils } from '@/utils/dist/core/identity-generator';
import type { Credential } from '@/utils/dist/core/models/vault';
import type { Item } from '@/utils/dist/core/models/vault';
import { getFieldValue, FieldKey } from '@/utils/dist/core/models/vault';
import FormInputCopyToClipboard from '@/components/form/FormInputCopyToClipboard';
import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
type AliasDetailsProps = {
credential: Credential;
item: Item;
};
/**
* Alias details component.
*/
export const AliasDetails: React.FC<AliasDetailsProps> = ({ credential }) : React.ReactNode => {
export const AliasDetails: React.FC<AliasDetailsProps> = ({ item }) : React.ReactNode => {
const { t } = useTranslation();
const hasName = Boolean(credential.Alias?.FirstName?.trim() ?? credential.Alias?.LastName?.trim());
const fullName = [credential.Alias?.FirstName, credential.Alias?.LastName].filter(Boolean).join(' ');
const firstName = getFieldValue(item, FieldKey.AliasFirstName)?.trim();
const lastName = getFieldValue(item, FieldKey.AliasLastName)?.trim();
const birthDate = getFieldValue(item, FieldKey.AliasBirthdate);
if (!hasName && !credential.Alias?.NickName && !IdentityHelperUtils.isValidBirthDate(credential.Alias?.BirthDate)) {
const hasName = Boolean(firstName || lastName);
const fullName = [firstName, lastName].filter(Boolean).join(' ');
if (!hasName && !IdentityHelperUtils.isValidBirthDate(birthDate)) {
return null;
}
@@ -32,28 +37,22 @@ export const AliasDetails: React.FC<AliasDetailsProps> = ({ credential }) : Reac
value={fullName}
/>
)}
{credential.Alias?.FirstName && (
{firstName && (
<FormInputCopyToClipboard
label={t('credentials.firstName')}
value={credential.Alias.FirstName}
value={firstName}
/>
)}
{credential.Alias?.LastName && (
{lastName && (
<FormInputCopyToClipboard
label={t('credentials.lastName')}
value={credential.Alias.LastName}
value={lastName}
/>
)}
{credential.Alias?.NickName && (
<FormInputCopyToClipboard
label={t('credentials.nickName')}
value={credential.Alias.NickName}
/>
)}
{IdentityHelperUtils.isValidBirthDate(credential.Alias?.BirthDate) && (
{IdentityHelperUtils.isValidBirthDate(birthDate) && (
<FormInputCopyToClipboard
label={t('credentials.birthDate')}
value={IdentityHelperUtils.normalizeBirthDate(credential.Alias.BirthDate)}
value={IdentityHelperUtils.normalizeBirthDate(birthDate!)}
/>
)}
</ThemedView>

View File

@@ -7,7 +7,7 @@ import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { View, StyleSheet, TouchableOpacity, Alert } from 'react-native';
import type { Credential, Attachment } from '@/utils/dist/core/models/vault';
import type { Item, Attachment } from '@/utils/dist/core/models/vault';
import emitter from '@/utils/EventEmitter';
import { useColors } from '@/hooks/useColorScheme';
@@ -19,13 +19,13 @@ import { useDb } from '@/context/DbContext';
import { FilePreviewModal } from './FilePreviewModal';
type AttachmentSectionProps = {
credential: Credential;
item: Item;
};
/**
* Attachment section component.
*/
export const AttachmentSection: React.FC<AttachmentSectionProps> = ({ credential }): React.ReactNode => {
export const AttachmentSection: React.FC<AttachmentSectionProps> = ({ item }): React.ReactNode => {
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [previewModalVisible, setPreviewModalVisible] = useState(false);
const [selectedFile, setSelectedFile] = useState<{
@@ -176,26 +176,26 @@ export const AttachmentSection: React.FC<AttachmentSectionProps> = ({ credential
}
try {
const attachmentList = await dbContext.sqliteClient.getAttachmentsForCredential(credential.Id);
const attachmentList = await dbContext.sqliteClient.getAttachmentsForItem(item.Id);
setAttachments(attachmentList);
} catch (error) {
console.error('Error loading attachments:', error);
}
}, [credential.Id, dbContext?.sqliteClient]);
}, [item.Id, dbContext?.sqliteClient]);
useEffect((): (() => void) => {
loadAttachments();
const credentialChangedSub = emitter.addListener('credentialChanged', async (changedId: string) => {
if (changedId === credential.Id) {
const itemChangedSub = emitter.addListener('credentialChanged', async (changedId: string) => {
if (changedId === item.Id) {
await loadAttachments();
}
});
return () => {
credentialChangedSub.remove();
itemChangedSub.remove();
};
}, [credential.Id, dbContext?.sqliteClient, loadAttachments]);
}, [item.Id, dbContext?.sqliteClient, loadAttachments]);
if (attachments.length === 0) {
return null;

View File

@@ -78,7 +78,7 @@ export const AttachmentUploader: React.FC<AttachmentUploaderProps> = ({
Id: crypto.randomUUID(),
Filename: file.name,
Blob: byteArray,
CredentialId: '', // Will be set when saving credential
ItemId: '', // Will be set when saving item
CreatedAt: new Date().toISOString(),
UpdatedAt: new Date().toISOString(),
IsDeleted: false,

View File

@@ -372,7 +372,7 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) : React.Rea
const emailPrefix = email.split('@')[0];
Linking.openURL(`https://spamok.com/${emailPrefix}/${mail.id}`);
} else {
router.push(`/(tabs)/credentials/email/${mail.id}`);
router.push(`/(tabs)/items/email/${mail.id}`);
}
}}
>

View File

@@ -2,7 +2,8 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useTranslation } from 'react-i18next';
import { StyleSheet, View } from 'react-native';
import type { Credential } from '@/utils/dist/core/models/vault';
import type { Item } from '@/utils/dist/core/models/vault';
import { getFieldValue, FieldKey } from '@/utils/dist/core/models/vault';
import { useColors } from '@/hooks/useColorScheme';
@@ -10,22 +11,22 @@ import FormInputCopyToClipboard from '@/components/form/FormInputCopyToClipboard
import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
type LoginCredentialsProps = {
credential: Credential;
type LoginFieldsProps = {
item: Item;
};
/**
* Login credentials component.
* Login fields component.
*/
export const LoginCredentials: React.FC<LoginCredentialsProps> = ({ credential }) : React.ReactNode => {
export const LoginFields: React.FC<LoginFieldsProps> = ({ item }) : React.ReactNode => {
const { t } = useTranslation();
const colors = useColors();
const email = credential.Alias?.Email?.trim();
const username = credential.Username?.trim();
const password = credential.Password?.trim();
const email = getFieldValue(item, FieldKey.LoginEmail)?.trim();
const username = getFieldValue(item, FieldKey.LoginUsername)?.trim();
const password = getFieldValue(item, FieldKey.LoginPassword)?.trim();
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const hasLoginCredentials = email || username || password || credential.HasPasskey;
const hasLoginCredentials = email || username || password || item.HasPasskey;
if (!hasLoginCredentials) {
return null;
@@ -88,7 +89,7 @@ export const LoginCredentials: React.FC<LoginCredentialsProps> = ({ credential }
value={username}
/>
)}
{credential.HasPasskey && (
{item.HasPasskey && (
<View style={passkeyStyles.container}>
<View style={passkeyStyles.contentRow}>
<MaterialIcons
@@ -101,26 +102,6 @@ export const LoginCredentials: React.FC<LoginCredentialsProps> = ({ credential }
<ThemedText style={passkeyStyles.label}>
{t('passkeys.passkey')}
</ThemedText>
{credential.PasskeyRpId && (
<View style={passkeyStyles.metadataRow}>
<ThemedText style={passkeyStyles.metadataLabel}>
{t('passkeys.site')}:{' '}
</ThemedText>
<ThemedText style={passkeyStyles.metadataValue}>
{credential.PasskeyRpId}
</ThemedText>
</View>
)}
{credential.PasskeyDisplayName && (
<View style={passkeyStyles.metadataRow}>
<ThemedText style={passkeyStyles.metadataLabel}>
{t('passkeys.displayName')}:{' '}
</ThemedText>
<ThemedText style={passkeyStyles.metadataValue}>
{credential.PasskeyDisplayName}
</ThemedText>
</View>
)}
<ThemedText style={passkeyStyles.helpText}>
{t('passkeys.helpText')}
</ThemedText>

View File

@@ -1,7 +1,8 @@
import { useTranslation } from 'react-i18next';
import { View, Text, StyleSheet, Linking, Pressable } from 'react-native';
import { View, Text, StyleSheet, Linking } from 'react-native';
import type { Credential } from '@/utils/dist/core/models/vault';
import type { Item } from '@/utils/dist/core/models/vault';
import { getFieldValue, FieldKey } from '@/utils/dist/core/models/vault';
import { useColors } from '@/hooks/useColorScheme';
@@ -10,7 +11,7 @@ import { ThemedView } from '@/components/themed/ThemedView';
import { RobustPressable } from '@/components/ui/RobustPressable';
type NotesSectionProps = {
credential: Credential;
item: Item;
};
/**
@@ -64,15 +65,17 @@ const splitTextAndUrls = (text: string): { type: 'text' | 'url', content: string
/**
* Notes section component.
*/
export const NotesSection: React.FC<NotesSectionProps> = ({ credential }) : React.ReactNode => {
export const NotesSection: React.FC<NotesSectionProps> = ({ item }) : React.ReactNode => {
const { t } = useTranslation();
const colors = useColors();
if (!credential.Notes) {
const notes = getFieldValue(item, FieldKey.NotesContent);
if (!notes) {
return null;
}
const parts = splitTextAndUrls(credential.Notes);
const parts = splitTextAndUrls(notes);
/**
* Handle the link press.

View File

@@ -110,7 +110,7 @@ export const TotpEditor: React.FC<TotpEditorProps> = ({
Id: crypto.randomUUID().toUpperCase(),
Name: name,
SecretKey: secretKey,
CredentialId: '' // Will be set when saving the credential
ItemId: '' // Will be set when saving the item
};
// Add to the list

View File

@@ -5,7 +5,7 @@ import { View, StyleSheet, TouchableOpacity, Platform } from 'react-native';
import Toast from 'react-native-toast-message';
import { copyToClipboardWithExpiration } from '@/utils/ClipboardUtility';
import type { Credential, TotpCode } from '@/utils/dist/core/models/vault';
import type { Item, TotpCode } from '@/utils/dist/core/models/vault';
import { useColors } from '@/hooks/useColorScheme';
@@ -15,13 +15,13 @@ import { useAuth } from '@/context/AuthContext';
import { useDb } from '@/context/DbContext';
type TotpSectionProps = {
credential: Credential;
item: Item;
};
/**
* Totp section component.
*/
export const TotpSection: React.FC<TotpSectionProps> = ({ credential }) : React.ReactNode => {
export const TotpSection: React.FC<TotpSectionProps> = ({ item }) : React.ReactNode => {
const [totpCodes, setTotpCodes] = useState<TotpCode[]>([]);
const [currentCodes, setCurrentCodes] = useState<Record<string, string>>({});
const colors = useColors();
@@ -103,7 +103,7 @@ export const TotpSection: React.FC<TotpSectionProps> = ({ credential }) : React.
}
try {
const codes = await dbContext.sqliteClient.getTotpCodesForCredential(credential.Id);
const codes = await dbContext.sqliteClient.getTotpCodesForItem(item.Id);
setTotpCodes(codes);
} catch (error) {
console.error('Error loading TOTP codes:', error);
@@ -111,7 +111,7 @@ export const TotpSection: React.FC<TotpSectionProps> = ({ credential }) : React.
};
loadTotpCodes();
}, [credential, dbContext?.sqliteClient]);
}, [item, dbContext?.sqliteClient]);
useEffect(() => {
/**

View File

@@ -56,8 +56,8 @@ export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ ch
return;
}
// Priority 2: Default navigation to credentials
router.replace('/(tabs)/credentials');
// Priority 2: Default navigation to items
router.replace('/(tabs)/items');
}, [returnUrl, router]);
/**
@@ -72,13 +72,13 @@ export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ ch
const params = returnUrl.params || {};
// Check if this is a detail route (has a sub-page after the tab)
const isCredentialRoute = normalizedPath.includes('/(tabs)/credentials/');
const isItemRoute = normalizedPath.includes('/(tabs)/items/');
const isSettingsRoute = normalizedPath.includes('/(tabs)/settings/') &&
!normalizedPath.endsWith('/(tabs)/settings');
if (isCredentialRoute) {
// Navigate to credentials tab first, then push detail page
router.replace('/(tabs)/credentials');
if (isItemRoute) {
// Navigate to items tab first, then push detail page
router.replace('/(tabs)/items');
setTimeout(() => {
const queryParams = new URLSearchParams(params as Record<string, string>).toString();
const targetUrl = queryParams ? `${normalizedPath}?${queryParams}` : normalizedPath;
@@ -110,7 +110,7 @@ export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ ch
*
* Supports:
* - Action-based URLs: aliasvault://open/mobile-unlock/[id]
* - Direct routes: aliasvault://credentials/[id], aliasvault://settings/[page]
* - Direct routes: aliasvault://items/[id], aliasvault://settings/[page]
*/
const normalizeDeepLinkPath = (urlOrPath: string): string => {
// Remove all URL schemes first
@@ -124,8 +124,8 @@ export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ ch
return path;
}
// Handle credential paths
if (path.startsWith('credentials/') || path.includes('/credentials/')) {
// Handle item paths
if (path.startsWith('items/') || path.includes('/items/')) {
if (!path.startsWith('/')) {
path = `/${path}`;
}

View File

@@ -429,9 +429,122 @@
},
"navigation": {
"credentials": "Credentials",
"vault": "Vault",
"emails": "Emails",
"settings": "Settings"
},
"items": {
"title": "Items",
"addItem": "Add Item",
"editItem": "Edit Item",
"deleteItem": "Delete Item",
"itemDetails": "Item Details",
"itemCreated": "Item Created",
"emailPreview": "Email Preview",
"untitled": "Untitled",
"name": "Name",
"namePlaceholder": "e.g., Google, Amazon",
"url": "URL",
"urlPlaceholder": "e.g., https://google.com",
"service": "Service",
"serviceName": "Service Name",
"serviceUrl": "Service URL",
"loginCredentials": "Login credentials",
"username": "Username",
"email": "Email",
"alias": "Alias",
"metadata": "Metadata",
"firstName": "First Name",
"lastName": "Last Name",
"nickName": "Nick Name",
"fullName": "Full Name",
"gender": "Gender",
"birthDate": "Birth Date",
"birthDatePlaceholder": "YYYY-MM-DD",
"notes": "Notes",
"randomAlias": "Random Alias",
"manual": "Manual",
"generateRandomAlias": "Generate Random Alias",
"clearAliasFields": "Clear Alias Fields",
"enterFullEmail": "Enter full email address",
"enterEmailPrefix": "Enter email prefix",
"useDomainChooser": "Use domain chooser",
"enterCustomDomain": "Enter custom domain",
"selectEmailDomain": "Select Email Domain",
"privateEmailTitle": "Private Email",
"privateEmailAliasVaultServer": "AliasVault server",
"privateEmailDescription": "E2E encrypted, fully private.",
"publicEmailTitle": "Public Temp Email Providers",
"publicEmailDescription": "Anonymous but limited privacy. Email content is readable by anyone that knows the address.",
"searchPlaceholder": "Search vault...",
"noMatchingItems": "No matching items found",
"noItemsFound": "No items found. Create one to get started. Tip: you can also login to the AliasVault web app to import credentials from other password managers.",
"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",
"recentEmails": "Recent emails",
"loadingEmails": "Loading emails...",
"noEmailsYet": "No emails received yet.",
"offlineEmailsMessage": "You are offline. Please connect to the internet to load your emails.",
"emailLoadError": "An error occurred while loading emails. Please try again later.",
"emailUnexpectedError": "An unexpected error occurred while loading emails. Please try again later.",
"password": "Password",
"passwordLength": "Password Length",
"changePasswordComplexity": "Password Settings",
"includeLowercase": "Lowercase (a-z)",
"includeUppercase": "Uppercase (A-Z)",
"includeNumbers": "Numbers (0-9)",
"includeSpecialChars": "Special Characters (!@#)",
"avoidAmbiguousChars": "Avoid Ambiguous Characters",
"deletingItem": "Deleting item...",
"errorLoadingItems": "Error loading items",
"vaultSyncFailed": "Vault sync failed",
"vaultSyncedSuccessfully": "Vault synced successfully",
"vaultUpToDate": "Vault is up-to-date",
"offlineMessage": "You are offline. Please connect to the internet to sync your vault.",
"itemCreatedMessage": "Your new item has been added to your vault and is ready to use.",
"switchBackToBrowser": "Switch back to your browser to continue.",
"filters": {
"all": "(All) Items",
"passkeys": "Passkeys",
"aliases": "Aliases",
"userpass": "Passwords",
"attachments": "Attachments"
},
"twoFactorAuth": "Two-factor authentication",
"totpCode": "TOTP Code",
"attachments": "Attachments",
"deleteAttachment": "Delete",
"fileSavedTo": "File saved to",
"previewNotSupported": "Preview not supported",
"downloadToView": "Download the file to view it",
"unsavedChanges": {
"title": "Discard Changes?",
"message": "You have unsaved changes. Are you sure you want to discard them?",
"discard": "Discard"
},
"toasts": {
"itemUpdated": "Item updated successfully",
"itemCreated": "Item created successfully",
"itemDeleted": "Item deleted successfully",
"usernameCopied": "Username copied to clipboard",
"emailCopied": "Email copied to clipboard",
"passwordCopied": "Password copied to clipboard"
},
"createNewAliasFor": "Create new alias for",
"errors": {
"loadFailed": "Failed to load item",
"saveFailed": "Failed to save item"
},
"contextMenu": {
"title": "Item Options",
"edit": "Edit",
"delete": "Delete",
"copyUsername": "Copy Username",
"copyEmail": "Copy Email",
"copyPassword": "Copy Password"
},
"deleteConfirm": "Are you sure you want to delete this item? This action cannot be undone."
},
"emails": {
"title": "Emails",
"emailDetails": "Email Details",

View File

@@ -51,8 +51,8 @@ export class PostUnlockNavigation {
return;
}
// Priority 2: Default navigation to credentials
router.replace('/(tabs)/credentials');
// Priority 2: Default navigation to items
router.replace('/(tabs)/items');
}
/**
@@ -67,13 +67,13 @@ export class PostUnlockNavigation {
const params = returnUrl.params || {};
// Check if this is a detail route (has a sub-page after the tab)
const isCredentialRoute = normalizedPath.includes('/(tabs)/credentials/');
const isItemRoute = normalizedPath.includes('/(tabs)/items/');
const isSettingsRoute = normalizedPath.includes('/(tabs)/settings/') &&
!normalizedPath.endsWith('/(tabs)/settings');
if (isCredentialRoute) {
// Navigate to credentials tab first, then push detail page
router.replace('/(tabs)/credentials');
if (isItemRoute) {
// Navigate to items tab first, then push detail page
router.replace('/(tabs)/items');
setTimeout(() => {
const queryParams = new URLSearchParams(params as Record<string, string>).toString();
const targetUrl = queryParams ? `${normalizedPath}?${queryParams}` : normalizedPath;
@@ -102,7 +102,7 @@ export class PostUnlockNavigation {
*
* Supports:
* - Action-based URLs: aliasvault://open/mobile-unlock/[id]
* - Direct routes: aliasvault://credentials/[id], aliasvault://settings/[page]
* - Direct routes: aliasvault://items/[id], aliasvault://settings/[page]
*/
private static normalizeDeepLinkPath(urlOrPath: string): string {
// Remove all URL schemes first
@@ -116,8 +116,8 @@ export class PostUnlockNavigation {
return path;
}
// Handle credential paths
if (path.startsWith('credentials/') || path.includes('/credentials/')) {
// Handle item paths
if (path.startsWith('items/') || path.includes('/items/')) {
if (!path.startsWith('/')) {
path = `/${path}`;
}

View File

@@ -1,19 +1,89 @@
import { Buffer } from 'buffer';
import type { EncryptionKeyDerivationParams, VaultMetadata } from '@/utils/dist/core/models/metadata';
import type { Attachment, Credential, EncryptionKey, PasswordSettings, TotpCode, Passkey } from '@/utils/dist/core/models/vault';
import type { Attachment, Credential, EncryptionKey, PasswordSettings, TotpCode, Passkey, Item } from '@/utils/dist/core/models/vault';
import { VaultSqlGenerator, VaultVersion, checkVersionCompatibility, extractVersionFromMigrationId } from '@/utils/dist/core/vault';
import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionIncompatibleError';
import NativeVaultManager from '@/specs/NativeVaultManager';
import * as dateFormatter from '@/utils/dateFormatter';
import { ItemRepository } from '@/utils/db/repositories/ItemRepository';
import { SettingsRepository } from '@/utils/db/repositories/SettingsRepository';
import { LogoRepository } from '@/utils/db/repositories/LogoRepository';
import type { IDatabaseClient, SqliteBindValue } from '@/utils/db/BaseRepository';
import type { ItemWithDeletedAt } from '@/utils/db/mappers/ItemMapper';
type SQLiteBindValue = string | number | null | Uint8Array;
/**
* Client for interacting with the SQLite database through native code.
* Implements IDatabaseClient interface for repository pattern.
*/
class SqliteClient {
class SqliteClient implements IDatabaseClient {
// Repositories for Item-based access (lazy initialized)
private _itemRepository: ItemRepository | null = null;
private _settingsRepository: SettingsRepository | null = null;
private _logoRepository: LogoRepository | null = null;
/**
* Get the ItemRepository instance (lazy initialization).
*/
public get itemRepository(): ItemRepository {
if (!this._itemRepository) {
// Use a factory function to create the repository with 'this' as the client
this._itemRepository = Object.setPrototypeOf(
{ client: this as IDatabaseClient },
ItemRepository.prototype
) as ItemRepository;
// Manually bind 'this' context to all repository methods
Object.getOwnPropertyNames(ItemRepository.prototype).forEach(name => {
const method = ItemRepository.prototype[name as keyof typeof ItemRepository.prototype];
if (typeof method === 'function' && name !== 'constructor') {
(this._itemRepository as unknown as Record<string, unknown>)[name] = method.bind(this._itemRepository);
}
});
}
return this._itemRepository;
}
/**
* Get the SettingsRepository instance (lazy initialization).
*/
public get settingsRepository(): SettingsRepository {
if (!this._settingsRepository) {
this._settingsRepository = Object.setPrototypeOf(
{ client: this as IDatabaseClient },
SettingsRepository.prototype
) as SettingsRepository;
Object.getOwnPropertyNames(SettingsRepository.prototype).forEach(name => {
const method = SettingsRepository.prototype[name as keyof typeof SettingsRepository.prototype];
if (typeof method === 'function' && name !== 'constructor') {
(this._settingsRepository as unknown as Record<string, unknown>)[name] = method.bind(this._settingsRepository);
}
});
}
return this._settingsRepository;
}
/**
* Get the LogoRepository instance (lazy initialization).
*/
public get logoRepository(): LogoRepository {
if (!this._logoRepository) {
this._logoRepository = Object.setPrototypeOf(
{ client: this as IDatabaseClient },
LogoRepository.prototype
) as LogoRepository;
Object.getOwnPropertyNames(LogoRepository.prototype).forEach(name => {
const method = LogoRepository.prototype[name as keyof typeof LogoRepository.prototype];
if (typeof method === 'function' && name !== 'constructor') {
(this._logoRepository as unknown as Record<string, unknown>)[name] = method.bind(this._logoRepository);
}
});
}
return this._logoRepository;
}
/**
* Store the encrypted database via the native code implementation.
*/
@@ -222,6 +292,122 @@ class SqliteClient {
// No-op since the native code handles connection lifecycle
}
// ============================================================================
// NEW: Item-based methods using repository pattern
// ============================================================================
/**
* Fetch all items (new V5 schema).
* @returns Array of Item objects
*/
public async getAllItems(): Promise<Item[]> {
return this.itemRepository.getAll();
}
/**
* Fetch a single item by ID (new V5 schema).
* @param itemId - The ID of the item to fetch
* @returns Item object or null if not found
*/
public async getItemById(itemId: string): Promise<Item | null> {
return this.itemRepository.getById(itemId);
}
/**
* Fetch all unique email addresses from items.
* @returns Array of email addresses
*/
public async getAllItemEmailAddresses(): Promise<string[]> {
return this.itemRepository.getAllEmailAddresses();
}
/**
* Get recently deleted items (in trash).
* @returns Array of items with DeletedAt field
*/
public async getRecentlyDeletedItems(): Promise<ItemWithDeletedAt[]> {
return this.itemRepository.getRecentlyDeleted();
}
/**
* Create a new item with its fields and related entities.
* @param item - The item to create
* @param attachments - Array of attachments
* @param totpCodes - Array of TOTP codes
* @returns The ID of the created item
*/
public async createItem(item: Item, attachments: Attachment[] = [], totpCodes: TotpCode[] = []): Promise<string> {
return this.itemRepository.create(item, attachments, totpCodes);
}
/**
* Update an existing item.
* @param item - The item to update
* @param originalAttachmentIds - IDs of attachments before edit
* @param attachments - Current attachments
* @param originalTotpCodeIds - IDs of TOTP codes before edit
* @param totpCodes - Current TOTP codes
* @returns Number of rows affected
*/
public async updateItem(
item: Item,
originalAttachmentIds: string[],
attachments: Attachment[],
originalTotpCodeIds: string[],
totpCodes: TotpCode[]
): Promise<number> {
return this.itemRepository.update(item, originalAttachmentIds, attachments, originalTotpCodeIds, totpCodes);
}
/**
* Move an item to trash.
* @param itemId - The ID of the item
* @returns Number of rows affected
*/
public async trashItem(itemId: string): Promise<number> {
return this.itemRepository.trash(itemId);
}
/**
* Restore an item from trash.
* @param itemId - The ID of the item
* @returns Number of rows affected
*/
public async restoreItem(itemId: string): Promise<number> {
return this.itemRepository.restore(itemId);
}
/**
* Permanently delete an item.
* @param itemId - The ID of the item
* @returns Number of rows affected
*/
public async permanentlyDeleteItem(itemId: string): Promise<number> {
return this.itemRepository.permanentlyDelete(itemId);
}
/**
* Get TOTP codes for an item (new V5 schema).
* @param itemId - The ID of the item
* @returns Array of TotpCode objects
*/
public async getTotpCodesForItem(itemId: string): Promise<TotpCode[]> {
return this.settingsRepository.getTotpCodesForItem(itemId);
}
/**
* Get attachments for an item (new V5 schema).
* @param itemId - The ID of the item
* @returns Array of attachments
*/
public async getAttachmentsForItem(itemId: string): Promise<Attachment[]> {
return this.settingsRepository.getAttachmentsForItem(itemId);
}
// ============================================================================
// LEGACY: Credential-based methods (kept for backward compatibility)
// ============================================================================
/**
* Fetch a single credential with its associated service information.
* @param credentialId - The ID of the credential to fetch.
@@ -1203,9 +1389,8 @@ class SqliteClient {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return results.map((row: any) => ({
Id: row.Id,
CredentialId: row.CredentialId,
ItemId: row.ItemId ?? row.CredentialId, // Support both old and new schema
RpId: row.RpId,
UserId: row.UserId,
PublicKey: row.PublicKey,
PrivateKey: row.PrivateKey,
DisplayName: row.DisplayName,
@@ -1257,9 +1442,8 @@ class SqliteClient {
const row: any = results[0];
return {
Id: row.Id,
CredentialId: row.CredentialId,
ItemId: row.ItemId ?? row.CredentialId, // Support both old and new schema
RpId: row.RpId,
UserId: row.UserId,
PublicKey: row.PublicKey,
PrivateKey: row.PrivateKey,
DisplayName: row.DisplayName,
@@ -1303,9 +1487,8 @@ class SqliteClient {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return results.map((row: any) => ({
Id: row.Id,
CredentialId: row.CredentialId,
ItemId: row.ItemId ?? row.CredentialId, // Support both old and new schema
RpId: row.RpId,
UserId: row.UserId,
PublicKey: row.PublicKey,
PrivateKey: row.PrivateKey,
DisplayName: row.DisplayName,
@@ -1318,7 +1501,7 @@ class SqliteClient {
}
/**
* Create a new passkey linked to a credential
* Create a new passkey linked to an item
* @param passkey - The passkey object to create
*/
public async createPasskey(passkey: Omit<Passkey, 'CreatedAt' | 'UpdatedAt' | 'IsDeleted'>): Promise<void> {
@@ -1329,10 +1512,10 @@ class SqliteClient {
const query = `
INSERT INTO Passkeys (
Id, CredentialId, RpId, UserId, PublicKey, PrivateKey,
Id, ItemId, RpId, PublicKey, PrivateKey,
PrfKey, DisplayName, AdditionalData, CreatedAt, UpdatedAt, IsDeleted
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
// Convert PrfKey to Uint8Array if it's a number array
@@ -1343,9 +1526,8 @@ class SqliteClient {
await this.executeUpdate(query, [
passkey.Id,
passkey.CredentialId,
passkey.ItemId,
passkey.RpId,
passkey.UserId ?? null,
passkey.PublicKey,
passkey.PrivateKey,
prfKeyData,

View File

@@ -0,0 +1,140 @@
import * as dateFormatter from '@/utils/dateFormatter';
import NativeVaultManager from '@/specs/NativeVaultManager';
export type SqliteBindValue = string | number | null | Uint8Array;
/**
* Interface for the core database operations needed by repositories.
* Mobile app version is async as it communicates with native modules.
*/
export interface IDatabaseClient {
executeQuery<T>(query: string, params?: SqliteBindValue[]): Promise<T[]>;
executeUpdate(query: string, params?: SqliteBindValue[]): Promise<number>;
}
/**
* Base repository class with common database operations.
* Provides transaction handling, soft delete, and other shared functionality.
*
* Mobile-specific: All operations are async as they communicate with native modules.
*/
export abstract class BaseRepository {
/**
* Constructor for the BaseRepository class.
* @param client - The database client to use for the repository
*/
protected 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> {
await NativeVaultManager.beginTransaction();
try {
const result = await fn();
await NativeVaultManager.commitTransaction();
return result;
} catch (error) {
await NativeVaultManager.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 async softDelete(table: string, id: string): Promise<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 async softDeleteByForeignKey(table: string, foreignKey: string, foreignKeyValue: string): Promise<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 async hardDelete(table: string, id: string): Promise<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 async hardDeleteByForeignKey(table: string, foreignKey: string, foreignKeyValue: string): Promise<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 async tableExists(tableName: string): Promise<boolean> {
const results = await 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,18 @@
// Base
export { BaseRepository, type IDatabaseClient, type SqliteBindValue } from './BaseRepository';
// Mappers
export { FieldMapper, type FieldRow } from './mappers/FieldMapper';
export { ItemMapper, type ItemRow, type TagRow, type ItemWithDeletedAt } from './mappers/ItemMapper';
// Queries
export {
ItemQueries,
FieldValueQueries,
FieldDefinitionQueries
} from './queries/ItemQueries';
// Repositories
export { ItemRepository } from './repositories/ItemRepository';
export { SettingsRepository } from './repositories/SettingsRepository';
export { LogoRepository } from './repositories/LogoRepository';

View File

@@ -0,0 +1,208 @@
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 type FieldRow = {
ItemId: string;
FieldKey: string | null;
FieldDefinitionId: string | null;
CustomLabel: string | null;
CustomFieldType: string | null;
CustomIsHidden: number | null;
CustomEnableHistory: number | null;
Value: string;
DisplayOrder: number;
};
/**
* Intermediate field representation before grouping.
*/
export type ProcessedField = {
ItemId: string;
FieldKey: string;
Label: string;
FieldType: string;
IsHidden: number;
Value: string;
DisplayOrder: number;
IsCustomField: boolean;
EnableHistory: boolean;
};
/**
* 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,
IsCustomField: field.IsCustomField,
EnableHistory: field.EnableHistory
});
}
}
// 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,
IsCustomField: false,
EnableHistory: systemField?.EnableHistory ?? false
};
} else {
// Custom field: has FieldDefinitionId, get metadata from FieldDefinitions
return {
ItemId: row.ItemId,
FieldKey: row.FieldDefinitionId || '', // Use FieldDefinitionId (UUID) as the key for custom fields
Label: row.CustomLabel || '',
FieldType: row.CustomFieldType || FieldTypes.Text,
IsHidden: row.CustomIsHidden || 0,
Value: row.Value,
DisplayOrder: row.DisplayOrder,
IsCustomField: true,
EnableHistory: row.CustomEnableHistory === 1
};
}
}
/**
* 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;
IsCustomField: boolean;
EnableHistory: boolean;
}>();
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,
IsCustomField: false,
EnableHistory: systemField?.EnableHistory ?? false
});
} else {
// Custom field
uniqueFields.set(fieldKey, {
FieldKey: fieldKey,
Label: row.CustomLabel || '',
FieldType: row.CustomFieldType || FieldTypes.Text,
IsHidden: row.CustomIsHidden || 0,
DisplayOrder: row.DisplayOrder,
IsCustomField: true,
EnableHistory: row.CustomEnableHistory === 1
});
}
}
}
// 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,
IsCustomField: metadata.IsCustomField,
EnableHistory: metadata.EnableHistory
};
});
}
}

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 type 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 type 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,252 @@
/**
* SQL query constants for Item operations.
* Centralizes all item-related queries to avoid duplication.
* Mirrors the browser extension implementation.
*/
export class ItemQueries {
/**
* Base SELECT for items with common fields.
* Includes LEFT JOIN to Logos, and subqueries for HasPasskey/HasAttachment/HasTotp.
*/
public static readonly BASE_SELECT = `
SELECT DISTINCT
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`;
/**
* 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 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,
fd.EnableHistory as CustomEnableHistory,
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,
fd.EnableHistory as CustomEnableHistory,
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 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
AND i.DeletedAt IS NULL`;
/**
* 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
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`;
/**
* 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 = ?`;
}
/**
* 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 = ?`;
}
/**
* 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 = ?`;
}

View File

@@ -0,0 +1,454 @@
import type { Item, ItemField, TotpCode, Attachment } from '@/utils/dist/core/models/vault';
import { FieldKey } from '@/utils/dist/core/models/vault';
import { BaseRepository } from '../BaseRepository';
import { ItemQueries, FieldValueQueries } from '../queries/ItemQueries';
import { FieldMapper, type FieldRow } from '../mappers/FieldMapper';
import { ItemMapper, type ItemRow, type TagRow, type ItemWithDeletedAt } from '../mappers/ItemMapper';
/**
* SQL query constants for Item-related tag operations.
*/
const TagQueries = {
/**
* Get tags for multiple items.
*/
GET_TAGS_FOR_ITEMS: (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`;
},
/**
* Get tags for a single item.
*/
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`
};
/**
* Repository for Item CRUD operations.
* Handles fetching, creating, updating, and deleting items with their related data.
*/
export class ItemRepository extends BaseRepository {
/**
* Fetch all active items (not deleted, not in trash) with their fields and tags.
* @returns Array of Item objects
*/
public async getAll(): Promise<Item[]> {
// 1. Fetch all item rows
const itemRows = await this.client.executeQuery<ItemRow>(ItemQueries.GET_ALL_ACTIVE);
if (itemRows.length === 0) {
return [];
}
// 2. Fetch field values for all items
const itemIds = itemRows.map(row => row.Id);
const fieldQuery = ItemQueries.getFieldValuesForItems(itemIds.length);
const fieldRows = await this.client.executeQuery<FieldRow>(fieldQuery, itemIds);
// 3. Process fields into a map by ItemId
const fieldsByItem = FieldMapper.processFieldRows(fieldRows);
// 4. Fetch tags for all items
let tagsByItem = new Map<string, { Id: string; Name: string; Color?: string }[]>();
if (await this.tableExists('ItemTags')) {
const tagQuery = TagQueries.GET_TAGS_FOR_ITEMS(itemIds.length);
const tagRows = await this.client.executeQuery<TagRow>(tagQuery, itemIds);
tagsByItem = ItemMapper.groupTagsByItem(tagRows);
}
// 5. Map rows to Item objects
return ItemMapper.mapRows(itemRows, fieldsByItem, tagsByItem);
}
/**
* Fetch a single item by ID with its fields and tags.
* @param itemId - The ID of the item to fetch
* @returns Item object or null if not found
*/
public async getById(itemId: string): Promise<Item | null> {
// 1. Fetch item row
const itemRows = await this.client.executeQuery<ItemRow>(ItemQueries.GET_BY_ID, [itemId]);
if (itemRows.length === 0) {
return null;
}
const itemRow = itemRows[0];
// 2. Fetch field values
const fieldRows = await this.client.executeQuery<Omit<FieldRow, 'ItemId'>>(
ItemQueries.GET_FIELD_VALUES_FOR_ITEM,
[itemId]
);
const fields = FieldMapper.processFieldRowsForSingleItem(fieldRows);
// 3. Fetch tags
let tags: { Id: string; Name: string; Color?: string }[] = [];
if (await this.tableExists('ItemTags')) {
const tagRows = await this.client.executeQuery<Omit<TagRow, 'ItemId'>>(
TagQueries.GET_TAGS_FOR_ITEM,
[itemId]
);
tags = ItemMapper.mapTagRows(tagRows);
}
// 4. Map to Item object
return ItemMapper.mapRow(itemRow, fields, tags);
}
/**
* Fetch all unique email addresses from field values.
* @returns Array of email addresses
*/
public async getAllEmailAddresses(): Promise<string[]> {
const results = await this.client.executeQuery<{ Email: string }>(
ItemQueries.GET_ALL_EMAIL_ADDRESSES,
[FieldKey.LoginEmail]
);
return results.map(row => row.Email);
}
/**
* Get recently deleted items (in trash).
* @returns Array of items with DeletedAt field
*/
public async getRecentlyDeleted(): Promise<ItemWithDeletedAt[]> {
const itemRows = await this.client.executeQuery<ItemRow & { DeletedAt: string }>(
ItemQueries.GET_RECENTLY_DELETED
);
if (itemRows.length === 0) {
return [];
}
// Fetch fields for deleted items
const itemIds = itemRows.map(row => row.Id);
const fieldQuery = ItemQueries.getFieldValuesForItems(itemIds.length);
const fieldRows = await this.client.executeQuery<FieldRow>(fieldQuery, itemIds);
const fieldsByItem = FieldMapper.processFieldRows(fieldRows);
return itemRows.map(row => ItemMapper.mapDeletedItemRow(row, fieldsByItem.get(row.Id) || []));
}
/**
* Get count of items in trash.
* @returns Number of items in trash
*/
public async getRecentlyDeletedCount(): Promise<number> {
const results = await this.client.executeQuery<{ count: number }>(ItemQueries.COUNT_RECENTLY_DELETED);
return results.length > 0 ? results[0].count : 0;
}
/**
* Move an item to trash (set DeletedAt timestamp).
* @param itemId - The ID of the item to trash
* @returns Number of rows affected
*/
public async trash(itemId: string): Promise<number> {
const now = this.now();
return this.withTransaction(async () => {
return this.client.executeUpdate(ItemQueries.TRASH_ITEM, [now, now, itemId]);
});
}
/**
* Restore an item from trash (clear DeletedAt).
* @param itemId - The ID of the item to restore
* @returns Number of rows affected
*/
public async restore(itemId: string): Promise<number> {
const now = this.now();
return this.withTransaction(async () => {
return this.client.executeUpdate(ItemQueries.RESTORE_ITEM, [now, itemId]);
});
}
/**
* Permanently delete an item (tombstone).
* Converts item to tombstone and hard deletes all related data.
* @param itemId - The ID of the item to permanently delete
* @returns Number of rows affected
*/
public async permanentlyDelete(itemId: string): Promise<number> {
return this.withTransaction(async () => {
const now = this.now();
// Soft delete related FieldValues
await this.softDeleteByForeignKey('FieldValues', 'ItemId', itemId);
// Soft delete related data
await this.softDeleteByForeignKey('TotpCodes', 'ItemId', itemId);
await this.softDeleteByForeignKey('Attachments', 'ItemId', itemId);
await this.softDeleteByForeignKey('Passkeys', 'ItemId', itemId);
if (await this.tableExists('ItemTags')) {
await this.softDeleteByForeignKey('ItemTags', 'ItemId', itemId);
}
if (await this.tableExists('FieldHistories')) {
await this.softDeleteByForeignKey('FieldHistories', 'ItemId', itemId);
}
// Convert item to tombstone
return this.client.executeUpdate(ItemQueries.TOMBSTONE_ITEM, [now, itemId]);
});
}
/**
* Create a new item with its fields and related entities.
* @param item - The item to create
* @param attachments - Array of attachments to create
* @param totpCodes - Array of TOTP codes to create
* @param logoRepository - Optional logo repository for logo handling
* @returns The ID of the created item
*/
public async create(
item: Item,
attachments: Attachment[] = [],
totpCodes: TotpCode[] = []
): Promise<string> {
return this.withTransaction(async () => {
const now = this.now();
const itemId = item.Id || this.generateId();
// 1. Insert Item
await this.client.executeUpdate(ItemQueries.INSERT_ITEM, [
itemId,
item.Name,
item.ItemType,
null, // LogoId - handled separately if needed
item.FolderId || null,
now,
now,
0
]);
// 2. Insert FieldValues
await this.insertFieldValues(itemId, item.Fields, now);
// 3. Insert TOTP codes
for (const totp of totpCodes) {
if (totp.IsDeleted) continue;
await this.client.executeUpdate(`
INSERT INTO TotpCodes (Id, Name, SecretKey, ItemId, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[totp.Id || this.generateId(), totp.Name, totp.SecretKey, itemId, now, now, 0]);
}
// 4. Insert Attachments
for (const attachment of attachments) {
await this.client.executeUpdate(`
INSERT INTO Attachments (Id, Filename, Blob, ItemId, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[attachment.Id, attachment.Filename, attachment.Blob as Uint8Array, itemId, now, now, 0]);
}
return itemId;
});
}
/**
* Update an existing item with its fields and related entities.
* @param item - The item to update
* @param originalAttachmentIds - IDs of attachments that existed before edit
* @param attachments - Current attachments (new and existing)
* @param originalTotpCodeIds - IDs of TOTP codes that existed before edit
* @param totpCodes - Current TOTP codes (new and existing)
* @returns Number of rows affected
*/
public async update(
item: Item,
originalAttachmentIds: string[] = [],
attachments: Attachment[] = [],
originalTotpCodeIds: string[] = [],
totpCodes: TotpCode[] = []
): Promise<number> {
return this.withTransaction(async () => {
const now = this.now();
// 1. Update Item
await this.client.executeUpdate(ItemQueries.UPDATE_ITEM, [
item.Name,
item.ItemType,
item.FolderId || null,
null, // LogoId update handled separately if needed
now,
item.Id
]);
// 2. Update FieldValues using preserve-and-track strategy
await this.updateFieldValues(item.Id, item.Fields, now);
// 3. Handle TOTP codes
await this.syncRelatedEntities(
'TotpCodes',
'ItemId',
item.Id,
originalTotpCodeIds,
totpCodes.filter(tc => !tc.IsDeleted),
(totp) => [totp.Id || this.generateId(), totp.Name, totp.SecretKey, item.Id, now, now, 0],
`INSERT INTO TotpCodes (Id, Name, SecretKey, ItemId, CreatedAt, UpdatedAt, IsDeleted) VALUES (?, ?, ?, ?, ?, ?, ?)`
);
// 4. Handle Attachments
await this.syncRelatedEntities(
'Attachments',
'ItemId',
item.Id,
originalAttachmentIds,
attachments,
(att) => [att.Id, att.Filename, att.Blob as Uint8Array, item.Id, now, now, 0],
`INSERT INTO Attachments (Id, Filename, Blob, ItemId, CreatedAt, UpdatedAt, IsDeleted) VALUES (?, ?, ?, ?, ?, ?, ?)`
);
return 1;
});
}
/**
* Insert field values for an item.
*/
private async insertFieldValues(itemId: string, fields: ItemField[], now: string): Promise<void> {
for (let i = 0; i < fields.length; i++) {
const field = fields[i];
const values = Array.isArray(field.Value) ? field.Value : [field.Value];
for (let j = 0; j < values.length; j++) {
const value = values[j];
if (value === undefined || value === null || value === '') continue;
await this.client.executeUpdate(FieldValueQueries.INSERT, [
this.generateId(),
itemId,
field.IsCustomField ? field.FieldKey : null, // FieldDefinitionId for custom
field.IsCustomField ? null : field.FieldKey, // FieldKey for system
value,
(i * 100) + j, // Weight for ordering
now,
now,
0
]);
}
}
}
/**
* Update field values using preserve-and-track strategy.
* Preserves existing field value IDs when possible for stable merge behavior.
*/
private async updateFieldValues(itemId: string, fields: ItemField[], now: string): Promise<void> {
// 1. Get existing field values
const existingFields = await this.client.executeQuery<{
Id: string;
FieldKey: string | null;
FieldDefinitionId: string | null;
Value: string;
}>(FieldValueQueries.GET_EXISTING_FOR_ITEM, [itemId]);
// 2. Build lookup by composite key (FieldKey or FieldDefinitionId + index)
const existingByKey = new Map<string, { Id: string; Value: string }[]>();
for (const existing of existingFields) {
const key = existing.FieldKey || existing.FieldDefinitionId || '';
if (!existingByKey.has(key)) {
existingByKey.set(key, []);
}
existingByKey.get(key)!.push({ Id: existing.Id, Value: existing.Value });
}
// 3. Track which existing IDs we've processed
const processedIds = new Set<string>();
// 4. Process each field
for (let i = 0; i < fields.length; i++) {
const field = fields[i];
const values = Array.isArray(field.Value) ? field.Value : [field.Value];
const existingForKey = existingByKey.get(field.FieldKey) || [];
for (let j = 0; j < values.length; j++) {
const value = values[j];
if (value === undefined || value === null || value === '') continue;
const existingEntry = existingForKey[j];
if (existingEntry) {
// Update existing if value changed
processedIds.add(existingEntry.Id);
if (existingEntry.Value !== value) {
await this.client.executeUpdate(FieldValueQueries.UPDATE, [
value,
(i * 100) + j,
now,
existingEntry.Id
]);
}
} else {
// Insert new field value
await this.client.executeUpdate(FieldValueQueries.INSERT, [
this.generateId(),
itemId,
field.IsCustomField ? field.FieldKey : null,
field.IsCustomField ? null : field.FieldKey,
value,
(i * 100) + j,
now,
now,
0
]);
}
}
}
// 5. Soft delete removed fields
for (const existing of existingFields) {
if (!processedIds.has(existing.Id)) {
await this.client.executeUpdate(FieldValueQueries.SOFT_DELETE, [now, existing.Id]);
}
}
}
/**
* Sync related entities (TOTP codes, attachments) with insert/delete tracking.
*/
private async syncRelatedEntities<T extends { Id: string }>(
tableName: string,
foreignKey: string,
foreignKeyValue: string,
originalIds: string[],
currentEntities: T[],
toParams: (entity: T) => (string | number | null | Uint8Array)[],
insertQuery: string
): Promise<void> {
const now = this.now();
const currentIds = currentEntities.map(e => e.Id);
// Delete entities that were removed
const toDelete = originalIds.filter(id => !currentIds.includes(id));
for (const id of toDelete) {
await this.client.executeUpdate(
`UPDATE ${tableName} SET IsDeleted = 1, UpdatedAt = ? WHERE Id = ?`,
[now, id]
);
}
// Insert new entities
for (const entity of currentEntities) {
if (!originalIds.includes(entity.Id)) {
await this.client.executeUpdate(insertQuery, toParams(entity));
}
}
}
}

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 async hasLogoForSource(source: string): Promise<boolean> {
const existingLogos = await 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 async getIdForSource(source: string): Promise<string | null> {
const existingLogos = await 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 async getOrCreate(source: string, logoData: Uint8Array, currentDateTime: string): Promise<string> {
// Check if a logo for this source already exists
const existingId = await this.getIdForSource(source);
if (existingId) {
return existingId;
}
// Create new logo entry
const logoId = this.generateId();
await 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 async cleanupOrphanedLogo(logoId: string): Promise<void> {
const usageResult = await this.client.executeQuery<{ count: number }>(
LogoQueries.COUNT_USAGE,
[logoId]
);
const usageCount = usageResult.length > 0 ? usageResult[0].count : 0;
if (usageCount === 0) {
await 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,209 @@
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 async getSetting(key: string, defaultValue: string = ''): Promise<string> {
const results = await 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 async getDefaultIdentityLanguage(): Promise<string> {
return this.getSetting('DefaultIdentityLanguage');
}
/**
* Get the default identity gender preference from the database.
* @returns The gender preference or 'random' if not set
*/
public async getDefaultIdentityGender(): Promise<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 async getDefaultIdentityAgeRange(): Promise<string> {
return this.getSetting('DefaultIdentityAgeRange', 'random');
}
/**
* Get the password settings from the database.
* @returns Password settings object
*/
public async getPasswordSettings(): Promise<PasswordSettings> {
const settingsJson = await 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 async getAllEncryptionKeys(): Promise<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 async getTotpCodesForItem(itemId: string): Promise<TotpCode[]> {
try {
if (!await 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 async getAttachmentsForItem(itemId: string): Promise<Attachment[]> {
try {
if (!await 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 async getDefaultEmailDomain(): Promise<string> {
return this.getSetting('DefaultEmailDomain');
}
/**
* Update or insert a setting.
* @param key - The setting key
* @param value - The setting value
*/
public async updateSetting(key: string, value: string): Promise<void> {
await this.withTransaction(async () => {
const now = this.now();
// Check if setting exists
const results = await this.client.executeQuery<{ count: number }>(
`SELECT COUNT(*) as count FROM Settings WHERE Key = ?`,
[key]
);
const exists = results[0]?.count > 0;
if (exists) {
await this.client.executeUpdate(
`UPDATE Settings SET Value = ?, UpdatedAt = ? WHERE Key = ?`,
[value, now, key]
);
} else {
await this.client.executeUpdate(
`INSERT INTO Settings (Key, Value, CreatedAt, UpdatedAt, IsDeleted) VALUES (?, ?, ?, ?, ?)`,
[key, value, now, now, 0]
);
}
});
}
}