Tweak passkey create and auth flow, replace literal item type with references (#1404)

This commit is contained in:
Leendert de Borst
2025-12-12 19:05:25 +01:00
parent 48d9f1c743
commit 765341c7af
15 changed files with 587 additions and 65 deletions

View File

@@ -0,0 +1,109 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import type { Passkey } from '@/utils/dist/core/models/vault';
type PasskeyBlockProps = {
itemId: string;
}
/**
* Passkey icon component.
*/
const PasskeyIcon: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
<svg
className={className}
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>
);
/**
* Display passkey information for an item in view mode.
*/
const PasskeyBlock: React.FC<PasskeyBlockProps> = ({ itemId }) => {
const { t } = useTranslation();
const dbContext = useDb();
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!dbContext?.sqliteClient || !itemId) {
setLoading(false);
return;
}
try {
const itemPasskeys = dbContext.sqliteClient.getPasskeysByItemId(itemId);
setPasskeys(itemPasskeys);
} catch (err) {
console.error('Error loading passkeys:', err);
} finally {
setLoading(false);
}
}, [dbContext?.sqliteClient, itemId]);
if (loading) {
return (
<div className="flex justify-center p-2">
<LoadingSpinner />
</div>
);
}
if (passkeys.length === 0) {
return null;
}
return (
<div className="space-y-2">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
{t('passkeys.passkey')}
</h2>
{passkeys.map((passkey) => (
<div
key={passkey.Id}
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">
<PasskeyIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<div className="mb-1">
<span className="text-sm font-medium text-gray-900 dark:text-white">
{passkey.DisplayName || t('passkeys.passkey')}
</span>
</div>
<div className="space-y-1 mb-2">
{passkey.RpId && (
<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">
{passkey.RpId}
</span>
</div>
)}
</div>
<p className="text-xs text-gray-600 dark:text-gray-400">
{t('passkeys.helpText')}
</p>
</div>
</div>
</div>
))}
</div>
);
};
export default PasskeyBlock;
export { PasskeyIcon };

View File

@@ -0,0 +1,185 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import type { Passkey } from '@/utils/dist/core/models/vault';
import { PasskeyIcon } from './PasskeyBlock';
type PasskeyEditorProps = {
itemId: string;
passkeyIdsMarkedForDeletion: string[];
onPasskeyMarkedForDeletion: (passkeyIds: string[]) => void;
}
/**
* Edit passkey information for an item (supports marking for deletion).
* Passkeys cannot be manually created or edited - only deleted.
*/
const PasskeyEditor: React.FC<PasskeyEditorProps> = ({
itemId,
passkeyIdsMarkedForDeletion,
onPasskeyMarkedForDeletion
}) => {
const { t } = useTranslation();
const dbContext = useDb();
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!dbContext?.sqliteClient || !itemId) {
setLoading(false);
return;
}
try {
const itemPasskeys = dbContext.sqliteClient.getPasskeysByItemId(itemId);
setPasskeys(itemPasskeys);
} catch (err) {
console.error('Error loading passkeys:', err);
} finally {
setLoading(false);
}
}, [dbContext?.sqliteClient, itemId]);
/**
* Mark a passkey for deletion.
*/
const handleMarkForDeletion = (passkeyId: string): void => {
if (!passkeyIdsMarkedForDeletion.includes(passkeyId)) {
onPasskeyMarkedForDeletion([...passkeyIdsMarkedForDeletion, passkeyId]);
}
};
/**
* Undo marking a passkey for deletion.
*/
const handleUndoDeletion = (passkeyId: string): void => {
onPasskeyMarkedForDeletion(passkeyIdsMarkedForDeletion.filter(id => id !== passkeyId));
};
if (loading) {
return (
<div className="flex justify-center p-2">
<LoadingSpinner />
</div>
);
}
if (passkeys.length === 0) {
return null;
}
return (
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
{t('passkeys.passkey')}
</h2>
<div className="space-y-3">
{passkeys.map((passkey) => {
const isMarkedForDeletion = passkeyIdsMarkedForDeletion.includes(passkey.Id);
if (isMarkedForDeletion) {
return (
<div
key={passkey.Id}
className="p-3 rounded bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
>
<div className="flex items-start gap-2">
<PasskeyIcon className="w-5 h-5 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<div className="mb-1 flex items-center justify-between">
<span className="text-sm font-medium text-red-900 dark:text-red-100">
{t('passkeys.passkeyMarkedForDeletion')}
</span>
<button
type="button"
onClick={() => handleUndoDeletion(passkey.Id)}
className="text-gray-600 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
title={t('common.undo')}
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 7v6h6" />
<path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13" />
</svg>
</button>
</div>
<p className="text-xs text-red-800 dark:text-red-200">
{t('passkeys.passkeyWillBeDeleted')}
</p>
</div>
</div>
</div>
);
}
return (
<div
key={passkey.Id}
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">
<PasskeyIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<div className="mb-1 flex items-center justify-between">
<span className="text-sm font-medium text-gray-900 dark:text-white">
{passkey.DisplayName || t('passkeys.passkey')}
</span>
<button
type="button"
onClick={() => handleMarkForDeletion(passkey.Id)}
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
title={t('common.delete')}
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
</button>
</div>
<div className="space-y-1 mb-2">
{passkey.RpId && (
<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">
{passkey.RpId}
</span>
</div>
)}
</div>
<p className="text-xs text-gray-600 dark:text-gray-400">
{t('passkeys.helpText')}
</p>
</div>
</div>
</div>
);
})}
</div>
</div>
);
};
export default PasskeyEditor;

