diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index df35f67..3ec57dd 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -104,6 +104,7 @@ export default function VaultPage(props: VaultPageProps) { const [repromptOpen, setRepromptOpen] = useState(false); const [repromptPassword, setRepromptPassword] = useState(''); const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState(null); + const [pendingDeletePasskeyIndex, setPendingDeletePasskeyIndex] = useState(null); const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout); const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list'); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); @@ -445,6 +446,7 @@ function folderName(id: string | null | undefined): string { setLocalError(''); setAttachmentQueue([]); setRemovedAttachmentIds({}); + setPendingDeletePasskeyIndex(null); if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list'); } @@ -452,6 +454,18 @@ function folderName(id: string | null | undefined): string { setDraft((prev) => (prev ? { ...prev, ...patch } : prev)); } + function confirmDeleteLoginPasskey(): void { + if (pendingDeletePasskeyIndex == null) return; + setDraft((prev) => { + if (!prev) return prev; + return { + ...prev, + loginFido2Credentials: prev.loginFido2Credentials.filter((_, index) => index !== pendingDeletePasskeyIndex), + }; + }); + setPendingDeletePasskeyIndex(null); + } + async function seedSshDefaults(force = false): Promise { const ticket = ++sshSeedTicketRef.current; try { @@ -947,6 +961,7 @@ function folderName(id: string | null | undefined): string { onUpdateDraftLoginUri={updateDraftLoginUri} onUpdateDraftLoginUriMatch={updateDraftLoginUriMatch} onReorderDraftLoginUri={reorderDraftLoginUri} + onRequestDeleteLoginPasskey={setPendingDeletePasskeyIndex} onQueueAttachmentFiles={queueAttachmentFiles} onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval} onRemoveQueuedAttachment={removeQueuedAttachment} @@ -1015,6 +1030,7 @@ function folderName(id: string | null | undefined): string { deleteAllFoldersOpen={deleteAllFoldersOpen} repromptOpen={repromptOpen} repromptPassword={repromptPassword} + deletePasskeyOpen={pendingDeletePasskeyIndex != null} onConfirmAddField={() => { if (!draft) return; if (!fieldLabel.trim()) { @@ -1077,6 +1093,8 @@ function folderName(id: string | null | undefined): string { setRepromptPassword(''); }} onRepromptPasswordChange={setRepromptPassword} + onConfirmDeletePasskey={confirmDeleteLoginPasskey} + onCancelDeletePasskey={() => setPendingDeletePasskeyIndex(null)} /> ); diff --git a/webapp/src/components/vault/VaultDialogs.tsx b/webapp/src/components/vault/VaultDialogs.tsx index 9ffb057..80f5fd4 100644 --- a/webapp/src/components/vault/VaultDialogs.tsx +++ b/webapp/src/components/vault/VaultDialogs.tsx @@ -25,6 +25,7 @@ interface VaultDialogsProps { deleteAllFoldersOpen: boolean; repromptOpen: boolean; repromptPassword: string; + deletePasskeyOpen: boolean; onConfirmAddField: () => void; onCancelFieldModal: () => void; onFieldTypeChange: (value: CustomFieldType) => void; @@ -54,6 +55,8 @@ interface VaultDialogsProps { onConfirmReprompt: () => void; onCancelReprompt: () => void; onRepromptPasswordChange: (value: string) => void; + onConfirmDeletePasskey: () => void; + onCancelDeletePasskey: () => void; } export default function VaultDialogs(props: VaultDialogsProps) { @@ -181,6 +184,17 @@ export default function VaultDialogs(props: VaultDialogsProps) { props.onRepromptPasswordChange((e.currentTarget as HTMLInputElement).value)} /> + + ); } diff --git a/webapp/src/components/vault/VaultEditor.tsx b/webapp/src/components/vault/VaultEditor.tsx index 93b262f..8a64bb1 100644 --- a/webapp/src/components/vault/VaultEditor.tsx +++ b/webapp/src/components/vault/VaultEditor.tsx @@ -20,7 +20,15 @@ import { import { CSS } from '@dnd-kit/utilities'; import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types'; import { t } from '@/lib/i18n'; -import { CREATE_TYPE_OPTIONS, cipherTypeLabel, createEmptyLoginUri, formatAttachmentSize, toBooleanFieldValue, WEBSITE_MATCH_OPTIONS } from '@/components/vault/vault-page-helpers'; +import { + CREATE_TYPE_OPTIONS, + cipherTypeLabel, + createEmptyLoginUri, + formatAttachmentSize, + formatHistoryTime, + toBooleanFieldValue, + WEBSITE_MATCH_OPTIONS, +} from '@/components/vault/vault-page-helpers'; interface VaultEditorProps { draft: VaultDraft; @@ -44,6 +52,7 @@ interface VaultEditorProps { onUpdateDraftLoginUri: (index: number, value: string) => void; onUpdateDraftLoginUriMatch: (index: number, value: number | null) => void; onReorderDraftLoginUri: (fromIndex: number, toIndex: number) => void; + onRequestDeleteLoginPasskey: (index: number) => void; onQueueAttachmentFiles: (list: FileList | null) => void; onToggleExistingAttachmentRemoval: (attachmentId: string) => void; onRemoveQueuedAttachment: (index: number) => void; @@ -288,6 +297,42 @@ export default function VaultEditor(props: VaultEditorProps) { ))} + {props.draft.loginFido2Credentials.length > 0 && ( + <> +
+

{t('txt_passkeys')}

+
+
+ {props.draft.loginFido2Credentials.map((credential, index) => { + const createdAt = String(credential?.creationDate || '').trim(); + const label = createdAt + ? t('txt_passkey_created_at_value', { value: formatHistoryTime(createdAt) }) + : t('txt_passkey'); + return ( +
+
+
+ {t('txt_passkey')} + {label} +
+
+
+ +
+
+ ); + })} +
+ + )} )} diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index 5a911bc..34f427a 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -293,6 +293,7 @@ const messages: Record> = { txt_are_you_sure_you_want_to_delete_count_selected_items: "Are you sure you want to delete {count} selected items?", txt_are_you_sure_you_want_to_delete_count_selected_items_permanently: "Are you sure you want to permanently delete {count} selected items?", txt_are_you_sure_you_want_to_delete_this_item: "Are you sure you want to delete this item?", + txt_are_you_sure_you_want_to_delete_this_passkey: "Are you sure you want to delete this passkey?", txt_are_you_sure_you_want_to_log_out: "Are you sure you want to log out?", txt_authenticator_key: "Authenticator Key", txt_authorized_devices: "Authorized Devices", @@ -352,6 +353,7 @@ const messages: Record> = { txt_delete_all_invite_codes_active_inactive: "Delete all invite codes (active/inactive)?", txt_delete_all_invites: "Delete all invites", txt_delete_item: "Delete Item", + txt_delete_passkey: "Delete Passkey", txt_delete_item_failed: "Delete item failed", txt_delete_permanently: "Delete Permanently", txt_archive: "Archive", @@ -572,6 +574,7 @@ const messages: Record> = { txt_password_hint_load_failed: "Failed to load password hint", txt_password_hint_too_long: "Password hint must be 120 characters or fewer", txt_passkey: "Passkey", + txt_passkeys: "Passkeys", txt_passkey_created_at_value: "Created on {value}", txt_phone: "Phone", txt_please_input_email_and_password: "Please input email and password", @@ -1163,6 +1166,7 @@ const zhCNOverrides: Record = { txt_no_name: '(无名称)', txt_are_you_sure_you_want_to_log_out: '确认要退出登录吗?', txt_delete_item: '删除项目', + txt_delete_passkey: '删除通行密钥', txt_delete_selected_items: '删除所选项目', txt_move_selected_items: '移动所选项目', txt_create_folder: '创建文件夹', @@ -1226,6 +1230,7 @@ const zhCNOverrides: Record = { txt_are_you_sure_you_want_to_delete_count_selected_items: '确认删除所选的 {count} 个项目?', txt_are_you_sure_you_want_to_delete_count_selected_items_permanently: '确认永久删除所选的 {count} 个项目?', txt_are_you_sure_you_want_to_delete_this_item: '确认删除此项目?', + txt_are_you_sure_you_want_to_delete_this_passkey: '确认删除这个通行密钥?', txt_authenticator_key: '验证器密钥', txt_brand: '品牌', txt_bulk_delete_failed: '批量删除失败', @@ -1327,6 +1332,7 @@ const zhCNOverrides: Record = { txt_password_hint_load_failed: '加载密码提示失败', txt_password_hint_too_long: '密码提示最多只能输入 120 个字符', txt_passkey: '通行密钥', + txt_passkeys: '通行密钥', txt_passkey_created_at_value: '创建于 {value}', txt_phone: '电话', txt_please_input_email_and_password: '请输入邮箱和密码',