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.
1504 lines
47 KiB
1504 lines
47 KiB
<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="
|
|
() => $refs.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="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-l"
|
|
/>
|
|
<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-r"
|
|
@click="openImageDialog()"
|
|
/>
|
|
</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="!loadingLimits && !endorserLimits?.nextWeekBeginDateTime"
|
|
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
|
|
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
|
|
v-if="!notificationMaybeChanged"
|
|
class="flex items-center justify-between cursor-pointer"
|
|
@click="showNotificationChoice()"
|
|
>
|
|
<!-- label -->
|
|
<div>App Notifications</div>
|
|
<!-- toggle -->
|
|
<div class="relative ml-2">
|
|
<!-- input -->
|
|
<input
|
|
type="checkbox"
|
|
v-model="isSubscribed"
|
|
name="toggleNotificationsInput"
|
|
class="sr-only"
|
|
/>
|
|
<!-- line -->
|
|
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
|
<!-- dot -->
|
|
<div
|
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
<div v-else>
|
|
Notification status may have changed. Refresh this page to see the
|
|
latest setting.
|
|
</div>
|
|
<router-link class="pl-4 text-sm text-blue-500" to="/help-notifications">
|
|
Troubleshoot your notification setup.
|
|
</router-link>
|
|
</div>
|
|
|
|
<div
|
|
id="sectionSearchLocation"
|
|
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
|
|
>
|
|
<!-- label -->
|
|
<div class="mb-2 font-bold">Location for Searches</div>
|
|
<router-link
|
|
:to="{ name: 'search-area' }"
|
|
class="block w-full text-center 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 mt-6"
|
|
>
|
|
Set Search Area…
|
|
<!-- If already set, change button label to "Change Search Area" -->
|
|
</router-link>
|
|
</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>
|
|
{{ limitsMessage }}
|
|
</div>
|
|
<div v-if="!!endorserLimits?.nextWeekBeginDateTime">
|
|
<p class="text-sm">
|
|
You have done
|
|
<b>{{ endorserLimits.doneClaimsThisWeek }} claims</b> out of
|
|
<b>{{ endorserLimits.maxClaimsPerWeek }}</b> for this week. Your
|
|
claims counter resets at
|
|
<b class="whitespace-nowrap">{{
|
|
readableDate(endorserLimits.nextWeekBeginDateTime)
|
|
}}</b>
|
|
</p>
|
|
<p class="mt-3 text-sm">
|
|
You have done
|
|
<b>{{ endorserLimits.doneRegistrationsThisMonth }} registrations</b>
|
|
out of <b>{{ endorserLimits.maxRegistrationsPerMonth }}</b> for this
|
|
month.
|
|
<i>(You cannot register anyone else 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" v-if="!!imageLimits">
|
|
You have uploaded
|
|
<b>{{ imageLimits?.doneImagesThisWeek }} images</b> out of
|
|
<b>{{ imageLimits?.maxImagesPerWeek }}</b> for this week. Your image
|
|
counter resets at
|
|
<b class="whitespace-nowrap">{{
|
|
readableDate(imageLimits?.nextWeekBeginDateTime)
|
|
}}</b>
|
|
</p>
|
|
</div>
|
|
<button
|
|
class="block float-right w-fit text-center text-md 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" />
|
|
<div v-if="showContactImport()" class="mt-4">
|
|
<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>
|
|
<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>
|
|
</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 id="sectionNotificationPushServer" class="px-3 py-4">
|
|
<input
|
|
type="text"
|
|
class="block w-full rounded border border-slate-400 px-3 py-2"
|
|
v-model="webPushServerInput"
|
|
/>
|
|
<button
|
|
v-if="webPushServerInput != webPushServer"
|
|
class="w-full px-4 rounded bg-yellow-500 border border-slate-400"
|
|
@click="onClickSavePushServer()"
|
|
>
|
|
<fa icon="floppy-disk" class="fa-fw" color="white"></fa>
|
|
</button>
|
|
<button
|
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
|
@click="webPushServerInput = AppConstants.PROD_PUSH_SERVER"
|
|
>
|
|
Use Prod
|
|
</button>
|
|
<button
|
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
|
@click="webPushServerInput = AppConstants.TEST1_PUSH_SERVER"
|
|
>
|
|
Use Test 1
|
|
</button>
|
|
<button
|
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
|
@click="webPushServerInput = AppConstants.TEST2_PUSH_SERVER"
|
|
>
|
|
Use Test 2
|
|
</button>
|
|
</div>
|
|
<span class="px-4 text-sm" v-if="!webPushServerInput">
|
|
When that setting is blank, this app will use the default web push
|
|
server URL:
|
|
{{ DEFAULT_PUSH_SERVER }}
|
|
</span>
|
|
|
|
<div id="sectionImageServerURL" 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>
|
|
|
|
<div id="sectionPartnerServerURL" class="mt-2">
|
|
<span class="text-slate-500 text-sm font-bold">Partner Server URL</span>
|
|
|
|
<span class="text-sm">{{ DEFAULT_PARTNER_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 { AxiosError } from "axios";
|
|
import { Buffer } from "buffer/";
|
|
import Dexie from "dexie";
|
|
import "dexie-export-import";
|
|
import { ImportProgress } from "dexie-export-import/dist/import";
|
|
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 EntityIcon from "@/components/EntityIcon.vue";
|
|
import ImageMethodDialog from "@/components/ImageMethodDialog.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,
|
|
accountsDB,
|
|
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,
|
|
ErrorResponse,
|
|
EndorserRateLimits,
|
|
fetchEndorserRateLimits,
|
|
fetchImageRateLimits,
|
|
getHeaders,
|
|
ImageRateLimits,
|
|
tokenExpiryTimeDescription,
|
|
} from "@/libs/endorserServer";
|
|
import { getAccount } from "@/libs/util";
|
|
|
|
const inputImportFileNameRef = ref<Blob>();
|
|
|
|
@Component({
|
|
components: {
|
|
EntityIcon,
|
|
ImageMethodDialog,
|
|
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;
|
|
imageServer = "";
|
|
isRegistered = false;
|
|
isSubscribed = false;
|
|
limitsMessage = "";
|
|
loadingLimits = false;
|
|
notificationMaybeChanged = false;
|
|
passkeyExpirationDescription = "";
|
|
passkeyExpirationMinutes = DEFAULT_PASSKEY_EXPIRATION_MINUTES;
|
|
previousPasskeyExpirationMinutes = DEFAULT_PASSKEY_EXPIRATION_MINUTES;
|
|
profileImageUrl?: string;
|
|
publicHex = "";
|
|
publicBase64 = "";
|
|
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 = "";
|
|
webPushServerInput = "";
|
|
|
|
/**
|
|
* 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();
|
|
|
|
this.passkeyExpirationDescription = tokenExpiryTimeDescription();
|
|
|
|
/**
|
|
* Beware! I've seen where this "ready" never resolves.
|
|
*/
|
|
const registration = await navigator.serviceWorker?.ready;
|
|
this.subscription = await registration.pushManager.getSubscription();
|
|
this.isSubscribed = !!this.subscription;
|
|
// 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 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(
|
|
"Telling user to clear cache at page create because (error added): " +
|
|
error,
|
|
);
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error Loading Account",
|
|
text: "Clear your cache and start over (after data backup).",
|
|
},
|
|
-1,
|
|
);
|
|
}
|
|
}
|
|
|
|
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.isRegistered = !!settings?.isRegistered;
|
|
this.imageServer = settings.imageServer || "";
|
|
this.profileImageUrl = settings.profileImageUrl;
|
|
this.showContactGives = !!settings.showContactGivesInline;
|
|
this.hideRegisterPromptOnNewContact =
|
|
!!settings.hideRegisterPromptOnNewContact;
|
|
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.webPushServerInput = settings.webPushServer || "";
|
|
}
|
|
|
|
// call fn, copy text to the clipboard, then redo fn after 2 seconds
|
|
doCopyTwoSecRedo(text: string, fn: () => void) {
|
|
fn();
|
|
useClipboard()
|
|
.copy(text)
|
|
.then(() => setTimeout(fn, 2000));
|
|
}
|
|
|
|
toggleShowContactAmounts() {
|
|
this.showContactGives = !this.showContactGives;
|
|
this.updateShowContactAmounts();
|
|
}
|
|
|
|
toggleShowGeneralAdvanced() {
|
|
this.showGeneralAdvanced = !this.showGeneralAdvanced;
|
|
this.updateShowGeneralAdvanced();
|
|
}
|
|
|
|
toggleProdWarning() {
|
|
this.warnIfProdServer = !this.warnIfProdServer;
|
|
this.updateWarnIfProdServer(this.warnIfProdServer);
|
|
}
|
|
|
|
toggleTestWarning() {
|
|
this.warnIfTestServer = !this.warnIfTestServer;
|
|
this.updateWarnIfTestServer(this.warnIfTestServer);
|
|
}
|
|
|
|
toggleShowShortcutBvc() {
|
|
this.showShortcutBvc = !this.showShortcutBvc;
|
|
this.updateShowShortcutBvc(this.showShortcutBvc);
|
|
}
|
|
|
|
readableDate(timeStr: string) {
|
|
return timeStr.substring(0, timeStr.indexOf("T"));
|
|
}
|
|
|
|
/**
|
|
* Processes the identity and updates the component's state.
|
|
*/
|
|
async processIdentity() {
|
|
const account: Account | undefined = await getAccount(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.checkLimitsFor(this.activeDid);
|
|
} else if (account?.publicKeyHex) {
|
|
this.publicHex = account.publicKeyHex as string;
|
|
this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64");
|
|
await this.checkLimitsFor(this.activeDid);
|
|
}
|
|
}
|
|
|
|
async showNotificationChoice() {
|
|
if (!this.subscription) {
|
|
this.$notify(
|
|
{
|
|
group: "modal",
|
|
type: "notification-permission",
|
|
title: "", // unused, only here to satisfy type check
|
|
text: "", // unused, only here to satisfy type check
|
|
},
|
|
-1,
|
|
);
|
|
} else {
|
|
this.$notify(
|
|
{
|
|
group: "modal",
|
|
type: "notification-off",
|
|
title: "", // unused, only here to satisfy type check
|
|
text: "", // unused, only here to satisfy type check
|
|
},
|
|
-1,
|
|
);
|
|
}
|
|
this.notificationMaybeChanged = true;
|
|
}
|
|
|
|
public async updateShowContactAmounts() {
|
|
await db.open();
|
|
await db.settings.update(MASTER_SETTINGS_KEY, {
|
|
showContactGivesInline: this.showContactGives,
|
|
});
|
|
}
|
|
|
|
public async updateShowGeneralAdvanced() {
|
|
await db.open();
|
|
await db.settings.update(MASTER_SETTINGS_KEY, {
|
|
showGeneralAdvanced: this.showGeneralAdvanced,
|
|
});
|
|
}
|
|
|
|
public async updateWarnIfProdServer(newSetting: boolean) {
|
|
try {
|
|
await db.open();
|
|
await db.settings.update(MASTER_SETTINGS_KEY, {
|
|
warnIfProdServer: newSetting,
|
|
});
|
|
} catch (err) {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error Updating Prod Warning",
|
|
text: "The setting may not have saved. Try again, maybe after restarting the app.",
|
|
},
|
|
-1,
|
|
);
|
|
console.error(
|
|
"Telling user to try again after prod-server-warning setting update because:",
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
|
|
public async updateWarnIfTestServer(newSetting: boolean) {
|
|
await db.open();
|
|
await db.settings.update(MASTER_SETTINGS_KEY, {
|
|
warnIfTestServer: newSetting,
|
|
});
|
|
}
|
|
|
|
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 updateShowShortcutBvc(newSetting: boolean) {
|
|
await db.open();
|
|
await db.settings.update(MASTER_SETTINGS_KEY, {
|
|
showShortcutBvc: newSetting,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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.",
|
|
},
|
|
-1,
|
|
);
|
|
}
|
|
|
|
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.";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Asynchronously checks rate limits for the given identity.
|
|
*
|
|
* Updates component state variables `limits`, `limitsMessage`, and `loadingLimits`.
|
|
*/
|
|
public 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.",
|
|
},
|
|
-1,
|
|
);
|
|
}
|
|
}
|
|
const imageResp = await fetchImageRateLimits(this.axios, did);
|
|
if (imageResp.status === 200) {
|
|
this.imageLimits = imageResp.data;
|
|
}
|
|
}
|
|
} 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) {
|
|
const data = error.response?.data as ErrorResponse;
|
|
this.limitsMessage =
|
|
(data?.error?.message as string) || "Bad server response.";
|
|
console.error(
|
|
"Got bad response retrieving limits, which usually means user isn't registered.",
|
|
error,
|
|
);
|
|
} else {
|
|
this.limitsMessage = "Got an error retrieving limits.";
|
|
console.error("Got some error retrieving limits:", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Asynchronously switches the active account based on the provided account number.
|
|
*
|
|
* @param {number} accountNum - The account number to switch to. 0 means none.
|
|
*/
|
|
public async switchAccount(accountNum: number) {
|
|
await db.open(); // Assumes db needs to be open for both cases
|
|
|
|
if (accountNum === 0) {
|
|
this.switchToNoAccount();
|
|
} else {
|
|
await this.switchToAccountNumber(accountNum);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Switches to no active account and clears relevant properties.
|
|
*/
|
|
private async switchToNoAccount() {
|
|
await db.settings.update(MASTER_SETTINGS_KEY, { activeDid: undefined });
|
|
this.clearActiveAccountProperties();
|
|
}
|
|
|
|
/**
|
|
* Clears properties related to the active account.
|
|
*/
|
|
private clearActiveAccountProperties() {
|
|
this.activeDid = "";
|
|
this.derivationPath = "";
|
|
this.publicHex = "";
|
|
this.publicBase64 = "";
|
|
}
|
|
|
|
/**
|
|
* Switches to an account based on its number in the list.
|
|
*
|
|
* @param {number} accountNum - The account number to switch to.
|
|
*/
|
|
private async switchToAccountNumber(accountNum: number) {
|
|
await accountsDB.open();
|
|
const accounts = await accountsDB.accounts.toArray();
|
|
const account = accounts[accountNum - 1];
|
|
|
|
await db.open();
|
|
await db.settings.update(MASTER_SETTINGS_KEY, { activeDid: account.did });
|
|
|
|
this.updateActiveAccountProperties(account);
|
|
}
|
|
|
|
/**
|
|
* Updates properties related to the active account.
|
|
*
|
|
* @param {AccountType} account - The account object.
|
|
*/
|
|
private updateActiveAccountProperties(account: Account) {
|
|
this.activeDid = account.did;
|
|
this.derivationPath = account.derivationPath || "";
|
|
this.publicHex = account.publicKeyHex;
|
|
this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64");
|
|
}
|
|
|
|
public showContactGivesClassNames() {
|
|
return {
|
|
"bg-slate-900": !this.showContactGives,
|
|
"bg-green-600": this.showContactGives,
|
|
};
|
|
}
|
|
|
|
async onClickSaveApiServer() {
|
|
await db.open();
|
|
await db.settings.update(MASTER_SETTINGS_KEY, {
|
|
apiServer: this.apiServerInput,
|
|
});
|
|
this.apiServer = this.apiServerInput;
|
|
}
|
|
|
|
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.",
|
|
},
|
|
-1,
|
|
);
|
|
}
|
|
|
|
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();
|
|
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.",
|
|
},
|
|
-1,
|
|
);
|
|
// 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.",
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|