mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-25 00:42:57 -04:00
Remove credential related structure from browser extension (#1404)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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(() => {
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user