From 23d72ef4bfbb774b8dc72c8b066f8a5164064897 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 15 Dec 2025 21:52:28 +0100 Subject: [PATCH] Remove credential related structure from browser extension (#1404) --- .../background/PopupMessageHandler.ts | 12 +- .../background/VaultMessageHandler.ts | 10 +- .../src/entrypoints/popup/App.tsx | 7 - .../components/Credentials/CredentialCard.tsx | 111 -- .../Credentials/Details/AliasBlock.tsx | 61 - .../Credentials/Details/AttachmentBlock.tsx | 20 +- .../Credentials/Details/HeaderBlock.tsx | 42 - .../Details/LoginCredentialsBlock.tsx | 93 -- .../Credentials/Details/NotesBlock.tsx | 44 - .../Credentials/Details/TotpBlock.tsx | 20 +- .../components/Credentials/Details/index.tsx | 8 - .../popup/pages/auth/UnlockSuccess.tsx | 6 +- .../entrypoints/popup/pages/auth/Upgrade.tsx | 12 +- .../pages/credentials/CredentialAddEdit.tsx | 1000 ----------------- .../pages/credentials/CredentialDetails.tsx | 117 -- .../pages/credentials/CredentialsList.tsx | 427 ------- .../popup/pages/credentials/ItemAddEdit.tsx | 2 +- .../popup/pages/credentials/ItemDetails.tsx | 4 +- .../popup/pages/passkeys/PasskeyCreate.tsx | 166 ++- .../src/utils/SqliteClient.ts | 690 +----------- 20 files changed, 117 insertions(+), 2735 deletions(-) delete mode 100644 apps/browser-extension/src/entrypoints/popup/components/Credentials/CredentialCard.tsx delete mode 100644 apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/AliasBlock.tsx delete mode 100644 apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/HeaderBlock.tsx delete mode 100644 apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/LoginCredentialsBlock.tsx delete mode 100644 apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/NotesBlock.tsx delete mode 100644 apps/browser-extension/src/entrypoints/popup/pages/credentials/CredentialAddEdit.tsx delete mode 100644 apps/browser-extension/src/entrypoints/popup/pages/credentials/CredentialDetails.tsx delete mode 100644 apps/browser-extension/src/entrypoints/popup/pages/credentials/CredentialsList.tsx diff --git a/apps/browser-extension/src/entrypoints/background/PopupMessageHandler.ts b/apps/browser-extension/src/entrypoints/background/PopupMessageHandler.ts index 14e59c965..eb8e8dfb4 100644 --- a/apps/browser-extension/src/entrypoints/background/PopupMessageHandler.ts +++ b/apps/browser-extension/src/entrypoints/background/PopupMessageHandler.ts @@ -28,7 +28,7 @@ export function handleOpenPopup() : Promise { export function handlePopupWithItem(message: any) : Promise { return (async () : Promise => { browser.windows.create({ - url: browser.runtime.getURL(`/popup.html?expanded=true#/credentials/${message.itemId}`), + url: browser.runtime.getURL(`/popup.html?expanded=true#/items/${message.itemId}`), type: 'popup', width: 400, height: 600, @@ -39,7 +39,7 @@ export function handlePopupWithItem(message: any) : Promise { } /** - * Handle opening the popup on create credential page with prefilled service name. + * Handle opening the popup on create item page with prefilled service name. */ export function handleOpenPopupCreateCredential(message: any) : Promise { return (async () : Promise => { @@ -59,13 +59,13 @@ export function handleOpenPopupCreateCredential(message: any) : Promise { - // TODO: create separate query to only get email addresses to avoid loading all credentials. - const credentials = sqliteClient.getAllCredentials(); + const emailAddresses = sqliteClient.getAllEmailAddresses(); // Get metadata from local: storage const privateEmailDomains = await getItemWithFallback('local:privateEmailDomains') ?? []; - const emailAddresses = credentials - .filter(cred => cred.Alias?.Email != null) - .map(cred => cred.Alias.Email ?? '') - .filter((email, index, self) => self.indexOf(email) === index); - return emailAddresses.filter(email => { const domain = email?.split('@')[1]; return domain && privateEmailDomains.includes(domain); @@ -629,7 +623,7 @@ async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise { { path: '/unlock-success', element: , showBackButton: false }, { path: '/upgrade', element: , showBackButton: false }, { path: '/auth-settings', element: , showBackButton: true, title: t('settings.title') }, - { path: '/credentials', element: , showBackButton: false }, - { path: '/credentials/add', element: , showBackButton: true, title: t('credentials.addCredential') }, - { path: '/credentials/:id', element: , showBackButton: true, title: t('credentials.credentialDetails') }, - { path: '/credentials/:id/edit', element: , showBackButton: true, title: t('credentials.editCredential') }, { path: '/items', element: , showBackButton: false }, { path: '/items/folder/:folderId', element: , showBackButton: true, title: t('items.title') }, { path: '/items/select-type', element: , showBackButton: true, title: t('itemTypes.selectType') }, diff --git a/apps/browser-extension/src/entrypoints/popup/components/Credentials/CredentialCard.tsx b/apps/browser-extension/src/entrypoints/popup/components/Credentials/CredentialCard.tsx deleted file mode 100644 index b93877866..000000000 --- a/apps/browser-extension/src/entrypoints/popup/components/Credentials/CredentialCard.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import React from 'react'; -import { useNavigate } from 'react-router-dom'; - -import type { Credential } from '@/utils/dist/core/models/vault'; -import SqliteClient from '@/utils/SqliteClient'; - -type CredentialCardProps = { - credential: Credential; -}; - -/** - * CredentialCard component - * - * This component displays a credential card with a service name, username, and email. - * It allows the user to navigate to the credential details page when clicked. - * - */ -const CredentialCard: React.FC = ({ credential }) => { - const navigate = useNavigate(); - - /** - * Get the display text for the credential - * @param cred - The credential to get the display text for - * @returns The display text for the credential - */ - const getDisplayText = (cred: Credential): string => { - let returnValue = ''; - - // Show username if available - if (cred.Username) { - returnValue = cred.Username; - } - - // Show email if username is not available - if (cred.Alias?.Email) { - returnValue = cred.Alias.Email; - } - - // Trim the return value to max. 33 characters. - return returnValue.length > 33 ? returnValue.slice(0, 30) + '...' : returnValue; - }; - - /** - * Get the service name for a credential, 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; - } - - // Trim the return value to max. 33 characters. - return returnValue.length > 33 ? returnValue.slice(0, 30) + '...' : returnValue; - }; - - return ( -
  • - -
  • - ); -}; - -export default CredentialCard; diff --git a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/AliasBlock.tsx b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/AliasBlock.tsx deleted file mode 100644 index c32525ce7..000000000 --- a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/AliasBlock.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -import { FormInputCopyToClipboard } from '@/entrypoints/popup/components/Forms/FormInputCopyToClipboard'; - -import { IdentityHelperUtils } from '@/utils/dist/core/identity-generator'; -import type { Credential } from '@/utils/dist/core/models/vault'; - -type AliasBlockProps = { - credential: Credential; -} - -/** - * Render the alias block. - */ -const AliasBlock: React.FC = ({ credential }) => { - const { t } = useTranslation(); - const hasFirstName = Boolean(credential.Alias?.FirstName?.trim()); - const hasLastName = Boolean(credential.Alias?.LastName?.trim()); - const hasBirthDate = IdentityHelperUtils.isValidBirthDate(credential.Alias?.BirthDate); - - if (!hasFirstName && !hasLastName && !hasBirthDate) { - return null; - } - - return ( -
    -

    {t('common.alias')}

    - {(hasFirstName || hasLastName) && ( - - )} - {hasFirstName && ( - - )} - {hasLastName && ( - - )} - {hasBirthDate && ( - - )} -
    - ); -}; - -export default AliasBlock; \ No newline at end of file diff --git a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/AttachmentBlock.tsx b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/AttachmentBlock.tsx index 2ce21b7c4..9a989ab5a 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/AttachmentBlock.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/AttachmentBlock.tsx @@ -6,14 +6,13 @@ import { useDb } from '@/entrypoints/popup/context/DbContext'; import type { Attachment } from '@/utils/dist/core/models/vault'; type AttachmentBlockProps = { - credentialId?: string; - itemId?: string; + itemId: string; } /** - * This component shows attachments for a credential or item. + * This component shows attachments for an item. */ -const AttachmentBlock: React.FC = ({ credentialId, itemId }) => { +const AttachmentBlock: React.FC = ({ itemId }) => { const { t } = useTranslation(); const [attachments, setAttachments] = useState([]); const [loading, setLoading] = useState(true); @@ -50,20 +49,15 @@ const AttachmentBlock: React.FC = ({ credentialId, itemId useEffect(() => { /** - * Loads the attachments for the credential or item. + * Loads the attachments for the item. */ const loadAttachments = async (): Promise => { - if (!dbContext?.sqliteClient) { + if (!dbContext?.sqliteClient || !itemId) { return; } try { - let attachmentList: Attachment[] = []; - if (itemId) { - attachmentList = dbContext.sqliteClient.getAttachmentsForItem(itemId); - } else if (credentialId) { - attachmentList = dbContext.sqliteClient.getAttachmentsForCredential(credentialId); - } + const attachmentList = dbContext.sqliteClient.getAttachmentsForItem(itemId); setAttachments(attachmentList); } catch (error) { console.error('Error loading attachments:', error); @@ -73,7 +67,7 @@ const AttachmentBlock: React.FC = ({ credentialId, itemId }; loadAttachments(); - }, [credentialId, itemId, dbContext?.sqliteClient]); + }, [itemId, dbContext?.sqliteClient]); if (loading) { return ( diff --git a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/HeaderBlock.tsx b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/HeaderBlock.tsx deleted file mode 100644 index a1adab2a9..000000000 --- a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/HeaderBlock.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; - -import type { Credential } from '@/utils/dist/core/models/vault'; -import SqliteClient from '@/utils/SqliteClient'; - -type HeaderBlockProps = { - credential: Credential; -} - -/** - * Render the header block. - */ -const HeaderBlock: React.FC = ({ credential }) => ( -
    -
    - {credential.ServiceName} -
    -

    {credential.ServiceName}

    - {credential.ServiceUrl && ( - /^https?:\/\//i.test(credential.ServiceUrl) ? ( - - {credential.ServiceUrl} - - ) : ( - {credential.ServiceUrl} - ) - )} -
    -
    -
    -); - -export default HeaderBlock; \ No newline at end of file diff --git a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/LoginCredentialsBlock.tsx b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/LoginCredentialsBlock.tsx deleted file mode 100644 index c273b4e0a..000000000 --- a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/LoginCredentialsBlock.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -import { FormInputCopyToClipboard } from '@/entrypoints/popup/components/Forms/FormInputCopyToClipboard'; - -import type { Credential } from '@/utils/dist/core/models/vault'; - -type LoginCredentialsBlockProps = { - credential: Credential; -} - -/** - * Render the login credentials block. - */ -const LoginCredentialsBlock: React.FC = ({ credential }) => { - const { t } = useTranslation(); - const email = credential.Alias?.Email?.trim(); - const username = credential.Username?.trim(); - const password = credential.Password?.trim(); - - if (!email && !username && !password && !credential.HasPasskey) { - return null; - } - - return ( -
    -

    {t('common.loginCredentials')}

    - {email && ( - - )} - {username && ( - - )} - {credential.HasPasskey && ( -
    -
    - - - -
    -
    - {t('passkeys.passkey')} -
    -
    - {credential.PasskeyRpId && ( -
    - {t('passkeys.site')}: - {credential.PasskeyRpId} -
    - )} - {credential.PasskeyDisplayName && ( -
    - {t('passkeys.displayName')}: - {credential.PasskeyDisplayName} -
    - )} -
    -

    - {t('passkeys.helpText')} -

    -
    -
    -
    - )} - {password && ( - - )} -
    - ); -}; - -export default LoginCredentialsBlock; \ No newline at end of file diff --git a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/NotesBlock.tsx b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/NotesBlock.tsx deleted file mode 100644 index b2f5a3169..000000000 --- a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/NotesBlock.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -type NotesBlockProps = { - notes: string | undefined; -} - -/** - * Convert URLs in text to clickable links. - */ -const convertUrlsToLinks = (text: string): string => { - const urlPattern = /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/g; - - return text.replace(urlPattern, (url) => { - const href = url.startsWith('http') ? url : `http://${url}`; - return `${url}`; - }); -}; - -/** - * Render the notes block. - */ -const NotesBlock: React.FC = ({ notes }) => { - const { t } = useTranslation(); - if (!notes) { - return null; - } - - const formattedNotes = convertUrlsToLinks(notes); - - return ( -
    -

    {t('common.notes')}

    -
    -

    -

    -
    - ); -}; - -export default NotesBlock; \ No newline at end of file diff --git a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/TotpBlock.tsx b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/TotpBlock.tsx index c66739e78..888fae703 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/TotpBlock.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/TotpBlock.tsx @@ -8,14 +8,13 @@ import { useDb } from '@/entrypoints/popup/context/DbContext'; import type { TotpCode } from '@/utils/dist/core/models/vault'; type TotpBlockProps = { - credentialId?: string; - itemId?: string; + itemId: string; } /** - * This component shows TOTP codes for a credential or item. + * This component shows TOTP codes for an item. */ -const TotpBlock: React.FC = ({ credentialId, itemId }) => { +const TotpBlock: React.FC = ({ itemId }) => { const { t } = useTranslation(); const [totpCodes, setTotpCodes] = useState([]); const [loading, setLoading] = useState(true); @@ -85,20 +84,15 @@ const TotpBlock: React.FC = ({ credentialId, itemId }) => { useEffect(() => { /** - * Loads the TOTP codes for the credential or item. + * Loads the TOTP codes for the item. */ const loadTotpCodes = async (): Promise => { - if (!dbContext?.sqliteClient) { + if (!dbContext?.sqliteClient || !itemId) { return; } try { - let codes: TotpCode[] = []; - if (itemId) { - codes = dbContext.sqliteClient.getTotpCodesForItem(itemId); - } else if (credentialId) { - codes = dbContext.sqliteClient.getTotpCodesForCredential(credentialId); - } + const codes = dbContext.sqliteClient.getTotpCodesForItem(itemId); setTotpCodes(codes); } catch (error) { console.error('Error loading TOTP codes:', error); @@ -108,7 +102,7 @@ const TotpBlock: React.FC = ({ credentialId, itemId }) => { }; loadTotpCodes(); - }, [credentialId, itemId, dbContext?.sqliteClient]); + }, [itemId, dbContext?.sqliteClient]); useEffect(() => { /** diff --git a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/index.tsx b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/index.tsx index 508840de5..0651bbf6d 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/index.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/index.tsx @@ -1,19 +1,11 @@ -import AliasBlock from './AliasBlock'; import AttachmentBlock from './AttachmentBlock'; import FieldBlock from './FieldBlock'; -import HeaderBlock from './HeaderBlock'; -import LoginCredentialsBlock from './LoginCredentialsBlock'; -import NotesBlock from './NotesBlock'; import PasskeyBlock from './PasskeyBlock'; import PasskeyEditor from './PasskeyEditor'; import TotpBlock from './TotpBlock'; export { - HeaderBlock, TotpBlock, - LoginCredentialsBlock, - AliasBlock, - NotesBlock, AttachmentBlock, FieldBlock, PasskeyBlock, diff --git a/apps/browser-extension/src/entrypoints/popup/pages/auth/UnlockSuccess.tsx b/apps/browser-extension/src/entrypoints/popup/pages/auth/UnlockSuccess.tsx index 2fc77a761..b240c77ef 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/auth/UnlockSuccess.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/auth/UnlockSuccess.tsx @@ -11,7 +11,7 @@ const UnlockSuccess: React.FC = () => { const { t } = useTranslation(); /** - * Handle browsing vault contents - navigate to credentials page and reset mode parameter + * Handle browsing vault contents - navigate to items page and reset mode parameter */ const handleBrowseVaultContents = (): void => { // Remove mode=inline from URL before navigating @@ -19,8 +19,8 @@ const UnlockSuccess: React.FC = () => { url.searchParams.delete('mode'); window.history.replaceState({}, '', url); - // Navigate to credentials page - navigate('/credentials'); + // Navigate to items page + navigate('/items'); }; return ( diff --git a/apps/browser-extension/src/entrypoints/popup/pages/auth/Upgrade.tsx b/apps/browser-extension/src/entrypoints/popup/pages/auth/Upgrade.tsx index fd20ac13d..6484f1708 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/auth/Upgrade.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/auth/Upgrade.tsx @@ -169,8 +169,8 @@ const Upgrade: React.FC = () => { * Handle successful sync completion. */ onSuccess: () => { - // Navigate to credentials page - navigate('/credentials'); + // Navigate to items page + navigate('/items'); }, /** * Handle sync error. @@ -178,14 +178,14 @@ const Upgrade: React.FC = () => { */ onError: (error: string) => { console.error('Sync error after upgrade:', error); - // Still navigate to credentials even if sync fails - navigate('/credentials'); + // Still navigate to items even if sync fails + navigate('/items'); } }); } catch (error) { console.error('Error during post-upgrade sync:', error); - // Navigate to credentials even if sync fails - navigate('/credentials'); + // Navigate to items even if sync fails + navigate('/items'); } }; diff --git a/apps/browser-extension/src/entrypoints/popup/pages/credentials/CredentialAddEdit.tsx b/apps/browser-extension/src/entrypoints/popup/pages/credentials/CredentialAddEdit.tsx deleted file mode 100644 index b7fd46b5e..000000000 --- a/apps/browser-extension/src/entrypoints/popup/pages/credentials/CredentialAddEdit.tsx +++ /dev/null @@ -1,1000 +0,0 @@ -import { Buffer } from 'buffer'; - -import { yupResolver } from '@hookform/resolvers/yup'; -import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; -import { useNavigate, useParams } from 'react-router-dom'; -import { sendMessage } from 'webext-bridge/popup'; -import * as Yup from 'yup'; - -import AttachmentUploader from '@/entrypoints/popup/components/Credentials/Details/AttachmentUploader'; -import TotpEditor from '@/entrypoints/popup/components/Credentials/Details/TotpEditor'; -import Modal from '@/entrypoints/popup/components/Dialogs/Modal'; -import EmailDomainField from '@/entrypoints/popup/components/Forms/EmailDomainField'; -import { FormInput } from '@/entrypoints/popup/components/Forms/FormInput'; -import PasswordField from '@/entrypoints/popup/components/Forms/PasswordField'; -import UsernameField from '@/entrypoints/popup/components/Forms/UsernameField'; -import HeaderButton from '@/entrypoints/popup/components/HeaderButton'; -import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons'; -import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner'; -import { useDb } from '@/entrypoints/popup/context/DbContext'; -import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext'; -import { useLoading } from '@/entrypoints/popup/context/LoadingContext'; -import { useWebApi } from '@/entrypoints/popup/context/WebApiContext'; -import { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate'; - -import { SKIP_FORM_RESTORE_KEY } from '@/utils/Constants'; -import { IdentityHelperUtils, CreateIdentityGenerator, CreateUsernameEmailGenerator, Identity, Gender, convertAgeRangeToBirthdateOptions } from '@/utils/dist/core/identity-generator'; -import type { Attachment, Credential, TotpCode } from '@/utils/dist/core/models/vault'; -import { CreatePasswordGenerator } from '@/utils/dist/core/password-generator'; -import { ServiceDetectionUtility } from '@/utils/serviceDetection/ServiceDetectionUtility'; - -import { browser } from '#imports'; - -type CredentialMode = 'random' | 'manual'; - -// Persisted form data type used for JSON serialization. -type PersistedFormData = { - credentialId: string | null; - mode: CredentialMode; - formValues: Omit & { Logo?: string | null }; - totpEditorState?: { - isAddFormVisible: boolean; - formData: { - name: string; - secretKey: string; - }; - }; -} - -/** - * Add or edit credential page. - */ -const CredentialAddEdit: React.FC = () => { - const { t } = useTranslation(); - const { id } = useParams(); - const navigate = useNavigate(); - const dbContext = useDb(); - // If we received an ID, we're in edit mode - const isEditMode = id !== undefined && id.length > 0; - - /** - * Validation schema for the credential form with translatable messages. - */ - const credentialSchema = useMemo(() => Yup.object().shape({ - Id: Yup.string(), - ServiceName: Yup.string().required(t('credentials.validation.serviceNameRequired')), - ServiceUrl: Yup.string().nullable().optional(), - Alias: Yup.object().shape({ - FirstName: Yup.string().nullable().optional(), - LastName: Yup.string().nullable().optional(), - BirthDate: Yup.string() - .nullable() - .optional() - .test( - 'is-valid-date-format', - t('credentials.validation.invalidDateFormat'), - value => { - if (!value) { - return true; - } - return /^\d{4}-\d{2}-\d{2}$/.test(value); - }, - ), - Gender: Yup.string().nullable().optional(), - Email: Yup.string().email(t('credentials.validation.invalidEmail')).nullable().optional() - }), - Username: Yup.string().nullable().optional(), - Password: Yup.string().nullable().optional(), - Notes: Yup.string().nullable().optional() - }), [t]); - - const { executeVaultMutationAsync } = useVaultMutate(); - const [mode, setMode] = useState('random'); - const { setHeaderButtons } = useHeaderButtons(); - const { setIsInitialLoading } = useLoading(); - const [localLoading, setLocalLoading] = useState(true); - const [showPassword, setShowPassword] = useState(!isEditMode); - const [showDeleteModal, setShowDeleteModal] = useState(false); - const [attachments, setAttachments] = useState([]); - const [originalAttachmentIds, setOriginalAttachmentIds] = useState([]); - const [totpCodes, setTotpCodes] = useState([]); - const [originalTotpCodeIds, setOriginalTotpCodeIds] = useState([]); - const [totpEditorState, setTotpEditorState] = useState<{ - isAddFormVisible: boolean; - formData: { name: string; secretKey: string }; - }>({ - isAddFormVisible: false, - formData: { name: '', secretKey: '' } - }); - const [passkeyMarkedForDeletion, setPasskeyMarkedForDeletion] = useState(false); - const webApi = useWebApi(); - - // Track last generated values to avoid overwriting manual entries - const [lastGeneratedValues, setLastGeneratedValues] = useState<{ - username: string | null; - password: string | null; - email: string | null; - }>({ username: null, password: null, email: null }); - - const serviceNameRef = useRef(null); - - const { handleSubmit, setValue, watch, formState: { errors } } = useForm({ - resolver: yupResolver(credentialSchema as Yup.ObjectSchema), - defaultValues: { - Id: "", - Username: "", - Password: "", - ServiceName: "", - ServiceUrl: "https://", - Notes: "", - Alias: { - FirstName: "", - LastName: "", - BirthDate: "", - Gender: undefined, - Email: "" - } - } - }); - - /** - * Persists the current form values to storage - * @returns Promise that resolves when the form values are persisted - */ - const persistFormValues = useCallback(async (): Promise => { - if (localLoading) { - // Do not persist values if the page is still loading. - return; - } - - const formValues = watch(); - const persistedData: PersistedFormData = { - credentialId: id || null, - mode, - formValues: { - ...formValues, - Logo: null // Don't persist the Logo field as it can't be user modified in the UI. - }, - totpEditorState - }; - await sendMessage('PERSIST_FORM_VALUES', JSON.stringify(persistedData), 'background'); - }, [watch, id, mode, localLoading, totpEditorState]); - - /** - * Watch for mode and totpEditorState changes and persist form values - */ - useEffect(() => { - if (!localLoading) { - void persistFormValues(); - } - }, [mode, totpEditorState, persistFormValues, localLoading]); - - // Watch for form changes and persist them - useEffect(() => { - const subscription = watch(() => { - void persistFormValues(); - }); - return (): void => subscription.unsubscribe(); - }, [watch, persistFormValues]); - - /** - * Loads persisted form values from storage. This is used to keep track of form changes - * and restore them when the page is reloaded. The browser extension popup will close - * automatically by clicking outside of the popup, but with this logic we can restore - * the form values when the page is reloaded so the user can continue their mutation operation. - * - * @returns Promise that resolves when the form values are loaded - */ - const loadPersistedValues = useCallback(async (): Promise => { - const persistedData = await sendMessage('GET_PERSISTED_FORM_VALUES', null, 'background') as string | null; - - // Try to parse the persisted data as a JSON object. - try { - let persistedDataObject: PersistedFormData | null = null; - try { - if (persistedData) { - persistedDataObject = JSON.parse(persistedData) as PersistedFormData; - } - } catch (error) { - console.error('Error parsing persisted data:', error); - } - - // Check if the object has a value and is not null - const objectEmpty = persistedDataObject === null || persistedDataObject === undefined; - if (objectEmpty) { - // If the persisted data object is empty, we don't have any values to restore and can exit early. - setLocalLoading(false); - return; - } - - const isCurrentPage = persistedDataObject?.credentialId == id; - if (persistedDataObject && isCurrentPage) { - // Only restore if the persisted credential ID matches current page - setMode(persistedDataObject.mode); - Object.entries(persistedDataObject.formValues).forEach(([key, value]) => { - setValue(key as keyof Credential, value as Credential[keyof Credential]); - }); - - // Restore TOTP editor state if it exists - if (persistedDataObject.totpEditorState) { - setTotpEditorState(persistedDataObject.totpEditorState); - } - } else { - console.error('Persisted values do not match current page'); - } - } catch (error) { - console.error('Error loading persisted data:', error); - } - - // Set local loading state to false which also activates the persisting of form value changes from this point on. - setLocalLoading(false); - }, [setValue, id, setMode, setLocalLoading]); - - /** - * Clears persisted form values from storage - * @returns Promise that resolves when the form values are cleared - */ - const clearPersistedValues = useCallback(async (): Promise => { - await sendMessage('CLEAR_PERSISTED_FORM_VALUES', null, 'background'); - }, []); - - // Clear persisted values when the page is unmounted. - useEffect(() => { - return (): void => { - void clearPersistedValues(); - }; - }, [clearPersistedValues]); - - /** - * Load an existing credential from the database in edit mode. - */ - useEffect(() => { - if (!dbContext?.sqliteClient) { - return; - } - - if (!id) { - // On create mode, check for URL parameters first, then fallback to tab detection - const urlParams = new URLSearchParams(window.location.search); - const serviceName = urlParams.get('serviceName'); - const serviceUrl = urlParams.get('serviceUrl'); - const currentUrl = urlParams.get('currentUrl'); - - /** - * Initialize service detection from URL parameters or current tab - */ - const initializeServiceDetection = async (): Promise => { - try { - // If URL parameters are present (e.g., from content script popout), use them - if (serviceName || serviceUrl || currentUrl) { - if (serviceName) { - setValue('ServiceName', decodeURIComponent(serviceName)); - } - if (serviceUrl) { - setValue('ServiceUrl', decodeURIComponent(serviceUrl)); - } - - // If we have currentUrl but missing serviceName or serviceUrl, derive them - if (currentUrl && (!serviceName || !serviceUrl)) { - const decodedCurrentUrl = decodeURIComponent(currentUrl); - const serviceInfo = ServiceDetectionUtility.getServiceInfoFromTab(decodedCurrentUrl); - - if (!serviceName && serviceInfo.suggestedNames.length > 0) { - setValue('ServiceName', serviceInfo.suggestedNames[0]); - } - if (!serviceUrl && serviceInfo.serviceUrl) { - setValue('ServiceUrl', serviceInfo.serviceUrl); - } - } - return; - } - - // Otherwise, detect from current active tab (for dashboard case) - const [activeTab] = await browser.tabs.query({ active: true, currentWindow: true }); - - if (activeTab?.url) { - const serviceInfo = ServiceDetectionUtility.getServiceInfoFromTab( - activeTab.url, - activeTab.title - ); - - if (serviceInfo.suggestedNames.length > 0) { - setValue('ServiceName', serviceInfo.suggestedNames[0]); - } - if (serviceInfo.serviceUrl) { - setValue('ServiceUrl', serviceInfo.serviceUrl); - } - } - } catch (error) { - console.error('Error detecting service information:', error); - } - }; - - initializeServiceDetection(); - - // Focus the service name field after a short delay to ensure the component is mounted. - setTimeout(() => { - serviceNameRef.current?.focus(); - }, 100); - setIsInitialLoading(false); - - // Check if we should skip form restoration (e.g., when opened from popout button) - browser.storage.local.get([SKIP_FORM_RESTORE_KEY]).then((result) => { - if (result[SKIP_FORM_RESTORE_KEY]) { - // Clear the flag after using it - browser.storage.local.remove([SKIP_FORM_RESTORE_KEY]); - // Don't load persisted values, but set local loading to false - setLocalLoading(false); - } else { - // Load persisted form values normally - loadPersistedValues(); - } - }); - return; - } - - try { - const result = dbContext.sqliteClient.getCredentialById(id); - - if (result) { - result.Alias.BirthDate = IdentityHelperUtils.normalizeBirthDate(result.Alias.BirthDate); - - // Set form values - Object.entries(result).forEach(([key, value]) => { - setValue(key as keyof Credential, value); - }); - - // Load attachments for this credential - const credentialAttachments = dbContext.sqliteClient.getAttachmentsForCredential(id); - setAttachments(credentialAttachments); - setOriginalAttachmentIds(credentialAttachments.map(a => a.Id)); - - // Load TOTP codes for this credential - const credentialTotpCodes = dbContext.sqliteClient.getTotpCodesForCredential(id); - setTotpCodes(credentialTotpCodes); - setOriginalTotpCodeIds(credentialTotpCodes.map(tc => tc.Id)); - - setMode('manual'); - setIsInitialLoading(false); - - // Check for persisted values that might override the loaded values if they exist. - loadPersistedValues(); - } else { - console.error('Credential not found'); - navigate('/credentials'); - } - } catch (err) { - console.error('Error loading credential:', err); - setIsInitialLoading(false); - } - }, [dbContext.sqliteClient, id, navigate, setIsInitialLoading, setValue, loadPersistedValues, clearPersistedValues]); - - /** - * Handle the delete button click. - */ - const handleDelete = useCallback(async (): Promise => { - if (!id) { - return; - } - - await executeVaultMutationAsync(async () => { - dbContext.sqliteClient!.deleteCredentialById(id); - }); - - void clearPersistedValues(); - navigate('/credentials'); - }, [id, executeVaultMutationAsync, dbContext.sqliteClient, navigate, clearPersistedValues]); - - /** - * Initialize the identity and password generators with settings from user's vault. - */ - const initializeGenerators = useCallback(async () => { - // Get effective identity language (smart default based on UI language if no explicit override) - const identityLanguage = await dbContext.sqliteClient!.getEffectiveIdentityLanguage(); - - // Initialize identity generator based on language - const identityGenerator = CreateIdentityGenerator(identityLanguage); - - // Initialize password generator with settings from vault - const passwordSettings = dbContext.sqliteClient!.getPasswordSettings(); - const passwordGenerator = CreatePasswordGenerator(passwordSettings); - - return { identityGenerator, passwordGenerator }; - }, [dbContext.sqliteClient]); - - /** - * Generate a random alias and password. - */ - const generateRandomAlias = useCallback(async () => { - const { identityGenerator, passwordGenerator } = await initializeGenerators(); - - // Get gender preference from database - const genderPreference = dbContext.sqliteClient!.getDefaultIdentityGender(); - - // Get age range preference and convert to birthdate options - const ageRange = dbContext.sqliteClient!.getDefaultIdentityAgeRange(); - const birthdateOptions = convertAgeRangeToBirthdateOptions(ageRange); - - // Generate identity with gender preference and birthdate options (null is handled by generator) - const identity = identityGenerator.generateRandomIdentity(genderPreference, birthdateOptions); - const password = passwordGenerator.generateRandomPassword(); - - const defaultEmailDomain = await dbContext.sqliteClient!.getDefaultEmailDomain(); - const email = defaultEmailDomain ? `${identity.emailPrefix}@${defaultEmailDomain}` : identity.emailPrefix; - - // Check current values - const currentUsername = watch('Username') ?? ''; - const currentPassword = watch('Password') ?? ''; - const currentEmail = watch('Alias.Email') ?? ''; - - // Only overwrite email if it's empty or matches the last generated value - if (!currentEmail || currentEmail === lastGeneratedValues.email) { - setValue('Alias.Email', email); - } - setValue('Alias.FirstName', identity.firstName); - setValue('Alias.LastName', identity.lastName); - setValue('Alias.Gender', identity.gender); - setValue('Alias.BirthDate', IdentityHelperUtils.normalizeBirthDate(identity.birthDate.toISOString())); - - // Only overwrite username if it's empty or matches the last generated value - if (!currentUsername || currentUsername === lastGeneratedValues.username) { - setValue('Username', identity.nickName); - } - - // Only overwrite password if it's empty or matches the last generated value - if (!currentPassword || currentPassword === lastGeneratedValues.password) { - setValue('Password', password); - } - - // Update tracking with new generated values - setLastGeneratedValues({ - username: identity.nickName, - password: password, - email: email - }); - }, [watch, setValue, initializeGenerators, dbContext, lastGeneratedValues, setLastGeneratedValues]); - - /** - * Clear all alias fields. - */ - const clearAliasFields = useCallback(() => { - setValue('Alias.FirstName', ''); - setValue('Alias.LastName', ''); - setValue('Alias.Gender', ''); - setValue('Alias.BirthDate', ''); - }, [setValue]); - - // Check if any alias fields have values. - const hasAliasValues = !!(watch('Alias.FirstName') || watch('Alias.LastName') || watch('Alias.Gender') || watch('Alias.BirthDate')); - - /** - * Handle the generate random alias button press. - */ - const handleGenerateRandomAlias = useCallback(() => { - if (hasAliasValues) { - clearAliasFields(); - } else { - void generateRandomAlias(); - } - }, [generateRandomAlias, clearAliasFields, hasAliasValues]); - - const generateRandomUsername = useCallback(async () => { - try { - const firstName = watch('Alias.FirstName') ?? ''; - const lastName = watch('Alias.LastName') ?? ''; - const birthDate = watch('Alias.BirthDate') ?? ''; - - let username: string; - - // If alias fields are empty, generate a completely random username - if (!firstName && !lastName && !birthDate) { - const { identityGenerator } = await initializeGenerators(); - const genderPreference = dbContext.sqliteClient!.getDefaultIdentityGender(); - const ageRange = dbContext.sqliteClient!.getDefaultIdentityAgeRange(); - const birthdateOptions = convertAgeRangeToBirthdateOptions(ageRange); - const randomIdentity = identityGenerator.generateRandomIdentity(genderPreference, birthdateOptions); - username = randomIdentity.nickName; - } else { - // Generate username based on current identity fields - const usernameEmailGenerator = CreateUsernameEmailGenerator(); - - let gender = Gender.Other; - try { - gender = watch('Alias.Gender') as Gender; - } catch { - // Gender parsing failed, default to other. - } - - // Parse birthDate, fallback to current date if invalid - let parsedBirthDate = new Date(birthDate); - if (!birthDate || isNaN(parsedBirthDate.getTime())) { - parsedBirthDate = new Date(); - } - - const identity: Identity = { - firstName, - lastName, - nickName: '', // nickName is auto-generated but no longer stored as a separate alias field - gender, - birthDate: parsedBirthDate, - emailPrefix: watch('Alias.Email') ?? '', - }; - - username = usernameEmailGenerator.generateUsername(identity); - } - - setValue('Username', username); - // Update the tracking for username - setLastGeneratedValues(prev => ({ ...prev, username })); - } catch (error) { - console.error('Error generating random username:', error); - } - }, [setValue, watch, setLastGeneratedValues, initializeGenerators, dbContext.sqliteClient]); - - /** - * Handle form submission. - */ - const onSubmit = useCallback(async (data: Credential): Promise => { - // Normalize the birth date for database entry. - let birthdate = data.Alias.BirthDate; - if (birthdate) { - birthdate = IdentityHelperUtils.normalizeBirthDate(birthdate); - } - - // Clean up empty protocol-only URLs - if (data.ServiceUrl === 'http://' || data.ServiceUrl === 'https://') { - data.ServiceUrl = ''; - } - - // If we're creating a new credential and mode is random, generate random values here - if (!isEditMode && mode === 'random') { - // Generate random values now and then read them from the form fields to manually assign to the credentialToSave object - await generateRandomAlias(); - data.Username = watch('Username'); - data.Password = watch('Password'); - data.Alias.FirstName = watch('Alias.FirstName'); - data.Alias.LastName = watch('Alias.LastName'); - data.Alias.BirthDate = watch('Alias.BirthDate'); - data.Alias.Gender = watch('Alias.Gender'); - data.Alias.Email = watch('Alias.Email'); - // Clean up ServiceUrl for random mode too - const serviceUrl = watch('ServiceUrl'); - data.ServiceUrl = (serviceUrl === 'http://' || serviceUrl === 'https://') ? '' : serviceUrl; - } - - // Extract favicon from service URL if the credential has one - if (data.ServiceUrl) { - setLocalLoading(true); - try { - const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error('Favicon extraction timed out')), 5000) - ); - - const faviconPromise = webApi.get<{ image: string }>('Favicon/Extract?url=' + data.ServiceUrl); - const faviconResponse = await Promise.race([faviconPromise, timeoutPromise]) as { image: string }; - - if (faviconResponse?.image) { - const decodedImage = Uint8Array.from(Buffer.from(faviconResponse.image, 'base64')); - data.Logo = decodedImage; - } - } catch { - // Favicon extraction failed or timed out, this is not a critical error so we can ignore it. - } - } - - await executeVaultMutationAsync(async () => { - setLocalLoading(false); - - if (isEditMode) { - await dbContext.sqliteClient!.updateCredentialById(data, originalAttachmentIds, attachments, originalTotpCodeIds, totpCodes); - - // Delete passkeys if marked for deletion - if (passkeyMarkedForDeletion) { - await dbContext.sqliteClient!.deletePasskeysByItemId(data.Id); - } - } else { - const credentialId = await dbContext.sqliteClient!.createCredential(data, attachments, totpCodes); - data.Id = credentialId.toString(); - } - }); - - void clearPersistedValues(); - // If in add mode, navigate to the credential details page. - if (!isEditMode) { - // Navigate to the credential details page. - navigate(`/credentials/${data.Id}`, { replace: true }); - } else { - // If in edit mode, pop the current page from the history stack to end up on details page as well. - navigate(-1); - } - }, [isEditMode, dbContext.sqliteClient, executeVaultMutationAsync, navigate, mode, watch, generateRandomAlias, webApi, clearPersistedValues, originalAttachmentIds, attachments, originalTotpCodeIds, totpCodes, passkeyMarkedForDeletion]); - - // Set header buttons on mount and clear on unmount - useEffect((): (() => void) => { - // Only set the header buttons once on mount. - const headerButtonsJSX = ( -
    - {isEditMode && ( - setShowDeleteModal(true)} - title={t('credentials.deleteCredential')} - iconType={HeaderIconType.DELETE} - variant="danger" - /> - )} - -
    - ); - - setHeaderButtons(headerButtonsJSX); - return () => {}; - }, [setHeaderButtons, handleSubmit, onSubmit, isEditMode, t]); - - // Clear header buttons on unmount - useEffect((): (() => void) => { - return () => setHeaderButtons(null); - }, [setHeaderButtons]); - - if (isEditMode && !watch('ServiceName')) { - return
    {t('common.loading')}
    ; - } - - return ( -
    - - - - )} - -
    -
    -

    {t('credentials.service')}

    -
    - setValue('ServiceName', value)} - required - error={errors.ServiceName?.message} - /> - setValue('ServiceUrl', value)} - error={errors.ServiceUrl?.message} - /> -
    -
    - - {(mode === 'manual' || isEditMode) && ( - <> -
    -

    {t('credentials.loginCredentials')}

    -
    - {watch('HasPasskey') ? ( - <> - {/* When passkey exists: username, passkey, email, password */} - setValue('Username', value)} - error={errors.Username?.message} - onRegenerate={generateRandomUsername} - /> - {!passkeyMarkedForDeletion && ( -
    -
    - - - -
    -
    - {t('passkeys.passkey')} - -
    -
    - {watch('PasskeyRpId') && ( -
    - {t('passkeys.site')}: - {watch('PasskeyRpId')} -
    - )} - {watch('PasskeyDisplayName') && ( -
    - {t('passkeys.displayName')}: - {watch('PasskeyDisplayName')} -
    - )} -
    -

    - {t('passkeys.helpText')} -

    -
    -
    -
    - )} - {passkeyMarkedForDeletion && ( -
    -
    - - - -
    -
    - {t('passkeys.passkeyMarkedForDeletion')} - -
    -

    - {t('passkeys.passkeyWillBeDeleted')} -

    -
    -
    -
    - )} - setValue('Alias.Email', value)} - error={errors.Alias?.Email?.message} - /> - setValue('Password', value)} - error={errors.Password?.message} - showPassword={showPassword} - onShowPasswordChange={setShowPassword} - /> - - ) : ( - <> - {/* When no passkey: email, username, password */} - setValue('Alias.Email', value)} - error={errors.Alias?.Email?.message} - /> - setValue('Username', value)} - error={errors.Username?.message} - onRegenerate={generateRandomUsername} - /> - setValue('Password', value)} - error={errors.Password?.message} - showPassword={showPassword} - onShowPasswordChange={setShowPassword} - /> - - )} -
    -
    - -
    -

    {t('credentials.alias')}

    -
    - - setValue('Alias.FirstName', value)} - error={errors.Alias?.FirstName?.message} - /> - setValue('Alias.LastName', value)} - error={errors.Alias?.LastName?.message} - /> - setValue('Alias.Gender', value)} - error={errors.Alias?.Gender?.message} - /> - setValue('Alias.BirthDate', value)} - error={errors.Alias?.BirthDate?.message} - /> -
    -
    - -
    -

    {t('credentials.metadata')}

    -
    - setValue('Notes', value)} - multiline - rows={4} - error={errors.Notes?.message} - /> -
    -
    - - - - - - )} -
    -
    - ); -}; - -export default CredentialAddEdit; \ No newline at end of file diff --git a/apps/browser-extension/src/entrypoints/popup/pages/credentials/CredentialDetails.tsx b/apps/browser-extension/src/entrypoints/popup/pages/credentials/CredentialDetails.tsx deleted file mode 100644 index 65307e585..000000000 --- a/apps/browser-extension/src/entrypoints/popup/pages/credentials/CredentialDetails.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useNavigate, useParams } from 'react-router-dom'; - -import { - HeaderBlock, - TotpBlock, - LoginCredentialsBlock, - AliasBlock, - NotesBlock, - AttachmentBlock -} from '@/entrypoints/popup/components/Credentials/Details'; -import HeaderButton from '@/entrypoints/popup/components/HeaderButton'; -import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons'; -import { useDb } from '@/entrypoints/popup/context/DbContext'; -import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext'; -import { useLoading } from '@/entrypoints/popup/context/LoadingContext'; -import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility'; - -import type { Credential } from '@/utils/dist/core/models/vault'; - -/** - * Credential details page. - */ -const CredentialDetails: React.FC = (): React.ReactElement => { - const { t } = useTranslation(); - const { id } = useParams(); - const navigate = useNavigate(); - const dbContext = useDb(); - const [credential, setCredential] = useState(null); - const { setIsInitialLoading } = useLoading(); - const { setHeaderButtons } = useHeaderButtons(); - - /** - * Open the credential details in a new expanded popup. - */ - const openInNewPopup = useCallback((): void => { - PopoutUtility.openInNewPopup(`/credentials/${id}`); - }, [id]); - - /** - * Navigate to the edit page for this credential. - */ - const handleEdit = useCallback((): void => { - navigate(`/credentials/${id}/edit`); - }, [id, navigate]); - - useEffect(() => { - if (PopoutUtility.isPopup()) { - window.history.replaceState({}, '', `popup.html#/credentials`); - window.history.pushState({}, '', `popup.html#/credentials/${id}`); - } - - if (!dbContext?.sqliteClient || !id) { - return; - } - - try { - const result = dbContext.sqliteClient.getCredentialById(id); - if (result) { - setCredential(result); - setIsInitialLoading(false); - } else { - console.error('Credential not found'); - navigate('/credentials'); - } - } catch (err) { - console.error('Error loading credential:', err); - } - }, [dbContext.sqliteClient, id, navigate, setIsInitialLoading]); - - // Set header buttons on mount and clear on unmount - useEffect((): (() => void) => { - const headerButtonsJSX = ( -
    - {!PopoutUtility.isPopup() && ( - - )} - -
    - ); - setHeaderButtons(headerButtonsJSX); - return () => {}; - }, [setHeaderButtons, handleEdit, openInNewPopup, t]); - - // Clear header buttons on unmount - useEffect((): (() => void) => { - return () => setHeaderButtons(null); - }, [setHeaderButtons]); - - if (!credential) { - return
    {t('common.loading')}
    ; - } - - return ( -
    -
    - -
    - - - - - -
    - ); -}; - -export default CredentialDetails; \ No newline at end of file diff --git a/apps/browser-extension/src/entrypoints/popup/pages/credentials/CredentialsList.tsx b/apps/browser-extension/src/entrypoints/popup/pages/credentials/CredentialsList.tsx deleted file mode 100644 index 6a2420293..000000000 --- a/apps/browser-extension/src/entrypoints/popup/pages/credentials/CredentialsList.tsx +++ /dev/null @@ -1,427 +0,0 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; - -import CredentialCard from '@/entrypoints/popup/components/Credentials/CredentialCard'; -import HeaderButton from '@/entrypoints/popup/components/HeaderButton'; -import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons'; -import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner'; -import ReloadButton from '@/entrypoints/popup/components/ReloadButton'; -import { useApp } from '@/entrypoints/popup/context/AppContext'; -import { useDb } from '@/entrypoints/popup/context/DbContext'; -import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext'; -import { useLoading } from '@/entrypoints/popup/context/LoadingContext'; -import { useVaultSync } from '@/entrypoints/popup/hooks/useVaultSync'; -import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility'; - -import type { Credential } from '@/utils/dist/core/models/vault'; - -import { useMinDurationLoading } from '@/hooks/useMinDurationLoading'; - -type FilterType = 'all' | 'passkeys' | 'aliases' | 'userpass' | 'attachments'; - -const FILTER_STORAGE_KEY = 'credentials-filter'; -const FILTER_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes - -/** - * Get stored filter from localStorage if not expired - */ -const getStoredFilter = (): FilterType => { - try { - const stored = localStorage.getItem(FILTER_STORAGE_KEY); - if (!stored) { - return 'all'; - } - - const { filter, timestamp } = JSON.parse(stored); - const now = Date.now(); - - // Check if expired (5 minutes) - if (now - timestamp > FILTER_EXPIRY_MS) { - localStorage.removeItem(FILTER_STORAGE_KEY); - return 'all'; - } - - return filter as FilterType; - } catch { - return 'all'; - } -}; - -/** - * Store filter in localStorage with timestamp - */ -const storeFilter = (filter: FilterType): void => { - try { - localStorage.setItem(FILTER_STORAGE_KEY, JSON.stringify({ - filter, - timestamp: Date.now() - })); - } catch { - // Ignore storage errors - } -}; - -/** - * Credentials list page. - */ -const CredentialsList: React.FC = () => { - const { t } = useTranslation(); - const dbContext = useDb(); - const app = useApp(); - const navigate = useNavigate(); - const { syncVault } = useVaultSync(); - const { setHeaderButtons } = useHeaderButtons(); - const [credentials, setCredentials] = useState([]); - const [searchTerm, setSearchTerm] = useState(''); - const [filterType, setFilterType] = useState(getStoredFilter()); - const [showFilterMenu, setShowFilterMenu] = useState(false); - const { setIsInitialLoading } = useLoading(); - - /** - * Loading state with minimum duration for more fluid UX. - */ - const [isLoading, setIsLoading] = useMinDurationLoading(true, 100); - - /** - * Handle add new credential. - * Navigate to item type selector for new item-based flow. - */ - const handleAddCredential = useCallback(() : void => { - navigate('/items/select-type'); - }, [navigate]); - - /** - * Retrieve latest vault and refresh the credentials list. - */ - const onRefresh = useCallback(async () : Promise => { - if (!dbContext?.sqliteClient) { - return; - } - - try { - // Sync vault and load credentials - await syncVault({ - /** - * On success. - */ - onSuccess: async (_hasNewVault) => { - // Credentials list is refreshed automatically when the (new) sqlite client is available via useEffect hook below. - }, - /** - * On offline. - */ - onOffline: () => { - // Continue with local vault in offline mode. - }, - /** - * On error. - */ - onError: async (error) => { - console.error('Error syncing vault:', error); - }, - }); - } catch (err) { - console.error('Error refreshing credentials:', err); - await app.logout('Error while syncing vault, please re-authenticate.'); - } - }, [dbContext, app, syncVault]); - - /** - * Get latest vault from server and refresh the credentials list. - */ - const syncVaultAndRefresh = useCallback(async () : Promise => { - setIsLoading(true); - await onRefresh(); - setIsLoading(false); - }, [onRefresh, setIsLoading]); - - // Set header buttons on mount and clear on unmount - useEffect((): (() => void) => { - const headerButtonsJSX = ( -
    - {!PopoutUtility.isPopup() && ( - PopoutUtility.openInNewPopup()} - title="Open in new window" - iconType={HeaderIconType.EXPAND} - /> - )} - -
    - ); - - setHeaderButtons(headerButtonsJSX); - return () => setHeaderButtons(null); - }, [setHeaderButtons, handleAddCredential]); - - /** - * Load credentials list on mount and on sqlite client change. - */ - useEffect(() => { - /** - * Refresh credentials list when a (new) sqlite client is available. - */ - const refreshCredentials = async () : Promise => { - if (dbContext?.sqliteClient) { - setIsLoading(true); - const results = dbContext.sqliteClient?.getAllCredentials() ?? []; - setCredentials(results); - setIsLoading(false); - setIsInitialLoading(false); - } - }; - - refreshCredentials(); - }, [dbContext?.sqliteClient, setIsLoading, setIsInitialLoading]); - - /** - * Get the title based on the active filter - */ - const getFilterTitle = () : string => { - switch (filterType) { - case 'passkeys': - return t('credentials.filters.passkeys'); - case 'aliases': - return t('credentials.filters.aliases'); - case 'userpass': - return t('credentials.filters.userpass'); - case 'attachments': - return t('credentials.filters.attachments'); - default: - return t('credentials.title'); - } - }; - - const filteredCredentials = credentials.filter((credential: 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?.Gender && credential.Alias.Gender.trim()) || - (credential.Alias?.BirthDate && credential.Alias.BirthDate.trim()) - ); - } 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?.Gender && credential.Alias.Gender.trim()) || - (credential.Alias?.BirthDate && credential.Alias.BirthDate.trim()) - ); - const hasUsernameOrPassword = !!( - (credential.Username && credential.Username.trim()) || - (credential.Password && credential.Password.trim()) - ); - passesTypeFilter = hasUsernameOrPassword && !credential.HasPasskey && !hasAliasFields; - } else if (filterType === 'attachments') { - passesTypeFilter = credential.HasAttachment === true; - } - - if (!passesTypeFilter) { - return false; - } - - // Then apply search filter - const searchLower = searchTerm.toLowerCase().trim(); - - if (!searchLower) { - return true; // No search term, include all - } - - /** - * We filter credentials by searching in the following fields: - * - Service name - * - Username - * - Alias email - * - Service URL - * - Notes - */ - const searchableFields = [ - credential.ServiceName?.toLowerCase() || '', - credential.Username?.toLowerCase() || '', - credential.Alias?.Email?.toLowerCase() || '', - credential.ServiceUrl?.toLowerCase() || '', - credential.Notes?.toLowerCase() || '', - ]; - - // Split search term into words for AND search - const searchWords = searchLower.split(/\s+/).filter(word => word.length > 0); - - // All search words must be found (each in at least one field) - return searchWords.every(word => - searchableFields.some(field => field.includes(word)) - ); - }); - - if (isLoading) { - return ( -
    - -
    - ); - } - - return ( -
    -
    -
    - - - {showFilterMenu && ( - <> -
    setShowFilterMenu(false)} - /> -
    -
    - - - - - -
    -
    - - )} -
    - -
    - - {credentials.length > 0 ? ( -
    - setSearchTerm(e.target.value)} - placeholder={`${t('content.searchVault')}`} - autoFocus - className="w-full p-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-blue-500 focus:border-blue-500" - /> -
    - ) : ( - <> - )} - - {credentials.length === 0 ? ( -
    -

    - {t('credentials.welcomeTitle')} -

    -

    - {t('credentials.welcomeDescription')} -

    -
    - ) : filteredCredentials.length === 0 ? ( -
    -

    - {filterType === 'passkeys' - ? t('credentials.noPasskeysFound') - : filterType === 'attachments' - ? t('credentials.noAttachmentsFound') - : t('credentials.noMatchingCredentials') - } -

    -
    - ) : ( -
      - {filteredCredentials.map(cred => ( - - ))} -
    - )} -
    - ); -}; - -export default CredentialsList; \ No newline at end of file diff --git a/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemAddEdit.tsx b/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemAddEdit.tsx index ad6ddfa80..e5d42bf25 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemAddEdit.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemAddEdit.tsx @@ -507,7 +507,7 @@ const ItemAddEdit: React.FC = () => { setIsInitialLoading(false); } else { console.error('Item not found'); - navigate('/credentials'); + navigate('/items'); } } catch (err) { console.error('Error loading item:', err); diff --git a/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemDetails.tsx b/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemDetails.tsx index 63cc907bc..e309a4453 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemDetails.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemDetails.tsx @@ -50,7 +50,7 @@ const ItemDetails: React.FC = (): React.ReactElement => { useEffect(() => { if (PopoutUtility.isPopup()) { - window.history.replaceState({}, '', `popup.html#/credentials`); + window.history.replaceState({}, '', `popup.html#/items`); window.history.pushState({}, '', `popup.html#/items/${id}`); } @@ -65,7 +65,7 @@ const ItemDetails: React.FC = (): React.ReactElement => { setIsInitialLoading(false); } else { console.error('Item not found'); - navigate('/credentials'); + navigate('/items'); } } catch (err) { console.error('Error loading item:', err); diff --git a/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyCreate.tsx b/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyCreate.tsx index 7f4f1b48e..417195c3b 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyCreate.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyCreate.tsx @@ -15,8 +15,9 @@ import { useVaultLockRedirect } from '@/entrypoints/popup/hooks/useVaultLockRedi import { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate'; import { PASSKEY_DISABLED_SITES_KEY } from '@/utils/Constants'; -import { extractDomain, extractRootDomain, filterCredentials, AutofillMatchingMode } from '@/utils/credentialMatcher/CredentialMatcher'; -import type { Credential, Passkey } from '@/utils/dist/core/models/vault'; +import { extractDomain, extractRootDomain, filterItems, AutofillMatchingMode } from '@/utils/credentialMatcher/CredentialMatcher'; +import type { Item, Passkey } from '@/utils/dist/core/models/vault'; +import { FieldKey, FieldTypes, ItemTypes, getFieldValue } from '@/utils/dist/core/models/vault'; import { PasskeyAuthenticator } from '@/utils/passkey/PasskeyAuthenticator'; import { PasskeyHelper } from '@/utils/passkey/PasskeyHelper'; import type { CreateRequest, PasskeyCreateCredentialResponse, PendingPasskeyCreateRequest } from '@/utils/passkey/types'; @@ -39,9 +40,9 @@ const PasskeyCreate: React.FC = () => { const [error, setError] = useState(null); const { isLocked } = useVaultLockRedirect(); const [existingPasskeys, setExistingPasskeys] = useState>([]); - const [matchingCredentials, setMatchingCredentials] = useState([]); + const [matchingItems, setMatchingItems] = useState([]); const [selectedPasskeyToReplace, setSelectedPasskeyToReplace] = useState(null); - const [selectedCredentialToAttach, setSelectedCredentialToAttach] = useState(null); + const [selectedItemToAttach, setSelectedItemToAttach] = useState(null); const [showCreateForm, setShowCreateForm] = useState(false); const [localLoading, setLocalLoading] = useState(false); const [showBypassDialog, setShowBypassDialog] = useState(false); @@ -127,39 +128,41 @@ const PasskeyCreate: React.FC = () => { setExistingPasskeys(filtered); - // If no existing passkeys for this user, check for matching credentials + // If no existing passkeys for this user, check for matching items if (filtered.length === 0) { - // Get all credentials and filter for matches - const allCredentials = dbContext.sqliteClient.getAllCredentials(); + // Get all items and filter for matches + const allItems = dbContext.sqliteClient.getAllItems(); /* - * Filter credentials that: + * Filter items that: * 1. Match the RP origin URL - * 2. Have username/password (are login credentials) + * 2. Have username/password (are login items) * 3. Don't already have a passkey */ - const credentialsWithoutPasskeys = allCredentials.filter(cred => { - // Must have username or password to be a login credential - if (!cred.Username && !cred.Password) { + const itemsWithoutPasskeys = allItems.filter(item => { + // Must have username or password to be a login item + const username = getFieldValue(item, FieldKey.LoginUsername); + const password = getFieldValue(item, FieldKey.LoginPassword); + if (!username && !password) { return false; } - // Check if this credential already has a passkey - return !cred.HasPasskey; + // Check if this item already has a passkey + return !item.HasPasskey; }); - // Use the credential matcher to find matching credentials for the origin - let matches: Credential[] = []; - if (credentialsWithoutPasskeys.length > 0) { - matches = await filterCredentials( - credentialsWithoutPasskeys, + // Use the item matcher to find matching items for the origin + let matches: Item[] = []; + if (itemsWithoutPasskeys.length > 0) { + matches = await filterItems( + itemsWithoutPasskeys, data.origin, data.publicKey.rp.name || '', AutofillMatchingMode.URL_SUBDOMAIN ); - setMatchingCredentials(matches); + setMatchingItems(matches); } - // If no matching credentials, go straight to create form + // If no matching items, go straight to create form if (matches.length === 0) { setShowCreateForm(true); } @@ -210,7 +213,7 @@ const PasskeyCreate: React.FC = () => { */ const handleCreateNew = () : void => { setSelectedPasskeyToReplace(null); - setSelectedCredentialToAttach(null); + setSelectedItemToAttach(null); setShowCreateForm(true); }; @@ -219,15 +222,15 @@ const PasskeyCreate: React.FC = () => { */ const handleSelectReplace = (passkeyId: string) : void => { setSelectedPasskeyToReplace(passkeyId); - setSelectedCredentialToAttach(null); + setSelectedItemToAttach(null); setShowCreateForm(true); }; /** - * Handle when user selects an existing credential to attach the passkey to + * Handle when user selects an existing item to attach the passkey to */ - const handleSelectCredential = (credentialId: string) : void => { - setSelectedCredentialToAttach(credentialId); + const handleSelectItem = (itemId: string) : void => { + setSelectedItemToAttach(itemId); setSelectedPasskeyToReplace(null); setShowCreateForm(true); }; @@ -306,33 +309,31 @@ const PasskeyCreate: React.FC = () => { const { credential, stored, prfEnabled, prfResults } = result; - // Use vault mutation to store both credential and passkey + // Use vault mutation to store both item and passkey await executeVaultMutationAsync(async () => { if (selectedPasskeyToReplace) { // Replace existing passkey: update the item and passkey const existingPasskey = dbContext.sqliteClient!.getPasskeyById(selectedPasskeyToReplace); if (existingPasskey) { - // Update the parent item with new favicon and user-provided display name - await dbContext.sqliteClient!.updateCredentialById( - { - Id: existingPasskey.ItemId, - ServiceName: displayName, - ServiceUrl: request.origin, - Username: request.publicKey.user.name, - Password: '', - Notes: '', - Logo: faviconLogo ?? undefined, - Alias: { - FirstName: '', - LastName: '', - BirthDate: '', - Gender: '', - Email: '' + // Get existing item to preserve its data + const existingItem = dbContext.sqliteClient!.getItemById(existingPasskey.ItemId); + if (existingItem) { + // Update the parent item with new favicon and user-provided display name + await dbContext.sqliteClient!.updateItem( + { + ...existingItem, + Name: displayName, + Logo: faviconLogo ?? existingItem.Logo, + Fields: [ + ...(existingItem.Fields || []).filter(f => f.FieldKey !== FieldKey.LoginUrl && f.FieldKey !== FieldKey.LoginUsername), + { FieldKey: FieldKey.LoginUrl, Label: 'URL', FieldType: FieldTypes.URL, Value: request.origin, IsHidden: false, DisplayOrder: 0 }, + { FieldKey: FieldKey.LoginUsername, Label: 'Username', FieldType: FieldTypes.Text, Value: request.publicKey.user.name, IsHidden: false, DisplayOrder: 1 } + ] }, - }, - [], - [] - ); + [], + [] + ); + } // Delete the old passkey await dbContext.sqliteClient!.deletePasskeyById(selectedPasskeyToReplace); @@ -363,8 +364,8 @@ const PasskeyCreate: React.FC = () => { AdditionalData: null }); } - } else if (selectedCredentialToAttach) { - // Attach passkey to existing credential/item + } else if (selectedItemToAttach) { + // Attach passkey to existing item /** * Create the Passkey linked to the existing item * Convert userId from base64 string to byte array for database storage @@ -381,7 +382,7 @@ const PasskeyCreate: React.FC = () => { await dbContext.sqliteClient!.createPasskey({ Id: newPasskeyGuid, - ItemId: selectedCredentialToAttach, + ItemId: selectedItemToAttach, RpId: stored.rpId, UserHandle: userHandleBytes, PublicKey: JSON.stringify(stored.publicKey), @@ -392,25 +393,20 @@ const PasskeyCreate: React.FC = () => { }); } else { // Create new item and passkey - const itemId = await dbContext.sqliteClient!.createCredential( - { - Id: '', - ServiceName: displayName, - ServiceUrl: request.origin, - Username: request.publicKey.user.name, - Password: '', - Notes: '', - Logo: faviconLogo ?? undefined, - Alias: { - FirstName: '', - LastName: '', - BirthDate: '', - Gender: '', - Email: '' - } - }, - [] - ); + const newItem: Item = { + Id: '', + Name: displayName, + ItemType: ItemTypes.Login, + Logo: faviconLogo, + Fields: [ + { FieldKey: FieldKey.LoginUrl, Label: 'URL', FieldType: FieldTypes.URL, Value: request.origin, IsHidden: false, DisplayOrder: 0 }, + { FieldKey: FieldKey.LoginUsername, Label: 'Username', FieldType: FieldTypes.Text, Value: request.publicKey.user.name, IsHidden: false, DisplayOrder: 1 } + ], + CreatedAt: new Date().toISOString(), + UpdatedAt: new Date().toISOString() + }; + + const itemId = await dbContext.sqliteClient!.createItem(newItem, []); /** * Create the Passkey linked to the item @@ -642,8 +638,8 @@ const PasskeyCreate: React.FC = () => {
    )} - {/* Step 1b: Show matching credentials to attach passkey to (when no existing passkeys) */} - {!showCreateForm && existingPasskeys.length === 0 && matchingCredentials.length > 0 && ( + {/* Step 1b: Show matching items to attach passkey to (when no existing passkeys) */} + {!showCreateForm && existingPasskeys.length === 0 && matchingItems.length > 0 && (
    - {(existingPasskeys.length > 0 || matchingCredentials.length > 0) ? ( + {(existingPasskeys.length > 0 || matchingItems.length > 0) ? (