<template> <QuickNav selected="Profile" /> <TopMessage /> <!-- CONTENT --> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <!-- Heading --> <h1 id="ViewHeading" class="text-4xl text-center font-light"> Your Identity </h1> <!-- ID notice --> <div v-if="!activeDid" id="noticeBeforeShare" class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mt-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 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-4 py-2 rounded-md" > Create An Identifier </router-link> </div> <!-- Identity Details --> <div id="sectionIdentityDetails" class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mt-4" > <div v-if="givenName"> <h2 class="text-xl font-semibold mb-2"> <span class="whitespace-nowrap"> <router-link :to="{ name: 'contact-qr' }" class="bg-slate-500 text-white px-1.5 py-1 rounded-md" > <fa icon="qrcode" class="fa-fw text-xl"></fa> </router-link> </span> {{ givenName }} <router-link :to="{ name: 'new-edit-account' }"> <fa icon="pen" class="text-xs text-blue-500 ml-2 mb-1"></fa> </router-link> </h2> </div> <span v-else class="block w-full text-center text-md bg-amber-200 border border-dashed border-slate-400 px-1.5 py-2 rounded-md mb-2" > <button @click=" () => (this.$refs.userNameDialog as UserNameDialog).open( (name) => (this.givenName = name), ) " class="inline-block 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-4 py-2 rounded-md" > Set Your Name </button> <UserNameDialog ref="userNameDialog" /> </span> <div class="flex justify-center mt-4"> <span v-if="profileImageUrl" class="flex justify-between"> <EntityIcon :icon-size="96" :profileImageUrl="profileImageUrl" class="inline-block align-text-bottom border border-slate-300 rounded" @click="showLargeIdenticonUrl = profileImageUrl" /> <fa icon="trash-can" @click="confirmDeleteImage" class="text-red-500 fa-fw ml-8 mt-8 w-12 h-12" /> </span> <div v-else class="text-center"> <div class @click="openImageDialog()"> <fa icon="image-portrait" 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-l" /> <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-r" /> </div> </div> <ImageMethodDialog ref="imageMethodDialog" /> </div> <div class="mt-6"> <div class="flex justify-center text-center"> People {{ profileImageUrl ? "without your image" : "" }} see this <br /> (if you've let them see your activity): </div> <div class="flex justify-center"> <EntityIcon :entityId="activeDid" :iconSize="64" class="inline-block align-middle border border-slate-300 rounded-md mr-1" @click="showLargeIdenticonId = activeDid" /> </div> </div> <div v-if="showLargeIdenticonId || showLargeIdenticonUrl" class="fixed z-[100] top-0 inset-x-0 w-full" > <div class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50" > <EntityIcon :entityId="showLargeIdenticonId" :iconSize="512" :profileImageUrl="showLargeIdenticonUrl" class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg" @click=" showLargeIdenticonId = undefined; showLargeIdenticonUrl = undefined; " /> </div> </div> <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" data-testId="didWrapper" > <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 class="text-blue-500 text-sm font-bold"> <router-link :to="{ path: '/did/' + encodeURIComponent(activeDid) }"> Your Activity </router-link> </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="!isRegistered" id="noticeBeforeAnnounce" class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mt-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-blue-400 to-blue-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 v-if="isRegistered" id="sectionNotifications" 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 class="flex items-center justify-between"> <!-- label --> <div> Reminder Notification <fa icon="question-circle" class="text-slate-400 fa-fw ml-2 cursor-pointer" @click.stop="showReminderNotificationInfo" /> </div> <!-- toggle --> <div class="relative ml-2 cursor-pointer" @click="showReminderNotificationChoice()" > <!-- input --> <input type="checkbox" v-model="notifyingReminder" 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-if="notifyingReminder" class="w-full flex justify-between"> <span class="ml-8 mr-8">Message: "{{ notifyingReminderMessage }}"</span> <span>{{ notifyingReminderTime.replace(" ", " ") }}</span> </div> <div class="mt-2 flex items-center justify-between"> <!-- label --> <div> New Activity Notification <fa icon="question-circle" class="text-slate-400 fa-fw ml-2 cursor-pointer" @click.stop="showNewActivityNotificationInfo" /> </div> <!-- toggle --> <div class="relative ml-2 cursor-pointer" @click="showNewActivityNotificationChoice()" > <!-- input --> <input type="checkbox" v-model="notifyingNewActivity" 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-if="notifyingNewActivityTime" class="w-full text-right"> {{ notifyingNewActivityTime.replace(" ", " ") }} </div> <router-link class="pl-4 text-sm text-blue-500" to="/help-notifications"> Troubleshoot your notifications. </router-link> </div> <PushNotificationPermission ref="pushNotificationPermission" /> <div id="sectionSearchLocation" class="flex justify-between bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8" > <!-- label --> <span class="mb-2 font-bold">Location for Searches</span> <router-link :to="{ name: 'search-area' }" class="text-m 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-2" > {{ isSearchAreasSet ? "Change" : "Set" }} Search Area… </router-link> </div> <!-- User Profile --> <div v-if="isRegistered" class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8" > <div v-if="loadingProfile" class="text-center mb-2"> <fa icon="spinner" class="fa-spin text-slate-400"></fa> Loading profile... </div> <div v-else class="flex items-center mb-2"> <span class="font-bold">Public Profile</span> <fa icon="circle-info" class="text-slate-400 fa-fw ml-2 cursor-pointer" @click="showProfileInfo" /> </div> <textarea v-model="userProfileDesc" class="w-full h-32 p-2 border border-slate-300 rounded-md" placeholder="Write something about yourself for the public..." :readonly="loadingProfile || savingProfile" :class="{ 'bg-slate-100': loadingProfile || savingProfile }" ></textarea> <div class="flex items-center mb-4" @click="toggleUserProfileLocation"> <input type="checkbox" class="mr-2" v-model="includeUserProfileLocation" /> <label for="includeUserProfileLocation">Include Location</label> </div> <div v-if="includeUserProfileLocation" class="mb-4 aspect-video"> <p class="text-sm mb-2 text-slate-500"> For your security, choose a location nearby but not exactly at your place. </p> <l-map ref="profileMap" class="!z-40 rounded-md" @click=" (event: LeafletMouseEvent) => { userProfileLatitude = event.latlng.lat; userProfileLongitude = event.latlng.lng; } " @ready="onMapReady" > <l-tile-layer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" layer-type="base" name="OpenStreetMap" /> <l-marker v-if="userProfileLatitude && userProfileLongitude" :lat-lng="[userProfileLatitude, userProfileLongitude]" @click="confirmEraseLatLong()" /> </l-map> </div> <div v-if="!loadingProfile && !savingProfile"> <div class="flex justify-between items-center"> <button @click="saveProfile" class="mt-2 px-4 py-2 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-md" :disabled="loadingProfile || savingProfile" :class="{ 'opacity-50 cursor-not-allowed': loadingProfile || savingProfile, }" > Save Profile </button> <button @click="confirmDeleteProfile" class="mt-2 px-4 py-2 bg-gradient-to-b from-red-400 to-red-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-md" :disabled="loadingProfile || savingProfile" :class="{ 'opacity-50 cursor-not-allowed': loadingProfile || savingProfile || (!userProfileDesc && !includeUserProfileLocation), }" > Delete Profile </button> </div> </div> <div v-else-if="loadingProfile">Loading...</div> <div v-else>Saving...</div> </div> <div v-if="activeDid" id="sectionUsageLimits" 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 class="mb-4 text-center"> {{ limitsMessage }} </div> <div> <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 this month. <i>(You cannot register anyone on your first day.)</i> Your registration counter resets at <b class="whitespace-nowrap"> {{ readableDate(endorserLimits?.nextMonthBeginDateTime) }} </b> </p> <p class="mt-3 text-sm"> 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 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 id="sectionDataExport" 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 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-2 mt-2" > Backup Identifier Seed </router-link> <button v-bind:class="computedStartDownloadLinkClassNames()" class="block w-full text-center text-md 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" @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 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 class="mt-4"> <p> After the download, you can save the file in your preferred storage location. </p> <ul> <li class="list-disc list-outside ml-4"> On iOS: Choose "More..." and select a place in iCloud, or go "Back" and save to another location. </li> <li class="list-disc list-outside ml-4"> On Android: Choose "Open" and then share <fa icon="share-nodes" class="fa-fw" /> to your prefered place. </li> </ul> </div> </div> <!-- id used by puppeteer test script --> <h3 id="advanced" class="text-sm uppercase font-semibold mb-3" @click="showAdvanced = !showAdvanced" > Advanced </h3> <div id="sectionAdvanced" v-if="showAdvanced || showGeneralAdvanced"> <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 id="sectionDeepIdentifier" 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 v-if="derivationPath" 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 v-else class="text-sm text-slate-500 flex justify-start items-center mb-1" > (none) </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> <div id="sectionImportContactsSettings" class="mt-4"> <h2 class="text-slate-500 text-sm font-bold"> Import Contacts & Settings Database </h2> <div class="ml-4 mt-2"> <input type="file" @change="uploadImportFile" class="ml-2" /> <transition enter-active-class="transform ease-out duration-300 transition" enter-from-class="translate-y-2 opacity-0 sm:translate-y-4" enter-to-class="translate-y-0 opacity-100 sm:translate-y-0" leave-active-class="transition ease-in duration-500" leave-from-class="opacity-100" leave-to-class="opacity-0" > <div v-if="showContactImport()" class="mt-4"> <div class="flex justify-center"> <button class="block text-center text-md 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="confirmSubmitImportFile()" > Overwrite Settings & Contacts <br /> (which doesn't include Identifier Data) </button> </div> <div class="flex justify-center"> <button class="block text-center text-md 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="checkContactImports()" > Import Only Contacts <br /> after comparing </button> </div> </div> </transition> </div> </div> <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 hours given & received</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 id="sectionClaimServer"> <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> <h2 class="text-slate-500 text-sm font-bold mb-2">Partner Server URL</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="partnerApiServerInput" /> <button v-if="partnerApiServerInput != partnerApiServer" class="w-full px-4 rounded bg-yellow-500 border border-slate-400" @click="onClickSavePartnerServer()" > <fa icon="floppy-disk" class="fa-fw" color="white"></fa> </button> <button class="px-3 rounded bg-slate-200 border border-slate-400" @click="partnerApiServerInput = AppConstants.PROD_PARTNER_API_SERVER" > Use Prod </button> <button class="px-3 rounded bg-slate-200 border border-slate-400" @click="partnerApiServerInput = AppConstants.TEST_PARTNER_API_SERVER" > Use Test </button> <button class="px-3 rounded bg-slate-200 border border-slate-400" @click="partnerApiServerInput = AppConstants.LOCAL_PARTNER_API_SERVER" > Use Local </button> </div> <span class="px-4 text-sm" v-if="!partnerApiServerInput"> When that setting is blank, this app will use the default partner server URL: {{ DEFAULT_PARTNER_API_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="toggleHideRegisterPromptOnNewContact" class="flex items-center justify-between cursor-pointer mt-4" @click="toggleHideRegisterPromptOnNewContact()" > <!-- label --> <span class="text-slate-500 text-sm font-bold"> Hide Register Prompt on New Contact </span> <!-- toggle --> <div class="relative ml-2"> <!-- input --> <input type="checkbox" v-model="hideRegisterPromptOnNewContact" class="sr-only" /> <!-- line --> <div class="block bg-slate-500 w-14 h-8 rounded-full" /> <!-- dot --> <div class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition" /> </div> </label> <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" /> <!-- dot --> <div class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition" /> </div> </label> <div class="flex mt-4"> <button> <router-link :to="{ name: 'statistics' }" class="block w-fit text-center text-md 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 id="sectionPasskeyExpiration" class="flex justify-between"> <span> <span class="text-slate-500 text-sm font-bold mb-2"> Passkey Expiration Minutes </span> <br /> <span class="text-sm ml-2"> {{ passkeyExpirationDescription }} </span> </span> <div class="relative ml-2"> <input type="number" class="border border-slate-400 rounded px-2 py-2 text-center w-20" v-model="passkeyExpirationMinutes" @change="updatePasskeyExpiration" /> </div> </div> <label for="toggleShowGeneralAdvanced" class="flex items-center justify-between cursor-pointer mt-4" @click="toggleShowGeneralAdvanced" > <!-- label --> <span class="text-slate-500 text-sm font-bold"> Show All General Advanced Functions </span> <!-- toggle --> <div class="relative ml-2"> <!-- input --> <input type="checkbox" v-model="showGeneralAdvanced" class="sr-only" /> <!-- line --> <div class="block bg-slate-500 w-14 h-8 rounded-full" /> <!-- dot --> <div class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition" /> </div> </label> </div> </section> </template> <script lang="ts"> import "leaflet/dist/leaflet.css"; import { AxiosError } from "axios"; import { Buffer } from "buffer/"; import Dexie from "dexie"; import "dexie-export-import"; import { importDB, ImportProgress } from "dexie-export-import"; import { LeafletMouseEvent } from "leaflet"; import * as R from "ramda"; import { IIdentifier } from "@veramo/core"; import { ref } from "vue"; import { Component, Vue } from "vue-facing-decorator"; import { Router } from "vue-router"; import { useClipboard } from "@vueuse/core"; import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet"; import EntityIcon from "../components/EntityIcon.vue"; import ImageMethodDialog from "../components/ImageMethodDialog.vue"; import PushNotificationPermission from "../components/PushNotificationPermission.vue"; import QuickNav from "../components/QuickNav.vue"; import TopMessage from "../components/TopMessage.vue"; import UserNameDialog from "../components/UserNameDialog.vue"; import { AppString, DEFAULT_IMAGE_API_SERVER, DEFAULT_PARTNER_API_SERVER, DEFAULT_PUSH_SERVER, IMAGE_TYPE_PROFILE, NotificationIface, } from "../constants/app"; import { db, logConsoleAndDb, retrieveSettingsForActiveAccount, updateAccountSettings, } from "../db/index"; import { Account } from "../db/tables/accounts"; import { Contact } from "../db/tables/contacts"; import { DEFAULT_PASSKEY_EXPIRATION_MINUTES, MASTER_SETTINGS_KEY, } from "../db/tables/settings"; import { clearPasskeyToken, EndorserRateLimits, ErrorResponse, errorStringForLog, fetchEndorserRateLimits, fetchImageRateLimits, getHeaders, ImageRateLimits, tokenExpiryTimeDescription, } from "../libs/endorserServer"; import { DAILY_CHECK_TITLE, DIRECT_PUSH_TITLE, retrieveAccountMetadata, } from "../libs/util"; const inputImportFileNameRef = ref<Blob>(); @Component({ components: { EntityIcon, ImageMethodDialog, LeafletMouseEvent, LMap, LMarker, LTileLayer, PushNotificationPermission, QuickNav, TopMessage, UserNameDialog, }, }) 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; DEFAULT_PARTNER_API_SERVER = DEFAULT_PARTNER_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 = ""; hideRegisterPromptOnNewContact = false; imageLimits: ImageRateLimits | null = null; includeUserProfileLocation = false; isRegistered = false; isSearchAreasSet = false; limitsMessage = ""; loadingLimits = false; loadingProfile = true; notifyingNewActivity = false; notifyingNewActivityTime = ""; notifyingReminder = false; notifyingReminderMessage = ""; notifyingReminderTime = ""; partnerApiServer = DEFAULT_PARTNER_API_SERVER; partnerApiServerInput = DEFAULT_PARTNER_API_SERVER; passkeyExpirationDescription = ""; passkeyExpirationMinutes = DEFAULT_PASSKEY_EXPIRATION_MINUTES; previousPasskeyExpirationMinutes = DEFAULT_PASSKEY_EXPIRATION_MINUTES; profileImageUrl?: string; publicHex = ""; publicBase64 = ""; savingProfile = false; showAdvanced = false; showB64Copy = false; showContactGives = false; showDidCopy = false; showDerCopy = false; showGeneralAdvanced = false; showLargeIdenticonId?: string; showLargeIdenticonUrl?: string; showPubCopy = false; showShortcutBvc = false; subscription: PushSubscription | null = null; warnIfProdServer = false; warnIfTestServer = false; webPushServer = DEFAULT_PUSH_SERVER; webPushServerInput = DEFAULT_PUSH_SERVER; userProfileDesc = ""; userProfileLatitude = 0; userProfileLongitude = 0; zoom = 2; /** * Async function executed when the component is mounted. * 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 mounted() { try { // Initialize component state with values from the database or defaults await this.initializeState(); await this.processIdentity(); // Load the user profile if (this.isRegistered) { try { const headers = await getHeaders(this.activeDid); const response = await this.axios.get( this.apiServer + "/api/partner/userProfileForIssuer/" + this.activeDid, { headers }, ); if (response.status === 200) { this.userProfileDesc = response.data.data.description || ""; this.userProfileLatitude = response.data.data.locLat || 0; this.userProfileLongitude = response.data.data.locLon || 0; if (this.userProfileLatitude && this.userProfileLongitude) { this.includeUserProfileLocation = true; } } else { // won't get here because axios throws an error instead throw Error("Unable to load profile."); } } catch (error) { if (error.status === 404) { // this is ok: the profile is not yet created } else { logConsoleAndDb( "Error loading profile: " + errorStringForLog(error), ); this.$notify( { group: "alert", type: "danger", title: "Error Loading Profile", text: "Your server profile is not available.", }, 5000, ); } } finally { this.loadingProfile = false; } } } catch (error) { // this can happen when running automated tests in dev mode because notifications don't work console.error( "Telling user to clear cache at page create because:", error, ); // this sometimes gives different information on the error console.error( "To repeat with concatenated error: telling user to clear cache at page create because: " + error, ); this.$notify( { group: "alert", type: "danger", title: "Error Loading Profile", text: "See the Help page about errors with your personal data.", }, 5000, ); } try { /** * Beware! I've seen where this "ready" never resolves. */ const registration = await navigator.serviceWorker?.ready; this.subscription = await registration.pushManager.getSubscription(); if (!this.subscription) { if (this.notifyingNewActivity || this.notifyingReminder) { // the app thought there was a subscription but there isn't, so fix the settings this.turnOffNotifyingFlags(); } } // console.log("Got to the end of 'mounted' call in AccountViewView."); /** * Beware! I've seen where we never get to this point because "ready" never resolves. */ } catch (error) { this.$notify( { group: "alert", type: "warning", title: "Cannot Set Notifications", text: "This browser does not support notifications. Use Chrome, or install this to the home screen, or try other suggestions on the 'Troubleshoot your notifications' page.", }, 3000, ); } this.passkeyExpirationDescription = tokenExpiryTimeDescription(); } beforeUnmount() { if (this.downloadUrl) { URL.revokeObjectURL(this.downloadUrl); } } /** * Initializes component state with values from the database or defaults. */ async initializeState() { await db.open(); const settings = await retrieveSettingsForActiveAccount(); this.activeDid = settings.activeDid || ""; this.apiServer = settings.apiServer || ""; this.apiServerInput = settings.apiServer || ""; this.givenName = (settings?.firstName || "") + (settings?.lastName ? ` ${settings.lastName}` : ""); // pre v 0.1.3 this.hideRegisterPromptOnNewContact = !!settings.hideRegisterPromptOnNewContact; this.isRegistered = !!settings?.isRegistered; this.isSearchAreasSet = !!settings.searchBoxes; this.notifyingNewActivity = !!settings.notifyingNewActivityTime; this.notifyingNewActivityTime = settings.notifyingNewActivityTime || ""; this.notifyingReminder = !!settings.notifyingReminderTime; this.notifyingReminderMessage = settings.notifyingReminderMessage || ""; this.notifyingReminderTime = settings.notifyingReminderTime || ""; this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer; this.partnerApiServerInput = settings.partnerApiServer || this.partnerApiServerInput; this.profileImageUrl = settings.profileImageUrl; this.showContactGives = !!settings.showContactGivesInline; this.passkeyExpirationMinutes = settings.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES; this.previousPasskeyExpirationMinutes = this.passkeyExpirationMinutes; this.showGeneralAdvanced = !!settings.showGeneralAdvanced; this.showShortcutBvc = !!settings.showShortcutBvc; this.warnIfProdServer = !!settings.warnIfProdServer; this.warnIfTestServer = !!settings.warnIfTestServer; this.webPushServer = settings.webPushServer || this.webPushServer; this.webPushServerInput = settings.webPushServer || this.webPushServerInput; } // 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)); } async toggleShowContactAmounts() { this.showContactGives = !this.showContactGives; await db.open(); await db.settings.update(MASTER_SETTINGS_KEY, { showContactGivesInline: this.showContactGives, }); } async toggleShowGeneralAdvanced() { this.showGeneralAdvanced = !this.showGeneralAdvanced; await db.open(); await db.settings.update(MASTER_SETTINGS_KEY, { showGeneralAdvanced: this.showGeneralAdvanced, }); } async toggleProdWarning() { this.warnIfProdServer = !this.warnIfProdServer; await db.open(); await db.settings.update(MASTER_SETTINGS_KEY, { warnIfProdServer: this.warnIfProdServer, }); } async toggleTestWarning() { this.warnIfTestServer = !this.warnIfTestServer; await db.open(); await db.settings.update(MASTER_SETTINGS_KEY, { warnIfTestServer: this.warnIfTestServer, }); } async toggleShowShortcutBvc() { this.showShortcutBvc = !this.showShortcutBvc; await db.open(); await db.settings.update(MASTER_SETTINGS_KEY, { showShortcutBvc: this.showShortcutBvc, }); } readableDate(timeStr: string) { return timeStr ? timeStr.substring(0, timeStr.indexOf("T")) : "?"; } /** * Processes the identity and updates the component's state. */ async processIdentity() { 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; this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64"); this.derivationPath = identity.keys[0].meta?.derivationPath as string; await this.checkLimits(); } else if (account?.publicKeyHex) { this.publicHex = account.publicKeyHex as string; this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64"); await this.checkLimits(); } } async showNewActivityNotificationInfo() { this.$notify( { group: "modal", type: "confirm", title: "New Activity Notification", text: ` This will only notify you when there is new relevant activity for you personally. Note that it runs on your device and many factors may affect delivery, so if you want a reliable but simple daily notification then choose a 'Reminder'. Do you want more details? `, onYes: async () => { await (this.$router as Router).push({ name: "help-notification-types", }); }, yesText: "tell me more.", }, -1, ); } async showNewActivityNotificationChoice() { if (!this.notifyingNewActivity) { ( this.$refs.pushNotificationPermission as PushNotificationPermission ).open(DAILY_CHECK_TITLE, async (success: boolean, timeText: string) => { if (success) { await db.settings.update(MASTER_SETTINGS_KEY, { notifyingNewActivityTime: timeText, }); this.notifyingNewActivity = true; this.notifyingNewActivityTime = timeText; } }); } else { this.$notify( { group: "modal", type: "notification-off", title: DAILY_CHECK_TITLE, // repurposed to indicate the type of notification text: "", // unused, only here to satisfy type check callback: async (success) => { if (success) { await db.settings.update(MASTER_SETTINGS_KEY, { notifyingNewActivityTime: "", }); this.notifyingNewActivity = false; this.notifyingNewActivityTime = ""; } }, }, -1, ); } } async showReminderNotificationInfo() { this.$notify( { group: "modal", type: "confirm", title: "Reminder Notification", text: ` This will notify you at a specific time each day. Note that it does not give you personalized notifications, so if you want less reliable but personalized notification then choose a 'New Activity' Notification. Do you want more details? `, onYes: async () => { await (this.$router as Router).push({ name: "help-notification-types", }); }, yesText: "tell me more.", }, -1, ); } async showReminderNotificationChoice() { if (!this.notifyingReminder) { ( this.$refs.pushNotificationPermission as PushNotificationPermission ).open( DIRECT_PUSH_TITLE, async (success: boolean, timeText: string, message?: string) => { if (success) { await db.settings.update(MASTER_SETTINGS_KEY, { notifyingReminderMessage: message, notifyingReminderTime: timeText, }); this.notifyingReminder = true; this.notifyingReminderMessage = message || ""; this.notifyingReminderTime = timeText; } }, ); } else { this.$notify( { group: "modal", type: "notification-off", title: DIRECT_PUSH_TITLE, // repurposed to indicate the type of notification text: "", // unused, only here to satisfy type check callback: async (success) => { if (success) { await db.settings.update(MASTER_SETTINGS_KEY, { notifyingReminderMessage: "", notifyingReminderTime: "", }); this.notifyingReminder = false; this.notifyingReminderMessage = ""; this.notifyingReminderTime = ""; } }, }, -1, ); } } public async toggleHideRegisterPromptOnNewContact() { const newSetting = !this.hideRegisterPromptOnNewContact; await db.open(); await db.settings.update(MASTER_SETTINGS_KEY, { hideRegisterPromptOnNewContact: newSetting, }); this.hideRegisterPromptOnNewContact = newSetting; } public async updatePasskeyExpiration() { await db.open(); await db.settings.update(MASTER_SETTINGS_KEY, { passkeyExpirationMinutes: this.passkeyExpirationMinutes, }); clearPasskeyToken(); this.passkeyExpirationDescription = tokenExpiryTimeDescription(); } public async turnOffNotifyingFlags() { // should tell the push server as well await db.open(); await db.settings.update(MASTER_SETTINGS_KEY, { notifyingNewActivityTime: "", notifyingReminderMessage: "", notifyingReminderTime: "", }); this.notifyingNewActivity = false; this.notifyingNewActivityTime = ""; this.notifyingReminder = false; this.notifyingReminderMessage = ""; this.notifyingReminderTime = ""; } /** * 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) { console.error("Export Error:", error); this.$notify( { group: "alert", type: "danger", title: "Export Error", text: "There was an error exporting the data.", }, 3000, ); } async uploadImportFile(event: Event) { inputImportFileNameRef.value = (event.target as EventTarget).files[0]; } showContactImport() { return !!inputImportFileNameRef.value; } confirmSubmitImportFile() { if (inputImportFileNameRef.value != null) { this.$notify( { group: "modal", type: "confirm", title: "Replace All", text: "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?", onYes: this.submitImportFile, }, -1, ); } } /** * Asynchronously imports the database from a downloadable JSON file. * * @throws Will notify the user if there is an export error. */ async submitImportFile() { if (inputImportFileNameRef.value != null) { await db.delete(); await Dexie.import(inputImportFileNameRef.value as Blob, { progressCallback: this.progressCallback, }); } } async checkContactImports() { const reader = new FileReader(); reader.onload = (event) => { const fileContent: string = (event.target?.result as string) || "{}"; try { const contents = JSON.parse(fileContent); const contactTableRows: Array<Contact> = ( contents.data?.data as [{ tableName: string; rows: Array<Contact> }] )?.find((table) => table.tableName === "contacts") ?.rows as Array<Contact>; const contactRows = contactTableRows.map( // @ts-expect-error for omitting this field that is found in the Dexie format (contact) => R.omit(["$types"], contact) as Contact, ); (this.$router as Router).push({ name: "contact-import", query: { contacts: JSON.stringify(contactRows) }, }); } catch (error) { console.error("Error checking contact imports:", error); this.$notify( { group: "alert", type: "danger", title: "Error Importing", text: "There was an error reading that Dexie file.", }, 3000, ); } }; reader.readAsText(inputImportFileNameRef.value as Blob); } 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() { if (this.activeDid) { this.checkLimitsFor(this.activeDid); } else { this.limitsMessage = "You have no identifier, or your data has been corrupted."; } } /** * Use "checkLimits" instead. * * Asynchronously checks rate limits for the given identity. * * Updates component state variables `limits`, `limitsMessage`, and `loadingLimits`. */ private async checkLimitsFor(did: string) { this.loadingLimits = true; this.limitsMessage = ""; try { const resp = await fetchEndorserRateLimits( this.apiServer, this.axios, did, ); 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 updateAccountSettings(did, { isRegistered: true }); this.isRegistered = true; } catch (err) { console.error("Got an error updating settings:", err); this.$notify( { group: "alert", type: "danger", title: "Update Error", text: "Unable to update your settings. Check claim limits again.", }, 5000, ); } } try { const imageResp = await fetchImageRateLimits(this.axios, did); if (imageResp.status === 200) { this.imageLimits = imageResp.data; } else { this.limitsMessage = "You don't have access to upload images."; } } catch { this.limitsMessage = "You cannot upload images."; } } } catch (error) { this.handleRateLimitsError(error); } this.loadingLimits = false; } /** * Handles errors that occur while fetching rate limits. * * @param {AxiosError | Error} error - The error object. */ private handleRateLimitsError(error: unknown) { if (error instanceof AxiosError) { if (error.status == 400 || error.status == 404) { // no worries: they probably just aren't registered and don't have any limits console.log( "Got 400 or 404 response retrieving limits which probably means they're not registered:", error, ); this.limitsMessage = "No limits were found, so no actions are allowed."; } else { const data = error.response?.data as ErrorResponse; this.limitsMessage = (data?.error?.message as string) || "Bad server response."; console.error("Got bad response retrieving limits:", error); } } else { this.limitsMessage = "Got an error retrieving limits."; console.error("Got some error retrieving limits:", error); } } async onClickSaveApiServer() { await db.open(); await db.settings.update(MASTER_SETTINGS_KEY, { apiServer: this.apiServerInput, }); this.apiServer = this.apiServerInput; } async onClickSavePartnerServer() { await db.open(); await db.settings.update(MASTER_SETTINGS_KEY, { partnerApiServer: this.partnerApiServerInput, }); this.partnerApiServer = this.partnerApiServerInput; } async onClickSavePushServer() { await db.open(); await 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.", }, 5000, ); } openImageDialog() { (this.$refs.imageMethodDialog as ImageMethodDialog).open( async (imgUrl) => { await db.open(); await db.settings.update(MASTER_SETTINGS_KEY, { profileImageUrl: imgUrl, }); this.profileImageUrl = imgUrl; //console.log("Got image URL:", imgUrl); }, IMAGE_TYPE_PROFILE, true, ); } confirmDeleteImage() { this.$notify( { group: "modal", type: "confirm", title: "Note that anyone with you already as a contact will no longer see a picture, and you will have to reshare your data with them if you save a new picture. Are you sure you want to delete your profile picture?", text: "", onYes: this.deleteImage, }, -1, ); } async deleteImage() { if (!this.profileImageUrl) { return; } try { const headers = await getHeaders(this.activeDid); this.passkeyExpirationDescription = tokenExpiryTimeDescription(); if ( window.location.hostname === "localhost" && !DEFAULT_IMAGE_API_SERVER.includes("localhost") ) { console.log( "Using shared image API server, so only users on that server can play with images.", ); } const response = await this.axios.delete( DEFAULT_IMAGE_API_SERVER + "/image/" + encodeURIComponent(this.profileImageUrl), { headers }, ); 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. Contact support if you want it removed from the servers.", }, 5000, ); // keep the imageUrl in localStorage so the user can try again if they want } await db.open(); await db.settings.update(MASTER_SETTINGS_KEY, { profileImageUrl: undefined, }); this.profileImageUrl = undefined; } 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.error("The image was already deleted:", error); await updateAccountSettings(this.activeDid, { profileImageUrl: undefined, }); this.profileImageUrl = undefined; // 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.", }, 3000, ); } } } onMapReady(map: L.Map) { // doing this here instead of on the l-map element avoids a recentering after a drag then zoom at startup const zoom = this.userProfileLatitude && this.userProfileLongitude ? 12 : 2; map.setView([this.userProfileLatitude, this.userProfileLongitude], zoom); } showProfileInfo() { this.$notify( { group: "alert", type: "info", title: "Public Profile Information", text: "This data will be published for all to see, so be careful what your write. Your ID will only be shared with people who you allow to see your activity.", }, 7000, ); } async saveProfile() { this.savingProfile = true; try { const headers = await getHeaders(this.activeDid); const payload: UserProfile = { description: this.userProfileDesc, }; if (this.userProfileLatitude && this.userProfileLongitude) { payload.locLat = this.userProfileLatitude; payload.locLon = this.userProfileLongitude; } else if (this.includeUserProfileLocation) { this.$notify( { group: "alert", type: "toast", title: "", text: "No profile location is saved.", }, 3000, ); } const response = await this.axios.post( this.apiServer + "/api/partner/userProfile", payload, { headers }, ); if (response.status === 201) { this.$notify( { group: "alert", type: "success", title: "Profile Saved", text: "Your profile has been updated successfully.", }, 3000, ); } else { // won't get here because axios throws an error on non-success throw Error("Profile not saved"); } } catch (error) { logConsoleAndDb("Error saving profile: " + errorStringForLog(error)); const errorMessage: string = error.response?.data?.error?.message || error.response?.data?.error || error.message || "There was an error saving your profile."; this.$notify( { group: "alert", type: "danger", title: "Error Saving Profile", text: errorMessage, }, 3000, ); } finally { this.savingProfile = false; } } toggleUserProfileLocation() { this.includeUserProfileLocation = !this.includeUserProfileLocation; if (!this.includeUserProfileLocation) { this.userProfileLatitude = 0; this.userProfileLongitude = 0; this.zoom = 2; } } confirmEraseLatLong() { this.$notify( { group: "modal", type: "confirm", title: "Erase Marker", text: "Are you sure you don't want to mark a location? This will erase the current location.", onYes: async () => { this.eraseLatLong(); }, }, -1, ); } eraseLatLong() { this.userProfileLatitude = 0; this.userProfileLongitude = 0; this.zoom = 2; this.includeUserProfileLocation = false; } async confirmDeleteProfile() { this.$notify( { group: "modal", type: "confirm", title: "Delete Profile", text: "Are you sure you want to delete your public profile? This will remove your description and location from the server, and it cannot be undone.", onYes: this.deleteProfile, }, -1, ); } async deleteProfile() { this.savingProfile = true; try { const headers = await getHeaders(this.activeDid); const response = await this.axios.delete( this.apiServer + "/api/partner/userProfile", { headers }, ); if (response.status === 204) { this.userProfileDesc = ""; this.userProfileLatitude = 0; this.userProfileLongitude = 0; this.includeUserProfileLocation = false; this.$notify( { group: "alert", type: "success", title: "Profile Deleted", text: "Your profile has been deleted successfully.", }, 3000, ); } else { throw Error("Profile not deleted"); } } catch (error) { logConsoleAndDb("Error deleting profile: " + errorStringForLog(error)); const errorMessage: string = error.response?.data?.error?.message || error.response?.data?.error || error.message || "There was an error deleting your profile."; this.$notify( { group: "alert", type: "danger", title: "Error Deleting Profile", text: errorMessage, }, 3000, ); } finally { this.savingProfile = false; } } } </script>