diff --git a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/TotpEditor.tsx b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/TotpEditor.tsx new file mode 100644 index 000000000..342e308f7 --- /dev/null +++ b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/TotpEditor.tsx @@ -0,0 +1,317 @@ +import * as OTPAuth from 'otpauth'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { TotpCode } from '@/utils/dist/shared/models/vault'; + +type TotpFormData = { + name: string; + secretKey: string; +} + +type TotpEditorState = { + isAddFormVisible: boolean; + formData: TotpFormData; +} + +type TotpEditorProps = { + totpCodes: TotpCode[]; + onTotpCodesChange: (totpCodes: TotpCode[]) => void; + originalTotpCodeIds: string[]; + isAddFormVisible: boolean; + formData: TotpFormData; + onStateChange: (state: TotpEditorState) => void; +} + +/** + * Component for editing TOTP codes for a credential. + */ +const TotpEditor: React.FC = ({ + totpCodes, + onTotpCodesChange, + originalTotpCodeIds, + isAddFormVisible, + formData, + onStateChange +}) => { + const { t } = useTranslation(); + const [formError, setFormError] = useState(null); + + /** + * Sanitizes the secret key by extracting it from a TOTP URI if needed + */ + const sanitizeSecretKey = (secretKeyInput: string, nameInput: string): { secretKey: string, name: string } => { + let secretKey = secretKeyInput.trim(); + let name = nameInput.trim(); + + // Check if it's a TOTP URI + if (secretKey.toLowerCase().startsWith('otpauth://totp/')) { + try { + const uri = OTPAuth.URI.parse(secretKey); + if (uri instanceof OTPAuth.TOTP) { + secretKey = uri.secret.base32; + // If name is empty, use the label from the URI + if (!name && uri.label) { + name = uri.label; + } + } + } catch { + throw new Error(t('totp.errors.invalidSecretKey')); + } + } + + // Remove spaces from the secret key + secretKey = secretKey.replace(/\s/g, ''); + + // Validate the secret key format (base32) + if (!/^[A-Z2-7]+=*$/i.test(secretKey)) { + throw new Error(t('totp.errors.invalidSecretKey')); + } + + return { secretKey, name: name || 'Authenticator' }; + }; + + /** + * Shows the add form + */ + const showAddForm = (): void => { + onStateChange({ + isAddFormVisible: true, + formData: { name: '', secretKey: '' } + }); + setFormError(null); + }; + + /** + * Hides the add form + */ + const hideAddForm = (): void => { + onStateChange({ + isAddFormVisible: false, + formData: { name: '', secretKey: '' } + }); + setFormError(null); + }; + + /** + * Updates form data + */ + const updateFormData = (updates: Partial): void => { + onStateChange({ + isAddFormVisible, + formData: { ...formData, ...updates } + }); + }; + + /** + * Handles adding a new TOTP code + */ + const handleAddTotpCode = (e?: React.MouseEvent | React.KeyboardEvent): void => { + e?.preventDefault(); + setFormError(null); + + // Validate required fields + if (!formData.secretKey) { + setFormError(t('credentials.validation.required')); + return; + } + + try { + // Sanitize the secret key + const { secretKey, name } = sanitizeSecretKey(formData.secretKey, formData.name); + + // Create new TOTP code + const newTotpCode: TotpCode = { + Id: crypto.randomUUID().toUpperCase(), + Name: name, + SecretKey: secretKey, + CredentialId: '' // Will be set when saving the credential + }; + + // Add to the list + const updatedTotpCodes = [...totpCodes, newTotpCode]; + onTotpCodesChange(updatedTotpCodes); + + // Hide the form + hideAddForm(); + } catch (error) { + if (error instanceof Error) { + setFormError(error.message); + } else { + setFormError(t('common.errors.unknownErrorTryAgain')); + } + } + }; + + /** + * Initiates the delete process for a TOTP code + */ + const deleteTotpCode = (totpToDelete: TotpCode): void => { + // Check if this TOTP code was part of the original set + const wasOriginal = originalTotpCodeIds.includes(totpToDelete.Id); + + let updatedTotpCodes: TotpCode[]; + if (wasOriginal) { + // Mark as deleted (soft delete for syncing) + updatedTotpCodes = totpCodes.map(tc => + tc.Id === totpToDelete.Id + ? { ...tc, IsDeleted: true } + : tc + ); + } else { + // Hard delete (remove from array) + updatedTotpCodes = totpCodes.filter(tc => tc.Id !== totpToDelete.Id); + } + + onTotpCodesChange(updatedTotpCodes); + }; + + // Filter out deleted TOTP codes for display + const activeTotpCodes = totpCodes.filter(tc => !tc.IsDeleted); + const hasActiveTotpCodes = activeTotpCodes.length > 0; + + return ( +
+
+

+ {t('common.twoFactorAuthentication')} +

+ {hasActiveTotpCodes && !isAddFormVisible && ( + + )} +
+ + {!hasActiveTotpCodes && !isAddFormVisible && ( + + )} + + {isAddFormVisible && ( +
+
+

+ {t('totp.addCode')} +

+ +
+ +

+ {t('totp.instructions')} +

+ + {formError && ( +
+

{formError}

+
+ )} + +
+ + updateFormData({ name: e.target.value })} + className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white" + /> +
+ +
+ + updateFormData({ secretKey: e.target.value })} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddTotpCode(e); + } + }} + className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white" + /> +
+ +
+ +
+
+ )} + + {hasActiveTotpCodes && ( +
+ {activeTotpCodes.map(totpCode => ( +
+
+
+

+ {totpCode.Name} +

+
+
+
+
+ {t('totp.saveToViewCode')} +
+
+ +
+
+
+ ))} +
+ )} +
+ ); +}; + +export default TotpEditor; diff --git a/apps/browser-extension/src/entrypoints/popup/pages/credentials/CredentialAddEdit.tsx b/apps/browser-extension/src/entrypoints/popup/pages/credentials/CredentialAddEdit.tsx index 651cf9460..9a671be78 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/credentials/CredentialAddEdit.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/credentials/CredentialAddEdit.tsx @@ -9,6 +9,7 @@ import { sendMessage } from 'webext-bridge/popup'; import * as Yup from 'yup'; import AttachmentUploader from '@/entrypoints/popup/components/Credentials/Details/AttachmentUploader'; +import TotpEditor from '@/entrypoints/popup/components/Credentials/Details/TotpEditor'; import Modal from '@/entrypoints/popup/components/Dialogs/Modal'; import EmailDomainField from '@/entrypoints/popup/components/Forms/EmailDomainField'; import { FormInput } from '@/entrypoints/popup/components/Forms/FormInput'; @@ -25,7 +26,7 @@ import { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate'; import { SKIP_FORM_RESTORE_KEY } from '@/utils/Constants'; import { IdentityHelperUtils, CreateIdentityGenerator, CreateUsernameEmailGenerator, Identity, Gender, convertAgeRangeToBirthdateOptions } from '@/utils/dist/shared/identity-generator'; -import type { Attachment, Credential } from '@/utils/dist/shared/models/vault'; +import type { Attachment, Credential, TotpCode } from '@/utils/dist/shared/models/vault'; import { CreatePasswordGenerator } from '@/utils/dist/shared/password-generator'; import { ServiceDetectionUtility } from '@/utils/serviceDetection/ServiceDetectionUtility'; @@ -38,6 +39,13 @@ type PersistedFormData = { credentialId: string | null; mode: CredentialMode; formValues: Omit & { Logo?: string | null }; + totpEditorState?: { + isAddFormVisible: boolean; + formData: { + name: string; + secretKey: string; + }; + }; } /** @@ -92,6 +100,15 @@ const CredentialAddEdit: React.FC = () => { const [showDeleteModal, setShowDeleteModal] = useState(false); const [attachments, setAttachments] = useState([]); const [originalAttachmentIds, setOriginalAttachmentIds] = useState([]); + const [totpCodes, setTotpCodes] = useState([]); + const [originalTotpCodeIds, setOriginalTotpCodeIds] = useState([]); + const [totpEditorState, setTotpEditorState] = useState<{ + isAddFormVisible: boolean; + formData: { name: string; secretKey: string }; + }>({ + isAddFormVisible: false, + formData: { name: '', secretKey: '' } + }); const [passkeyMarkedForDeletion, setPasskeyMarkedForDeletion] = useState(false); const webApi = useWebApi(); @@ -141,19 +158,20 @@ const CredentialAddEdit: React.FC = () => { formValues: { ...formValues, Logo: null // Don't persist the Logo field as it can't be user modified in the UI. - } + }, + totpEditorState }; await sendMessage('PERSIST_FORM_VALUES', JSON.stringify(persistedData), 'background'); - }, [watch, id, mode, localLoading]); + }, [watch, id, mode, localLoading, totpEditorState]); /** - * Watch for mode changes and persist form values + * Watch for mode and totpEditorState changes and persist form values */ useEffect(() => { if (!localLoading) { void persistFormValues(); } - }, [mode, persistFormValues, localLoading]); + }, [mode, totpEditorState, persistFormValues, localLoading]); // Watch for form changes and persist them useEffect(() => { @@ -200,6 +218,11 @@ const CredentialAddEdit: React.FC = () => { Object.entries(persistedDataObject.formValues).forEach(([key, value]) => { setValue(key as keyof Credential, value as Credential[keyof Credential]); }); + + // Restore TOTP editor state if it exists + if (persistedDataObject.totpEditorState) { + setTotpEditorState(persistedDataObject.totpEditorState); + } } else { console.error('Persisted values do not match current page'); } @@ -330,6 +353,11 @@ const CredentialAddEdit: React.FC = () => { setAttachments(credentialAttachments); setOriginalAttachmentIds(credentialAttachments.map(a => a.Id)); + // Load TOTP codes for this credential + const credentialTotpCodes = dbContext.sqliteClient.getTotpCodesForCredential(id); + setTotpCodes(credentialTotpCodes); + setOriginalTotpCodeIds(credentialTotpCodes.map(tc => tc.Id)); + setMode('manual'); setIsInitialLoading(false); @@ -571,14 +599,14 @@ const CredentialAddEdit: React.FC = () => { setLocalLoading(false); if (isEditMode) { - await dbContext.sqliteClient!.updateCredentialById(data, originalAttachmentIds, attachments); + await dbContext.sqliteClient!.updateCredentialById(data, originalAttachmentIds, attachments, originalTotpCodeIds, totpCodes); // Delete passkeys if marked for deletion if (passkeyMarkedForDeletion) { await dbContext.sqliteClient!.deletePasskeysByCredentialId(data.Id); } } else { - const credentialId = await dbContext.sqliteClient!.createCredential(data, attachments); + const credentialId = await dbContext.sqliteClient!.createCredential(data, attachments, totpCodes); data.Id = credentialId.toString(); } }, { @@ -597,7 +625,7 @@ const CredentialAddEdit: React.FC = () => { } }, }); - }, [isEditMode, dbContext.sqliteClient, executeVaultMutation, navigate, mode, watch, generateRandomAlias, webApi, clearPersistedValues, originalAttachmentIds, attachments, passkeyMarkedForDeletion]); + }, [isEditMode, dbContext.sqliteClient, executeVaultMutation, navigate, mode, watch, generateRandomAlias, webApi, clearPersistedValues, originalAttachmentIds, attachments, originalTotpCodeIds, totpCodes, passkeyMarkedForDeletion]); // Set header buttons on mount and clear on unmount useEffect((): (() => void) => { @@ -974,6 +1002,15 @@ const CredentialAddEdit: React.FC = () => { + + { + public async createCredential(credential: Credential, attachments: Attachment[], totpCodes: TotpCode[] = []): Promise { if (!this.db) { throw new Error('Database not initialized'); } @@ -617,6 +617,30 @@ export class SqliteClient { } } + // 6. 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, CredentialId, CreatedAt, UpdatedAt, IsDeleted) + VALUES (?, ?, ?, ?, ?, ?, ?)`; + + this.executeUpdate(totpCodeQuery, [ + totpCode.Id || crypto.randomUUID().toUpperCase(), + totpCode.Name, + totpCode.SecretKey, + credentialId, + currentDateTime, + currentDateTime, + 0 + ]); + } + } + await this.commitTransaction(); return credentialId; @@ -857,7 +881,7 @@ export class SqliteClient { * @param attachments The attachments to update * @returns The number of rows modified */ - public async updateCredentialById(credential: Credential, originalAttachmentIds: string[], attachments: Attachment[]): Promise { + public async updateCredentialById(credential: Credential, originalAttachmentIds: string[], attachments: Attachment[], originalTotpCodeIds: string[] = [], totpCodes: TotpCode[] = []): Promise { if (!this.db) { throw new Error('Database not initialized'); } @@ -1038,6 +1062,76 @@ export class SqliteClient { } } + // 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, CredentialId, 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; diff --git a/apps/browser-extension/src/utils/dist/shared/models/vault/index.d.ts b/apps/browser-extension/src/utils/dist/shared/models/vault/index.d.ts index 17fbf2f77..8362233b7 100644 --- a/apps/browser-extension/src/utils/dist/shared/models/vault/index.d.ts +++ b/apps/browser-extension/src/utils/dist/shared/models/vault/index.d.ts @@ -50,6 +50,8 @@ type TotpCode = { SecretKey: string; /** The credential ID this TOTP code belongs to */ CredentialId: string; + /** Whether the TOTP code has been deleted (soft delete) */ + IsDeleted?: boolean; }; /** diff --git a/shared/models/src/vault/TotpCode.ts b/shared/models/src/vault/TotpCode.ts index 914948d03..e97b13fcf 100644 --- a/shared/models/src/vault/TotpCode.ts +++ b/shared/models/src/vault/TotpCode.ts @@ -13,4 +13,7 @@ export type TotpCode = { /** The credential ID this TOTP code belongs to */ CredentialId: string; + + /** Whether the TOTP code has been deleted (soft delete) */ + IsDeleted?: boolean; }