diff --git a/src/constants/notifications.ts b/src/constants/notifications.ts index 49b14812..5cc75bd4 100644 --- a/src/constants/notifications.ts +++ b/src/constants/notifications.ts @@ -1689,3 +1689,11 @@ export const NOTIFY_CONTACTS_ADDED_CONFIRM = { title: "They're Added To Your List", message: "Would you like to go to the main page now?", }; + +// ImportAccountView.vue specific constants +// Used in: ImportAccountView.vue (onImportClick method - duplicate account warning) +export const NOTIFY_DUPLICATE_ACCOUNT_IMPORT = { + title: "Account Already Imported", + message: + "This account has already been imported. Please use a different seed phrase or check your existing accounts.", +}; diff --git a/src/libs/util.ts b/src/libs/util.ts index dda77a70..dfd3dde5 100644 --- a/src/libs/util.ts +++ b/src/libs/util.ts @@ -614,57 +614,64 @@ export const retrieveAllAccountsMetadata = async (): Promise< return result; }; +export const DUPLICATE_ACCOUNT_ERROR = "Cannot import duplicate account."; + /** - * Saves a new identity to both SQL and Dexie databases + * Saves a new identity to SQL database */ export async function saveNewIdentity( identity: IIdentifier, mnemonic: string, derivationPath: string, ): Promise { - try { - // add to the new sql db - const platformService = await getPlatformService(); - - const secrets = await platformService.dbQuery( - `SELECT secretBase64 FROM secret`, - ); - if (!secrets?.values?.length || !secrets.values[0]?.length) { - throw new Error( - "No initial encryption supported. We recommend you clear your data and start over.", - ); - } + // add to the new sql db + const platformService = await getPlatformService(); - const secretBase64 = secrets.values[0][0] as string; + // Check if account already exists before attempting to save + const existingAccount = await platformService.dbQuery( + "SELECT did FROM accounts WHERE did = ?", + [identity.did], + ); - const secret = base64ToArrayBuffer(secretBase64); - const identityStr = JSON.stringify(identity); - const encryptedIdentity = await simpleEncrypt(identityStr, secret); - const encryptedMnemonic = await simpleEncrypt(mnemonic, secret); - const encryptedIdentityBase64 = arrayBufferToBase64(encryptedIdentity); - const encryptedMnemonicBase64 = arrayBufferToBase64(encryptedMnemonic); + if (existingAccount?.values?.length) { + throw new Error( + `Account with DID ${identity.did} already exists. ${DUPLICATE_ACCOUNT_ERROR}`, + ); + } - const sql = `INSERT INTO accounts (dateCreated, derivationPath, did, identityEncrBase64, mnemonicEncrBase64, publicKeyHex) - VALUES (?, ?, ?, ?, ?, ?)`; - const params = [ - new Date().toISOString(), - derivationPath, - identity.did, - encryptedIdentityBase64, - encryptedMnemonicBase64, - identity.keys[0].publicKeyHex, - ]; - await platformService.dbExec(sql, params); - - await platformService.updateDefaultSettings({ activeDid: identity.did }); - - await platformService.insertNewDidIntoSettings(identity.did); - } catch (error) { - logger.error("Failed to update default settings:", error); + const secrets = await platformService.dbQuery( + `SELECT secretBase64 FROM secret`, + ); + if (!secrets?.values?.length || !secrets.values[0]?.length) { throw new Error( - "Failed to set default settings. Please try again or restart the app.", + "No initial encryption supported. We recommend you clear your data and start over.", ); } + + const secretBase64 = secrets.values[0][0] as string; + + const secret = base64ToArrayBuffer(secretBase64); + const identityStr = JSON.stringify(identity); + const encryptedIdentity = await simpleEncrypt(identityStr, secret); + const encryptedMnemonic = await simpleEncrypt(mnemonic, secret); + const encryptedIdentityBase64 = arrayBufferToBase64(encryptedIdentity); + const encryptedMnemonicBase64 = arrayBufferToBase64(encryptedMnemonic); + + const sql = `INSERT INTO accounts (dateCreated, derivationPath, did, identityEncrBase64, mnemonicEncrBase64, publicKeyHex) + VALUES (?, ?, ?, ?, ?, ?)`; + const params = [ + new Date().toISOString(), + derivationPath, + identity.did, + encryptedIdentityBase64, + encryptedMnemonicBase64, + identity.keys[0].publicKeyHex, + ]; + await platformService.dbExec(sql, params); + + await platformService.updateDefaultSettings({ activeDid: identity.did }); + + await platformService.insertNewDidIntoSettings(identity.did); } /** @@ -1032,3 +1039,58 @@ export async function importFromMnemonic( } } } + +/** + * Checks if an account with the given DID already exists in the database + * + * @param did - The DID to check for duplicates + * @returns Promise - True if account already exists, false otherwise + * @throws Error if database query fails + */ +export async function checkForDuplicateAccount(did: string): Promise; + +/** + * Checks if an account with the given DID already exists in the database + * + * @param mnemonic - The mnemonic phrase to derive DID from + * @param derivationPath - The derivation path to use + * @returns Promise - True if account already exists, false otherwise + * @throws Error if database query fails + */ +export async function checkForDuplicateAccount( + mnemonic: string, + derivationPath: string, +): Promise; + +/** + * Implementation of checkForDuplicateAccount with overloaded signatures + */ +export async function checkForDuplicateAccount( + didOrMnemonic: string, + derivationPath?: string, +): Promise { + let didToCheck: string; + + if (derivationPath) { + // Derive the DID from mnemonic and derivation path + const [address, privateHex, publicHex] = deriveAddress( + didOrMnemonic.trim().toLowerCase(), + derivationPath, + ); + + const newId = newIdentifier(address, privateHex, publicHex, derivationPath); + didToCheck = newId.did; + } else { + // Use the provided DID directly + didToCheck = didOrMnemonic; + } + + // Check if an account with this DID already exists + const platformService = await getPlatformService(); + const existingAccount = await platformService.dbQuery( + "SELECT did FROM accounts WHERE did = ?", + [didToCheck], + ); + + return (existingAccount?.values?.length ?? 0) > 0; +} diff --git a/src/views/ImportAccountView.vue b/src/views/ImportAccountView.vue index 97d1d22d..d4588423 100644 --- a/src/views/ImportAccountView.vue +++ b/src/views/ImportAccountView.vue @@ -88,9 +88,15 @@ import { Router } from "vue-router"; import { AppString, NotificationIface } from "../constants/app"; import { DEFAULT_ROOT_DERIVATION_PATH } from "../libs/crypto"; -import { retrieveAccountCount, importFromMnemonic } from "../libs/util"; +import { + retrieveAccountCount, + importFromMnemonic, + checkForDuplicateAccount, + DUPLICATE_ACCOUNT_ERROR, +} from "../libs/util"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; +import { NOTIFY_DUPLICATE_ACCOUNT_IMPORT } from "@/constants/notifications"; /** * Import Account View Component @@ -198,6 +204,19 @@ export default class ImportAccountView extends Vue { } try { + // Check for duplicate account before importing + const isDuplicate = await checkForDuplicateAccount( + this.mnemonic, + this.derivationPath, + ); + if (isDuplicate) { + this.notify.warning( + NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message, + TIMEOUTS.LONG, + ); + return; + } + await importFromMnemonic( this.mnemonic, this.derivationPath, @@ -223,9 +242,20 @@ export default class ImportAccountView extends Vue { this.$router.push({ name: "account" }); } catch (error: unknown) { this.$logError("Import failed: " + error); + + // Check if this is a duplicate account error from saveNewIdentity + const errorMessage = + error instanceof Error ? error.message : String(error); + if (errorMessage.includes(DUPLICATE_ACCOUNT_ERROR)) { + this.notify.warning( + NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message, + TIMEOUTS.LONG, + ); + return; + } + this.notify.error( - (error instanceof Error ? error.message : String(error)) || - "Failed to import account.", + errorMessage || "Failed to import account.", TIMEOUTS.LONG, ); } diff --git a/src/views/ImportDerivedAccountView.vue b/src/views/ImportDerivedAccountView.vue index 9127326b..2772cbe8 100644 --- a/src/views/ImportDerivedAccountView.vue +++ b/src/views/ImportDerivedAccountView.vue @@ -83,6 +83,7 @@ import { retrieveAllAccountsMetadata, retrieveFullyDecryptedAccount, saveNewIdentity, + checkForDuplicateAccount, } from "../libs/util"; import { logger } from "../utils/logger"; import { Account, AccountEncrypted } from "../db/tables/accounts"; @@ -171,6 +172,16 @@ export default class ImportAccountView extends Vue { const newId = newIdentifier(address, publicHex, privateHex, newDerivPath); try { + // Check for duplicate account before creating + const isDuplicate = await checkForDuplicateAccount(newId.did); + if (isDuplicate) { + this.notify.warning( + "This derived account already exists. Please try a different derivation path.", + TIMEOUTS.LONG, + ); + return; + } + await saveNewIdentity(newId, mne, newDerivPath); // record that as the active DID diff --git a/test-playwright/03-duplicate-import-test.spec.ts b/test-playwright/03-duplicate-import-test.spec.ts new file mode 100644 index 00000000..0bdc80bd --- /dev/null +++ b/test-playwright/03-duplicate-import-test.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from '@playwright/test'; +import { importUserFromAccount, getTestUserData } from './testUtils'; +import { NOTIFY_DUPLICATE_ACCOUNT_IMPORT } from '../src/constants/notifications'; + +/** + * Test duplicate account import functionality + * + * This test verifies that: + * 1. A user can successfully import an account the first time + * 2. Attempting to import the same account again shows a warning message + * 3. The duplicate import is prevented + */ +test.describe('Duplicate Account Import', () => { + test('should prevent importing the same account twice', async ({ page }) => { + const userData = getTestUserData("00"); + + // First import - should succeed + await page.goto("./start"); + await page.getByText("You have a seed").click(); + await page.getByPlaceholder("Seed Phrase").fill(userData.seedPhrase); + await page.getByRole("button", { name: "Import" }).click(); + + // Verify first import was successful + await expect(page.getByRole("code")).toContainText(userData.did); + + // Navigate back to start page for second import attempt + await page.goto("./start"); + await page.getByText("You have a seed").click(); + await page.getByPlaceholder("Seed Phrase").fill(userData.seedPhrase); + await page.getByRole("button", { name: "Import" }).click(); + + // Verify duplicate import shows warning message + // The warning can appear either from the pre-check or from the saveNewIdentity error handling + await expect(page.getByText(NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message)).toBeVisible(); + + // Verify we're still on the import page (not redirected to account) + await expect(page.getByPlaceholder("Seed Phrase")).toBeVisible(); + }); + + test('should allow importing different accounts', async ({ page }) => { + const userZeroData = getTestUserData("00"); + const userOneData = getTestUserData("01"); + + // Import first user + await page.goto("./start"); + await page.getByText("You have a seed").click(); + await page.getByPlaceholder("Seed Phrase").fill(userZeroData.seedPhrase); + await page.getByRole("button", { name: "Import" }).click(); + + // Verify first import was successful + await expect(page.getByRole("code")).toContainText(userZeroData.did); + + // Navigate back to start page for second user import + await page.goto("./start"); + await page.getByText("You have a seed").click(); + await page.getByPlaceholder("Seed Phrase").fill(userOneData.seedPhrase); + await page.getByRole("button", { name: "Import" }).click(); + + // Verify second import was successful (should not show duplicate warning) + await expect(page.getByRole("code")).toContainText(userOneData.did); + await expect(page.getByText(NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message)).not.toBeVisible(); + }); +});