You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1661 lines
54 KiB
1661 lines
54 KiB
<template>
|
|
<QuickNav selected="Profile" />
|
|
<TopMessage />
|
|
|
|
<!-- CONTENT -->
|
|
<main
|
|
id="Content"
|
|
class="p-6 pb-24 max-w-3xl mx-auto"
|
|
role="main"
|
|
aria-label="Account Profile"
|
|
>
|
|
<!-- 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"
|
|
role="alert"
|
|
aria-live="polite"
|
|
>
|
|
<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 -->
|
|
<IdentitySection
|
|
:given-name="givenName"
|
|
:profile-image-url="profileImageUrl"
|
|
:active-did="activeDid"
|
|
:is-registered="isRegistered"
|
|
:show-large-identicon-id="showLargeIdenticonId"
|
|
:show-large-identicon-url="showLargeIdenticonUrl"
|
|
:show-did-copy="showDidCopy"
|
|
@edit-name="onEditName"
|
|
@show-qr-code="onShowQrCode"
|
|
@add-image="onAddImage"
|
|
@delete-image="onDeleteImage"
|
|
@show-large-identicon-id="onShowLargeIdenticonId"
|
|
@show-large-identicon-url="onShowLargeIdenticonUrl"
|
|
@close-large-identicon="onCloseLargeIdenticon"
|
|
@copy-did="onCopyDid"
|
|
/>
|
|
|
|
<!-- Registration notice -->
|
|
<RegistrationNotice
|
|
:is-registered="isRegistered"
|
|
:show="showRegistrationNotice"
|
|
@share-info="onShareInfo"
|
|
/>
|
|
|
|
<section
|
|
v-if="isRegistered"
|
|
id="sectionNotifications"
|
|
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
|
|
aria-labelledby="notificationsHeading"
|
|
>
|
|
<h2 id="notificationsHeading" class="mb-2 font-bold">Notifications</h2>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
Reminder Notification
|
|
<button
|
|
class="text-slate-400 fa-fw cursor-pointer"
|
|
aria-label="Learn more about reminder notifications"
|
|
@click.stop="showReminderNotificationInfo"
|
|
>
|
|
<font-awesome
|
|
icon="question-circle"
|
|
aria-hidden="true"
|
|
></font-awesome>
|
|
</button>
|
|
</div>
|
|
<div
|
|
class="relative ml-2 cursor-pointer"
|
|
role="switch"
|
|
:aria-checked="notifyingReminder"
|
|
aria-label="Toggle reminder notifications"
|
|
tabindex="0"
|
|
@click="showReminderNotificationChoice()"
|
|
>
|
|
<!-- input -->
|
|
<input v-model="notifyingReminder" type="checkbox" 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
|
|
<font-awesome
|
|
icon="question-circle"
|
|
class="text-slate-400 fa-fw cursor-pointer"
|
|
@click.stop="showNewActivityNotificationInfo"
|
|
/>
|
|
</div>
|
|
<!-- toggle -->
|
|
<div
|
|
class="relative ml-2 cursor-pointer"
|
|
@click="showNewActivityNotificationChoice()"
|
|
>
|
|
<!-- input -->
|
|
<input
|
|
v-model="notifyingNewActivity"
|
|
type="checkbox"
|
|
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>
|
|
<div class="mt-2 text-center">
|
|
<router-link class="text-sm text-blue-500" to="/help-notifications">
|
|
Troubleshoot your notifications…
|
|
</router-link>
|
|
</div>
|
|
</section>
|
|
<PushNotificationPermission ref="pushNotificationPermission" />
|
|
|
|
<section
|
|
id="sectionSearchLocation"
|
|
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
|
|
aria-labelledby="searchLocationHeading"
|
|
>
|
|
<h2 id="searchLocationHeading" class="mb-2 font-bold">
|
|
Location for Searches
|
|
</h2>
|
|
<LocationSearchSection
|
|
:is-registered="isRegistered"
|
|
:search-area-label="searchAreaLabel"
|
|
@set-search-area="onSetSearchArea"
|
|
/>
|
|
</section>
|
|
|
|
<!-- User Profile -->
|
|
<section
|
|
v-if="isRegistered"
|
|
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
|
|
aria-labelledby="userProfileHeading"
|
|
>
|
|
<h2 id="userProfileHeading" class="mb-2 font-bold">
|
|
Public Profile
|
|
<button
|
|
class="text-slate-400 fa-fw cursor-pointer"
|
|
aria-label="Learn more about public profile"
|
|
@click="showProfileInfo"
|
|
>
|
|
<font-awesome icon="circle-info" aria-hidden="true"></font-awesome>
|
|
</button>
|
|
</h2>
|
|
<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 }"
|
|
aria-label="Public profile description"
|
|
:aria-busy="loadingProfile || savingProfile"
|
|
></textarea>
|
|
|
|
<div class="flex items-center mb-4" @click="toggleUserProfileLocation">
|
|
<input
|
|
v-model="includeUserProfileLocation"
|
|
type="checkbox"
|
|
class="mr-2"
|
|
/>
|
|
<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">
|
|
The location you choose will be shared with the world until you remove
|
|
this checkbox. For your security, choose a location nearby but not
|
|
exactly at your true location, like at your town center.
|
|
</p>
|
|
|
|
<l-map
|
|
ref="profileMap"
|
|
class="!z-40 rounded-md"
|
|
@click="onProfileMapClick"
|
|
@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="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-4">
|
|
<button
|
|
class="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,
|
|
}"
|
|
@click="saveProfile"
|
|
>
|
|
Save Profile
|
|
</button>
|
|
<button
|
|
class="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),
|
|
}"
|
|
@click="confirmDeleteProfile"
|
|
>
|
|
Delete Profile
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div v-else-if="loadingProfile">Loading...</div>
|
|
<div v-else>Saving...</div>
|
|
</section>
|
|
|
|
<UsageLimitsSection
|
|
:loading-limits="loadingLimits"
|
|
:limits-message="limitsMessage"
|
|
@recheck-limits="onRecheckLimits"
|
|
/>
|
|
|
|
<DataExportSection :active-did="activeDid" />
|
|
|
|
<!-- id used by puppeteer test script -->
|
|
<h3
|
|
id="advanced"
|
|
class="text-blue-500 text-sm font-semibold mb-3 cursor-pointer"
|
|
@click="showAdvanced = !showAdvanced"
|
|
>
|
|
{{ showAdvanced ? "Hide Advanced Settings" : "Show Advanced Settings" }}
|
|
</h3>
|
|
<section
|
|
v-if="showAdvanced || showGeneralAdvanced"
|
|
id="sectionAdvanced"
|
|
aria-labelledby="advancedHeading"
|
|
>
|
|
<h2 id="advancedHeading" class="sr-only">Advanced Settings</h2>
|
|
<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">
|
|
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
|
|
class="ml-2"
|
|
@click="
|
|
doCopyTwoSecRedo(publicBase64, () => (showB64Copy = !showB64Copy))
|
|
"
|
|
>
|
|
<font-awesome
|
|
icon="copy"
|
|
class="text-slate-400 fa-fw"
|
|
></font-awesome>
|
|
</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
|
|
class="ml-2"
|
|
@click="
|
|
doCopyTwoSecRedo(publicHex, () => (showPubCopy = !showPubCopy))
|
|
"
|
|
>
|
|
<font-awesome
|
|
icon="copy"
|
|
class="text-slate-400 fa-fw"
|
|
></font-awesome>
|
|
</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
|
|
class="ml-2"
|
|
@click="
|
|
doCopyTwoSecRedo(
|
|
derivationPath,
|
|
() => (showDerCopy = !showDerCopy),
|
|
)
|
|
"
|
|
>
|
|
<font-awesome
|
|
icon="copy"
|
|
class="text-slate-400 fa-fw"
|
|
></font-awesome>
|
|
</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 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</h2>
|
|
|
|
<div class="ml-4 mt-2">
|
|
<input type="file" class="ml-2" @change="uploadImportFile" />
|
|
<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">
|
|
<!-- Bulk import has an error
|
|
<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 Contacts
|
|
</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
|
|
v-model="showContactGives"
|
|
type="checkbox"
|
|
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"
|
|
role="group"
|
|
aria-labelledby="claimServerHeading"
|
|
>
|
|
<h3 id="claimServerHeading" class="sr-only">
|
|
Claim Server Configuration
|
|
</h3>
|
|
<label for="apiServerInput" class="sr-only">API Server URL</label>
|
|
<input
|
|
id="apiServerInput"
|
|
v-model="apiServerInput"
|
|
type="text"
|
|
class="block w-full rounded border border-slate-400 px-4 py-2"
|
|
aria-describedby="apiServerDescription"
|
|
placeholder="Enter API server URL"
|
|
/>
|
|
<div id="apiServerDescription" class="sr-only" role="tooltip">
|
|
Enter the URL for the claim server. You can use the buttons below to
|
|
quickly set common server URLs.
|
|
</div>
|
|
<button
|
|
v-if="apiServerInput != apiServer"
|
|
class="w-full px-4 rounded bg-yellow-500 border border-slate-400"
|
|
aria-label="Save API server URL"
|
|
@click="onClickSaveApiServer()"
|
|
>
|
|
<font-awesome
|
|
icon="floppy-disk"
|
|
class="fa-fw"
|
|
color="white"
|
|
aria-hidden="true"
|
|
></font-awesome>
|
|
</button>
|
|
<div class="mt-2" role="group" aria-label="Quick server selection">
|
|
<button
|
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
|
aria-label="Use production server URL"
|
|
@click="apiServerInput = AppConstants.PROD_ENDORSER_API_SERVER"
|
|
>
|
|
Use Prod
|
|
</button>
|
|
<button
|
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
|
aria-label="Use test server URL"
|
|
@click="apiServerInput = AppConstants.TEST_ENDORSER_API_SERVER"
|
|
>
|
|
Use Test
|
|
</button>
|
|
<button
|
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
|
aria-label="Use local server URL"
|
|
@click="apiServerInput = AppConstants.LOCAL_ENDORSER_API_SERVER"
|
|
>
|
|
Use Local
|
|
</button>
|
|
</div>
|
|
</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 v-model="warnIfProdServer" type="checkbox" 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 v-model="warnIfTestServer" type="checkbox" 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
|
|
v-model="webPushServerInput"
|
|
type="text"
|
|
class="block w-full rounded border border-slate-400 px-3 py-2"
|
|
/>
|
|
<button
|
|
v-if="webPushServerInput != webPushServer"
|
|
class="w-full px-4 rounded bg-yellow-500 border border-slate-400"
|
|
@click="onClickSavePushServer()"
|
|
>
|
|
<font-awesome
|
|
icon="floppy-disk"
|
|
class="fa-fw"
|
|
color="white"
|
|
></font-awesome>
|
|
</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 v-if="!webPushServerInput" class="px-4 text-sm">
|
|
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
|
|
v-model="partnerApiServerInput"
|
|
type="text"
|
|
class="block w-full rounded border border-slate-400 px-3 py-2"
|
|
/>
|
|
<button
|
|
v-if="partnerApiServerInput != partnerApiServer"
|
|
class="w-full px-4 rounded bg-yellow-500 border border-slate-400"
|
|
@click="onClickSavePartnerServer()"
|
|
>
|
|
<font-awesome
|
|
icon="floppy-disk"
|
|
class="fa-fw"
|
|
color="white"
|
|
></font-awesome>
|
|
</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 v-if="!partnerApiServerInput" class="px-4 text-sm">
|
|
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
|
|
v-model="hideRegisterPromptOnNewContact"
|
|
type="checkbox"
|
|
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 v-model="showShortcutBvc" type="checkbox" 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 id="sectionPasskeyExpiration" class="flex mt-4 justify-between">
|
|
<span>
|
|
<span class="text-slate-500 text-sm font-bold">
|
|
Passkey Expiration Minutes
|
|
</span>
|
|
<br />
|
|
<span class="text-sm ml-2">
|
|
{{ passkeyExpirationDescription }}
|
|
</span>
|
|
</span>
|
|
<div class="relative ml-2">
|
|
<input
|
|
v-model="passkeyExpirationMinutes"
|
|
type="number"
|
|
class="border border-slate-400 rounded px-2 py-2 text-center w-20"
|
|
@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
|
|
v-model="showGeneralAdvanced"
|
|
type="checkbox"
|
|
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>
|
|
|
|
<router-link
|
|
:to="{ name: 'logs' }"
|
|
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 mt-2"
|
|
>
|
|
Logs
|
|
</router-link>
|
|
<router-link
|
|
:to="{ name: 'test' }"
|
|
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 mt-2"
|
|
>
|
|
Test Page
|
|
</router-link>
|
|
|
|
<div class="flex mt-2">
|
|
<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"
|
|
>
|
|
See Global Animated History of Giving
|
|
</router-link>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import "leaflet/dist/leaflet.css";
|
|
|
|
import { Buffer } from "buffer/";
|
|
import "dexie-export-import";
|
|
// @ts-expect-error - they aren't exporting it but it's there
|
|
import { 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 { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
|
import { useClipboard } from "@vueuse/core";
|
|
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
|
import { Capacitor } from "@capacitor/core";
|
|
|
|
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 DataExportSection from "../components/DataExportSection.vue";
|
|
import IdentitySection from "@/components/IdentitySection.vue";
|
|
import RegistrationNotice from "@/components/RegistrationNotice.vue";
|
|
import LocationSearchSection from "@/components/LocationSearchSection.vue";
|
|
import UsageLimitsSection from "@/components/UsageLimitsSection.vue";
|
|
import {
|
|
AppString,
|
|
DEFAULT_IMAGE_API_SERVER,
|
|
DEFAULT_PARTNER_API_SERVER,
|
|
DEFAULT_PUSH_SERVER,
|
|
IMAGE_TYPE_PROFILE,
|
|
NotificationIface,
|
|
} from "../constants/app";
|
|
import { Contact } from "../db/tables/contacts";
|
|
import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "../db/tables/settings";
|
|
import { EndorserRateLimits, ImageRateLimits } from "../interfaces";
|
|
|
|
import {
|
|
clearPasskeyToken,
|
|
fetchEndorserRateLimits,
|
|
fetchImageRateLimits,
|
|
getHeaders,
|
|
tokenExpiryTimeDescription,
|
|
} from "../libs/endorserServer";
|
|
import {
|
|
DAILY_CHECK_TITLE,
|
|
DIRECT_PUSH_TITLE,
|
|
retrieveAccountMetadata,
|
|
} from "../libs/util";
|
|
import { logger } from "../utils/logger";
|
|
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
|
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
|
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
|
|
import {
|
|
AccountSettings,
|
|
isApiError,
|
|
ImportContent,
|
|
} from "@/interfaces/accountView";
|
|
import {
|
|
ProfileService,
|
|
createProfileService,
|
|
ProfileData,
|
|
} from "@/services/ProfileService";
|
|
|
|
const inputImportFileNameRef = ref<Blob>();
|
|
|
|
@Component({
|
|
components: {
|
|
EntityIcon,
|
|
ImageMethodDialog,
|
|
LMap,
|
|
LMarker,
|
|
LTileLayer,
|
|
PushNotificationPermission,
|
|
QuickNav,
|
|
TopMessage,
|
|
UserNameDialog,
|
|
DataExportSection,
|
|
IdentitySection,
|
|
RegistrationNotice,
|
|
LocationSearchSection,
|
|
UsageLimitsSection,
|
|
},
|
|
mixins: [PlatformServiceMixin],
|
|
})
|
|
export default class AccountViewView extends Vue {
|
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
|
$route!: RouteLocationNormalizedLoaded;
|
|
$router!: Router;
|
|
|
|
// Constants
|
|
readonly AppConstants: typeof AppString = AppString;
|
|
readonly DEFAULT_PUSH_SERVER: string = DEFAULT_PUSH_SERVER;
|
|
readonly DEFAULT_IMAGE_API_SERVER: string = DEFAULT_IMAGE_API_SERVER;
|
|
readonly DEFAULT_PARTNER_API_SERVER: string = DEFAULT_PARTNER_API_SERVER;
|
|
|
|
// Identity and settings properties
|
|
activeDid: string = "";
|
|
apiServer: string = "";
|
|
apiServerInput: string = "";
|
|
derivationPath: string = "";
|
|
givenName: string = "";
|
|
hideRegisterPromptOnNewContact: boolean = false;
|
|
isRegistered: boolean = false;
|
|
isSearchAreasSet: boolean = false;
|
|
partnerApiServer: string = DEFAULT_PARTNER_API_SERVER;
|
|
partnerApiServerInput: string = DEFAULT_PARTNER_API_SERVER;
|
|
passkeyExpirationDescription: string = "";
|
|
passkeyExpirationMinutes: number = DEFAULT_PASSKEY_EXPIRATION_MINUTES;
|
|
previousPasskeyExpirationMinutes: number = DEFAULT_PASSKEY_EXPIRATION_MINUTES;
|
|
profileImageUrl?: string;
|
|
publicHex: string = "";
|
|
publicBase64: string = "";
|
|
webPushServer: string = DEFAULT_PUSH_SERVER;
|
|
webPushServerInput: string = DEFAULT_PUSH_SERVER;
|
|
|
|
// Profile properties
|
|
userProfileDesc: string = "";
|
|
userProfileLatitude: number = 0;
|
|
userProfileLongitude: number = 0;
|
|
includeUserProfileLocation: boolean = false;
|
|
savingProfile: boolean = false;
|
|
|
|
// Notification properties
|
|
notifyingNewActivity: boolean = false;
|
|
notifyingNewActivityTime: string = "";
|
|
notifyingReminder: boolean = false;
|
|
notifyingReminderMessage: string = "";
|
|
notifyingReminderTime: string = "";
|
|
subscription: PushSubscription | null = null;
|
|
|
|
// UI state properties
|
|
downloadUrl: string = ""; // because DuckDuckGo doesn't download on automated call to "click" on the anchor
|
|
loadingLimits: boolean = false;
|
|
loadingProfile: boolean = true;
|
|
showAdvanced: boolean = false;
|
|
showB64Copy: boolean = false;
|
|
showContactGives: boolean = false;
|
|
showDidCopy: boolean = false;
|
|
showDerCopy: boolean = false;
|
|
showGeneralAdvanced: boolean = false;
|
|
showLargeIdenticonId?: string;
|
|
showLargeIdenticonUrl?: string;
|
|
showPubCopy: boolean = false;
|
|
showShortcutBvc: boolean = false;
|
|
warnIfProdServer: boolean = false;
|
|
warnIfTestServer: boolean = false;
|
|
zoom: number = 2;
|
|
|
|
// Limits and validation properties
|
|
endorserLimits: EndorserRateLimits | null = null;
|
|
imageLimits: ImageRateLimits | null = null;
|
|
limitsMessage: string = "";
|
|
|
|
private profileService!: ProfileService;
|
|
private notify!: ReturnType<typeof createNotifyHelpers>;
|
|
|
|
/**
|
|
* 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(): Promise<void> {
|
|
this.notify = createNotifyHelpers(this.$notify);
|
|
this.profileService = createProfileService(
|
|
this.axios,
|
|
this.partnerApiServer,
|
|
);
|
|
try {
|
|
await this.initializeState();
|
|
await this.processIdentity();
|
|
|
|
if (this.isRegistered) {
|
|
try {
|
|
const profile = await this.profileService.loadProfile(this.activeDid);
|
|
if (profile) {
|
|
this.userProfileDesc = profile.description;
|
|
this.userProfileLatitude = profile.latitude;
|
|
this.userProfileLongitude = profile.longitude;
|
|
this.includeUserProfileLocation = profile.includeLocation;
|
|
} else {
|
|
// Profile not created yet; leave defaults
|
|
}
|
|
} catch (error) {
|
|
this.notify.error(
|
|
ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_NOT_AVAILABLE,
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error(
|
|
"Telling user to clear cache at page create because:",
|
|
error,
|
|
);
|
|
logger.error(
|
|
"To repeat with concatenated error: telling user to clear cache at page create because: " +
|
|
error,
|
|
);
|
|
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_LOAD_ERROR);
|
|
} finally {
|
|
this.loadingProfile = false;
|
|
}
|
|
|
|
// Only check service worker on web platform - Capacitor/Electron don't support it
|
|
if (!Capacitor.isNativePlatform()) {
|
|
try {
|
|
/**
|
|
* Service workers only exist on web platforms
|
|
*/
|
|
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();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
this.notify.warning(
|
|
ACCOUNT_VIEW_CONSTANTS.ERRORS.BROWSER_NOTIFICATIONS_UNSUPPORTED,
|
|
TIMEOUTS.VERY_LONG,
|
|
);
|
|
}
|
|
} else {
|
|
// On native platforms (Capacitor/Electron), skip service worker checks
|
|
// Native notifications are handled differently
|
|
this.subscription = null;
|
|
}
|
|
this.passkeyExpirationDescription = tokenExpiryTimeDescription();
|
|
}
|
|
|
|
beforeUnmount(): void {
|
|
if (this.downloadUrl) {
|
|
URL.revokeObjectURL(this.downloadUrl);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initializes component state with values from the database or defaults.
|
|
*/
|
|
async initializeState(): Promise<void> {
|
|
const settings: AccountSettings = await this.$accountSettings();
|
|
|
|
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): void {
|
|
fn();
|
|
useClipboard()
|
|
.copy(text)
|
|
.then(() => setTimeout(fn, 2000));
|
|
}
|
|
|
|
async toggleShowContactAmounts(): Promise<void> {
|
|
this.showContactGives = !this.showContactGives;
|
|
await this.$saveSettings({
|
|
showContactGivesInline: this.showContactGives,
|
|
});
|
|
}
|
|
|
|
async toggleShowGeneralAdvanced(): Promise<void> {
|
|
this.showGeneralAdvanced = !this.showGeneralAdvanced;
|
|
await this.$saveSettings({
|
|
showGeneralAdvanced: this.showGeneralAdvanced,
|
|
});
|
|
}
|
|
|
|
async toggleProdWarning(): Promise<void> {
|
|
this.warnIfProdServer = !this.warnIfProdServer;
|
|
await this.$saveSettings({
|
|
warnIfProdServer: this.warnIfProdServer,
|
|
});
|
|
}
|
|
|
|
async toggleTestWarning(): Promise<void> {
|
|
this.warnIfTestServer = !this.warnIfTestServer;
|
|
await this.$saveSettings({
|
|
warnIfTestServer: this.warnIfTestServer,
|
|
});
|
|
}
|
|
|
|
async toggleShowShortcutBvc(): Promise<void> {
|
|
this.showShortcutBvc = !this.showShortcutBvc;
|
|
await this.$saveSettings({
|
|
showShortcutBvc: this.showShortcutBvc,
|
|
});
|
|
}
|
|
|
|
readableDate(timeStr: string): string {
|
|
return timeStr ? timeStr.substring(0, timeStr.indexOf("T")) : "?";
|
|
}
|
|
|
|
/**
|
|
* Processes the identity and updates the component's state.
|
|
*/
|
|
async processIdentity(): Promise<void> {
|
|
const account = 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) {
|
|
// use the backup values in the top level of the account object
|
|
this.publicHex = account.publicKeyHex as string;
|
|
this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64");
|
|
this.derivationPath = account.derivationPath as string;
|
|
await this.checkLimits();
|
|
}
|
|
}
|
|
|
|
async showNewActivityNotificationInfo(): Promise<void> {
|
|
this.notify.confirm(
|
|
ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.NEW_ACTIVITY_INFO,
|
|
async () => {
|
|
await (this.$router as Router).push({
|
|
name: "help-notification-types",
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
async showNewActivityNotificationChoice(): Promise<void> {
|
|
if (!this.notifyingNewActivity) {
|
|
(
|
|
this.$refs.pushNotificationPermission as PushNotificationPermission
|
|
).open(DAILY_CHECK_TITLE, async (success: boolean, timeText: string) => {
|
|
if (success) {
|
|
await this.$saveSettings({
|
|
notifyingNewActivityTime: timeText,
|
|
});
|
|
this.notifyingNewActivity = true;
|
|
this.notifyingNewActivityTime = timeText;
|
|
}
|
|
});
|
|
} else {
|
|
this.notify.notificationOff(DAILY_CHECK_TITLE, async (success) => {
|
|
if (success) {
|
|
await this.$saveSettings({
|
|
notifyingNewActivityTime: "",
|
|
});
|
|
this.notifyingNewActivity = false;
|
|
this.notifyingNewActivityTime = "";
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
async showReminderNotificationInfo(): Promise<void> {
|
|
this.notify.confirm(
|
|
ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.REMINDER_INFO,
|
|
async () => {
|
|
await (this.$router as Router).push({
|
|
name: "help-notification-types",
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
async showReminderNotificationChoice(): Promise<void> {
|
|
if (!this.notifyingReminder) {
|
|
(
|
|
this.$refs.pushNotificationPermission as PushNotificationPermission
|
|
).open(
|
|
DIRECT_PUSH_TITLE,
|
|
async (success: boolean, timeText: string, message?: string) => {
|
|
if (success) {
|
|
await this.$saveSettings({
|
|
notifyingReminderMessage: message,
|
|
notifyingReminderTime: timeText,
|
|
});
|
|
this.notifyingReminder = true;
|
|
this.notifyingReminderMessage = message || "";
|
|
this.notifyingReminderTime = timeText;
|
|
}
|
|
},
|
|
);
|
|
} else {
|
|
this.notify.notificationOff(DIRECT_PUSH_TITLE, async (success) => {
|
|
if (success) {
|
|
await this.$saveSettings({
|
|
notifyingReminderMessage: "",
|
|
notifyingReminderTime: "",
|
|
});
|
|
this.notifyingReminder = false;
|
|
this.notifyingReminderMessage = "";
|
|
this.notifyingReminderTime = "";
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
public async toggleHideRegisterPromptOnNewContact(): Promise<void> {
|
|
const newSetting = !this.hideRegisterPromptOnNewContact;
|
|
await this.$saveSettings({
|
|
hideRegisterPromptOnNewContact: newSetting,
|
|
});
|
|
this.hideRegisterPromptOnNewContact = newSetting;
|
|
}
|
|
|
|
public async updatePasskeyExpiration(): Promise<void> {
|
|
await this.$saveSettings({
|
|
passkeyExpirationMinutes: this.passkeyExpirationMinutes,
|
|
});
|
|
clearPasskeyToken();
|
|
this.passkeyExpirationDescription = tokenExpiryTimeDescription();
|
|
}
|
|
|
|
public async turnOffNotifyingFlags(): Promise<void> {
|
|
// should tell the push server as well
|
|
await this.$saveSettings({
|
|
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> {
|
|
// TODO: Implement this for SQLite
|
|
throw new Error("Not implemented");
|
|
}
|
|
|
|
/**
|
|
* 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 = `${AppString.APP_NAME_NO_SPACES}-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.downloadStarted();
|
|
}
|
|
|
|
/**
|
|
* Handles errors during the database export process.
|
|
*
|
|
* @param {Error} error - The error object.
|
|
*/
|
|
private handleExportError(error: unknown): void {
|
|
logger.error("Export Error:", error);
|
|
this.notify.error(
|
|
ACCOUNT_VIEW_CONSTANTS.ERRORS.EXPORT_ERROR,
|
|
TIMEOUTS.STANDARD,
|
|
);
|
|
}
|
|
|
|
async uploadImportFile(event: Event): Promise<void> {
|
|
inputImportFileNameRef.value = (
|
|
event.target as HTMLInputElement
|
|
).files?.[0];
|
|
}
|
|
|
|
showContactImport(): boolean {
|
|
return !!inputImportFileNameRef.value;
|
|
}
|
|
|
|
confirmSubmitImportFile(): void {
|
|
if (inputImportFileNameRef.value != null) {
|
|
this.notify.confirm(
|
|
ACCOUNT_VIEW_CONSTANTS.WARNINGS.IMPORT_REPLACE_WARNING,
|
|
this.submitImportFile,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Asynchronously imports the database from a downloadable JSON file.
|
|
*
|
|
* @throws Will notify the user if there is an export error.
|
|
*/
|
|
async submitImportFile(): Promise<void> {
|
|
if (inputImportFileNameRef.value != null) {
|
|
// TODO: implement this for SQLite
|
|
}
|
|
}
|
|
|
|
async checkContactImports(): Promise<void> {
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => {
|
|
const fileContent: string = (event.target?.result as string) || "{}";
|
|
try {
|
|
const contents: ImportContent = 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) {
|
|
logger.error("Error checking contact imports:", error);
|
|
this.notify.error(
|
|
ACCOUNT_VIEW_CONSTANTS.ERRORS.IMPORT_ERROR,
|
|
TIMEOUTS.STANDARD,
|
|
);
|
|
}
|
|
};
|
|
reader.readAsText(inputImportFileNameRef.value as Blob);
|
|
}
|
|
|
|
private progressCallback(progress: ImportProgress): boolean {
|
|
logger.log(
|
|
`Import progress: ${progress.completedRows} of ${progress.totalRows} rows completed.`,
|
|
);
|
|
if (progress.done) {
|
|
// console.log(`Imported ${progress.completedTables} tables.`);
|
|
this.notify.success(
|
|
ACCOUNT_VIEW_CONSTANTS.SUCCESS.IMPORT_COMPLETE,
|
|
TIMEOUTS.LONG,
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async checkLimits(): Promise<void> {
|
|
this.loadingLimits = true;
|
|
try {
|
|
const did = this.activeDid;
|
|
if (!did) {
|
|
this.limitsMessage = ACCOUNT_VIEW_CONSTANTS.LIMITS.NO_IDENTIFIER;
|
|
return;
|
|
}
|
|
|
|
await this.$saveUserSettings(did, {
|
|
apiServer: this.apiServer,
|
|
partnerApiServer: this.partnerApiServer,
|
|
webPushServer: this.webPushServer,
|
|
});
|
|
|
|
const imageResp = await fetchImageRateLimits(this.axios, did);
|
|
if (imageResp.status === 200) {
|
|
this.imageLimits = imageResp.data;
|
|
} else {
|
|
await this.$saveSettings({
|
|
profileImageUrl: "",
|
|
});
|
|
this.profileImageUrl = "";
|
|
this.limitsMessage = ACCOUNT_VIEW_CONSTANTS.LIMITS.NO_IMAGE_ACCESS;
|
|
this.notify.warning(ACCOUNT_VIEW_CONSTANTS.LIMITS.CANNOT_UPLOAD_IMAGES);
|
|
return;
|
|
}
|
|
|
|
const endorserResp = await fetchEndorserRateLimits(
|
|
this.apiServer,
|
|
this.axios,
|
|
did,
|
|
);
|
|
if (endorserResp.status === 200) {
|
|
this.endorserLimits = endorserResp.data;
|
|
} else {
|
|
await this.$saveSettings({
|
|
profileImageUrl: "",
|
|
});
|
|
this.profileImageUrl = "";
|
|
this.limitsMessage = ACCOUNT_VIEW_CONSTANTS.LIMITS.NO_LIMITS_FOUND;
|
|
this.notify.warning(ACCOUNT_VIEW_CONSTANTS.LIMITS.BAD_SERVER_RESPONSE);
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
this.limitsMessage =
|
|
ACCOUNT_VIEW_CONSTANTS.LIMITS.ERROR_RETRIEVING_LIMITS;
|
|
this.notify.error(ACCOUNT_VIEW_CONSTANTS.LIMITS.ERROR_RETRIEVING_LIMITS);
|
|
} finally {
|
|
this.loadingLimits = false;
|
|
}
|
|
}
|
|
|
|
async onClickSaveApiServer(): Promise<void> {
|
|
await this.$saveSettings({
|
|
apiServer: this.apiServerInput,
|
|
});
|
|
this.apiServer = this.apiServerInput;
|
|
}
|
|
|
|
async onClickSavePartnerServer(): Promise<void> {
|
|
await this.$saveSettings({
|
|
partnerApiServer: this.partnerApiServerInput,
|
|
});
|
|
this.partnerApiServer = this.partnerApiServerInput;
|
|
await this.$saveUserSettings(this.activeDid, {
|
|
partnerApiServer: this.partnerApiServer,
|
|
});
|
|
}
|
|
|
|
async onClickSavePushServer(): Promise<void> {
|
|
await this.$saveSettings({
|
|
webPushServer: this.webPushServerInput,
|
|
});
|
|
this.webPushServer = this.webPushServerInput;
|
|
this.notify.warning(ACCOUNT_VIEW_CONSTANTS.INFO.RELOAD_VAPID);
|
|
}
|
|
|
|
openImageDialog(): void {
|
|
(this.$refs.imageMethodDialog as ImageMethodDialog).open(
|
|
async (imgUrl) => {
|
|
await this.$saveSettings({
|
|
profileImageUrl: imgUrl,
|
|
});
|
|
this.profileImageUrl = imgUrl;
|
|
//console.log("Got image URL:", imgUrl);
|
|
},
|
|
IMAGE_TYPE_PROFILE,
|
|
true,
|
|
);
|
|
}
|
|
|
|
confirmDeleteImage(): void {
|
|
this.notify.confirm(
|
|
ACCOUNT_VIEW_CONSTANTS.WARNINGS.IMAGE_DELETE_WARNING,
|
|
this.deleteImage,
|
|
);
|
|
}
|
|
|
|
async deleteImage(): Promise<void> {
|
|
try {
|
|
const response = await this.axios.delete(
|
|
this.apiServer + "/api/image/" + this.profileImageUrl,
|
|
{ headers: await getHeaders(this.activeDid) },
|
|
);
|
|
if (response.status === 204) {
|
|
this.profileImageUrl = "";
|
|
await this.$saveSettings({
|
|
profileImageUrl: "",
|
|
});
|
|
this.notify.success("Image deleted successfully.");
|
|
} else {
|
|
logger.error("Non-success deleting image:", response);
|
|
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.IMAGE_DELETE_PROBLEM);
|
|
// keep the imageUrl in localStorage so the user can try again if they want
|
|
}
|
|
} catch (error) {
|
|
if (isApiError(error) && error.response?.status === 404) {
|
|
// it already doesn't exist so we won't say anything to the user
|
|
} else {
|
|
this.notify.error(
|
|
ACCOUNT_VIEW_CONSTANTS.ERRORS.IMAGE_DELETE_ERROR,
|
|
TIMEOUTS.STANDARD,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
onMapReady(map: L.Map): void {
|
|
// 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(): void {
|
|
this.notify.info(
|
|
ACCOUNT_VIEW_CONSTANTS.INFO.PROFILE_INFO,
|
|
TIMEOUTS.VERY_LONG,
|
|
);
|
|
}
|
|
|
|
async saveProfile(): Promise<void> {
|
|
this.savingProfile = true;
|
|
const profileData: ProfileData = {
|
|
description: this.userProfileDesc,
|
|
latitude: this.userProfileLatitude,
|
|
longitude: this.userProfileLongitude,
|
|
includeLocation: this.includeUserProfileLocation,
|
|
};
|
|
try {
|
|
const success = await this.profileService.saveProfile(
|
|
this.activeDid,
|
|
profileData,
|
|
);
|
|
if (success) {
|
|
this.notify.success(ACCOUNT_VIEW_CONSTANTS.SUCCESS.PROFILE_SAVED);
|
|
} else {
|
|
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_SAVE_ERROR);
|
|
}
|
|
} catch (error) {
|
|
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_SAVE_ERROR);
|
|
} finally {
|
|
this.savingProfile = false;
|
|
}
|
|
}
|
|
|
|
toggleUserProfileLocation(): void {
|
|
const updated = this.profileService.toggleProfileLocation({
|
|
description: this.userProfileDesc,
|
|
latitude: this.userProfileLatitude,
|
|
longitude: this.userProfileLongitude,
|
|
includeLocation: this.includeUserProfileLocation,
|
|
});
|
|
this.userProfileLatitude = updated.latitude;
|
|
this.userProfileLongitude = updated.longitude;
|
|
this.includeUserProfileLocation = updated.includeLocation;
|
|
}
|
|
|
|
confirmEraseLatLong(): void {
|
|
this.notify.confirm(
|
|
ACCOUNT_VIEW_CONSTANTS.WARNINGS.ERASE_LOCATION_WARNING,
|
|
async () => {
|
|
this.eraseLatLong();
|
|
},
|
|
);
|
|
}
|
|
|
|
eraseLatLong(): void {
|
|
this.userProfileLatitude = 0;
|
|
this.userProfileLongitude = 0;
|
|
this.zoom = 2;
|
|
this.includeUserProfileLocation = false;
|
|
}
|
|
|
|
async confirmDeleteProfile(): Promise<void> {
|
|
this.notify.confirm(
|
|
ACCOUNT_VIEW_CONSTANTS.WARNINGS.DELETE_PROFILE_WARNING,
|
|
this.deleteProfile,
|
|
);
|
|
}
|
|
|
|
async deleteProfile(): Promise<void> {
|
|
try {
|
|
const success = await this.profileService.deleteProfile(this.activeDid);
|
|
if (success) {
|
|
this.notify.success(ACCOUNT_VIEW_CONSTANTS.SUCCESS.PROFILE_DELETED);
|
|
this.userProfileDesc = "";
|
|
this.userProfileLatitude = 0;
|
|
this.userProfileLongitude = 0;
|
|
this.includeUserProfileLocation = false;
|
|
} else {
|
|
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_DELETE_ERROR);
|
|
}
|
|
} catch (error) {
|
|
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_DELETE_ERROR);
|
|
}
|
|
}
|
|
|
|
private handleQRCodeClick() {
|
|
if (Capacitor.isNativePlatform()) {
|
|
this.$router.push({ name: "contact-qr-scan-full" });
|
|
} else {
|
|
this.$router.push({ name: "contact-qr" });
|
|
}
|
|
}
|
|
|
|
onProfileMapClick(event: LeafletMouseEvent) {
|
|
this.userProfileLatitude = event.latlng.lat;
|
|
this.userProfileLongitude = event.latlng.lng;
|
|
}
|
|
|
|
// IdentitySection event handlers
|
|
onEditName() {
|
|
(this.$refs.userNameDialog as any).open((name: string) => {
|
|
if (name) this.givenName = name;
|
|
});
|
|
}
|
|
onShowQrCode() {
|
|
this.handleQRCodeClick();
|
|
}
|
|
onAddImage() {
|
|
this.openImageDialog();
|
|
}
|
|
onDeleteImage() {
|
|
this.confirmDeleteImage();
|
|
}
|
|
onShowLargeIdenticonId(id: string) {
|
|
this.showLargeIdenticonId = id;
|
|
}
|
|
onShowLargeIdenticonUrl(url: string) {
|
|
this.showLargeIdenticonUrl = url;
|
|
}
|
|
onCloseLargeIdenticon() {
|
|
this.showLargeIdenticonId = undefined;
|
|
this.showLargeIdenticonUrl = undefined;
|
|
}
|
|
onCopyDid(did: string) {
|
|
this.doCopyTwoSecRedo(did, () => (this.showDidCopy = !this.showDidCopy));
|
|
}
|
|
|
|
get showRegistrationNotice(): boolean {
|
|
// Show the notice if not registered and any other conditions you want
|
|
return !this.isRegistered;
|
|
}
|
|
|
|
onShareInfo() {
|
|
// Call the existing logic for sharing info, e.g., open the share dialog
|
|
this.openShareDialog();
|
|
}
|
|
|
|
// Placeholder for share dialog logic
|
|
openShareDialog() {
|
|
// TODO: Implement share dialog logic
|
|
this.notify.info("Share dialog not yet implemented.");
|
|
}
|
|
|
|
get searchAreaLabel(): string {
|
|
// Return a string representing the current search area, or blank if not set
|
|
// Example: return this.searchAreaName || '';
|
|
return this.isSearchAreasSet ? "Custom Area Set" : "";
|
|
}
|
|
|
|
onSetSearchArea() {
|
|
// Call the existing logic for setting the search area, e.g., open the dialog
|
|
this.openSearchAreaDialog();
|
|
}
|
|
|
|
openSearchAreaDialog() {
|
|
// TODO: Implement search area dialog logic
|
|
this.notify.info("Search area dialog not yet implemented.");
|
|
}
|
|
|
|
onRecheckLimits() {
|
|
this.checkLimits();
|
|
}
|
|
}
|
|
</script>
|
|
|