diff --git a/apps/browser-extension/src/entrypoints/popup/pages/credentials/CredentialsList.tsx b/apps/browser-extension/src/entrypoints/popup/pages/credentials/CredentialsList.tsx index 61dd17f23..d97ad4903 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/credentials/CredentialsList.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/credentials/CredentialsList.tsx @@ -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')} diff --git a/apps/browser-extension/src/i18n/locales/en.json b/apps/browser-extension/src/i18n/locales/en.json index 2cc556e79..d36691698 100644 --- a/apps/browser-extension/src/i18n/locales/en.json +++ b/apps/browser-extension/src/i18n/locales/en.json @@ -218,7 +218,7 @@ "all": "All Credentials", "passkeys": "Passkeys", "aliases": "Aliases", - "userpass": "Username/Passwords" + "userpass": "Passwords" }, "randomAlias": "Random Alias", "manual": "Manual", diff --git a/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx b/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx index 643a36d59..00ad0ebb8 100644 --- a/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx +++ b/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx @@ -56,6 +56,7 @@ export default function AddEditCredentialScreen() : React.ReactNode { const [isSaveDisabled, setIsSaveDisabled] = useState(false); const [attachments, setAttachments] = useState([]); const [originalAttachmentIds, setOriginalAttachmentIds] = useState([]); + 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 { {t('credentials.loginCredentials')} - setValue('Alias.Email', newValue)} - label={t('credentials.email')} - /> - - + {watch('HasPasskey') ? ( + <> + {/* When passkey exists: username, passkey, email, password */} + + {!passkeyMarkedForDeletion && ( + + + + + + + {t('passkeys.passkey')} + + setPasskeyMarkedForDeletion(true)} + style={{ + padding: 6, + borderRadius: 4, + backgroundColor: colors.destructive + '15' + }} + > + + + + {watch('PasskeyRpId') && ( + + + {t('passkeys.site')}:{' '} + + {watch('PasskeyRpId')} + + + + )} + {watch('PasskeyDisplayName') && ( + + + {t('passkeys.displayName')}:{' '} + + {watch('PasskeyDisplayName')} + + + + )} + + {t('passkeys.helpText')} + + + + + )} + {passkeyMarkedForDeletion && ( + + + + + + + {t('passkeys.passkeyMarkedForDeletion')} + + setPasskeyMarkedForDeletion(false)} + style={{ padding: 4 }} + > + + + + + {t('passkeys.passkeyWillBeDeleted')} + + + + + )} + setValue('Alias.Email', newValue)} + label={t('credentials.email')} + /> + + + ) : ( + <> + {/* When no passkey: email, username, password */} + setValue('Alias.Email', newValue)} + label={t('credentials.email')} + /> + + + + )} diff --git a/apps/mobile-app/app/(tabs)/credentials/index.tsx b/apps/mobile-app/app/(tabs)/credentials/index.tsx index 8234335a1..8b33de69b 100644 --- a/apps/mobile-app/app/(tabs)/credentials/index.tsx +++ b/apps/mobile-app/app/(tabs)/credentials/index.tsx @@ -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('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' ? : {t('credentials.title')}, + headerTitle: (): React.ReactNode => { + if (Platform.OS === 'android') { + return ( + { + setShowFilterMenu(!showFilterMenu); + }, + position: 'right' + } + ]} + /> + ); + } + return {t('credentials.title')}; + }, }); - }, [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={ - + {Platform.OS === 'ios' && ( + setShowFilterMenu(!showFilterMenu)} + > + + + {getFilterTitle()} + + + ({filteredCredentials.length}) + + + + )} {serviceUrl && ( setServiceUrl(null)} /> )} + {showFilterMenu && ( + + { + setFilterType('all'); + setShowFilterMenu(false); + }} + > + + {t('credentials.filters.all')} + + + { + setFilterType('passkeys'); + setShowFilterMenu(false); + }} + > + + {t('credentials.filters.passkeys')} + + + { + setFilterType('aliases'); + setShowFilterMenu(false); + }} + > + + {t('credentials.filters.aliases')} + + + { + setFilterType('userpass'); + setShowFilterMenu(false); + }} + > + + {t('credentials.filters.userpass')} + + + + )} - - {getCredentialServiceName(credential)} - + + + {getCredentialServiceName(credential)} + + {credential.HasPasskey && ( + + )} + {getCredentialDisplayText(credential)} diff --git a/apps/mobile-app/components/credentials/details/LoginCredentials.tsx b/apps/mobile-app/components/credentials/details/LoginCredentials.tsx index 99a6f544a..c089f14f3 100644 --- a/apps/mobile-app/components/credentials/details/LoginCredentials.tsx +++ b/apps/mobile-app/components/credentials/details/LoginCredentials.tsx @@ -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 = ({ 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 ( {t('credentials.loginCredentials')} @@ -41,6 +88,46 @@ export const LoginCredentials: React.FC = ({ credential } value={username} /> )} + {credential.HasPasskey && ( + + + + + + {t('passkeys.passkey')} + + {credential.PasskeyRpId && ( + + + {t('passkeys.site')}:{' '} + + + {credential.PasskeyRpId} + + + )} + {credential.PasskeyDisplayName && ( + + + {t('passkeys.displayName')}:{' '} + + + {credential.PasskeyDisplayName} + + + )} + + {t('passkeys.helpText')} + + + + + )} {password && ( = ({ flexDirection: 'row', }, label: { - color: colors.text, - fontSize: 14, - fontWeight: '500', - marginBottom: 6, + color: colors.textMuted, + fontSize: 12, + marginBottom: 4, }, modalCloseButton: { padding: 8, diff --git a/apps/mobile-app/i18n/locales/en.json b/apps/mobile-app/i18n/locales/en.json index 606904bc1..8b8037705 100644 --- a/apps/mobile-app/i18n/locales/en.json +++ b/apps/mobile-app/i18n/locales/en.json @@ -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", diff --git a/apps/mobile-app/utils/SqliteClient.tsx b/apps/mobile-app/utils/SqliteClient.tsx index 600533f53..581b51599 100644 --- a/apps/mobile-app/utils/SqliteClient.tsx +++ b/apps/mobile-app/utils/SqliteClient.tsx @@ -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,