Add react native credential filter and passkey indicators (#520)

This commit is contained in:
Leendert de Borst
2025-10-14 17:35:11 +02:00
parent dad709fc20
commit dd2b08a4a3
9 changed files with 541 additions and 45 deletions

View File

@@ -218,7 +218,11 @@ const CredentialsList: React.FC = () => {
(credential.Alias?.Gender && credential.Alias.Gender.trim()) ||
(credential.Alias?.BirthDate && credential.Alias.BirthDate.trim() && credential.Alias.BirthDate.trim() !== '0001-01-01 00:00:00')
);
passesTypeFilter = !!(credential.Username || credential.Password) && !credential.HasPasskey && !hasAliasFields;
const hasUsernameOrPassword = !!(
(credential.Username && credential.Username.trim()) ||
(credential.Password && credential.Password.trim())
);
passesTypeFilter = hasUsernameOrPassword && !credential.HasPasskey && !hasAliasFields;
}
if (!passesTypeFilter) {
@@ -295,7 +299,7 @@ const CredentialsList: React.FC = () => {
setShowFilterMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
filterType === 'all' ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300'
filterType === 'all' ? 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400' : 'text-gray-700 dark:text-gray-300'
}`}
>
{t('credentials.filters.all')}
@@ -308,7 +312,7 @@ const CredentialsList: React.FC = () => {
setShowFilterMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
filterType === 'passkeys' ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300'
filterType === 'passkeys' ? 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400' : 'text-gray-700 dark:text-gray-300'
}`}
>
{t('credentials.filters.passkeys')}
@@ -321,7 +325,7 @@ const CredentialsList: React.FC = () => {
setShowFilterMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
filterType === 'aliases' ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300'
filterType === 'aliases' ? 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400' : 'text-gray-700 dark:text-gray-300'
}`}
>
{t('credentials.filters.aliases')}
@@ -334,7 +338,7 @@ const CredentialsList: React.FC = () => {
setShowFilterMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
filterType === 'userpass' ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300'
filterType === 'userpass' ? 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400' : 'text-gray-700 dark:text-gray-300'
}`}
>
{t('credentials.filters.userpass')}

View File

@@ -218,7 +218,7 @@
"all": "All Credentials",
"passkeys": "Passkeys",
"aliases": "Aliases",
"userpass": "Username/Passwords"
"userpass": "Passwords"
},
"randomAlias": "Random Alias",
"manual": "Manual",

View File

