mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-03-10 19:04:06 -04:00
Add react native credential filter and passkey indicators (#520)
This commit is contained in:
@@ -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')}
|
||||
|
||||
@@ -218,7 +218,7 @@
|
||||
"all": "All Credentials",
|
||||
"passkeys": "Passkeys",
|
||||
"aliases": "Aliases",
|
||||
"userpass": "Username/Passwords"
|
||||
"userpass": "Passwords"
|
||||
},
|
||||
"randomAlias": "Random Alias",
|
||||
"manual": "Manual",
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user