<template> <QuickNav selected="Profile" /> <TopMessage /> <!-- CONTENT --> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <!-- Back --> <div class="text-lg text-center font-light relative px-7"> <h1 class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" @click="$router.back()" > <fa icon="chevron-left" class="fa-fw"></fa> </h1> </div> <!-- Heading --> <h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-4"> Your Identity </h1> <div class="flex justify-between"> <span /> <span class="whitespace-nowrap"> <router-link :to="{ name: 'contact-qr' }" class="text-xs uppercase bg-slate-500 text-white px-1.5 py-1 rounded-md" > <fa icon="qrcode" class="fa-fw"></fa> </router-link> </span> <span /> </div> <div class="flex justify-between py-2"> <span /> <span> <router-link :to="{ name: 'help' }" class="text-xs uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 rounded-md ml-1" > Help </router-link> </span> </div> <!-- ID notice --> <div v-if="!activeDid" class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mb-4" > <p class="mb-4"> <b>Note:</b> Before you can share with others or take any action, you need an identifier. </p> <router-link :to="{ name: 'start' }" class="inline-block text-md uppercase bg-gradient-to-b from-amber-400 to-amber-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md" > Create An Identifier </router-link> </div> <!-- Identity Details --> <div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4"> <h2 v-if="givenName" class="text-xl font-semibold mb-2"> {{ givenName }} <router-link :to="{ name: 'new-edit-account' }"> <fa icon="pen" class="text-xs text-blue-500 mb-1"></fa> </router-link> <div class="flex justify-center mt-4"> <span v-if="profileImageUrl" class="flex justify-between"> <a :href="profileImageUrl" target="_blank" class="text-blue-500 ml-4" > <img :src="profileImageUrl" class="h-24 rounded-xl" /> </a> <fa icon="trash-can" @click="confirmDeleteImage" class="text-red-500 fa-fw ml-8 mt-10" /> </span> <span v-else> <fa icon="camera" class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md" @click="openPhotoDialog" /> </span> <GiftedPhotoDialog ref="photoDialog" /> </div> </h2> <span v-else> <router-link :to="{ name: 'new-edit-account' }" class="block w-full text-center text-md bg-amber-200 text-blue-500 uppercase border border-dashed border-slate-400 px-1.5 py-2 rounded-md mb-2" > Set Your Name </router-link> </span> <div class="text-slate-500 text-sm font-bold">ID</div> <div class="text-sm text-slate-500 flex justify-start items-center mb-1"> <code class="truncate">{{ activeDid }}</code> <button @click=" doCopyTwoSecRedo(activeDid, () => (showDidCopy = !showDidCopy)) " class="ml-2" > <fa icon="copy" class="text-slate-400 fa-fw"></fa> </button> <span v-show="showDidCopy">Copied</span> </div> </div> <!-- Registration notice --> <!-- We won't show any loading indicator because it usually doesn't change anything. We'll just pop the message in only if we discover that they need it. --> <div v-if="!loadingLimits && !endorserLimits?.nextWeekBeginDateTime" class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mb-4" > <p class="mb-4"> <b>Note:</b> Before you can publicly announce a new project or time commitment, a friend needs to register you. </p> <router-link :to="{ name: 'contact-qr' }" class="inline-block text-md uppercase bg-gradient-to-b from-amber-400 to-amber-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md" > Share Your Info </router-link> </div> <div class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"> <!-- label --> <div class="mb-2 font-bold">Notifications</div> <div v-if="!notificationMaybeChanged" class="flex items-center justify-between cursor-pointer" @click="showNotificationChoice()" > <!-- label --> <div>App Notifications</div> <!-- toggle --> <div class="relative ml-2"> <!-- input --> <input type="checkbox" v-model="isSubscribed" name="toggleNotificationsInput" class="sr-only" /> <!-- line --> <div class="block bg-slate-500 w-14 h-8 rounded-full"></div> <!-- dot --> <div class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition" ></div> </div> </div> <div v-else> Notification status may have changed. Refresh this page to see the latest setting. </div> <router-link class="pl-4 text-sm text-blue-500" to="/help-notifications"> Troubleshoot your notification setup. </router-link> </div> <div class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"> <!-- label --> <div class="mb-2 font-bold">Location</div> <router-link :to="{ name: 'search-area' }" v-if="activeDid" class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-6" > Set Search Area… <!-- If already set, change button label to "Change Search Area" --> </router-link> </div> <div v-if="activeDid" class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8" > <div class="mb-2 font-bold">Usage Limits</div> <!-- show spinner if loading limits --> <div v-if="loadingLimits" class="text-center"> Checking… <fa icon="spinner" class="fa-spin"></fa> </div> <div> {{ limitsMessage }} </div> <div v-if="!!endorserLimits?.nextWeekBeginDateTime"> <p class="text-sm"> You have done <b>{{ endorserLimits.doneClaimsThisWeek }} claims</b> out of <b>{{ endorserLimits.maxClaimsPerWeek }}</b> for this week. Your claims counter resets at <b class="whitespace-nowrap">{{ readableDate(endorserLimits.nextWeekBeginDateTime) }}</b> </p> <p class="mt-3 text-sm"> You have done <b>{{ endorserLimits.doneRegistrationsThisMonth }} registrations</b> out of <b>{{ endorserLimits.maxRegistrationsPerMonth }}</b> for this month. <i >(You can register nobody on your first day, and after that only one a day in your first month.)</i > Your registration counter resets at <b class="whitespace-nowrap"> {{ readableDate(endorserLimits.nextMonthBeginDateTime) }} </b> </p> <p class="mt-3 text-sm" v-if="!!imageLimits"> You have uploaded <b>{{ imageLimits?.doneImagesThisWeek }} images</b> out of <b>{{ imageLimits?.maxImagesPerWeek }}</b> for this week. Your image counter resets at <b class="whitespace-nowrap">{{ readableDate(imageLimits?.nextWeekBeginDateTime) }}</b> </p> </div> <button class="block float-right w-fit text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md mt-2" @click="checkLimits()" > Recheck Limits </button> </div> <div class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"> <div class="mb-2 font-bold">Data Export</div> <router-link :to="{ name: 'seed-backup' }" v-if="activeDid" class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2" > Backup Identifier Seed </router-link> <button v-bind:class="computedStartDownloadLinkClassNames()" class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md" @click="exportDatabase()" > Download Settings & Contacts <br /> (excluding Identifier Data) </button> <a ref="downloadLink" v-bind:class="computedDownloadLinkClassNames()" class="block w-full text-center text-md uppercase bg-gradient-to-b from-green-500 to-green-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6" > If no download happened yet, click again here to download now. </a> </div> <!-- id used by puppeteer test script --> <h3 id="advanced" class="text-sm uppercase font-semibold mb-3" @click="showAdvanced = !showAdvanced" > Advanced </h3> <div v-if="showAdvanced"> <p class="text-rose-600 mb-8"> Beware: the features here can be confusing and even change data in ways you do not expect. But we support your freedom! </p> <!-- Deep Identity Details --> <span class="text-slate-500 text-sm font-bold mb-2"> Deep Identifier Details </span> <div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4"> <div class="text-slate-500 text-sm font-bold">Public Key (base 64)</div> <div class="text-sm text-slate-500 flex justify-start items-center mb-1" > <code class="truncate">{{ publicBase64 }}</code> <button @click=" doCopyTwoSecRedo(publicBase64, () => (showB64Copy = !showB64Copy)) " class="ml-2" > <fa icon="copy" class="text-slate-400 fa-fw"></fa> </button> <span v-show="showB64Copy">Copied</span> </div> <div class="text-slate-500 text-sm font-bold">Public Key (hex)</div> <div class="text-sm text-slate-500 flex justify-start items-center mb-1" > <code class="truncate">{{ publicHex }}</code> <button @click=" doCopyTwoSecRedo(publicHex, () => (showPubCopy = !showPubCopy)) " class="ml-2" > <fa icon="copy" class="text-slate-400 fa-fw"></fa> </button> <span v-show="showPubCopy">Copied</span> </div> <div class="text-slate-500 text-sm font-bold">Derivation Path</div> <div class="text-sm text-slate-500 flex justify-start items-center mb-1" > <code class="truncate">{{ derivationPath }}</code> <button @click=" doCopyTwoSecRedo( derivationPath, () => (showDerCopy = !showDerCopy), ) " class="ml-2" > <fa icon="copy" class="text-slate-400 fa-fw"></fa> </button> <span v-show="showDerCopy">Copied</span> </div> </div> <!-- id used by puppeteer test script --> <router-link id="switch-identity-link" :to="{ name: 'identity-switcher' }" class="block w-fit text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md mb-2" > Switch Identifier </router-link> <label for="toggleShowAmounts" class="flex items-center justify-between cursor-pointer my-4" @click="toggleShowContactAmounts" > <!-- label --> <span class="text-slate-500 text-sm font-bold">Contacts Display</span> <span class="ml-2">Show amounts given</span> <!-- toggle --> <div class="relative ml-2"> <!-- input --> <input type="checkbox" v-model="showContactGives" name="showContactGives" class="sr-only" /> <!-- line --> <div class="block bg-slate-500 w-14 h-8 rounded-full"></div> <!-- dot --> <div class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition" ></div> </div> </label> <div> <h2 class="text-slate-500 text-sm font-bold mt-4">Claim Server</h2> <div class="px-4 py-4"> <input type="text" class="block w-full rounded border border-slate-400 px-4 py-2" v-model="apiServerInput" /> <button v-if="apiServerInput != apiServer" class="w-full px-4 rounded bg-yellow-500 border border-slate-400" @click="onClickSaveApiServer()" > <fa icon="floppy-disk" class="fa-fw" color="white"></fa> </button> <button class="px-3 rounded bg-slate-200 border border-slate-400" @click="apiServerInput = AppConstants.PROD_ENDORSER_API_SERVER" > Use Prod </button> <button class="px-3 rounded bg-slate-200 border border-slate-400" @click="apiServerInput = AppConstants.TEST_ENDORSER_API_SERVER" > Use Test </button> <button class="px-3 rounded bg-slate-200 border border-slate-400" @click="apiServerInput = AppConstants.LOCAL_ENDORSER_API_SERVER" > Use Local </button> </div> <label for="toggleProdWarningMessage" class="flex items-center justify-between cursor-pointer px-4 py-4" @click="toggleProdWarning" > <!-- label --> <h2>Show warning if on prod server</h2> <!-- toggle --> <div class="relative ml-2"> <!-- input --> <input type="checkbox" v-model="warnIfProdServer" class="sr-only" /> <!-- line --> <div class="block bg-slate-500 w-14 h-8 rounded-full"></div> <!-- dot --> <div class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition" ></div> </div> </label> <label for="toggleTestWarningMessage" class="flex items-center justify-between cursor-pointer px-4 py-4" @click="toggleTestWarning" > <!-- label --> <h2>Show warning if on non-prod server</h2> <!-- toggle --> <div class="relative ml-2"> <!-- input --> <input type="checkbox" v-model="warnIfTestServer" class="sr-only" /> <!-- line --> <div class="block bg-slate-500 w-14 h-8 rounded-full"></div> <!-- dot --> <div class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition" ></div> </div> </label> </div> <h2 class="text-slate-500 text-sm font-bold mb-2"> Notification Push Server </h2> <div class="px-3 py-4"> <input type="text" class="block w-full rounded border border-slate-400 px-3 py-2" v-model="webPushServerInput" /> <button v-if="webPushServerInput != webPushServer" class="w-full px-4 rounded bg-yellow-500 border border-slate-400" @click="onClickSavePushServer()" > <fa icon="floppy-disk" class="fa-fw" color="white"></fa> </button> <button class="px-3 rounded bg-slate-200 border border-slate-400" @click="webPushServerInput = AppConstants.PROD_PUSH_SERVER" > Use Prod </button> <button class="px-3 rounded bg-slate-200 border border-slate-400" @click="webPushServerInput = AppConstants.TEST1_PUSH_SERVER" > Use Test 1 </button> <button class="px-3 rounded bg-slate-200 border border-slate-400" @click="webPushServerInput = AppConstants.TEST2_PUSH_SERVER" > Use Test 2 </button> </div> <span class="px-4 text-sm" v-if="!webPushServerInput"> When that setting is blank, this app will use the default web push server URL: {{ DEFAULT_PUSH_SERVER }} </span> <div class="mt-2"> <span class="text-slate-500 text-sm font-bold">Image Server URL</span> <span class="text-sm">{{ DEFAULT_IMAGE_API_SERVER }}</span> </div> <label for="toggleShowShortcutBvc" class="flex items-center justify-between cursor-pointer mt-4" @click="toggleShowShortcutBvc" > <!-- label --> <span class="text-slate-500 text-sm font-bold" >Show BVC Shortcut on Home Page</span > <!-- toggle --> <div class="relative ml-2"> <!-- input --> <input type="checkbox" v-model="showShortcutBvc" class="sr-only" /> <!-- line --> <div class="block bg-slate-500 w-14 h-8 rounded-full"></div> <!-- dot --> <div class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition" ></div> </div> </label> <div class="mt-4"> <h2 class="text-slate-500 text-sm font-bold"> Contacts & Settings Database </h2> <div class="ml-4 mt-2"> Import <input type="file" @change="uploadFile" class="ml-2" /> <div v-if="showContactImport()"> <button class="block text-center text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6" @click="submitFile()" > Import Settings & Contacts <br /> (excluding Identifier Data) </button> </div> </div> </div> <div class="flex mt-4"> <button> <router-link :to="{ name: 'statistics' }" class="block w-fit text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md mb-2" > See Global Animated History of Giving </router-link> </button> </div> </div> </section> </template> <script lang="ts"> import { AxiosError, AxiosRequestConfig } from "axios"; import Dexie from "dexie"; import "dexie-export-import"; import { ImportProgress } from "dexie-export-import/dist/import"; import { ref } from "vue"; import { Component, Vue } from "vue-facing-decorator"; import { useClipboard } from "@vueuse/core"; import GiftedPhotoDialog from "@/components/GiftedPhotoDialog.vue"; import QuickNav from "@/components/QuickNav.vue"; import TopMessage from "@/components/TopMessage.vue"; import { AppString, DEFAULT_IMAGE_API_SERVER, DEFAULT_PUSH_SERVER, NotificationIface, } from "@/constants/app"; import { db, accountsDB } from "@/db/index"; import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; import { accessToken } from "@/libs/crypto"; import { IIdentifier } from "@veramo/core"; import { ErrorResponse, EndorserRateLimits, ImageRateLimits, } from "@/libs/endorserServer"; import { Buffer } from "buffer/"; interface IAccount { did: string; publicKeyHex: string; privateHex?: string; derivationPath: string; } const inputFileNameRef = ref<Blob>(); @Component({ components: { GiftedPhotoDialog, QuickNav, TopMessage }, }) export default class AccountViewView extends Vue { $notify!: (notification: NotificationIface, timeout?: number) => void; AppConstants = AppString; DEFAULT_PUSH_SERVER = DEFAULT_PUSH_SERVER; DEFAULT_IMAGE_API_SERVER = DEFAULT_IMAGE_API_SERVER; activeDid = ""; apiServer = ""; apiServerInput = ""; derivationPath = ""; downloadUrl = ""; // because DuckDuckGo doesn't download on automated call to "click" on the anchor endorserLimits: EndorserRateLimits | null = null; givenName = ""; imageLimits: ImageRateLimits | null = null; isRegistered = false; isSubscribed = false; notificationMaybeChanged = false; profileImageUrl: string | null = null; publicHex = ""; publicBase64 = ""; webPushServer = ""; webPushServerInput = ""; limitsMessage = ""; loadingLimits = false; showContactGives = false; showDidCopy = false; showDerCopy = false; showB64Copy = false; showPubCopy = false; showAdvanced = false; showShortcutBvc = false; subscription: PushSubscription | null = null; warnIfProdServer = false; warnIfTestServer = false; /** * Async function executed when the component is created. * Initializes the component's state with values from the database, * handles identity-related tasks, and checks limitations. * * @throws Will display specific messages to the user based on different errors. */ async created() { try { await db.open(); const settings = await db.settings.get(MASTER_SETTINGS_KEY); // Initialize component state with values from the database or defaults this.initializeState(settings); // Get and process the identity const identity = await this.getIdentity(this.activeDid); if (identity) { this.processIdentity(identity); } } catch (err: unknown) { this.handleError(err); } } async mounted() { try { const registration = await navigator.serviceWorker.ready; this.subscription = await registration.pushManager.getSubscription(); this.isSubscribed = !!this.subscription; } catch (error) { console.error("Mount error:", error); } } beforeUnmount() { if (this.downloadUrl) { URL.revokeObjectURL(this.downloadUrl); } } /** * Initializes component state with values from the database or defaults. * @param {SettingsType} settings - Object containing settings from the database. */ initializeState(settings: Settings | undefined) { this.activeDid = (settings?.activeDid as string) || ""; this.apiServer = (settings?.apiServer as string) || ""; this.apiServerInput = (settings?.apiServer as string) || ""; this.givenName = (settings?.firstName || "") + (settings?.lastName ? ` ${settings.lastName}` : ""); // pre v 0.1.3 this.isRegistered = !!settings?.isRegistered; this.showContactGives = !!settings?.showContactGivesInline; this.showShortcutBvc = !!settings?.showShortcutBvc; this.warnIfProdServer = !!settings?.warnIfProdServer; this.warnIfTestServer = !!settings?.warnIfTestServer; this.webPushServer = (settings?.webPushServer as string) || ""; this.webPushServerInput = (settings?.webPushServer as string) || ""; } public async getIdentity(activeDid: string): Promise<IIdentifier | null> { try { // Open the accounts database await accountsDB.open(); // Search for the account with the matching DID (decentralized identifier) const account: { identity?: string } | undefined = await accountsDB.accounts.where("did").equals(activeDid).first(); // Return parsed identity or null if not found return JSON.parse((account?.identity as string) || "null"); } catch (error) { console.error("Failed to find account:", error); return null; } } /** * Asynchronously retrieves headers for HTTP requests. * * @param {IIdentifier} identity - The identity object for which to generate the headers. * @returns {Promise<Record<string,string>>} A Promise that resolves to an object containing the headers. * * @throws Will throw an error if unable to generate an access token. */ public async getHeaders( identity: IIdentifier, ): Promise<Record<string, string>> { try { const token = await accessToken(identity); const headers: Record<string, string> = { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }; return headers; } catch (error) { console.error("Failed to get headers:", error); return Promise.reject(error); } } // call fn, copy text to the clipboard, then redo fn after 2 seconds doCopyTwoSecRedo(text: string, fn: () => void) { fn(); useClipboard() .copy(text) .then(() => setTimeout(fn, 2000)); } toggleShowContactAmounts() { this.showContactGives = !this.showContactGives; this.updateShowContactAmounts(); } toggleProdWarning() { this.warnIfProdServer = !this.warnIfProdServer; this.updateWarnIfProdServer(this.warnIfProdServer); } toggleTestWarning() { this.warnIfTestServer = !this.warnIfTestServer; this.updateWarnIfTestServer(this.warnIfTestServer); } toggleShowShortcutBvc() { this.showShortcutBvc = !this.showShortcutBvc; this.updateShowShortcutBvc(this.showShortcutBvc); } readableDate(timeStr: string) { return timeStr.substring(0, timeStr.indexOf("T")); } /** * Processes the identity and updates the component's state. * @param {IdentityType} identity - Object containing identity information. */ processIdentity(identity: IIdentifier) { if ( identity && identity.keys && identity.keys.length > 0 && identity.keys[0].meta ) { this.publicHex = identity.keys[0].publicKeyHex; this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64"); this.derivationPath = identity.keys[0].meta?.derivationPath as string; db.settings.update(MASTER_SETTINGS_KEY, { activeDid: identity.did, }); this.checkLimitsFor(identity); } else { // Handle the case where any of these are null or undefined } } async showNotificationChoice() { if (!this.subscription) { this.$notify( { group: "modal", type: "notification-permission", title: "", // unused, only here to satisfy type check text: "", // unused, only here to satisfy type check }, -1, ); } else { this.$notify( { group: "modal", type: "notification-off", title: "", // unused, only here to satisfy type check text: "", // unused, only here to satisfy type check }, -1, ); } this.notificationMaybeChanged = true; } /** * Handles errors and updates the component's state accordingly. * @param {Error} err - The error object. */ handleError(err: unknown) { if ( err instanceof Error && err.message === "Attempted to load account records with no identifier available." ) { this.limitsMessage = "No identifier."; } else { console.error("Telling user to clear cache at page create because:", err); this.$notify( { group: "alert", type: "danger", title: "Error Loading Account", text: "Clear your cache and start over (after data backup).", }, -1, ); } } public async updateShowContactAmounts() { try { await db.open(); db.settings.update(MASTER_SETTINGS_KEY, { showContactGivesInline: this.showContactGives, }); } catch (err) { this.$notify( { group: "alert", type: "danger", title: "Error Updating Contact Setting", text: "The setting may not have saved. Try again, maybe after restarting the app.", }, -1, ); console.error( "Telling user to try again after contact-amounts setting update because:", err, ); } } public async updateWarnIfProdServer(newSetting: boolean) { try { await db.open(); db.settings.update(MASTER_SETTINGS_KEY, { warnIfProdServer: newSetting, }); } catch (err) { this.$notify( { group: "alert", type: "danger", title: "Error Updating Prod Warning", text: "The setting may not have saved. Try again, maybe after restarting the app.", }, -1, ); console.error( "Telling user to try again after prod-server-warning setting update because:", err, ); } } public async updateWarnIfTestServer(newSetting: boolean) { try { await db.open(); db.settings.update(MASTER_SETTINGS_KEY, { warnIfTestServer: newSetting, }); } catch (err) { this.$notify( { group: "alert", type: "danger", title: "Error Updating Test Warning", text: "The setting may not have saved. Try again, maybe after restarting the app.", }, -1, ); console.error( "Telling user to try again after test-server-warning setting update because:", err, ); } } public async updateShowShortcutBvc(newSetting: boolean) { try { await db.open(); db.settings.update(MASTER_SETTINGS_KEY, { showShortcutBvc: newSetting, }); } catch (err) { this.$notify( { group: "alert", type: "danger", title: "Error Updating BVC Shortcut Setting", text: "The setting may not have saved. Try again, maybe after restarting the app.", }, -1, ); console.error( "Telling user to try again after BVC-shortcut setting update because:", err, ); } } /** * Asynchronously exports the database into a downloadable JSON file. * * @throws Will notify the user if there is an export error. */ public async exportDatabase() { try { // Generate the blob from the database const blob = await this.generateDatabaseBlob(); // Create a temporary URL for the blob this.downloadUrl = this.createBlobURL(blob); // Trigger the download this.downloadDatabaseBackup(this.downloadUrl); // Notify the user that the download has started this.notifyDownloadStarted(); // Revoke the temporary URL -- after a pause to avoid DuckDuckGo download failure setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000); } catch (error) { this.handleExportError(error); } } /** * Generates a blob object representing the database. * * @returns {Promise<Blob>} The generated blob object. */ private async generateDatabaseBlob(): Promise<Blob> { return await db.export({ prettyJson: true }); } /** * Creates a temporary URL for a blob object. * * @param {Blob} blob - The blob object. * @returns {string} The temporary URL for the blob. */ private createBlobURL(blob: Blob): string { return URL.createObjectURL(blob); } /** * Triggers the download of the database backup. * * @param {string} url - The temporary URL for the blob. */ private downloadDatabaseBackup(url: string) { const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement; downloadAnchor.href = url; downloadAnchor.download = `${db.name}-backup.json`; downloadAnchor.click(); // doesn't work for some browsers, eg. DuckDuckGo } public computedStartDownloadLinkClassNames() { return { hidden: this.downloadUrl, }; } public computedDownloadLinkClassNames() { return { hidden: !this.downloadUrl, }; } /** * Notifies the user that the download has started. */ private notifyDownloadStarted() { this.$notify( { group: "alert", type: "success", title: "Download Started", text: "See your downloads directory for the backup. It is in the Dexie format.", }, -1, ); } /** * Handles errors during the database export process. * * @param {Error} error - The error object. */ private handleExportError(error: unknown) { this.$notify( { group: "alert", type: "danger", title: "Export Error", text: "See console logs for more info.", }, -1, ); console.error("Export Error:", error); } // eslint-disable-next-line @typescript-eslint/no-explicit-any async uploadFile(event: any) { inputFileNameRef.value = event.target.files[0]; } showContactImport() { return !!inputFileNameRef.value; } /** * Asynchronously imports the database from a downloadable JSON file. * * @throws Will notify the user if there is an export error. */ async submitFile() { if (inputFileNameRef.value != null) { if ( confirm( "This will replace all settings and contacts, so we recommend you first do the backup step above." + " Are you sure you want to import and replace all contacts and settings?", ) ) { await db.delete(); await Dexie.import(inputFileNameRef.value, { progressCallback: this.progressCallback, }); } } } private progressCallback(progress: ImportProgress) { console.log( `Import progress: ${progress.completedRows} of ${progress.totalRows} rows completed.`, ); if (progress.done) { console.log(`Imported ${progress.completedTables} tables.`); this.$notify( { group: "alert", type: "success", title: "Import Complete", text: "", }, 5000, ); } return true; } async checkLimits() { const identity = await this.getIdentity(this.activeDid); if (identity) { this.checkLimitsFor(identity); } else { this.limitsMessage = "You have no identifier, or your data has been corrupted."; } } /** * Asynchronously checks rate limits for the given identity. * * Updates component state variables `limits`, `limitsMessage`, and `loadingLimits`. */ public async checkLimitsFor(identity: IIdentifier) { this.loadingLimits = true; this.limitsMessage = ""; try { const resp = await this.fetchEndorserRateLimits(identity); if (resp.status === 200) { this.endorserLimits = resp.data; if (!this.isRegistered) { // the user was not known to be registered, but now they are (because we got no error) so let's record it try { await db.open(); db.settings.update(MASTER_SETTINGS_KEY, { isRegistered: true, }); this.isRegistered = true; } catch (err) { console.error("Got an error updating settings:", err); this.$notify( { group: "alert", type: "warning", title: "Update Error", text: "Unable to update your settings. Check claim limits again.", }, -1, ); } } const imageResp = await this.fetchImageRateLimits(identity); if (imageResp.status === 200) { this.imageLimits = imageResp.data; } } } catch (error) { this.handleRateLimitsError(error); try { await db.open(); db.settings.update(MASTER_SETTINGS_KEY, { isRegistered: false, }); this.isRegistered = false; } catch (err) { console.error("Got an error marking user not registered:", err); // already set an error notification for the user } } this.loadingLimits = false; } /** * Fetches rate limits from the Endorser server. * * @param {IIdentifier} identity - The identity object to check rate limits for. * @returns {Promise<AxiosResponse>} The Axios response object. */ private async fetchEndorserRateLimits(identity: IIdentifier) { const url = `${this.apiServer}/api/report/rateLimits`; const headers = await this.getHeaders(identity); return await this.axios.get(url, { headers } as AxiosRequestConfig); } /** * Fetches rate limits from the image server. * * @param {IIdentifier} identity - The identity object to check rate limits for. * @returns {Promise<AxiosResponse>} The Axios response object. */ private async fetchImageRateLimits(identity: IIdentifier) { const url = DEFAULT_IMAGE_API_SERVER + "/image-limits"; const headers = await this.getHeaders(identity); return await this.axios.get(url, { headers } as AxiosRequestConfig); } /** * Handles errors that occur while fetching rate limits. * * @param {AxiosError | Error} error - The error object. */ private handleRateLimitsError(error: unknown) { if (error instanceof AxiosError) { const data = error.response?.data as ErrorResponse; this.limitsMessage = (data?.error?.message as string) || "Bad server response."; console.error( "Got bad response retrieving limits, which usually means user isn't registered.", ); //console.error(error); } else { this.limitsMessage = "Got an error retrieving limits."; console.error("Got some error retrieving limits:", error); } } /** * 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: IAccount) { 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(); db.settings.update(MASTER_SETTINGS_KEY, { apiServer: this.apiServerInput, }); this.apiServer = this.apiServerInput; } async onClickSavePushServer() { await db.open(); db.settings.update(MASTER_SETTINGS_KEY, { webPushServer: this.webPushServerInput, }); this.webPushServer = this.webPushServerInput; this.$notify( { group: "alert", type: "warning", title: "Reload", text: "Now reload the app to get a new VAPID to use with this push server.", }, -1, ); } openPhotoDialog() { (this.$refs.photoDialog as GiftedPhotoDialog).open((imgUrl) => { this.profileImageUrl = imgUrl; console.log("Got image URL:", imgUrl); }); } confirmDeleteImage() { this.$notify( { group: "modal", type: "confirm", title: "Are you sure you want to delete your profile picture?", text: "", onYes: this.deleteImage, }, -1, ); } async deleteImage() { if (!this.profileImageUrl) { return; } try { const identity = await this.getIdentity(this.activeDid); if (!identity) { throw Error("No identity found."); } const token = await accessToken(identity); const response = await this.axios.delete( DEFAULT_IMAGE_API_SERVER + "/image/" + encodeURIComponent(this.profileImageUrl), { headers: { Authorization: `Bearer ${token}`, }, }, ); if (response.status === 204) { // don't bother with a notification // (either they'll simply continue or they're canceling and going back) } else { console.error("Non-success deleting image:", response); this.$notify( { group: "alert", type: "danger", title: "Error", text: "There was a problem deleting the image.", }, 5000, ); // keep the imageUrl in localStorage so the user can try again if they want return; } this.profileImageUrl = ""; } catch (error) { console.error("Error deleting image:", error); // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((error as any).response.status === 404) { console.log("The image was already deleted:", error); this.profileImageUrl = ""; // it already doesn't exist so we won't say anything to the user } else { this.$notify( { group: "alert", type: "danger", title: "Error", text: "There was an error deleting the image.", }, 5000, ); } } } } </script>