Remove credential related structure from browser extension (#1404)

This commit is contained in:
Leendert de Borst
2025-12-15 21:52:28 +01:00
parent e58fb72971
commit 23d72ef4bf
20 changed files with 117 additions and 2735 deletions

View File

@@ -28,7 +28,7 @@ export function handleOpenPopup() : Promise<BoolResponse> {
export function handlePopupWithItem(message: any) : Promise<BoolResponse> {
return (async () : Promise<BoolResponse> => {
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<BoolResponse> {
}
/**
* 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<BoolResponse> {
return (async () : Promise<BoolResponse> => {
@@ -59,13 +59,13 @@ export function handleOpenPopupCreateCredential(message: any) : Promise<BoolResp
}
}
// Set a localStorage flag to skip restoring previously persisted form values as we want to start fresh with this explicit create credential request.
// Set a localStorage flag to skip restoring previously persisted form values as we want to start fresh with this explicit create item request.
await browser.storage.local.set({ [SKIP_FORM_RESTORE_KEY]: true });
const urlParams = new URLSearchParams();
urlParams.set('expanded', 'true');
if (serviceName) {
urlParams.set('serviceName', serviceName);
urlParams.set('name', serviceName);
}
if (serviceUrl) {
urlParams.set('serviceUrl', serviceUrl);
@@ -73,9 +73,11 @@ export function handleOpenPopupCreateCredential(message: any) : Promise<BoolResp
if (message.currentUrl) {
urlParams.set('currentUrl', message.currentUrl);
}
// Default to Login type for quick create from content script
urlParams.set('type', 'Login');
browser.windows.create({
url: browser.runtime.getURL(`/popup.html?${urlParams.toString()}#/credentials/add`),
url: browser.runtime.getURL(`/popup.html?${urlParams.toString()}#/items/add`),
type: 'popup',
width: 400,
height: 600,

View File

@@ -421,17 +421,11 @@ export async function handleGetSearchItems(
export async function getEmailAddressesForVault(
sqliteClient: SqliteClient
): Promise<string[]> {
// 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<string[]>('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<Vaul
const newVault: Vault = {
blob: encryptedVault,
createdAt: new Date().toISOString(),
credentialsCount: sqliteClient.getAllCredentials().length,
credentialsCount: sqliteClient.getAllItems().length,
currentRevisionNumber: serverRevision,
emailAddressList: emailAddresses,
updatedAt: new Date().toISOString(),

View File

@@ -16,9 +16,6 @@ import Login from '@/entrypoints/popup/pages/auth/Login';
import Unlock from '@/entrypoints/popup/pages/auth/Unlock';
import UnlockSuccess from '@/entrypoints/popup/pages/auth/UnlockSuccess';
import Upgrade from '@/entrypoints/popup/pages/auth/Upgrade';
import CredentialAddEdit from '@/entrypoints/popup/pages/credentials/CredentialAddEdit';
import CredentialDetails from '@/entrypoints/popup/pages/credentials/CredentialDetails';
import CredentialsList from '@/entrypoints/popup/pages/credentials/CredentialsList';
import ItemAddEdit from '@/entrypoints/popup/pages/credentials/ItemAddEdit';
import ItemDetails from '@/entrypoints/popup/pages/credentials/ItemDetails';
import ItemTypeSelector from '@/entrypoints/popup/pages/credentials/ItemTypeSelector';
@@ -187,10 +184,6 @@ const App: React.FC = () => {
{ path: '/unlock-success', element: <UnlockSuccess />, showBackButton: false },
{ path: '/upgrade', element: <Upgrade />, showBackButton: false },
{ path: '/auth-settings', element: <AuthSettings />, showBackButton: true, title: t('settings.title') },
{ path: '/credentials', element: <CredentialsList />, showBackButton: false },
{ path: '/credentials/add', element: <CredentialAddEdit />, showBackButton: true, title: t('credentials.addCredential') },
{ path: '/credentials/:id', element: <CredentialDetails />, showBackButton: true, title: t('credentials.credentialDetails') },
{ path: '/credentials/:id/edit', element: <CredentialAddEdit />, showBackButton: true, title: t('credentials.editCredential') },
{ path: '/items', element: <ItemsList />, showBackButton: false },
{ path: '/items/folder/:folderId', element: <ItemsList />, showBackButton: true, title: t('items.title') },
{ path: '/items/select-type', element: <ItemTypeSelector />, showBackButton: true, title: t('itemTypes.selectType') },

View File

@@ -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<CredentialCardProps> = ({ 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 (
<li>
<button
onClick={() => navigate(`/items/${credential.Id}`)}
className="w-full p-2 border dark:border-gray-600 rounded flex items-center bg-white dark:bg-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<img
src={SqliteClient.imgSrcFromBytes(credential.Logo)}
alt={credential.ServiceName}
className="w-8 h-8 mr-2 flex-shrink-0"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = '/assets/images/service-placeholder.webp';
}}
/>
<div className="text-left flex-1">
<div className="flex items-center gap-1.5">
<p className="font-medium text-gray-900 dark:text-white">{getCredentialServiceName(credential)}</p>
{credential.HasPasskey && (
<svg
className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-label="Has passkey"
>
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
</svg>
)}
{credential.HasAttachment && (
<svg
className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-label="Has attachments"
>
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
</svg>
)}
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">{getDisplayText(credential)}</p>
</div>
</button>
</li>
);
};
export default CredentialCard;

View File

@@ -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<AliasBlockProps> = ({ 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 (
<div className="space-y-2">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">{t('common.alias')}</h2>
{(hasFirstName || hasLastName) && (
<FormInputCopyToClipboard
id="fullName"
label={t('common.fullName')}
value={[credential.Alias?.FirstName, credential.Alias?.LastName].filter(Boolean).join(' ')}
/>
)}
{hasFirstName && (
<FormInputCopyToClipboard
id="firstName"
label={t('common.firstName')}
value={credential.Alias?.FirstName ?? ''}
/>
)}
{hasLastName && (
<FormInputCopyToClipboard
id="lastName"
label={t('common.lastName')}
value={credential.Alias?.LastName ?? ''}
/>
)}
{hasBirthDate && (
<FormInputCopyToClipboard
id="birthDate"
label={t('common.birthDate')}
value={IdentityHelperUtils.normalizeBirthDate(credential.Alias?.BirthDate)}
/>
)}
</div>
);
};
export default AliasBlock;

View File

@@ -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<AttachmentBlockProps> = ({ credentialId, itemId }) => {
const AttachmentBlock: React.FC<AttachmentBlockProps> = ({ itemId }) => {
const { t } = useTranslation();
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [loading, setLoading] = useState(true);
@@ -50,20 +49,15 @@ const AttachmentBlock: React.FC<AttachmentBlockProps> = ({ credentialId, itemId
useEffect(() => {
/**
* Loads the attachments for the credential or item.
* Loads the attachments for the item.
*/
const loadAttachments = async (): Promise<void> => {
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<AttachmentBlockProps> = ({ credentialId, itemId
};
loadAttachments();
}, [credentialId, itemId, dbContext?.sqliteClient]);
}, [itemId, dbContext?.sqliteClient]);
if (loading) {
return (

View File

@@ -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<HeaderBlockProps> = ({ credential }) => (
<div className="flex items-center justify-between">
<div className="flex items-center">
<img
src={SqliteClient.imgSrcFromBytes(credential.Logo)}
alt={credential.ServiceName}
className="w-12 h-12 rounded-lg mr-4"
/>
<div>
<h1 className="text-lg font-bold text-gray-900 dark:text-white">{credential.ServiceName}</h1>
{credential.ServiceUrl && (
/^https?:\/\//i.test(credential.ServiceUrl) ? (
<a
href={credential.ServiceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 break-all"
>
{credential.ServiceUrl}
</a>
) : (
<span className="text-gray-500 dark:text-gray-300 break-all">{credential.ServiceUrl}</span>
)
)}
</div>
</div>
</div>
);
export default HeaderBlock;

View File

@@ -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<LoginCredentialsBlockProps> = ({ 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 (
<div className="space-y-2">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">{t('common.loginCredentials')}</h2>
{email && (
<FormInputCopyToClipboard
id="email"
label={t('common.email')}
value={email}
/>
)}
{username && (
<FormInputCopyToClipboard
id="username"
label={t('common.username')}
value={username}
/>
)}
{credential.HasPasskey && (
<div className="p-3 rounded bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-2">
<svg
className="w-5 h-5 text-gray-600 dark:text-gray-400 mt-0.5 flex-shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
</svg>
<div className="flex-1">
<div className="mb-1">
<span className="text-sm font-medium text-gray-900 dark:text-white">{t('passkeys.passkey')}</span>
</div>
<div className="space-y-1 mb-2">
{credential.PasskeyRpId && (
<div>
<span className="text-xs text-gray-500 dark:text-gray-400">{t('passkeys.site')}: </span>
<span className="text-sm text-gray-900 dark:text-white">{credential.PasskeyRpId}</span>
</div>
)}
{credential.PasskeyDisplayName && (
<div>
<span className="text-xs text-gray-500 dark:text-gray-400">{t('passkeys.displayName')}: </span>
<span className="text-sm text-gray-900 dark:text-white">{credential.PasskeyDisplayName}</span>
</div>
)}
</div>
<p className="text-xs text-gray-600 dark:text-gray-400">
{t('passkeys.helpText')}
</p>
</div>
</div>
</div>
)}
{password && (
<FormInputCopyToClipboard
id="password"
label={t('common.password')}
value={password}
type="password"
/>
)}
</div>
);
};
export default LoginCredentialsBlock;

View File

@@ -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 `<a href="${href}" target="_blank" rel="noopener noreferrer" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">${url}</a>`;
});
};
/**
* Render the notes block.
*/
const NotesBlock: React.FC<NotesBlockProps> = ({ notes }) => {
const { t } = useTranslation();
if (!notes) {
return null;
}
const formattedNotes = convertUrlsToLinks(notes);
return (
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">{t('common.notes')}</h2>
<div className="p-4 bg-gray-50 rounded-lg dark:bg-gray-700">
<p
className="text-gray-900 dark:text-gray-100 whitespace-pre-wrap"
dangerouslySetInnerHTML={{ __html: formattedNotes }}
/>
</div>
</div>
);
};
export default NotesBlock;

View File

@@ -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<TotpBlockProps> = ({ credentialId, itemId }) => {
const TotpBlock: React.FC<TotpBlockProps> = ({ itemId }) => {
const { t } = useTranslation();
const [totpCodes, setTotpCodes] = useState<TotpCode[]>([]);
const [loading, setLoading] = useState(true);
@@ -85,20 +84,15 @@ const TotpBlock: React.FC<TotpBlockProps> = ({ credentialId, itemId }) => {
useEffect(() => {
/**
* Loads the TOTP codes for the credential or item.
* Loads the TOTP codes for the item.
*/
const loadTotpCodes = async (): Promise<void> => {
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<TotpBlockProps> = ({ credentialId, itemId }) => {
};
loadTotpCodes();
}, [credentialId, itemId, dbContext?.sqliteClient]);
}, [itemId, dbContext?.sqliteClient]);
useEffect(() => {
/**

View File

@@ -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,

View File

@@ -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 (

View File

@@ -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');
}
};

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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<Credential | null>(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 = (
<div className="flex items-center gap-2">
{!PopoutUtility.isPopup() && (
<HeaderButton
onClick={openInNewPopup}
title={t('common.openInNewWindow')}
iconType={HeaderIconType.EXPAND}
/>
)}
<HeaderButton
onClick={handleEdit}
title={t('credentials.editCredential')}
iconType={HeaderIconType.EDIT}
/>
</div>
);
setHeaderButtons(headerButtonsJSX);
return () => {};
}, [setHeaderButtons, handleEdit, openInNewPopup, t]);
// Clear header buttons on unmount
useEffect((): (() => void) => {
return () => setHeaderButtons(null);
}, [setHeaderButtons]);
if (!credential) {
return <div>{t('common.loading')}</div>;
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<HeaderBlock credential={credential} />
</div>
<TotpBlock credentialId={credential.Id} />
<LoginCredentialsBlock credential={credential} />
<AliasBlock credential={credential} />
<NotesBlock notes={credential.Notes} />
<AttachmentBlock credentialId={credential.Id} />
</div>
);
};
export default CredentialDetails;

View File

@@ -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<Credential[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState<FilterType>(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<void> => {
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<void> => {
setIsLoading(true);
await onRefresh();
setIsLoading(false);
}, [onRefresh, setIsLoading]);
// Set header buttons on mount and clear on unmount
useEffect((): (() => void) => {
const headerButtonsJSX = (
<div className="flex items-center gap-2">
{!PopoutUtility.isPopup() && (
<HeaderButton
onClick={() => PopoutUtility.openInNewPopup()}
title="Open in new window"
iconType={HeaderIconType.EXPAND}
/>
)}
<HeaderButton
onClick={handleAddCredential}
title="Add new credential"
iconType={HeaderIconType.PLUS}
/>
</div>
);
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<void> => {
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 (
<div className="flex justify-center items-center p-8">
<LoadingSpinner />
</div>
);
}
return (
<div>
<div className="flex justify-between items-center mb-4">
<div className="relative">
<button
onClick={() => setShowFilterMenu(!showFilterMenu)}
className="flex items-center gap-1 text-gray-900 dark:text-white text-xl hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none"
>
<h2 className="flex items-baseline gap-1.5">
{getFilterTitle()}
<span className="text-sm text-gray-500 dark:text-gray-400">({filteredCredentials.length})</span>
</h2>
<svg
className="w-4 h-4 mt-1"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{showFilterMenu && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setShowFilterMenu(false)}
/>
<div className="absolute left-0 mt-2 w-56 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-20">
<div className="py-1">
<button
onClick={() => {
const newFilter = 'all';
setFilterType(newFilter);
storeFilter(newFilter);
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-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')}
</button>
<button
onClick={() => {
const newFilter = 'passkeys';
setFilterType(newFilter);
storeFilter(newFilter);
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-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')}
</button>
<button
onClick={() => {
const newFilter = 'aliases';
setFilterType(newFilter);
storeFilter(newFilter);
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-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')}
</button>
<button
onClick={() => {
const newFilter = 'userpass';
setFilterType(newFilter);
storeFilter(newFilter);
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-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')}
</button>
<button
onClick={() => {
const newFilter = 'attachments';
setFilterType(newFilter);
storeFilter(newFilter);
setShowFilterMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
filterType === 'attachments' ? '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.attachments')}
</button>
</div>
</div>
</>
)}
</div>
<ReloadButton onClick={syncVaultAndRefresh} />
</div>
{credentials.length > 0 ? (
<div className="mb-4">
<input
type="text"
value={searchTerm}
onChange={(e) => 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"
/>
</div>
) : (
<></>
)}
{credentials.length === 0 ? (
<div className="text-gray-500 dark:text-gray-400 space-y-2 mb-10">
<p>
{t('credentials.welcomeTitle')}
</p>
<p>
{t('credentials.welcomeDescription')}
</p>
</div>
) : filteredCredentials.length === 0 ? (
<div className="text-gray-500 dark:text-gray-400 space-y-2 mb-10">
<p>
{filterType === 'passkeys'
? t('credentials.noPasskeysFound')
: filterType === 'attachments'
? t('credentials.noAttachmentsFound')
: t('credentials.noMatchingCredentials')
}
</p>
</div>
) : (
<ul className="space-y-2">
{filteredCredentials.map(cred => (
<CredentialCard key={cred.Id} credential={cred} />
))}
</ul>
)}
</div>
);
};
export default CredentialsList;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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<string | null>(null);
const { isLocked } = useVaultLockRedirect();
const [existingPasskeys, setExistingPasskeys] = useState<Array<Passkey & { Username?: string | null; ServiceName?: string | null }>>([]);
const [matchingCredentials, setMatchingCredentials] = useState<Credential[]>([]);
const [matchingItems, setMatchingItems] = useState<Item[]>([]);
const [selectedPasskeyToReplace, setSelectedPasskeyToReplace] = useState<string | null>(null);
const [selectedCredentialToAttach, setSelectedCredentialToAttach] = useState<string | null>(null);
const [selectedItemToAttach, setSelectedItemToAttach] = useState<string | null>(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 = () => {
</div>
)}
{/* 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 && (
<div className="space-y-4">
<Button
variant="primary"
@@ -679,28 +675,28 @@ const PasskeyCreate: React.FC = () => {
{t('passkeys.create.selectExistingLoginDescription')}
</p>
<div className="space-y-2 max-h-48 overflow-y-auto border rounded-lg p-2 bg-gray-50 dark:bg-gray-800">
{matchingCredentials.map((credential) => (
{matchingItems.map((item) => (
<button
key={credential.Id}
onClick={() => handleSelectCredential(credential.Id)}
key={item.Id}
onClick={() => handleSelectItem(item.Id)}
className="w-full p-3 text-left rounded-lg border cursor-pointer transition-colors bg-white border-gray-200 hover:bg-gray-100 hover:border-gray-300 dark:bg-gray-700 dark:border-gray-600 dark:hover:bg-gray-600 dark:hover:border-gray-500 focus:outline-none focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
>
<div className="flex items-center justify-between">
<div className="flex items-center flex-1 min-w-0">
{credential.Logo && (
{item.Logo && (
<img
src={SqliteClient.imgSrcFromBytes(credential.Logo)}
src={SqliteClient.imgSrcFromBytes(item.Logo)}
alt=""
className="w-8 h-8 rounded mr-3 flex-shrink-0"
/>
)}
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900 dark:text-white text-sm truncate">
{credential.ServiceName}
{item.Name}
</div>
{credential.Username && (
{getFieldValue(item, FieldKey.LoginUsername) && (
<div className="text-xs text-gray-600 dark:text-gray-400 truncate">
{credential.Username}
{getFieldValue(item, FieldKey.LoginUsername)}
</div>
)}
</div>
@@ -734,15 +730,15 @@ const PasskeyCreate: React.FC = () => {
</Alert>
)}
{selectedCredentialToAttach && (
{selectedItemToAttach && (
<Alert variant="info">
{t('passkeys.create.attachingToCredential', {
serviceName: matchingCredentials.find(c => c.Id === selectedCredentialToAttach)?.ServiceName || ''
serviceName: matchingItems.find(i => i.Id === selectedItemToAttach)?.Name || ''
})}
</Alert>
)}
{!selectedCredentialToAttach && (
{!selectedItemToAttach && (
<FormInput
id="displayName"
label={t('passkeys.create.titleLabel')}
@@ -760,18 +756,18 @@ const PasskeyCreate: React.FC = () => {
>
{selectedPasskeyToReplace
? t('passkeys.create.confirmReplace')
: selectedCredentialToAttach
: selectedItemToAttach
? t('passkeys.create.attachPasskey')
: t('passkeys.create.createButton')}
</Button>
{(existingPasskeys.length > 0 || matchingCredentials.length > 0) ? (
{(existingPasskeys.length > 0 || matchingItems.length > 0) ? (
<Button
variant="secondary"
onClick={() => {
setShowCreateForm(false);
setSelectedPasskeyToReplace(null);
setSelectedCredentialToAttach(null);
setSelectedItemToAttach(null);
}}
>
{t('common.back')}

View File

@@ -1,7 +1,7 @@
import initSqlJs, { Database } from 'sql.js';
import * as dateFormatter from '@/utils/dateFormatter';
import type { Credential, EncryptionKey, PasswordSettings, TotpCode, Passkey, Item, ItemField, ItemTagRef, FieldType, FieldHistory } from '@/utils/dist/core/models/vault';
import type { EncryptionKey, PasswordSettings, TotpCode, Passkey, Item, ItemField, ItemTagRef, FieldType, FieldHistory } from '@/utils/dist/core/models/vault';
import type { Attachment } from '@/utils/dist/core/models/vault';
import { FieldKey, FieldTypes, getSystemField, MAX_FIELD_HISTORY_RECORDS } from '@/utils/dist/core/models/vault';
import type { VaultVersion } from '@/utils/dist/core/vault';
@@ -201,162 +201,6 @@ export class SqliteClient {
}
}
/**
* Fetch a single credential with its associated service information.
* @param credentialId - The ID of the credential to fetch.
* @returns Credential object with service details or null if not found.
*/
public getCredentialById(credentialId: string): Credential | null {
// WIP: Quick V5 schema refactor - field-based queries
const query = `
SELECT DISTINCT
i.Id,
i.Name as ServiceName,
l.FileData as Logo,
CASE
WHEN EXISTS (
SELECT 1 FROM Passkeys pk
WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0
) THEN 1
ELSE 0
END as HasPasskey,
(SELECT pk.RpId FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0 LIMIT 1) as PasskeyRpId,
(SELECT pk.DisplayName FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0 LIMIT 1) as PasskeyDisplayName
FROM Items i
LEFT JOIN Logos l ON i.LogoId = l.Id
WHERE i.IsDeleted = 0 AND i.DeletedAt IS NULL
AND i.Id = ?`;
const results = this.executeQuery(query, [credentialId]);
if (results.length === 0) {
return null;
}
// Get field values for this item (only system fields, which have FieldKey)
const fieldQuery = `
SELECT
fv.FieldKey,
fv.Value
FROM FieldValues fv
WHERE fv.ItemId = ? AND fv.IsDeleted = 0 AND fv.FieldKey IS NOT NULL`;
const fieldResults = this.executeQuery<{FieldKey: string, Value: string}>(fieldQuery, [credentialId]);
// Map field values by FieldKey
const fields: {[key: string]: string} = {};
fieldResults.forEach(f => {
if (f.FieldKey) {
fields[f.FieldKey] = f.Value;
}
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const row = results[0] as any;
return {
Id: row.Id,
Username: fields[FieldKey.LoginUsername] || undefined,
Password: fields[FieldKey.LoginPassword] || '',
ServiceName: row.ServiceName,
ServiceUrl: fields[FieldKey.LoginUrl] || undefined,
Logo: row.Logo,
Notes: fields[FieldKey.LoginNotes] || undefined,
HasPasskey: row.HasPasskey === 1,
PasskeyRpId: row.PasskeyRpId,
PasskeyDisplayName: row.PasskeyDisplayName,
Alias: {
FirstName: fields[FieldKey.AliasFirstName] || undefined,
LastName: fields[FieldKey.AliasLastName] || undefined,
BirthDate: fields[FieldKey.AliasBirthdate] || '',
Gender: fields[FieldKey.AliasGender] || undefined,
Email: fields[FieldKey.LoginEmail] || undefined
},
};
}
/**
* Fetch all credentials with their associated service information.
* @returns Array of Credential objects with service details.
*/
public getAllCredentials(): Credential[] {
// WIP: Quick V5 schema refactor - field-based queries
const query = `
SELECT DISTINCT
i.Id,
i.Name as ServiceName,
l.FileData as Logo,
CASE
WHEN EXISTS (
SELECT 1 FROM Passkeys pk
WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0
) THEN 1
ELSE 0
END as HasPasskey,
CASE
WHEN EXISTS (
SELECT 1 FROM Attachments att
WHERE att.ItemId = i.Id AND att.IsDeleted = 0
) THEN 1
ELSE 0
END as HasAttachment
FROM Items i
LEFT JOIN Logos l ON i.LogoId = l.Id
WHERE i.IsDeleted = 0 AND i.DeletedAt IS NULL
ORDER BY i.CreatedAt DESC`;
const results = this.executeQuery(query);
// Get all field values in one query for performance
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const itemIds = results.map((r: any) => r.Id);
if (itemIds.length === 0) {
return [];
}
const fieldQuery = `
SELECT fv.ItemId, fv.FieldKey, fv.Value
FROM FieldValues fv
WHERE fv.ItemId IN (${itemIds.map(() => '?').join(',')})
AND fv.IsDeleted = 0
AND fv.FieldKey IS NOT NULL`;
const fieldResults = this.executeQuery<{ItemId: string, FieldKey: string, Value: string}>(fieldQuery, itemIds);
// Group fields by item ID
const fieldsByItem: {[itemId: string]: {[fieldKey: string]: string}} = {};
fieldResults.forEach(f => {
if (!fieldsByItem[f.ItemId]) {
fieldsByItem[f.ItemId] = {};
}
if (f.FieldKey) {
fieldsByItem[f.ItemId][f.FieldKey] = f.Value;
}
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return results.map((row: any) => {
const fields = fieldsByItem[row.Id] || {};
return {
Id: row.Id,
Username: fields[FieldKey.LoginUsername] || undefined,
Password: fields[FieldKey.LoginPassword] || '',
ServiceName: row.ServiceName,
ServiceUrl: fields[FieldKey.LoginUrl] || undefined,
Logo: row.Logo,
Notes: fields[FieldKey.LoginNotes] || undefined,
HasPasskey: row.HasPasskey === 1,
HasAttachment: row.HasAttachment === 1,
Alias: {
FirstName: fields[FieldKey.AliasFirstName] || undefined,
LastName: fields[FieldKey.AliasLastName] || undefined,
BirthDate: fields[FieldKey.AliasBirthdate] || '',
Gender: fields[FieldKey.AliasGender] || undefined,
Email: fields[FieldKey.LoginEmail] || undefined
}
};
});
}
/**
* Fetch all items with their dynamic fields and tags.
* @returns Array of Item objects with field-based data (empty array if Items table doesn't exist yet).
@@ -869,140 +713,6 @@ export class SqliteClient {
return defaultSettings;
}
/**
* Create a new credential with associated entities (using new field-based schema)
* @param credential The credential object to insert
* @param attachments The attachments to insert
* @returns The ID of the created item
*/
public async createCredential(credential: Credential, attachments: Attachment[], totpCodes: TotpCode[] = []): Promise<string> {
if (!this.db) {
throw new Error('Database not initialized');
}
try {
this.beginTransaction();
const currentDateTime = dateFormatter.now();
const itemId = crypto.randomUUID().toUpperCase();
// 1. Handle Logo - get or create logo entry
let logoId: string | null = null;
if (credential.Logo) {
const logoData = SqliteClient.convertLogoToUint8Array(credential.Logo);
if (logoData) {
const source = SqliteClient.extractSourceFromUrl(credential.ServiceUrl);
logoId = this.getOrCreateLogoId(source, logoData, currentDateTime);
}
}
// 2. Insert Item
const itemQuery = `
INSERT INTO Items (Id, Name, ItemType, LogoId, FolderId, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`;
this.executeUpdate(itemQuery, [
itemId,
credential.ServiceName ?? null,
'Login', // ItemType
logoId,
null, // FolderId
currentDateTime,
currentDateTime,
0
]);
// 3. Insert FieldValues for credential fields
const fieldsToInsert: Array<{ key: string; value: string | null }> = [
{ key: FieldKey.LoginUsername, value: credential.Username ?? null },
{ key: FieldKey.LoginPassword, value: credential.Password ?? null },
{ key: FieldKey.LoginUrl, value: credential.ServiceUrl ?? null },
{ key: FieldKey.LoginNotes, value: credential.Notes ?? null },
{ key: FieldKey.AliasFirstName, value: credential.Alias?.FirstName ?? null },
{ key: FieldKey.AliasLastName, value: credential.Alias?.LastName ?? null },
{ key: FieldKey.AliasBirthdate, value: credential.Alias?.BirthDate ?? null },
{ key: FieldKey.AliasGender, value: credential.Alias?.Gender ?? null },
{ key: FieldKey.LoginEmail, value: credential.Alias?.Email ?? null },
];
for (const field of fieldsToInsert) {
// Skip empty fields
if (!field.value || (typeof field.value === 'string' && field.value.trim() === '')) {
continue;
}
const fieldValueId = crypto.randomUUID().toUpperCase();
const fieldQuery = `
INSERT INTO FieldValues (Id, ItemId, FieldDefinitionId, FieldKey, Value, Weight, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
this.executeUpdate(fieldQuery, [
fieldValueId,
itemId,
null, // FieldDefinitionId is null for system fields
field.key, // FieldKey
field.value,
0, // Weight
currentDateTime,
currentDateTime,
0
]);
}
// 4. Insert Attachments
if (attachments) {
for (const attachment of attachments) {
const attachmentQuery = `
INSERT INTO Attachments (Id, Filename, Blob, ItemId, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?)`;
const attachmentId = crypto.randomUUID().toUpperCase();
this.executeUpdate(attachmentQuery, [
attachmentId,
attachment.Filename,
attachment.Blob as Uint8Array,
itemId,
currentDateTime,
currentDateTime,
0
]);
}
}
// 5. Insert TOTP codes
if (totpCodes) {
for (const totpCode of totpCodes) {
// Skip deleted codes
if (totpCode.IsDeleted) {
continue;
}
const totpCodeQuery = `
INSERT INTO TotpCodes (Id, Name, SecretKey, ItemId, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?)`;
this.executeUpdate(totpCodeQuery, [
totpCode.Id || crypto.randomUUID().toUpperCase(),
totpCode.Name,
totpCode.SecretKey,
itemId,
currentDateTime,
currentDateTime,
0
]);
}
}
await this.commitTransaction();
return itemId;
} catch (error) {
this.rollbackTransaction();
console.error('Error creating credential:', error);
throw error;
}
}
/**
* Get the current database version from the migrations history.
* Returns the semantic version (e.g., "1.4.1") from the latest migration.
@@ -1094,76 +804,6 @@ export class SqliteClient {
}
}
/**
* Get TOTP codes for a credential (alias for getTotpCodesForItem)
* @param credentialId - The ID of the item to get TOTP codes for
* @returns Array of TotpCode objects
*/
public getTotpCodesForCredential(credentialId: string): TotpCode[] {
if (!this.db) {
throw new Error('Database not initialized');
}
try {
/*
* Check if TotpCodes table exists (for backward compatibility).
* TODO: whenever the browser extension has a minimum client DB version of 1.5.0+,
* we can remove this check as the TotpCodes table then is guaranteed to exist.
*/
if (!this.tableExists('TotpCodes')) {
return [];
}
const query = `
SELECT
Id,
Name,
SecretKey,
ItemId
FROM TotpCodes
WHERE ItemId = ? AND IsDeleted = 0`;
return this.executeQuery<TotpCode>(query, [credentialId]);
} catch (error) {
console.error('Error getting TOTP codes:', error);
// Return empty array instead of throwing to be robust
return [];
}
}
/**
* Get attachments for a specific credential (alias for getAttachmentsForItem)
* @param credentialId - The ID of the item
* @returns Array of attachments for the item
*/
public getAttachmentsForCredential(credentialId: string): Attachment[] {
if (!this.db) {
throw new Error('Database not initialized');
}
try {
if (!this.tableExists('Attachments')) {
return [];
}
const query = `
SELECT
Id,
Filename,
Blob,
ItemId,
CreatedAt,
UpdatedAt,
IsDeleted
FROM Attachments
WHERE ItemId = ? AND IsDeleted = 0`;
return this.executeQuery<Attachment>(query, [credentialId]);
} catch (error) {
console.error('Error getting attachments:', error);
return [];
}
}
/**
* Get TOTP codes for an item
* @param itemId - The ID of the item to get TOTP codes for
@@ -1228,334 +868,6 @@ export class SqliteClient {
}
}
/**
* Delete a credential by ID
* @param credentialId - The ID of the credential to delete
* @returns The number of rows deleted
*/
public async deleteCredentialById(credentialId: string): Promise<number> {
if (!this.db) {
throw new Error('Database not initialized');
}
try {
this.beginTransaction();
const currentDateTime = dateFormatter.now();
// Update the credential, alias, and service to be deleted
const query = `
UPDATE Credentials
SET IsDeleted = 1,
UpdatedAt = ?
WHERE Id = ?`;
const aliasQuery = `
UPDATE Aliases
SET IsDeleted = 1,
UpdatedAt = ?
WHERE Id = (
SELECT AliasId
FROM Credentials
WHERE Id = ?
)`;
const serviceQuery = `
UPDATE Services
SET IsDeleted = 1,
UpdatedAt = ?
WHERE Id = (
SELECT ServiceId
FROM Credentials
WHERE Id = ?
)`;
const passkeyQuery = `
UPDATE Passkeys
SET IsDeleted = 1,
UpdatedAt = ?
WHERE ItemId = ?`;
const results = this.executeUpdate(query, [currentDateTime, credentialId]);
this.executeUpdate(aliasQuery, [currentDateTime, credentialId]);
this.executeUpdate(serviceQuery, [currentDateTime, credentialId]);
this.executeUpdate(passkeyQuery, [currentDateTime, credentialId]);
await this.commitTransaction();
return results;
} catch (error) {
this.rollbackTransaction();
console.error('Error deleting credential:', error);
throw error;
}
}
/**
* Update an existing credential with associated entities
* @param credential The credential object to update
* @param originalAttachmentIds The IDs of the original attachments
* @param attachments The attachments to update
* @returns The number of rows modified
*/
public async updateCredentialById(credential: Credential, originalAttachmentIds: string[], attachments: Attachment[], originalTotpCodeIds: string[] = [], totpCodes: TotpCode[] = []): Promise<number> {
if (!this.db) {
throw new Error('Database not initialized');
}
try {
this.beginTransaction();
const currentDateTime = dateFormatter.now();
// Get existing credential to compare changes
const existingCredential = this.getCredentialById(credential.Id);
if (!existingCredential) {
throw new Error('Credential not found');
}
// 1. Update Service
const serviceQuery = `
UPDATE Services
SET Name = ?,
Url = ?,
Logo = COALESCE(?, Logo),
UpdatedAt = ?
WHERE Id = (
SELECT ServiceId
FROM Credentials
WHERE Id = ?
)`;
let logoData = null;
try {
if (credential.Logo) {
// Handle object-like array conversion
if (typeof credential.Logo === 'object' && !ArrayBuffer.isView(credential.Logo)) {
const values = Object.values(credential.Logo);
logoData = new Uint8Array(values);
// Handle existing array types
} else if (Array.isArray(credential.Logo) || credential.Logo instanceof ArrayBuffer || credential.Logo instanceof Uint8Array) {
logoData = new Uint8Array(credential.Logo);
}
}
} catch (error) {
console.warn('Failed to convert logo to Uint8Array:', error);
logoData = null;
}
this.executeUpdate(serviceQuery, [
credential.ServiceName,
credential.ServiceUrl ?? null,
logoData,
currentDateTime,
credential.Id
]);
// 2. Update Alias
const aliasQuery = `
UPDATE Aliases
SET FirstName = ?,
LastName = ?,
BirthDate = ?,
Gender = ?,
Email = ?,
UpdatedAt = ?
WHERE Id = (
SELECT AliasId
FROM Credentials
WHERE Id = ?
)`;
// Only update BirthDate if it's actually different (accounting for format differences)
let birthDate = credential.Alias.BirthDate;
if (birthDate && existingCredential.Alias.BirthDate) {
const newDate = new Date(birthDate);
const existingDate = new Date(existingCredential.Alias.BirthDate);
if (newDate.getTime() === existingDate.getTime()) {
birthDate = existingCredential.Alias.BirthDate;
}
}
this.executeUpdate(aliasQuery, [
credential.Alias.FirstName ?? null,
credential.Alias.LastName ?? null,
birthDate ?? null,
credential.Alias.Gender ?? null,
credential.Alias.Email ?? null,
currentDateTime,
credential.Id
]);
// 3. Update Credential
const credentialQuery = `
UPDATE Credentials
SET Username = ?,
Notes = ?,
UpdatedAt = ?
WHERE Id = ?`;
this.executeUpdate(credentialQuery, [
credential.Username ?? null,
credential.Notes ?? null,
currentDateTime,
credential.Id
]);
// 4. Update Password if changed
if (credential.Password !== existingCredential.Password) {
// Check if a password record already exists for this credential, if not, then create one.
const passwordRecordExistsQuery = `
SELECT Id
FROM Passwords
WHERE CredentialId = ?`;
const passwordResults = this.executeQuery(passwordRecordExistsQuery, [credential.Id]);
if (passwordResults.length === 0) {
// Create a new password record
const passwordQuery = `
INSERT INTO Passwords (Id, Value, CredentialId, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?)`;
this.executeUpdate(passwordQuery, [
crypto.randomUUID().toUpperCase(),
credential.Password,
credential.Id,
currentDateTime,
currentDateTime,
0
]);
} else {
// Update the existing password record
const passwordQuery = `
UPDATE Passwords
SET Value = ?, UpdatedAt = ?
WHERE CredentialId = ?`;
this.executeUpdate(passwordQuery, [
credential.Password,
currentDateTime,
credential.Id
]);
}
}
// 5. Handle Attachments
if (attachments) {
// Get current attachment IDs to track what needs to be deleted
const currentAttachmentIds = attachments.map(a => a.Id);
// Delete attachments that were removed (in originalAttachmentIds but not in current attachments)
const attachmentsToDelete = originalAttachmentIds.filter(id => !currentAttachmentIds.includes(id));
for (const attachmentId of attachmentsToDelete) {
const deleteQuery = `
UPDATE Attachments
SET IsDeleted = 1,
UpdatedAt = ?
WHERE Id = ?`;
this.executeUpdate(deleteQuery, [currentDateTime, attachmentId]);
}
// Process each attachment
for (const attachment of attachments) {
const isExistingAttachment = originalAttachmentIds.includes(attachment.Id);
if (!isExistingAttachment) {
// Insert new attachment
const insertQuery = `
INSERT INTO Attachments (Id, Filename, Blob, ItemId, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?)`;
this.executeUpdate(insertQuery, [
attachment.Id,
attachment.Filename,
attachment.Blob as Uint8Array,
credential.Id,
currentDateTime,
currentDateTime,
0
]);
}
}
}
// 6. Handle TOTP codes
if (totpCodes) {
// Get current TOTP code IDs (excluding deleted ones)
const currentTotpCodeIds = totpCodes
.filter(tc => !tc.IsDeleted)
.map(tc => tc.Id);
// Mark TOTP codes as deleted that were removed
const totpCodesToDelete = originalTotpCodeIds.filter(id => !currentTotpCodeIds.includes(id));
for (const totpCodeId of totpCodesToDelete) {
const deleteQuery = `
UPDATE TotpCodes
SET IsDeleted = 1,
UpdatedAt = ?
WHERE Id = ?`;
this.executeUpdate(deleteQuery, [currentDateTime, totpCodeId]);
}
// Handle TOTP codes marked for deletion in the array
const markedForDeletion = totpCodes.filter(tc => tc.IsDeleted && originalTotpCodeIds.includes(tc.Id));
for (const totpCode of markedForDeletion) {
const deleteQuery = `
UPDATE TotpCodes
SET IsDeleted = 1,
UpdatedAt = ?
WHERE Id = ?`;
this.executeUpdate(deleteQuery, [currentDateTime, totpCode.Id]);
}
// Process each TOTP code
for (const totpCode of totpCodes) {
// Skip deleted codes
if (totpCode.IsDeleted) {
continue;
}
const isExistingTotpCode = originalTotpCodeIds.includes(totpCode.Id);
if (!isExistingTotpCode) {
// Insert new TOTP code
const insertQuery = `
INSERT INTO TotpCodes (Id, Name, SecretKey, ItemId, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?)`;
this.executeUpdate(insertQuery, [
totpCode.Id || crypto.randomUUID().toUpperCase(),
totpCode.Name,
totpCode.SecretKey,
credential.Id,
currentDateTime,
currentDateTime,
0
]);
} else {
// Update existing TOTP code
const updateQuery = `
UPDATE TotpCodes
SET Name = ?,
SecretKey = ?,
UpdatedAt = ?
WHERE Id = ?`;
this.executeUpdate(updateQuery, [
totpCode.Name,
totpCode.SecretKey,
currentDateTime,
totpCode.Id
]);
}
}
}
await this.commitTransaction();
return 1;
} catch (error) {
this.rollbackTransaction();
console.error('Error updating credential:', error);
throw error;
}
}
/**
* Convert binary data to a base64 encoded image source.
*/