@@ -56,6 +56,7 @@ export default function AddEditCredentialScreen() : React.ReactNode {
const [isSaveDisabled, setIsSaveDisabled] = useState(false);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [originalAttachmentIds, setOriginalAttachmentIds] = useState<string[]>([]);
const [passkeyMarkedForDeletion, setPasskeyMarkedForDeletion] = useState(false);
const { t } = useTranslation();
// Track last generated values to avoid overwriting manual entries
@@ -342,6 +343,11 @@ export default function AddEditCredentialScreen() : React.ReactNode {
await executeVaultMutation(async () => {
if (isEditMode) {
await dbContext.sqliteClient!.updateCredentialById(credentialToSave, originalAttachmentIds, attachments);
// Delete passkeys if marked for deletion
if (passkeyMarkedForDeletion) {
await dbContext.sqliteClient!.deletePasskeysByCredentialId(credentialToSave.Id);
}
} else {
const credentialId = await dbContext.sqliteClient!.createCredential(credentialToSave, attachments);
credentialToSave.Id = credentialId;
@@ -400,7 +406,7 @@ export default function AddEditCredentialScreen() : React.ReactNode {
setIsSyncing(false);
setIsSaveDisabled(false);
}, [isEditMode, id, serviceUrl, router, executeVaultMutation, dbContext.sqliteClient, mode, generateRandomAlias, webApi, watch, setIsSaveDisabled, setIsSyncing, isSaveDisabled, t, originalAttachmentIds, attachments]);
}, [isEditMode, id, serviceUrl, router, executeVaultMutation, dbContext.sqliteClient, mode, generateRandomAlias, webApi, watch, setIsSaveDisabled, setIsSyncing, isSaveDisabled, t, originalAttachmentIds, attachments, passkeyMarkedForDeletion]);
/**
* Generate a random username.
@@ -689,30 +695,167 @@ export default function AddEditCredentialScreen() : React.ReactNode {
<View style={styles.section}>
<ThemedText style={styles.sectionTitle}>{t('credentials.loginCredentials')}</ThemedText>
<EmailDomainField
value={watch('Alias.Email') ?? ''}
onChange={(newValue) => setValue('Alias.Email', newValue)}
label={t('credentials.email')}
/>
<ValidatedFormField
control={control}
name="Username"
label={t('credentials.username')}
buttons={[
{
icon: "refresh",
onPress: generateRandomUsername
}
]}
/>
<AdvancedPasswordField
control={control}
name="Password"
label={t('credentials.password')}
showPassword={isPasswordVisible}
onShowPasswordChange={setIsPasswordVisible}
isNewCredential={!isEditMode}
/>
{watch('HasPasskey') ? (
<>
{/* When passkey exists: username, passkey, email, password */}
<ValidatedFormField
control={control}
name="Username"
label={t('credentials.username')}
buttons={[
{
icon: "refresh",
onPress: generateRandomUsername
}
]}
/>
{!passkeyMarkedForDeletion && (
<View style={{
backgroundColor: colors.background,
borderColor: colors.accentBorder,
borderRadius: 8,
borderWidth: 1,
marginTop: 8,
marginBottom: 8,
padding: 12,
}}>
<View style={{ flexDirection: 'row', alignItems: 'flex-start' }}>
<MaterialIcons
name="vpn-key"
size={20}
color={colors.primary}
style={{ marginRight: 8, marginTop: 2 }}
/>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<ThemedText style={{ color: colors.text, fontSize: 14, fontWeight: '600' }}>
{t('passkeys.passkey')}
</ThemedText>
<RobustPressable
onPress={() => setPasskeyMarkedForDeletion(true)}
style={{
padding: 6,
borderRadius: 4,
backgroundColor: colors.destructive + '15'
}}
>
<MaterialIcons
name="delete"
size={18}
color={colors.destructive}
/>
</RobustPressable>
</View>
{watch('PasskeyRpId') && (
<View style={{ marginBottom: 4 }}>
<ThemedText style={{ color: colors.textMuted, fontSize: 12 }}>
{t('passkeys.site')}:{' '}
<ThemedText style={{ color: colors.text, fontSize: 12 }}>
{watch('PasskeyRpId')}
</ThemedText>
</ThemedText>
</View>
)}
{watch('PasskeyDisplayName') && (
<View style={{ marginBottom: 4 }}>
<ThemedText style={{ color: colors.textMuted, fontSize: 12 }}>
{t('passkeys.displayName')}:{' '}
<ThemedText style={{ color: colors.text, fontSize: 12 }}>
{watch('PasskeyDisplayName')}
</ThemedText>
</ThemedText>
</View>
)}
<ThemedText style={{ color: colors.textMuted, fontSize: 11, marginTop: 4 }}>
{t('passkeys.helpText')}
</ThemedText>
</View>
</View>
</View>
)}
{passkeyMarkedForDeletion && (
<View style={{
backgroundColor: colors.errorBackground,
borderColor: colors.errorBorder,
borderRadius: 8,
borderWidth: 1,
marginTop: 8,
marginBottom: 8,
padding: 12,
}}>
<View style={{ flexDirection: 'row', alignItems: 'flex-start' }}>
<MaterialIcons
name="vpn-key"
size={20}
color={colors.errorText}
style={{ marginRight: 8, marginTop: 2 }}
/>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<ThemedText style={{ color: colors.errorText, fontSize: 14, fontWeight: '600' }}>
{t('passkeys.passkeyMarkedForDeletion')}
</ThemedText>
<RobustPressable
onPress={() => setPasskeyMarkedForDeletion(false)}
style={{ padding: 4 }}
>
<MaterialIcons
name="undo"
size={18}
color={colors.textMuted}
/>
</RobustPressable>
</View>
<ThemedText style={{ color: colors.errorText, fontSize: 11 }}>
{t('passkeys.passkeyWillBeDeleted')}
</ThemedText>
</View>
</View>
</View>
)}
<EmailDomainField
value={watch('Alias.Email') ?? ''}
onChange={(newValue) => setValue('Alias.Email', newValue)}
label={t('credentials.email')}
/>
<AdvancedPasswordField
control={control}
name="Password"
label={t('credentials.password')}
showPassword={isPasswordVisible}
onShowPasswordChange={setIsPasswordVisible}
isNewCredential={!isEditMode}
/>
</>
) : (
<>
{/* When no passkey: email, username, password */}
<EmailDomainField
value={watch('Alias.Email') ?? ''}
onChange={(newValue) => setValue('Alias.Email', newValue)}
label={t('credentials.email')}
/>
<ValidatedFormField
control={control}
name="Username"
label={t('credentials.username')}
buttons={[
{
icon: "refresh",
onPress: generateRandomUsername
}
]}
/>
<AdvancedPasswordField
control={control}
name="Password"
label={t('credentials.password')}
showPassword={isPasswordVisible}
onShowPasswordChange={setIsPasswordVisible}
isNewCredential={!isEditMode}
/>
</>
)}
</View>
<View style={styles.section}>

