mirror of
https://github.com/aliasvault/aliasvault.git
synced 2025-12-23 22:28:22 -05:00
Add 2FA TOTP code editor to browser extension (#1391)
This commit is contained in:
committed by
Leendert de Borst
parent
dccbda7515
commit
a92bbef41a
@@ -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<TotpEditorProps> = ({
|
||||
totpCodes,
|
||||
onTotpCodesChange,
|
||||
originalTotpCodeIds,
|
||||
isAddFormVisible,
|
||||
formData,
|
||||
onStateChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [formError, setFormError] = useState<string | null>(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<TotpFormData>): 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 (
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('common.twoFactorAuthentication')}
|
||||
</h2>
|
||||
{hasActiveTotpCodes && !isAddFormVisible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={showAddForm}
|
||||
className="w-8 h-8 flex items-center justify-center text-primary-700 hover:text-white border border-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg dark:border-primary-500 dark:text-primary-500 dark:hover:text-white dark:hover:bg-primary-600 dark:focus:ring-primary-800"
|
||||
title={t('totp.addCode')}
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasActiveTotpCodes && !isAddFormVisible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={showAddForm}
|
||||
className="w-full py-1.5 px-4 flex items-center justify-center gap-2 text-primary-700 hover:text-white border border-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg dark:border-primary-500 dark:text-primary-500 dark:hover:text-white dark:hover:bg-primary-600 dark:focus:ring-primary-800"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
<span>{t('totp.addCode')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isAddFormVisible && (
|
||||
<div className="p-4 mb-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{t('totp.addCode')}
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={hideAddForm}
|
||||
className="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 14 14">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('totp.instructions')}
|
||||
</p>
|
||||
|
||||
{formError && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-900/20 dark:border-red-800">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">{formError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="totp-name" className="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('totp.nameOptional')}
|
||||
</label>
|
||||
<input
|
||||
id="totp-name"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="totp-secret" className="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('totp.secretKey')}
|
||||
</label>
|
||||
<input
|
||||
id="totp-secret"
|
||||
type="text"
|
||||
value={formData.secretKey}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleAddTotpCode(e)}
|
||||
className="text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasActiveTotpCodes && (
|
||||
<div className="grid grid-cols-1 gap-4 mt-4">
|
||||
{activeTotpCodes.map(totpCode => (
|
||||
<div
|
||||
key={totpCode.Id}
|
||||
className="p-2 ps-3 pe-3 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<div className="flex items-center flex-1">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{totpCode.Name}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col items-end">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('totp.saveToViewCode')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteTotpCode(totpCode)}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-500 dark:hover:text-red-400"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TotpEditor;
|
||||
@@ -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<Credential, 'Logo'> & { 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<Attachment[]>([]);
|
||||
const [originalAttachmentIds, setOriginalAttachmentIds] = useState<string[]>([]);
|
||||
const [totpCodes, setTotpCodes] = useState<TotpCode[]>([]);
|
||||
const [originalTotpCodeIds, setOriginalTotpCodeIds] = useState<string[]>([]);
|
||||
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 = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TotpEditor
|
||||
totpCodes={totpCodes}
|
||||
onTotpCodesChange={setTotpCodes}
|
||||
originalTotpCodeIds={originalTotpCodeIds}
|
||||
isAddFormVisible={totpEditorState.isAddFormVisible}
|
||||
formData={totpEditorState.formData}
|
||||
onStateChange={setTotpEditorState}
|
||||
/>
|
||||
|
||||
<AttachmentUploader
|
||||
attachments={attachments}
|
||||
onAttachmentsChange={setAttachments}
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"next": "Next",
|
||||
"use": "Use",
|
||||
"delete": "Delete",
|
||||
"save": "Save",
|
||||
"or": "Or",
|
||||
"close": "Close",
|
||||
"copied": "Copied!",
|
||||
@@ -240,6 +241,16 @@
|
||||
"enterFullEmail": "Enter full email address",
|
||||
"enterEmailPrefix": "Enter email prefix"
|
||||
},
|
||||
"totp": {
|
||||
"addCode": "Add 2FA Code",
|
||||
"instructions": "Enter the secret key shown by the website where you want to add two-factor authentication.",
|
||||
"nameOptional": "Name (optional)",
|
||||
"secretKey": "Secret Key",
|
||||
"saveToViewCode": "Save to view code",
|
||||
"errors": {
|
||||
"invalidSecretKey": "Invalid secret key format."
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
"title": "Emails",
|
||||
"deleteEmailTitle": "Delete Email",
|
||||
|
||||
@@ -506,7 +506,7 @@ export class SqliteClient {
|
||||
* @param attachments The attachments to insert
|
||||
* @returns The ID of the created credential
|
||||
*/
|
||||
public async createCredential(credential: Credential, attachments: Attachment[]): Promise<string> {
|
||||
public async createCredential(credential: Credential, attachments: Attachment[], totpCodes: TotpCode[] = []): Promise<string> {
|
||||
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<number> {
|
||||
public async updateCredentialById(credential: Credential, originalAttachmentIds: string[], attachments: Attachment[], originalTotpCodeIds: string[] = [], totpCodes: TotpCode[] = []): Promise<number> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user