From bb3807a80520afb90714ebdfb97561e08081f5a5 Mon Sep 17 00:00:00 2001 From: Trent Larson Date: Sun, 8 Dec 2024 19:34:31 -0700 Subject: [PATCH] switch the encryption secret from localStorage to IndexedDB (because localStorage gets lost so often) --- playwright.config-local.ts | 2 +- src/components/GiftedDialog.vue | 7 +- src/components/PushNotificationPermission.vue | 12 +- src/db/index.ts | 111 ++++++++++++++++-- src/db/tables/accounts.ts | 4 +- src/db/tables/temp.ts | 4 +- src/libs/endorserServer.ts | 7 +- src/libs/util.ts | 60 +++++++++- src/router/index.ts | 4 +- src/views/AccountViewView.vue | 81 ++----------- src/views/ClaimView.vue | 12 +- src/views/ConfirmGiftView.vue | 10 +- src/views/ContactAmountsView.vue | 6 +- src/views/ContactQRScanShowView.vue | 8 +- src/views/DIDView.vue | 12 +- src/views/DiscoverView.vue | 8 +- src/views/GiftedDetailsView.vue | 13 +- src/views/HomeView.vue | 9 +- src/views/IdentitySwitcherView.vue | 14 ++- src/views/ImportAccountView.vue | 12 +- src/views/ImportDerivedAccountView.vue | 8 +- src/views/NewActivityView.vue | 9 +- src/views/NewEditProjectView.vue | 15 ++- src/views/OfferDetailsView.vue | 13 +- src/views/ProjectViewView.vue | 27 ++--- src/views/ProjectsView.vue | 10 +- src/views/QuickActionBvcEndView.vue | 2 +- src/views/RecentOffersToUserProjectsView.vue | 9 +- src/views/RecentOffersToUserView.vue | 9 +- src/views/SeedBackupView.vue | 13 +- src/views/ShareMyContactInfoView.vue | 8 +- src/views/StartView.vue | 10 +- src/views/TestView.vue | 14 +-- sw_scripts/additional-scripts.js | 2 +- sw_scripts/safari-notifications.js | 1 + 35 files changed, 293 insertions(+), 253 deletions(-) diff --git a/playwright.config-local.ts b/playwright.config-local.ts index 8fbfd15..4ca4ec9 100644 --- a/playwright.config-local.ts +++ b/playwright.config-local.ts @@ -74,7 +74,7 @@ export default defineConfig({ /* Configure global timeout; default is 30000 milliseconds */ // the image upload will often not succeed at 5 seconds - timeout: 25000, // various tests fail at various times with 20000 + timeout: 30000, // various tests fail at various times with 25000 /* Run your local dev server before starting the tests */ /** diff --git a/src/components/GiftedDialog.vue b/src/components/GiftedDialog.vue index 2fe0ee4..92113fd 100644 --- a/src/components/GiftedDialog.vue +++ b/src/components/GiftedDialog.vue @@ -92,8 +92,9 @@ import { Vue, Component, Prop } from "vue-facing-decorator"; import { NotificationIface } from "@/constants/app"; import { createAndSubmitGive, didInfo } from "@/libs/endorserServer"; import * as libsUtil from "@/libs/util"; -import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index"; +import { db, retrieveSettingsForActiveAccount } from "@/db/index"; import { Contact } from "@/db/tables/contacts"; +import { retrieveAccountDids } from "@/libs/util"; @Component export default class GiftedDialog extends Vue { @@ -145,9 +146,7 @@ export default class GiftedDialog extends Vue { this.allContacts = await db.contacts.toArray(); - await accountsDB.open(); - const allAccounts = await accountsDB.accounts.toArray(); - this.allMyDids = allAccounts.map((acc) => acc.did); + this.allMyDids = await retrieveAccountDids(); if (this.giver && !this.giver.name) { this.giver.name = didInfo( diff --git a/src/components/PushNotificationPermission.vue b/src/components/PushNotificationPermission.vue index 8ffc984..dd9d283 100644 --- a/src/components/PushNotificationPermission.vue +++ b/src/components/PushNotificationPermission.vue @@ -101,7 +101,12 @@ import { Component, Vue } from "vue-facing-decorator"; import { DEFAULT_PUSH_SERVER, NotificationIface } from "@/constants/app"; -import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "@/db/index"; +import { + logConsoleAndDb, + retrieveSettingsForActiveAccount, + secretDB, +} from "@/db/index"; +import { MASTER_SECRET_KEY } from "@/db/tables/secret"; import { urlBase64ToUint8Array } from "@/libs/crypto/vc/util"; import * as libsUtil from "@/libs/util"; @@ -270,7 +275,7 @@ export default class PushNotificationPermission extends Vue { }); } - private askPermission(): Promise { + private async askPermission(): Promise { logConsoleAndDb( "Requesting permission for notifications: " + JSON.stringify(navigator), ); @@ -280,7 +285,8 @@ export default class PushNotificationPermission extends Vue { return Promise.reject("Service worker not available."); } - const secret = localStorage.getItem("secret"); + await secretDB.open(); + const secret = (await secretDB.secret.get(MASTER_SECRET_KEY))?.secret; if (!secret) { return Promise.reject("No secret found."); } diff --git a/src/db/index.ts b/src/db/index.ts index 3e0bf3e..11dce49 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -5,6 +5,7 @@ import * as R from "ramda"; import { Account, AccountsSchema } from "./tables/accounts"; import { Contact, ContactSchema } from "./tables/contacts"; import { Log, LogSchema } from "./tables/logs"; +import { MASTER_SECRET_KEY, Secret, SecretSchema } from "./tables/secret"; import { MASTER_SETTINGS_KEY, Settings, @@ -14,6 +15,7 @@ import { Temp, TempSchema } from "./tables/temp"; import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app"; // Define types for tables that hold sensitive and non-sensitive data +type SecretTable = { secret: Table }; type SensitiveTables = { accounts: Table }; type NonsensitiveTables = { contacts: Table; @@ -23,25 +25,39 @@ type NonsensitiveTables = { }; // Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings +export type SecretDexie = BaseDexie & T; export type SensitiveDexie = BaseDexie & T; export type NonsensitiveDexie = BaseDexie & T; -// Initialize Dexie databases for sensitive and non-sensitive data -export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie; -export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie; +//// Initialize the DBs, starting with the sensitive ones. + +// Initialize Dexie database for secret, which is then used to encrypt accountsDB +export const secretDB = new BaseDexie("TimeSafariSecret") as SecretDexie; +secretDB.version(1).stores(SecretSchema); + +// Initialize Dexie database for accounts +const accountsDexie = new BaseDexie("TimeSafariAccounts") as SensitiveDexie; -// Manage the encryption key. If not present in localStorage, create and store it. -const secret = - localStorage.getItem("secret") || Encryption.createRandomEncryptionKey(); -if (!localStorage.getItem("secret")) localStorage.setItem("secret", secret); +// Instead of accountsDBPromise, use libs/util retrieveAccountMetadata or retrieveFullyDecryptedAccount +// so that it's clear whether the usage needs the private key inside. +// +// This is a promise because the decryption key comes from IndexedDB +// and someday it may come from a password or keystore or external wallet. +// It's important that usages take into account that there may be a delay due +// to a user action required to unlock the data. +export const accountsDBPromise = useSecretAndInitializeAccountsDB( + secretDB, + accountsDexie, +); -// Apply encryption to the sensitive database using the secret key -encrypted(accountsDB, { secretKey: secret }); +//// Now initialize the other DB. + +// Initialize Dexie databases for non-sensitive data +export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie; -// Define the schemas for our databases // Only the tables with index modifications need listing. https://dexie.org/docs/Tutorial/Design#database-versioning -accountsDB.version(1).stores(AccountsSchema); + // v1 also had contacts & settings // v2 added Log db.version(2).stores({ @@ -73,6 +89,79 @@ db.on("populate", async () => { await db.settings.add(DEFAULT_SETTINGS); }); +// Manage the encryption key. + +// It's not really secure to maintain the secret next to the user's data. +// However, until we have better hooks into a real wallet or reliable secure +// storage, we'll do this for user convenience. As they sign more records +// and integrate with more people, they'll value it more and want to be more +// secure, so we'll prompt them to take steps to back it up, properly encrypt, +// etc. At the beginning, we'll prompt for a password, then we'll prompt for a +// PWA so it's not in a browser... and then we hope to be integrated with a +// real wallet or something else more secure. + +// One might ask: why encrypt at all? We figure a basic encryption is better +// than none. Plus, we expect to support their own password or keystore or +// external wallet as better signing options in the future, so it's gonna be +// important to have the structure where each account access might require +// user action. + +// (Once upon a time we stored the secret in localStorage, but it frequently +// got erased, even though the IndexedDB still had the identity data. This +// ended up throwing lots of errors to the user... and they'd end up in a state +// where they couldn't take action because they couldn't unlock that identity.) + +// check for the secret in storage +async function useSecretAndInitializeAccountsDB( + secretDB: SecretDexie, + accountsDB: SensitiveDexie, +): Promise { + return secretDB + .open() + .then(() => { + return secretDB.secret.get(MASTER_SECRET_KEY); + }) + .then((secretRow?: Secret) => { + let secret = secretRow?.secret; + if (secret != null) { + // they already have it in IndexedDB, so just pass it along + return secret; + } else { + // check localStorage (for users before v 0.3.37) + const localSecret = localStorage.getItem("secret"); + if (localSecret != null) { + // they had one, so we want to move it to IndexedDB + secret = localSecret; + } else { + // they didn't have one, so let's generate one + secret = Encryption.createRandomEncryptionKey(); + } + // it is not in IndexedDB, so add it now + return secretDB.secret + .add({ id: MASTER_SECRET_KEY, secret }) + .then(() => { + return secret; + }); + } + }) + .then((secret?: string) => { + if (secret == null) { + throw new Error("No secret found or created."); + } else { + // apply encryption to the sensitive database using the secret key + encrypted(accountsDB, { secretKey: secret }); + accountsDB.version(1).stores(AccountsSchema); + accountsDB.open(); + return accountsDB; + } + }) + .catch((error) => { + logConsoleAndDb("Error processing secret & encrypted accountsDB.", error); + // alert("There was an error processing encrypted data. See the Help page."); + throw error; + }); +} + // retrieves default settings // calls db.open() export async function retrieveSettingsForDefaultAccount(): Promise { diff --git a/src/db/tables/accounts.ts b/src/db/tables/accounts.ts index de405fa..eb2f1e4 100644 --- a/src/db/tables/accounts.ts +++ b/src/db/tables/accounts.ts @@ -5,7 +5,7 @@ export type Account = { /** * Auto-generated ID by Dexie */ - id?: number; + id?: number; // this is only blank on input, when the database assigns it /** * The date the account was created @@ -48,7 +48,7 @@ export type Account = { /** * Schema for the accounts table in the database. * Fields starting with a $ character are encrypted. - * @see {@link https://github.com/PVermeer/dexie-addon-suite-monorepo/tree/master/packages/dexie-encrypted-addon} + * @see {@link https://github.com/PVermeer/dexie-addon-suite-monorepo/tree/master/packages/dexie-encrypted-addon#added-schema-syntax} */ export const AccountsSchema = { accounts: diff --git a/src/db/tables/temp.ts b/src/db/tables/temp.ts index 0c77f56..3e80829 100644 --- a/src/db/tables/temp.ts +++ b/src/db/tables/temp.ts @@ -9,6 +9,4 @@ export type Temp = { /** * Schema for the Temp table in the database. */ -export const TempSchema = { - temp: "id", -}; +export const TempSchema = { temp: "id" }; diff --git a/src/libs/endorserServer.ts b/src/libs/endorserServer.ts index f10bd3f..fedf436 100644 --- a/src/libs/endorserServer.ts +++ b/src/libs/endorserServer.ts @@ -9,7 +9,8 @@ import { Contact } from "@/db/tables/contacts"; import { accessToken, deriveAddress, nextDerivationPath } from "@/libs/crypto"; import { NonsensitiveDexie } from "@/db/index"; import { - getAccount, + retrieveAccountMetadata, + retrieveFullyDecryptedAccount, getPasskeyExpirationSeconds, GiverReceiverInputInfo, } from "@/libs/util"; @@ -506,7 +507,7 @@ export async function getHeaders(did?: string) { }; if (did) { let token; - const account = await getAccount(did); + const account = await retrieveAccountMetadata(did); if (account?.passkeyCredIdHex) { if ( passkeyAccessToken && @@ -1054,7 +1055,7 @@ export async function createEndorserJwtForDid( payload: object, expiresIn?: number, ) { - const account = await getAccount(issuerDid); + const account = await retrieveFullyDecryptedAccount(issuerDid); return createEndorserJwtForKey(account as KeyMeta, payload, expiresIn); } diff --git a/src/libs/util.ts b/src/libs/util.ts index 47195e5..f449cbe 100644 --- a/src/libs/util.ts +++ b/src/libs/util.ts @@ -7,7 +7,7 @@ import { useClipboard } from "@vueuse/core"; import { DEFAULT_PUSH_SERVER, NotificationIface } from "@/constants/app"; import { - accountsDB, + accountsDBPromise, retrieveSettingsForActiveAccount, updateAccountSettings, updateDefaultSettings, @@ -422,10 +422,51 @@ export function findAllVisibleToDids( export interface AccountKeyInfo extends Account, KeyMeta {} -export const getAccount = async ( +export const retrieveAccountCount = async (): Promise => { + const accountsDB = await accountsDBPromise; + return await accountsDB.accounts.count(); +}; + +export const retrieveAccountDids = async (): Promise => { + const accountsDB = await accountsDBPromise; + const allAccounts = await accountsDB.accounts.toArray(); + const allDids = allAccounts.map((acc) => acc.did); + return allDids; +}; + +// This is provided and recommended when the full key is not necessary so that +// future work could separate this info from the sensitive key material. +export const retrieveAccountMetadata = async ( + activeDid: string, +): Promise => { + const accountsDB = await accountsDBPromise; + const account = (await accountsDB.accounts + .where("did") + .equals(activeDid) + .first()) as Account; + if (account) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { identity, mnemonic, ...metadata } = account; + return metadata; + } else { + return undefined; + } +}; + +export const retrieveAllAccountsMetadata = async (): Promise => { + const accountsDB = await accountsDBPromise; + const array = await accountsDB.accounts.toArray(); + return array.map((account) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { identity, mnemonic, ...metadata } = account; + return metadata; + }); +}; + +export const retrieveFullyDecryptedAccount = async ( activeDid: string, ): Promise => { - await accountsDB.open(); + const accountsDB = await accountsDBPromise; const account = (await accountsDB.accounts .where("did") .equals(activeDid) @@ -433,6 +474,15 @@ export const getAccount = async ( return account; }; +// let's try and eliminate this +export const retrieveAllFullyDecryptedAccounts = async (): Promise< + Array +> => { + const accountsDB = await accountsDBPromise; + const allAccounts = await accountsDB.accounts.toArray(); + return allAccounts; +}; + /** * Generates a new identity, saves it to the database, and sets it as the active identity. * @return {Promise} with the DID of the new identity @@ -446,7 +496,7 @@ export const generateSaveAndActivateIdentity = async (): Promise => { const newId = newIdentifier(address, publicHex, privateHex, derivationPath); const identity = JSON.stringify(newId); - await accountsDB.open(); + const accountsDB = await accountsDBPromise; await accountsDB.accounts.add({ dateCreated: new Date().toISOString(), derivationPath: derivationPath, @@ -477,7 +527,7 @@ export const registerAndSavePasskey = async ( passkeyCredIdHex, publicKeyHex: Buffer.from(publicKeyBytes).toString("hex"), }; - await accountsDB.open(); + const accountsDB = await accountsDBPromise; await accountsDB.accounts.add(account); return account; }; diff --git a/src/router/index.ts b/src/router/index.ts index e526302..efd96bb 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -5,7 +5,7 @@ import { RouteLocationNormalized, RouteRecordRaw, } from "vue-router"; -import { accountsDB } from "@/db/index"; +import { accountsDBPromise } from "@/db/index"; /** * @@ -18,7 +18,7 @@ const enterOrStart = async ( from: RouteLocationNormalized, next: NavigationGuardNext, ) => { - await accountsDB.open(); + const accountsDB = await accountsDBPromise; const num_accounts = await accountsDB.accounts.count(); if (num_accounts > 0) { next(); diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue index 0f509ea..b992359 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -819,7 +819,6 @@ import { } from "@/constants/app"; import { db, - accountsDB, retrieveSettingsForActiveAccount, updateAccountSettings, } from "@/db/index"; @@ -839,7 +838,11 @@ import { ImageRateLimits, tokenExpiryTimeDescription, } from "@/libs/endorserServer"; -import { DAILY_CHECK_TITLE, DIRECT_PUSH_TITLE, getAccount } from "@/libs/util"; +import { + DAILY_CHECK_TITLE, + DIRECT_PUSH_TITLE, + retrieveAccountMetadata, +} from "@/libs/util"; const inputImportFileNameRef = ref(); @@ -941,7 +944,7 @@ export default class AccountViewView extends Vue { ); // this sometimes gives different information on the error console.error( - "Telling user to clear cache at page create because (error added): " + + "To repeat with concatenated error: telling user to clear cache at page create because: " + error, ); this.$notify( @@ -1055,7 +1058,9 @@ export default class AccountViewView extends Vue { * Processes the identity and updates the component's state. */ async processIdentity() { - const account: Account | undefined = await getAccount(this.activeDid); + const account: Account | undefined = await retrieveAccountMetadata( + this.activeDid, + ); if (account?.identity) { const identity = JSON.parse(account.identity as string) as IIdentifier; this.publicHex = identity.keys[0].publicKeyHex; @@ -1495,74 +1500,6 @@ export default class AccountViewView extends Vue { } } - /** - * Asynchronously switches the active account based on the provided account number. - * - * @param {number} accountNum - The account number to switch to. 0 means none. - */ - public async switchAccount(accountNum: number) { - await db.open(); // Assumes db needs to be open for both cases - - if (accountNum === 0) { - this.switchToNoAccount(); - } else { - await this.switchToAccountNumber(accountNum); - } - } - - /** - * Switches to no active account and clears relevant properties. - */ - private async switchToNoAccount() { - await db.settings.update(MASTER_SETTINGS_KEY, { activeDid: undefined }); - this.clearActiveAccountProperties(); - } - - /** - * Clears properties related to the active account. - */ - private clearActiveAccountProperties() { - this.activeDid = ""; - this.derivationPath = ""; - this.publicHex = ""; - this.publicBase64 = ""; - } - - /** - * Switches to an account based on its number in the list. - * - * @param {number} accountNum - The account number to switch to. - */ - private async switchToAccountNumber(accountNum: number) { - await accountsDB.open(); - const accounts = await accountsDB.accounts.toArray(); - const account = accounts[accountNum - 1]; - - await db.open(); - await db.settings.update(MASTER_SETTINGS_KEY, { activeDid: account.did }); - - this.updateActiveAccountProperties(account); - } - - /** - * Updates properties related to the active account. - * - * @param {AccountType} account - The account object. - */ - private updateActiveAccountProperties(account: Account) { - this.activeDid = account.did; - this.derivationPath = account.derivationPath || ""; - this.publicHex = account.publicKeyHex; - this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64"); - } - - public showContactGivesClassNames() { - return { - "bg-slate-900": !this.showContactGives, - "bg-green-600": this.showContactGives, - }; - } - async onClickSaveApiServer() { await db.open(); await db.settings.update(MASTER_SETTINGS_KEY, { diff --git a/src/views/ClaimView.vue b/src/views/ClaimView.vue index e318d68..ce7898a 100644 --- a/src/views/ClaimView.vue +++ b/src/views/ClaimView.vue @@ -482,17 +482,16 @@ import { Router } from "vue-router"; import { useClipboard } from "@vueuse/core"; import GiftedDialog from "@/components/GiftedDialog.vue"; +import QuickNav from "@/components/QuickNav.vue"; import { NotificationIface } from "@/constants/app"; -import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index"; +import { db, retrieveSettingsForActiveAccount } from "@/db/index"; import { Contact } from "@/db/tables/contacts"; import * as serverUtil from "@/libs/endorserServer"; -import * as libsUtil from "@/libs/util"; -import QuickNav from "@/components/QuickNav.vue"; -import { Account } from "@/db/tables/accounts"; import { GenericCredWrapper, OfferVerifiableCredential, } from "@/libs/endorserServer"; +import * as libsUtil from "@/libs/util"; interface ProviderInfo { identifier: string; // could be a DID or a handleId @@ -560,10 +559,7 @@ export default class ClaimView extends Vue { this.allContacts = await db.contacts.toArray(); this.isRegistered = settings.isRegistered || false; - await accountsDB.open(); - const accounts = accountsDB.accounts; - const accountsArr: Array = await accounts?.toArray(); - this.allMyDids = accountsArr.map((acc) => acc.did); + this.allMyDids = await libsUtil.retrieveAccountDids(); const pathParam = window.location.pathname.substring("/claim/".length); let claimId; diff --git a/src/views/ConfirmGiftView.vue b/src/views/ConfirmGiftView.vue index 8c91adc..d515221 100644 --- a/src/views/ConfirmGiftView.vue +++ b/src/views/ConfirmGiftView.vue @@ -412,13 +412,12 @@ import { Router } from "vue-router"; import QuickNav from "@/components/QuickNav.vue"; import { NotificationIface } from "@/constants/app"; -import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index"; -import { Account } from "@/db/tables/accounts"; +import { db, retrieveSettingsForActiveAccount } from "@/db/index"; import { Contact } from "@/db/tables/contacts"; import * as serverUtil from "@/libs/endorserServer"; import { displayAmount, GiveSummaryRecord } from "@/libs/endorserServer"; import * as libsUtil from "@/libs/util"; -import { isGiveAction } from "@/libs/util"; +import { isGiveAction, retrieveAccountDids } from "@/libs/util"; import TopMessage from "@/components/TopMessage.vue"; @Component({ @@ -476,10 +475,7 @@ export default class ClaimView extends Vue { this.allContacts = await db.contacts.toArray(); this.isRegistered = settings.isRegistered || false; - await accountsDB.open(); - const accounts = accountsDB.accounts; - const accountsArr: Array = await accounts?.toArray(); - this.allMyDids = accountsArr.map((acc) => acc.did); + this.allMyDids = await retrieveAccountDids(); const pathParam = window.location.pathname.substring( "/confirm-gift/".length, diff --git a/src/views/ContactAmountsView.vue b/src/views/ContactAmountsView.vue index 9545cd5..60ad259 100644 --- a/src/views/ContactAmountsView.vue +++ b/src/views/ContactAmountsView.vue @@ -112,7 +112,7 @@ import { Router } from "vue-router"; import QuickNav from "@/components/QuickNav.vue"; import { NotificationIface } from "@/constants/app"; -import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index"; +import { db, retrieveSettingsForActiveAccount } from "@/db/index"; import { Contact } from "@/db/tables/contacts"; import { AgreeVerifiableCredential, @@ -123,6 +123,7 @@ import { GiveVerifiableCredential, SCHEMA_ORG_CONTEXT, } from "@/libs/endorserServer"; +import { retrieveAccountCount } from "@/libs/util"; @Component({ components: { QuickNav } }) export default class ContactAmountssView extends Vue { @@ -137,8 +138,7 @@ export default class ContactAmountssView extends Vue { displayAmount = displayAmount; async beforeCreate() { - await accountsDB.open(); - this.numAccounts = await accountsDB.accounts.count(); + this.numAccounts = await retrieveAccountCount(); } async created() { diff --git a/src/views/ContactQRScanShowView.vue b/src/views/ContactQRScanShowView.vue index ac0a550..670eeb3 100644 --- a/src/views/ContactQRScanShowView.vue +++ b/src/views/ContactQRScanShowView.vue @@ -91,7 +91,6 @@