View File

@@ -17,6 +17,9 @@ import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
import { useVaultMutate } from '@/hooks/useVaultMutate';
import { useVaultSync } from '@/hooks/useVaultSync';
type FilterType = 'all' | 'passkeys' | 'aliases' | 'userpass';
import Logo from '@/assets/images/logo.svg';
import { CredentialCard } from '@/components/credentials/CredentialCard';
import { ServiceUrlNotice } from '@/components/credentials/ServiceUrlNotice';
import LoadingOverlay from '@/components/LoadingOverlay';
@@ -27,7 +30,6 @@ import { AndroidHeader } from '@/components/ui/AndroidHeader';
import { CollapsibleHeader } from '@/components/ui/CollapsibleHeader';
import { RobustPressable } from '@/components/ui/RobustPressable';
import { SkeletonLoader } from '@/components/ui/SkeletonLoader';
import { TitleContainer } from '@/components/ui/TitleContainer';
import { useApp } from '@/context/AppContext';
import { useDb } from '@/context/DbContext';
@@ -52,6 +54,8 @@ export default function CredentialsScreen() : React.ReactNode {
const insets = useSafeAreaInsets();
const { executeVaultMutation, isLoading, syncStatus } = useVaultMutate();
const [isSyncing, setIsSyncing] = useState(false);
const [filterType, setFilterType] = useState<FilterType>('all');
const [showFilterMenu, setShowFilterMenu] = useState(false);
const authContext = useApp();
const dbContext = useDb();
@@ -209,7 +213,58 @@ export default function CredentialsScreen() : React.ReactNode {
loadCredentials();
}, [isAuthenticated, isDatabaseAvailable, loadCredentials, setIsLoadingCredentials]);
/**
* Get the title based on the active filter
*/
const getFilterTitle = useCallback(() : string => {
switch (filterType) {
case 'passkeys':
return t('credentials.filters.passkeys');
case 'aliases':
return t('credentials.filters.aliases');
case 'userpass':
return t('credentials.filters.userpass');
default:
return t('credentials.title');
}
}, [filterType, t]);
const filteredCredentials = credentialsList.filter(credential => {
// First apply type filter
let passesTypeFilter = true;
if (filterType === 'passkeys') {
passesTypeFilter = credential.HasPasskey === true;
} else if (filterType === 'aliases') {
// Check for non-empty alias fields (excluding email which is used everywhere)
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() !== '0001-01-01 00:00:00')
);
} else if (filterType === 'userpass') {
// Show only credentials that have username/password AND do NOT have alias fields AND do NOT have passkey
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() !== '0001-01-01 00:00:00')
);
const hasUsernameOrPassword = !!(
(credential.Username && credential.Username.trim()) ||
(credential.Password && credential.Password.trim())
);
passesTypeFilter = hasUsernameOrPassword && !credential.HasPasskey && !hasAliasFields;
}
if (!passesTypeFilter) {
return false;
}
// Then apply search filter
const searchLower = searchQuery.toLowerCase();
/**
@@ -244,6 +299,47 @@ export default function CredentialsScreen() : React.ReactNode {
container: {
paddingHorizontal: 0,
},
filterButton: {
alignItems: 'center',
flexDirection: 'row',
marginBottom: 16,
gap: 8,
},
filterButtonText: {
color: colors.text,
fontSize: 28,
fontWeight: 'bold',
lineHeight: 34,
},
filterCount: {
color: colors.textMuted,
fontSize: 20,
lineHeight: 28,
marginRight: 'auto',
},
filterMenu: {
backgroundColor: colors.accentBackground,
borderColor: colors.accentBorder,
borderRadius: 8,
borderWidth: 1,
marginBottom: 8,
overflow: 'hidden',
},
filterMenuItem: {
paddingHorizontal: 16,
paddingVertical: 12,
},
filterMenuItemActive: {
backgroundColor: colors.primary + '20',
},
filterMenuItemText: {
color: colors.text,
fontSize: 14,
},
filterMenuItemTextActive: {
color: colors.primary,
fontWeight: '600',
},
contentContainer: {
paddingBottom: Platform.OS === 'ios' ? insets.bottom + 60 : 10,
paddingHorizontal: 14,
@@ -313,9 +409,30 @@ export default function CredentialsScreen() : React.ReactNode {
* Define custom header which is shown on Android. iOS displays the custom CollapsibleHeader component instead.
* @returns
*/
headerTitle: (): React.ReactNode => Platform.OS === 'android' ? <AndroidHeader title={t('credentials.title')} /> : <Text>{t('credentials.title')}</Text>,
headerTitle: (): React.ReactNode => {
if (Platform.OS === 'android') {
return (
<AndroidHeader
title={`${getFilterTitle()} (${filteredCredentials.length})`}
headerButtons={[
{
icon: showFilterMenu ? "keyboard-arrow-up" : "keyboard-arrow-down",
/**
* Toggle the filter menu.
*/
onPress: () : void => {
setShowFilterMenu(!showFilterMenu);
},
position: 'right'
}
]}
/>
);
}
return <Text>{t('credentials.title')}</Text>;
},
});
}, [navigation, t]);
}, [navigation, t, filterType, showFilterMenu, getFilterTitle, filteredCredentials.length]);
/**
* Delete a credential.
@@ -381,13 +498,103 @@ export default function CredentialsScreen() : React.ReactNode {
removeClippedSubviews={false}
ListHeaderComponent={
<ThemedView>
<TitleContainer title={t('credentials.title')} />
{Platform.OS === 'ios' && (
<TouchableOpacity
style={styles.filterButton}
onPress={() => setShowFilterMenu(!showFilterMenu)}
>
<Logo width={40} height={40} />
<ThemedText style={styles.filterButtonText}>
{getFilterTitle()}
</ThemedText>
<ThemedText style={styles.filterCount}>
({filteredCredentials.length})
</ThemedText>
<MaterialIcons
name={showFilterMenu ? "keyboard-arrow-up" : "keyboard-arrow-down"}
size={24}
color={colors.text}
/>
</TouchableOpacity>
)}
{serviceUrl && (
<ServiceUrlNotice
serviceUrl={serviceUrl}
onDismiss={() => setServiceUrl(null)}
/>
)}
{showFilterMenu && (
<ThemedView style={styles.filterMenu}>
<TouchableOpacity
style={[
styles.filterMenuItem,
filterType === 'all' && styles.filterMenuItemActive
]}
onPress={() => {
setFilterType('all');
setShowFilterMenu(false);
}}
>
<ThemedText style={[
styles.filterMenuItemText,
filterType === 'all' && styles.filterMenuItemTextActive
]}>
{t('credentials.filters.all')}
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.filterMenuItem,
filterType === 'passkeys' && styles.filterMenuItemActive
]}
onPress={() => {
setFilterType('passkeys');
setShowFilterMenu(false);
}}
>
<ThemedText style={[
styles.filterMenuItemText,
filterType === 'passkeys' && styles.filterMenuItemTextActive
]}>
{t('credentials.filters.passkeys')}
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.filterMenuItem,
filterType === 'aliases' && styles.filterMenuItemActive
]}
onPress={() => {
setFilterType('aliases');
setShowFilterMenu(false);
}}
>
<ThemedText style={[
styles.filterMenuItemText,
filterType === 'aliases' && styles.filterMenuItemTextActive
]}>
{t('credentials.filters.aliases')}
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.filterMenuItem,
filterType === 'userpass' && styles.filterMenuItemActive
]}
onPress={() => {
setFilterType('userpass');
setShowFilterMenu(false);
}}
>
<ThemedText style={[
styles.filterMenuItemText,
filterType === 'userpass' && styles.filterMenuItemTextActive
]}>
{t('credentials.filters.userpass')}
</ThemedText>
</TouchableOpacity>
</ThemedView>
)}
<ThemedView style={styles.searchContainer}>
<MaterialIcons
name="search"

View File

@@ -1,3 +1,4 @@
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';
@@ -238,12 +239,19 @@ export function CredentialCard({ credential, onCredentialDelete }: CredentialCar
marginRight: 12,
width: 32,
},
passkeyIcon: {
marginLeft: 6,
},
serviceName: {
color: colors.text,
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
serviceNameRow: {
alignItems: 'center',
flexDirection: 'row',
},
});
return (
@@ -267,9 +275,19 @@ export function CredentialCard({ credential, onCredentialDelete }: CredentialCar
<View style={styles.credentialContent}>
<CredentialIcon logo={credential.Logo} style={styles.logo} />
<View style={styles.credentialInfo}>
<Text style={styles.serviceName}>
{getCredentialServiceName(credential)}
</Text>
<View style={styles.serviceNameRow}>
<Text style={styles.serviceName}>
{getCredentialServiceName(credential)}
</Text>
{credential.HasPasskey && (
<MaterialIcons
name="vpn-key"
size={14}
color={colors.textMuted}
style={styles.passkeyIcon}
/>
)}
</View>
<Text style={styles.credentialText}>
{getCredentialDisplayText(credential)}
</Text>

View File

@@ -1,7 +1,11 @@
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useTranslation } from 'react-i18next';
import { StyleSheet, View } from 'react-native';
import type { Credential } from '@/utils/dist/shared/models/vault';
import { useColors } from '@/hooks/useColorScheme';
import FormInputCopyToClipboard from '@/components/form/FormInputCopyToClipboard';
import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
@@ -15,17 +19,60 @@ type LoginCredentialsProps = {
*/
export const LoginCredentials: React.FC<LoginCredentialsProps> = ({ credential }) : React.ReactNode => {
const { t } = useTranslation();
const colors = useColors();
const email = credential.Alias?.Email?.trim();
const username = credential.Username?.trim();
const password = credential.Password?.trim();
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const hasLoginCredentials = email || username || password;
const hasLoginCredentials = email || username || password || credential.HasPasskey;
if (!hasLoginCredentials) {
return null;
}
const passkeyStyles = StyleSheet.create({
container: {
backgroundColor: colors.accentBackground,
borderRadius: 8,
marginTop: 8,
padding: 12,
},
contentRow: {
alignItems: 'flex-start',
flexDirection: 'row',
},
icon: {
marginRight: 8,
marginTop: 2,
},
infoContainer: {
flex: 1,
},
label: {
color: colors.text,
fontSize: 14,
fontWeight: '600',
marginBottom: 4,
},
metadataRow: {
marginBottom: 4,
},
metadataLabel: {
color: colors.textMuted,
fontSize: 12,
},
metadataValue: {
color: colors.text,
fontSize: 12,
},
helpText: {
color: colors.textMuted,
fontSize: 11,
marginTop: 4,
},
});
return (
<ThemedView style={styles.section}>
<ThemedText type="subtitle">{t('credentials.loginCredentials')}</ThemedText>
@@ -41,6 +88,46 @@ export const LoginCredentials: React.FC<LoginCredentialsProps> = ({ credential }
value={username}
/>
)}
{credential.HasPasskey && (
<View style={passkeyStyles.container}>
<View style={passkeyStyles.contentRow}>
<MaterialIcons
name="vpn-key"
size={20}
color={colors.textMuted}
style={passkeyStyles.icon}
/>
<View style={passkeyStyles.infoContainer}>
<ThemedText style={passkeyStyles.label}>
{t('passkeys.passkey')}
</ThemedText>
{credential.PasskeyRpId && (
<View style={passkeyStyles.metadataRow}>
<ThemedText style={passkeyStyles.metadataLabel}>
{t('passkeys.site')}:{' '}
</ThemedText>
<ThemedText style={passkeyStyles.metadataValue}>
{credential.PasskeyRpId}
</ThemedText>
</View>
)}
{credential.PasskeyDisplayName && (
<View style={passkeyStyles.metadataRow}>
<ThemedText style={passkeyStyles.metadataLabel}>
{t('passkeys.displayName')}:{' '}
</ThemedText>
<ThemedText style={passkeyStyles.metadataValue}>
{credential.PasskeyDisplayName}
</ThemedText>
</View>
)}
<ThemedText style={passkeyStyles.helpText}>
{t('passkeys.helpText')}
</ThemedText>
</View>
</View>
</View>
)}
{password && (
<FormInputCopyToClipboard
label={t('credentials.password')}

View File

@@ -263,10 +263,9 @@ export const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
flexDirection: 'row',
},
label: {
color: colors.text,
fontSize: 14,
fontWeight: '500',
marginBottom: 6,
color: colors.textMuted,
fontSize: 12,
marginBottom: 4,
},
modalCloseButton: {
padding: 8,

View File

@@ -151,6 +151,12 @@
"credentialDetails": "Credential Details",
"emailPreview": "Email Preview",
"switchBackToBrowser": "Switch back to your browser to continue.",
"filters": {
"all": "All Credentials",
"passkeys": "Passkeys",
"aliases": "Aliases",
"userpass": "Passwords"
},
"twoFactorAuth": "Two-factor authentication",
"totpCode": "TOTP Code",
"attachments": "Attachments",
@@ -178,6 +184,14 @@
"copyPassword": "Copy Password"
}
},
"passkeys": {
"passkey": "Passkey",
"site": "Site",
"displayName": "Display Name",
"helpText": "Passkeys are created on the website when prompted. They cannot be manually edited. To remove this passkey, you can delete it from this credential.",
"passkeyMarkedForDeletion": "Passkey marked for deletion",
"passkeyWillBeDeleted": "This passkey will be deleted when you save this credential."
},
"settings": {
"title": "Settings",
"iosAutofill": "iOS Autofill",

View File

@@ -238,7 +238,16 @@ class SqliteClient {
a.BirthDate,
a.Gender,
a.Email,
p.Value as Password
p.Value as Password,
CASE
WHEN EXISTS (
SELECT 1 FROM Passkeys pk
WHERE pk.CredentialId = c.Id AND pk.IsDeleted = 0
) THEN 1
ELSE 0
END as HasPasskey,
(SELECT pk.RpId FROM Passkeys pk WHERE pk.CredentialId = c.Id AND pk.IsDeleted = 0 LIMIT 1) as PasskeyRpId,
(SELECT pk.DisplayName FROM Passkeys pk WHERE pk.CredentialId = c.Id AND pk.IsDeleted = 0 LIMIT 1) as PasskeyDisplayName
FROM Credentials c
LEFT JOIN Services s ON c.ServiceId = s.Id
LEFT JOIN Aliases a ON c.AliasId = a.Id
@@ -263,6 +272,9 @@ class SqliteClient {
ServiceUrl: row.ServiceUrl,
Logo: row.Logo,
Notes: row.Notes,
HasPasskey: row.HasPasskey === 1,
PasskeyRpId: row.PasskeyRpId,
PasskeyDisplayName: row.PasskeyDisplayName,
Alias: {
FirstName: row.FirstName,
LastName: row.LastName,
@@ -294,7 +306,16 @@ class SqliteClient {
a.BirthDate,
a.Gender,
a.Email,
p.Value as Password
p.Value as Password,
CASE
WHEN EXISTS (
SELECT 1 FROM Passkeys pk
WHERE pk.CredentialId = c.Id AND pk.IsDeleted = 0
) THEN 1
ELSE 0
END as HasPasskey,
(SELECT pk.RpId FROM Passkeys pk WHERE pk.CredentialId = c.Id AND pk.IsDeleted = 0 LIMIT 1) as PasskeyRpId,
(SELECT pk.DisplayName FROM Passkeys pk WHERE pk.CredentialId = c.Id AND pk.IsDeleted = 0 LIMIT 1) as PasskeyDisplayName
FROM Credentials c
LEFT JOIN Services s ON c.ServiceId = s.Id
LEFT JOIN Aliases a ON c.AliasId = a.Id
@@ -313,6 +334,9 @@ class SqliteClient {
ServiceUrl: row.ServiceUrl,
Logo: row.Logo,
Notes: row.Notes,
HasPasskey: row.HasPasskey === 1,
PasskeyRpId: row.PasskeyRpId,
PasskeyDisplayName: row.PasskeyDisplayName,
Alias: {
FirstName: row.FirstName,
LastName: row.LastName,