mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-02-01 18:02:31 -05:00
Basic mobile app refactor scaffolding from credential to item structure (#1404)
This commit is contained in:
@@ -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} />,
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'} />;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
152
apps/mobile-app/components/form/FormField.tsx
Normal file
152
apps/mobile-app/components/form/FormField.tsx
Normal 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;
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -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 }
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
@@ -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}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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(() => {
|
||||
/**
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
140
apps/mobile-app/utils/db/BaseRepository.ts
Normal file
140
apps/mobile-app/utils/db/BaseRepository.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
18
apps/mobile-app/utils/db/index.ts
Normal file
18
apps/mobile-app/utils/db/index.ts
Normal 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';
|
||||
208
apps/mobile-app/utils/db/mappers/FieldMapper.ts
Normal file
208
apps/mobile-app/utils/db/mappers/FieldMapper.ts
Normal 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
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
149
apps/mobile-app/utils/db/mappers/ItemMapper.ts
Normal file
149
apps/mobile-app/utils/db/mappers/ItemMapper.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
252
apps/mobile-app/utils/db/queries/ItemQueries.ts
Normal file
252
apps/mobile-app/utils/db/queries/ItemQueries.ts
Normal 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 = ?`;
|
||||
}
|
||||
454
apps/mobile-app/utils/db/repositories/ItemRepository.ts
Normal file
454
apps/mobile-app/utils/db/repositories/ItemRepository.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
159
apps/mobile-app/utils/db/repositories/LogoRepository.ts
Normal file
159
apps/mobile-app/utils/db/repositories/LogoRepository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
209
apps/mobile-app/utils/db/repositories/SettingsRepository.ts
Normal file
209
apps/mobile-app/utils/db/repositories/SettingsRepository.ts
Normal 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]
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user