View File

@@ -5,6 +5,8 @@ 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 {
@@ -15,5 +17,7 @@ export {
AliasBlock,
NotesBlock,
AttachmentBlock,
FieldBlock
FieldBlock,
PasskeyBlock,
PasskeyEditor
};

View File

@@ -1,7 +1,7 @@
import React from 'react';
import type { Item } from '@/utils/dist/core/models/vault';
import { FieldKey } from '@/utils/dist/core/models/vault';
import { FieldKey, ItemTypes } from '@/utils/dist/core/models/vault';
import SqliteClient from '@/utils/SqliteClient';
type ItemIconProps = {
@@ -232,12 +232,12 @@ const getCardIcon = (brand: CardBrand): React.FC<{ className?: string }> => {
*/
const ItemIcon: React.FC<ItemIconProps> = ({ item, className = 'w-8 h-8' }) => {
// For Note type, always show note icon
if (item.ItemType === 'Note') {
if (item.ItemType === ItemTypes.Note) {
return <NoteIcon className={className} />;
}
// For CreditCard type, detect card brand and show appropriate icon
if (item.ItemType === 'CreditCard') {
if (item.ItemType === ItemTypes.CreditCard) {
const cardNumberField = item.Fields?.find(f => f.FieldKey === FieldKey.CardNumber);
const cardNumber = cardNumberField?.Value
? (Array.isArray(cardNumberField.Value) ? cardNumberField.Value[0] : cardNumberField.Value)

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import type { ItemType } from '@/utils/dist/core/models/vault';
import { ItemTypes } from '@/utils/dist/core/models/vault';
/**
* Item type option configuration.
@@ -17,7 +18,7 @@ type ItemTypeOption = {
*/
const ITEM_TYPE_OPTIONS: ItemTypeOption[] = [
{
type: 'Login',
type: ItemTypes.Login,
titleKey: 'itemTypes.login.title',
iconSvg: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -26,7 +27,7 @@ const ITEM_TYPE_OPTIONS: ItemTypeOption[] = [
)
},
{
type: 'Alias',
type: ItemTypes.Alias,
titleKey: 'itemTypes.alias.title',
iconSvg: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -35,7 +36,7 @@ const ITEM_TYPE_OPTIONS: ItemTypeOption[] = [
)
},
{
type: 'CreditCard',
type: ItemTypes.CreditCard,
titleKey: 'itemTypes.creditCard.title',
iconSvg: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -44,7 +45,7 @@ const ITEM_TYPE_OPTIONS: ItemTypeOption[] = [
)
},
{
type: 'Note',
type: ItemTypes.Note,
titleKey: 'itemTypes.note.title',
iconSvg: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -105,7 +106,7 @@ const ItemTypeSelector: React.FC<ItemTypeSelectorProps> = ({
</svg>
</button>
{/* Regenerate alias button - icon only for flexibility */}
{selectedType === 'Alias' && !isEditMode && onRegenerateAlias && (
{selectedType === ItemTypes.Alias && !isEditMode && onRegenerateAlias && (
<button
type="button"
onClick={(e) => {

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import AttachmentUploader from '@/entrypoints/popup/components/Credentials/Details/AttachmentUploader';
import PasskeyEditor from '@/entrypoints/popup/components/Credentials/Details/PasskeyEditor';
import TotpEditor from '@/entrypoints/popup/components/Credentials/Details/TotpEditor';
import Modal from '@/entrypoints/popup/components/Dialogs/Modal';
import AddFieldMenu from '@/entrypoints/popup/components/Forms/AddFieldMenu';
@@ -23,13 +24,13 @@ import useAliasGenerator from '@/entrypoints/popup/hooks/useAliasGenerator';
import { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate';
import type { Item, ItemField, ItemType, FieldType, Attachment, TotpCode } from '@/utils/dist/core/models/vault';
import { FieldCategories, FieldTypes, getSystemFieldsForItemType, isFieldShownByDefault } from '@/utils/dist/core/models/vault';
import { FieldCategories, FieldTypes, ItemTypes, getSystemFieldsForItemType, isFieldShownByDefault } from '@/utils/dist/core/models/vault';
// Valid item types from the shared model
const VALID_ITEM_TYPES: ItemType[] = ['Login', 'Alias', 'CreditCard', 'Note'];
const VALID_ITEM_TYPES: ItemType[] = [ItemTypes.Login, ItemTypes.Alias, ItemTypes.CreditCard, ItemTypes.Note];
// Default item type for new items
const DEFAULT_ITEM_TYPE: ItemType = 'Login';
const DEFAULT_ITEM_TYPE: ItemType = ItemTypes.Login;
/**
* Temporary custom field definition (before persisting to database)
@@ -107,6 +108,9 @@ const ItemAddEdit: React.FC = () => {
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [originalAttachmentIds, setOriginalAttachmentIds] = useState<string[]>([]);
// Passkeys state (only IDs marked for deletion - passkeys cannot be created/edited manually)
const [passkeyIdsMarkedForDeletion, setPasskeyIdsMarkedForDeletion] = useState<string[]>([]);
/**
* Get all applicable system fields for the current item type.
*/
@@ -432,6 +436,13 @@ const ItemAddEdit: React.FC = () => {
originalTotpCodeIds,
totpCodes
);
// Delete passkeys marked for deletion
if (passkeyIdsMarkedForDeletion.length > 0) {
for (const passkeyId of passkeyIdsMarkedForDeletion) {
await dbContext.sqliteClient!.deletePasskeyById(passkeyId);
}
}
} else {
await dbContext.sqliteClient!.createItem(updatedItem, attachments, totpCodes);
}
@@ -445,7 +456,7 @@ const ItemAddEdit: React.FC = () => {
} catch (err) {
console.error('Error saving item:', err);
}
}, [item, fieldValues, applicableSystemFields, customFields, dbContext, isEditMode, executeVaultMutationAsync, navigate, originalAttachmentIds, attachments, originalTotpCodeIds, totpCodes]);
}, [item, fieldValues, applicableSystemFields, customFields, dbContext, isEditMode, executeVaultMutationAsync, navigate, originalAttachmentIds, attachments, originalTotpCodeIds, totpCodes, passkeyIdsMarkedForDeletion]);
/**
* Handle delete action.
@@ -807,6 +818,15 @@ const ItemAddEdit: React.FC = () => {
))}
</FormSection>
{/* Passkey Section - only show in edit mode for items with passkeys */}
{isEditMode && item.HasPasskey && (
<PasskeyEditor
itemId={item.Id}
passkeyIdsMarkedForDeletion={passkeyIdsMarkedForDeletion}
onPasskeyMarkedForDeletion={setPasskeyIdsMarkedForDeletion}
/>
)}
{/* Render fields grouped by category */}
{Object.keys(groupedSystemFields).map(category => {
const categoryFields = groupedSystemFields[category];

View File

@@ -5,7 +5,8 @@ import { useNavigate, useParams } from 'react-router-dom';
import {
TotpBlock,
AttachmentBlock,
FieldBlock
FieldBlock,
PasskeyBlock
} from '@/entrypoints/popup/components/Credentials/Details';
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
@@ -16,6 +17,7 @@ import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility';
import type { Item } from '@/utils/dist/core/models/vault';
import { ItemTypes } from '@/utils/dist/core/models/vault';
import { groupFieldsByCategory } from '@/utils/dist/core/models/vault';
/**
@@ -158,10 +160,15 @@ const ItemDetails: React.FC = (): React.ReactElement => {
</div>
{/* TOTP codes - only for Login and Alias types, shown at top */}
{(item.ItemType === 'Login' || item.ItemType === 'Alias') && (
{(item.ItemType === ItemTypes.Login || item.ItemType === ItemTypes.Alias) && (
<TotpBlock itemId={item.Id} />
)}
{/* Passkeys - only for Login and Alias types */}
{(item.ItemType === ItemTypes.Login || item.ItemType === ItemTypes.Alias) && item.HasPasskey && (
<PasskeyBlock itemId={item.Id} />
)}
{/* Render fields dynamically by category */}
{Object.keys(groupedFields).length > 0 && (
<>

View File

@@ -15,11 +15,12 @@ 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 } from '@/utils/credentialMatcher/CredentialMatcher';
import type { Passkey } from '@/utils/dist/core/models/vault';
import { extractDomain, extractRootDomain, filterCredentials, AutofillMatchingMode } from '@/utils/credentialMatcher/CredentialMatcher';
import type { Credential, Passkey } 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';
import { SqliteClient } from '@/utils/SqliteClient';
import { storage } from "#imports";
@@ -38,7 +39,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 [selectedPasskeyToReplace, setSelectedPasskeyToReplace] = useState<string | null>(null);
const [selectedCredentialToAttach, setSelectedCredentialToAttach] = useState<string | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false);
const [localLoading, setLocalLoading] = useState(false);
const [showBypassDialog, setShowBypassDialog] = useState(false);
@@ -123,9 +126,43 @@ const PasskeyCreate: React.FC = () => {
}
setExistingPasskeys(filtered);
// If no existing passkeys for this user, go straight to create form
// If no existing passkeys for this user, check for matching credentials
if (filtered.length === 0) {
setShowCreateForm(true);
// Get all credentials and filter for matches
const allCredentials = dbContext.sqliteClient.getAllCredentials();
/*
* Filter credentials that:
* 1. Match the RP origin URL
* 2. Have username/password (are login credentials)
* 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) {
return false;
}
// Check if this credential already has a passkey
return !cred.HasPasskey;
});
// Use the credential matcher to find matching credentials for the origin
let matches: Credential[] = [];
if (credentialsWithoutPasskeys.length > 0) {
matches = await filterCredentials(
credentialsWithoutPasskeys,
data.origin,
data.publicKey.rp.name || '',
AutofillMatchingMode.URL_SUBDOMAIN
);
setMatchingCredentials(matches);
}
// If no matching credentials, go straight to create form
if (matches.length === 0) {
setShowCreateForm(true);
}
}
}
}
@@ -173,6 +210,7 @@ const PasskeyCreate: React.FC = () => {
*/
const handleCreateNew = () : void => {
setSelectedPasskeyToReplace(null);
setSelectedCredentialToAttach(null);
setShowCreateForm(true);
};
@@ -181,6 +219,16 @@ const PasskeyCreate: React.FC = () => {
*/
const handleSelectReplace = (passkeyId: string) : void => {
setSelectedPasskeyToReplace(passkeyId);
setSelectedCredentialToAttach(null);
setShowCreateForm(true);
};
/**
* Handle when user selects an existing credential to attach the passkey to
*/
const handleSelectCredential = (credentialId: string) : void => {
setSelectedCredentialToAttach(credentialId);
setSelectedPasskeyToReplace(null);
setShowCreateForm(true);
};
@@ -316,6 +364,33 @@ const PasskeyCreate: React.FC = () => {
AdditionalData: null
});
}
} else if (selectedCredentialToAttach) {
// Attach passkey to existing credential/item
/**
* Create the Passkey linked to the existing item
* Convert userId from base64 string to byte array for database storage
*/
let userHandleBytes: Uint8Array | null = null;
if (stored.userId) {
try {
userHandleBytes = PasskeyHelper.base64urlToBytes(stored.userId);
} catch {
// If conversion fails, store as null
userHandleBytes = null;
}
}
await dbContext.sqliteClient!.createPasskey({
Id: newPasskeyGuid,
ItemId: selectedCredentialToAttach,
RpId: stored.rpId,
UserHandle: userHandleBytes,
PublicKey: JSON.stringify(stored.publicKey),
PrivateKey: JSON.stringify(stored.privateKey),
DisplayName: request.publicKey.user.displayName || request.publicKey.user.name || '',
PrfKey: stored.prfSecret ? PasskeyHelper.base64urlToBytes(stored.prfSecret) : undefined,
AdditionalData: null
});
} else {
// Create new item and passkey
const itemId = await dbContext.sqliteClient!.createCredential(
@@ -503,7 +578,7 @@ const PasskeyCreate: React.FC = () => {
</Alert>
)}
{/* Step 1: Show existing passkeys selection or create new option */}
{/* Step 1a: Show existing passkeys selection or create new option */}
{!showCreateForm && existingPasskeys.length > 0 && (
<div className="space-y-4">
<Button
@@ -570,6 +645,87 @@ 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 && (
<div className="space-y-4">
<Button
variant="primary"
onClick={handleCreateNew}
ref={createNewButtonRef}
>
{t('passkeys.create.createNewPasskey')}
</Button>
<Button
variant="secondary"
onClick={handleFallback}
>
{t('passkeys.create.useBrowserPasskey')}
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300 dark:border-gray-600" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400">
{t('common.or')}
</span>
</div>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{t('passkeys.create.selectExistingLogin')}
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
{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) => (
<button
key={credential.Id}
onClick={() => handleSelectCredential(credential.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 && (
<img
src={SqliteClient.imgSrcFromBytes(credential.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}
</div>
{credential.Username && (
<div className="text-xs text-gray-600 dark:text-gray-400 truncate">
{credential.Username}
</div>
)}
</div>
</div>
<svg className="w-5 h-5 text-gray-400 flex-shrink-0 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
))}
</div>
</div>
<Button
variant="secondary"
onClick={handleCancel}
>
{t('common.cancel')}
</Button>
</div>
)}
{/* Step 2: Show create form with display name */}
{showCreateForm && (
<div className="space-y-4">
@@ -581,29 +737,44 @@ const PasskeyCreate: React.FC = () => {
</Alert>
)}
<FormInput
id="displayName"
label={t('passkeys.create.titleLabel')}
value={displayName}
onChange={setDisplayName}
placeholder={t('passkeys.create.titlePlaceholder')}
ref={displayNameInputRef}
/>
{selectedCredentialToAttach && (
<Alert variant="info">
{t('passkeys.create.attachingToCredential', {
serviceName: matchingCredentials.find(c => c.Id === selectedCredentialToAttach)?.ServiceName || ''
})}
</Alert>
)}
{!selectedCredentialToAttach && (
<FormInput
id="displayName"
label={t('passkeys.create.titleLabel')}
value={displayName}
onChange={setDisplayName}
placeholder={t('passkeys.create.titlePlaceholder')}
ref={displayNameInputRef}
/>
)}
<div className="space-y-3">
<Button
variant="primary"
onClick={handleCreate}
>
{selectedPasskeyToReplace ? t('passkeys.create.confirmReplace') : t('passkeys.create.createButton')}
{selectedPasskeyToReplace
? t('passkeys.create.confirmReplace')
: selectedCredentialToAttach
? t('passkeys.create.attachPasskey')
: t('passkeys.create.createButton')}
</Button>
{existingPasskeys.length > 0 ? (
{(existingPasskeys.length > 0 || matchingCredentials.length > 0) ? (
<Button
variant="secondary"
onClick={() => {
setShowCreateForm(false);
setSelectedPasskeyToReplace(null);
setSelectedCredentialToAttach(null);
}}
>
{t('common.back')}

View File

@@ -57,6 +57,7 @@
"next": "Next",
"use": "Use",
"delete": "Delete",
"undo": "Undo",
"save": "Save",
"saving": "Saving...",
"create": "Create",
@@ -525,7 +526,11 @@
"selectPasskeyToReplace": "Select a passkey to replace:",
"createNewPasskey": "Create New Passkey",
"replacingPasskey": "Replacing passkey: {{displayName}}",
"confirmReplace": "Confirm Replace"
"confirmReplace": "Confirm Replace",
"selectExistingLogin": "Add to existing login:",
"selectExistingLoginDescription": "Attach this passkey to an existing login credential for this site.",
"attachingToCredential": "Adding passkey to: {{serviceName}}",
"attachPasskey": "Add Passkey"
},
"settings": {
"passkeyProvider": "Passkey Provider",

View File

@@ -3066,8 +3066,10 @@ export class SqliteClient {
const currentDateTime = dateFormatter.now();
// 1. Move all items in this folder to trash (set DeletedAt) and clear FolderId
// so that when restored, items won't reference a deleted folder
/*
* 1. Move all items in this folder to trash (set DeletedAt) and clear FolderId
* so that when restored, items won't reference a deleted folder
*/
const itemsQuery = `
UPDATE Items
SET DeletedAt = ?,

View File

@@ -322,16 +322,20 @@ type ItemTag = {
};
/**
* Item types supported by the vault
* - Login: Username/password credentials with optional notes
* - Alias: Login with pre-filled alias identity fields (email, name, etc.)
* - CreditCard: Payment card information
* - Note: Secure notes
* Item types supported by the vault.
*/
type ItemType = 'Login' | 'Alias' | 'CreditCard' | 'Note';
declare const ItemTypes: {
readonly Login: "Login";
readonly Alias: "Alias";
readonly CreditCard: "CreditCard";
readonly Note: "Note";
};
/**
* Item type union derived from ItemTypes constant
*/
type ItemType = typeof ItemTypes[keyof typeof ItemTypes];
/**
* Item type representing vault entries in the new field-based data model.
* Replaces the old Credential type.
*/
type Item = {
Id: string;
@@ -361,7 +365,6 @@ type ItemField = {
};
/**
* Field types for rendering and validation.
* Single source of truth - the type is derived from this constant.
*/
declare const FieldTypes: {
readonly Text: "Text";
@@ -551,4 +554,4 @@ type FieldHistory = {
*/
declare const MAX_FIELD_HISTORY_RECORDS = 10;
export { type Alias, type Attachment, type Credential, type EncryptionKey, FieldCategories, type FieldCategory, type FieldHistory, FieldKey, type FieldKeyValue, type FieldType, FieldTypes, type Item, type ItemField, type ItemTag, type ItemTagRef, type ItemType, type ItemTypeFieldConfig, MAX_FIELD_HISTORY_RECORDS, type Passkey, type PasswordSettings, type SystemFieldDefinition, SystemFieldRegistry, type Tag, type TotpCode, fieldAppliesToType, getAllSystemFieldKeys, getDefaultFieldsForItemType, getFieldConfigForType, getFieldValue, getFieldValues, getOptionalFieldsForItemType, getSystemField, getSystemFieldsForItemType, groupFields, groupFieldsByCategory, hasField, isFieldShownByDefault, isSystemField, isSystemFieldPrefix, itemToCredential };
export { type Alias, type Attachment, type Credential, type EncryptionKey, FieldCategories, type FieldCategory, type FieldHistory, FieldKey, type FieldKeyValue, type FieldType, FieldTypes, type Item, type ItemField, type ItemTag, type ItemTagRef, type ItemType, type ItemTypeFieldConfig, ItemTypes, MAX_FIELD_HISTORY_RECORDS, type Passkey, type PasswordSettings, type SystemFieldDefinition, SystemFieldRegistry, type Tag, type TotpCode, fieldAppliesToType, getAllSystemFieldKeys, getDefaultFieldsForItemType, getFieldConfigForType, getFieldValue, getFieldValues, getOptionalFieldsForItemType, getSystemField, getSystemFieldsForItemType, groupFields, groupFieldsByCategory, hasField, isFieldShownByDefault, isSystemField, isSystemFieldPrefix, itemToCredential };

View File

@@ -152,6 +152,12 @@ var FieldKey = {
};
// src/vault/Item.ts
var ItemTypes = {
Login: "Login",
Alias: "Alias",
CreditCard: "CreditCard",
Note: "Note"
};
var FieldTypes = {
Text: "Text",
Password: "Password",
@@ -483,6 +489,6 @@ function isSystemFieldPrefix(fieldKey) {
// src/vault/FieldHistory.ts
var MAX_FIELD_HISTORY_RECORDS = 10;
export { FieldCategories, FieldKey, FieldTypes, MAX_FIELD_HISTORY_RECORDS, SystemFieldRegistry, fieldAppliesToType, getAllSystemFieldKeys, getDefaultFieldsForItemType, getFieldConfigForType, getFieldValue, getFieldValues, getOptionalFieldsForItemType, getSystemField, getSystemFieldsForItemType, groupFields, groupFieldsByCategory, hasField, isFieldShownByDefault, isSystemField, isSystemFieldPrefix, itemToCredential };
export { FieldCategories, FieldKey, FieldTypes, ItemTypes, MAX_FIELD_HISTORY_RECORDS, SystemFieldRegistry, fieldAppliesToType, getAllSystemFieldKeys, getDefaultFieldsForItemType, getFieldConfigForType, getFieldValue, getFieldValues, getOptionalFieldsForItemType, getSystemField, getSystemFieldsForItemType, groupFields, groupFieldsByCategory, hasField, isFieldShownByDefault, isSystemField, isSystemFieldPrefix, itemToCredential };
//# sourceMappingURL=index.js.map
//# sourceMappingURL=index.js.map

View File

@@ -322,16 +322,20 @@ type ItemTag = {
};
/**
* Item types supported by the vault
* - Login: Username/password credentials with optional notes
* - Alias: Login with pre-filled alias identity fields (email, name, etc.)
* - CreditCard: Payment card information
* - Note: Secure notes
* Item types supported by the vault.
*/
type ItemType = 'Login' | 'Alias' | 'CreditCard' | 'Note';
declare const ItemTypes: {
readonly Login: "Login";
readonly Alias: "Alias";
readonly CreditCard: "CreditCard";
readonly Note: "Note";
};
/**
* Item type union derived from ItemTypes constant
*/
type ItemType = typeof ItemTypes[keyof typeof ItemTypes];
/**
* Item type representing vault entries in the new field-based data model.
* Replaces the old Credential type.
*/
type Item = {
Id: string;
@@ -361,7 +365,6 @@ type ItemField = {
};
/**
* Field types for rendering and validation.
* Single source of truth - the type is derived from this constant.
*/
declare const FieldTypes: {
readonly Text: "Text";
@@ -551,4 +554,4 @@ type FieldHistory = {
*/
declare const MAX_FIELD_HISTORY_RECORDS = 10;
export { type Alias, type Attachment, type Credential, type EncryptionKey, FieldCategories, type FieldCategory, type FieldHistory, FieldKey, type FieldKeyValue, type FieldType, FieldTypes, type Item, type ItemField, type ItemTag, type ItemTagRef, type ItemType, type ItemTypeFieldConfig, MAX_FIELD_HISTORY_RECORDS, type Passkey, type PasswordSettings, type SystemFieldDefinition, SystemFieldRegistry, type Tag, type TotpCode, fieldAppliesToType, getAllSystemFieldKeys, getDefaultFieldsForItemType, getFieldConfigForType, getFieldValue, getFieldValues, getOptionalFieldsForItemType, getSystemField, getSystemFieldsForItemType, groupFields, groupFieldsByCategory, hasField, isFieldShownByDefault, isSystemField, isSystemFieldPrefix, itemToCredential };
export { type Alias, type Attachment, type Credential, type EncryptionKey, FieldCategories, type FieldCategory, type FieldHistory, FieldKey, type FieldKeyValue, type FieldType, FieldTypes, type Item, type ItemField, type ItemTag, type ItemTagRef, type ItemType, type ItemTypeFieldConfig, ItemTypes, MAX_FIELD_HISTORY_RECORDS, type Passkey, type PasswordSettings, type SystemFieldDefinition, SystemFieldRegistry, type Tag, type TotpCode, fieldAppliesToType, getAllSystemFieldKeys, getDefaultFieldsForItemType, getFieldConfigForType, getFieldValue, getFieldValues, getOptionalFieldsForItemType, getSystemField, getSystemFieldsForItemType, groupFields, groupFieldsByCategory, hasField, isFieldShownByDefault, isSystemField, isSystemFieldPrefix, itemToCredential };

View File

@@ -152,6 +152,12 @@ var FieldKey = {
};
// src/vault/Item.ts
var ItemTypes = {
Login: "Login",
Alias: "Alias",
CreditCard: "CreditCard",
Note: "Note"
};
var FieldTypes = {
Text: "Text",
Password: "Password",
@@ -483,6 +489,6 @@ function isSystemFieldPrefix(fieldKey) {
// src/vault/FieldHistory.ts
var MAX_FIELD_HISTORY_RECORDS = 10;
export { FieldCategories, FieldKey, FieldTypes, MAX_FIELD_HISTORY_RECORDS, SystemFieldRegistry, fieldAppliesToType, getAllSystemFieldKeys, getDefaultFieldsForItemType, getFieldConfigForType, getFieldValue, getFieldValues, getOptionalFieldsForItemType, getSystemField, getSystemFieldsForItemType, groupFields, groupFieldsByCategory, hasField, isFieldShownByDefault, isSystemField, isSystemFieldPrefix, itemToCredential };
export { FieldCategories, FieldKey, FieldTypes, ItemTypes, MAX_FIELD_HISTORY_RECORDS, SystemFieldRegistry, fieldAppliesToType, getAllSystemFieldKeys, getDefaultFieldsForItemType, getFieldConfigForType, getFieldValue, getFieldValues, getOptionalFieldsForItemType, getSystemField, getSystemFieldsForItemType, groupFields, groupFieldsByCategory, hasField, isFieldShownByDefault, isSystemField, isSystemFieldPrefix, itemToCredential };
//# sourceMappingURL=index.js.map
//# sourceMappingURL=index.js.map

View File

@@ -1,19 +1,20 @@
/**
* Item types supported by the vault
* - Login: Username/password credentials with optional notes
* - Alias: Login with pre-filled alias identity fields (email, name, etc.)
* - CreditCard: Payment card information
* - Note: Secure notes
* Item types supported by the vault.
*/
export type ItemType =
| 'Login'
| 'Alias'
| 'CreditCard'
| 'Note';
export const ItemTypes = {
Login: 'Login',
Alias: 'Alias',
CreditCard: 'CreditCard',
Note: 'Note',
} as const;
/**
* Item type union derived from ItemTypes constant
*/
export type ItemType = typeof ItemTypes[keyof typeof ItemTypes];
/**
* Item type representing vault entries in the new field-based data model.
* Replaces the old Credential type.
*/
export type Item = {
Id: string;
@@ -45,7 +46,6 @@ export type ItemField = {
/**
* Field types for rendering and validation.
* Single source of truth - the type is derived from this constant.
*/
export const FieldTypes = {
Text: 'Text',