Files
crowd-funder-for-time-pwa/src/views/AccountViewView.vue
T

3262 lines
107 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<QuickNav selected="Profile" />
<!-- CONTENT -->
<main
id="Content"
class="p-6 pb-24 max-w-3xl mx-auto"
role="main"
aria-label="Account Profile"
>
<TopMessage />
<!-- Main View Heading -->
<div class="flex gap-4 items-center mb-8">
<h1 id="ViewHeading" class="text-2xl font-bold leading-none">
Your Identity
</h1>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div>
<!-- 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: 'new-identifier' }"
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
v-if="!isRegistered"
:passkeys-enabled="PASSKEYS_ENABLED"
:given-name="givenName"
:message="
`Before you can publicly announce a new project or time commitment, ` +
`a friend needs to register you.`
"
/>
<!-- Notifications -->
<!-- Enabled for native platforms (iOS/Android) -->
<section
v-if="isNativePlatform"
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 mb-2">
<div>
Daily Reminder
<button
class="text-slate-400 fa-fw cursor-pointer"
aria-label="Learn more about Daily 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 Daily Reminder notifications"
tabindex="0"
@click.stop.prevent="showReminderNotificationChoice()"
>
<!-- input -->
<input
:checked="notifyingReminder"
type="checkbox"
class="sr-only"
readonly
@click.stop.prevent
@change.stop.prevent
/>
<!-- 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">
<div
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200"
>
<div>
<b>Time:</b> {{ notifyingReminderTime.replace(" ", "&nbsp;") }}
</div>
<div>
<b>Message:</b> <i>"{{ notifyingReminderMessage }}"</i>
</div>
</div>
<div class="mt-2 text-center">
<button
class="w-full 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"
@click="editReminderNotification"
>
Edit Daily Reminder…
</button>
</div>
</div>
<!--
New Activity Notification: two delivery channels for the same alert.
Both let the user pick a time; they differ only in how the alert is
delivered (and therefore in the parameters passed to the notify-api):
- In-App: on-device notification via the daily-notification-plugin.
- SMS: text message sent by the notify-api (opt-in).
-->
<div class="mt-4 mb-1">
<div class="font-semibold">
New Activity Notification
<button
class="text-slate-400 fa-fw cursor-pointer"
aria-label="Learn more about New Activity notifications"
@click.stop="showNewActivityNotificationInfo(false)"
>
<font-awesome icon="question-circle" aria-hidden="true" />
</button>
</div>
<p class="text-xs text-slate-500">
Get told about new relevant activity for you. Choose a delivery
channel below -- you can turn on either one, or both.
</p>
</div>
<!-- Channel 1: In-App notification (only with general advanced functions on) -->
<div
v-if="showGeneralAdvanced"
class="flex items-center justify-between mt-3 mb-2 pl-1"
>
<!-- label -->
<div>
<font-awesome
icon="comment"
class="fa-fw text-slate-400"
aria-hidden="true"
/>
In-App Notification
</div>
<!-- toggle -->
<div
class="relative ml-2 cursor-pointer"
role="switch"
:aria-checked="notifyingNewActivity"
aria-label="Toggle New Activity notifications in app"
tabindex="0"
@click.stop.prevent="showNewActivityNotificationChoice()"
>
<input
:checked="notifyingNewActivity"
type="checkbox"
class="sr-only"
readonly
@click.stop.prevent
@change.stop.prevent
/>
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div>
</div>
</div>
<div v-if="showGeneralAdvanced" class="mb-2 pl-1">
<router-link class="text-sm text-blue-500" to="/help-notifications">
Troubleshoot your in-app notifications&hellip;
</router-link>
</div>
<div
v-if="notifyingNewActivity && showGeneralAdvanced"
class="w-full pl-1"
>
<div
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200"
>
<div>
<b>Time:</b> {{ notifyingNewActivityTime.replace(" ", "&nbsp;") }}
</div>
</div>
<div class="mt-2 text-center">
<button
class="w-full 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"
@click="editNewActivityNotification"
>
Edit In-App Time…
</button>
</div>
</div>
<!-- Channel 2: SMS text messages (opt-in; gated on a verified phone) -->
<div class="flex items-center justify-between mt-3 mb-2 pl-1">
<!-- label -->
<div>
<font-awesome
icon="message"
class="fa-fw text-slate-400"
aria-hidden="true"
/>
{{
notifyingNewActivitySmsPhone
? "SMS Text Message"
: "Enable Text Messages"
}}
</div>
<!-- master toggle: enables the SMS channel (verifies phone on first use) -->
<div
class="relative ml-2 cursor-pointer"
role="switch"
:aria-checked="smsEnabled"
aria-label="Toggle text message delivery"
tabindex="0"
@click.stop.prevent="toggleSmsEnabled()"
>
<input
:checked="smsEnabled"
type="checkbox"
class="sr-only"
readonly
@click.stop.prevent
@change.stop.prevent
/>
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div>
</div>
</div>
<div v-if="notifyingNewActivitySmsPhone" class="w-full pl-1">
<div
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200 flex items-center justify-between gap-2 flex-wrap"
>
<!-- Label and value must share one line: Vue's whitespace
condensing deletes a newline-only text node between elements,
which would glue "Texting:" to the number. -->
<div>
<b>Texting:</b> <i>{{ notifyingNewActivitySmsPhone }}</i>
</div>
<button
class="text-red-600"
aria-label="Forget phone number and disable text messages"
@click="forgetSmsPhoneNumber()"
>
<font-awesome icon="trash-can" class="fa-fw" aria-hidden="true" />
Forget Phone Number
</button>
</div>
</div>
<!-- Per-notification SMS toggles: only available while SMS is enabled -->
<div v-if="smsEnabled" class="w-full pl-4">
<div class="flex items-center justify-between mt-1 mb-2">
<div>
New Activity Text
<button
class="text-slate-400 fa-fw cursor-pointer"
aria-label="Learn more about New Activity text messages"
@click.stop="showNewActivityNotificationInfo(true)"
>
<font-awesome icon="question-circle" aria-hidden="true" />
</button>
</div>
<!-- toggle -->
<div
class="relative ml-2 cursor-pointer"
role="switch"
:aria-checked="notifyingNewActivitySms"
aria-label="Toggle New Activity text messages"
tabindex="0"
@click.stop.prevent="showNewActivitySmsNotificationChoice()"
>
<input
:checked="notifyingNewActivitySms"
type="checkbox"
class="sr-only"
readonly
@click.stop.prevent
@change.stop.prevent
/>
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div>
</div>
</div>
<div v-if="notifyingNewActivitySms" class="w-full">
<div
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200"
>
<div>
<b>Time:</b>
{{ notifyingNewActivitySmsTime.replace(" ", "&nbsp;") }}
</div>
</div>
<div class="mt-2 text-center">
<button
class="w-full 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"
@click="editNewActivitySmsNotification"
>
Edit SMS Time…
</button>
</div>
</div>
</div>
</section>
<PushNotificationPermission ref="pushNotificationPermission" />
<SmsVerificationDialog ref="smsVerificationDialog" />
<ChoiceButtonDialog ref="smsOptInChoiceDialog" />
<!-- 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">
<input
v-model="includeUserProfileLocation"
type="checkbox"
class="mr-2"
@change="onLocationCheckboxChange"
/>
<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"
@mounted="onMapMounted"
>
<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>
<LocationSearchSection :search-box="searchBox" />
<UsageLimitsSection
v-if="activeDid"
:loading-limits="loadingLimits"
:limits-message="limitsMessage"
:active-did="activeDid"
:endorser-limits="endorserLimits"
:image-limits="imageLimits"
@recheck-limits="onRecheckLimits"
/>
<DataExportSection :active-did="activeDid" />
<!-- id used by puppeteer test script -->
<h3
data-testid="advancedSettings"
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"
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>
<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>
<h2
data-testid="serverUrlsToggle"
class="text-slate-500 text-sm font-bold mt-4 cursor-pointer"
role="button"
tabindex="0"
:aria-expanded="showServerSettings"
aria-controls="sectionServerUrls"
@click="showServerSettings = !showServerSettings"
@keydown.enter="showServerSettings = !showServerSettings"
>
Server URLs
<font-awesome
:icon="showServerSettings ? 'chevron-down' : 'chevron-right'"
class="fa-fw ml-1"
aria-hidden="true"
/>
</h2>
<div v-if="showServerSettings" id="sectionServerUrls" class="pl-2">
<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>
<span class="text-white">Unsavedaroonosity</span>
</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>
<!-- Notification Push Server setting - only show on web platforms -->
<!-- This will be removed because we're no longer using PWA notifications. -->
<div v-if="false">
<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>
<span class="text-white">Unsaved</span>
</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>
</div>
<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>
<span class="text-white">Unsaved</span>
</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>
<h2 class="text-slate-500 text-sm font-bold mb-2">Image Server URL</h2>
<div class="px-3 py-4">
<input
v-model="imageServerInput"
type="text"
class="block w-full rounded border border-slate-400 px-3 py-2"
/>
<button
v-if="imageServerInput != imageServer"
class="w-full px-4 rounded bg-yellow-500 border border-slate-400"
@click="onClickSaveImageServer()"
>
<font-awesome
icon="floppy-disk"
class="fa-fw"
color="white"
></font-awesome>
<span class="text-white">Unsaved</span>
</button>
<button
class="px-3 rounded bg-slate-200 border border-slate-400"
@click="imageServerInput = AppConstants.PROD_IMAGE_API_SERVER"
>
Use Prod
</button>
<button
class="px-3 rounded bg-slate-200 border border-slate-400"
@click="imageServerInput = AppConstants.TEST_IMAGE_API_SERVER"
>
Use Test
</button>
<button
class="px-3 rounded bg-slate-200 border border-slate-400"
@click="imageServerInput = AppConstants.LOCAL_IMAGE_API_SERVER"
>
Use Local
</button>
</div>
<span v-if="!imageServerInput" class="px-4 text-sm">
When that setting is blank, this app will use the default image server
URL:
{{ DEFAULT_IMAGE_API_SERVER }}
</span>
<div class="mt-2">
<span class="text-slate-500 text-sm font-bold"
>Notify Server URL</span
>
&nbsp;
<span class="text-sm break-all">{{ notifyApiServer }}</span>
<button
class="text-slate-400 fa-fw cursor-pointer"
aria-label="Learn more about the notify server URL"
@click="showNotifyServerInfo"
>
<font-awesome icon="circle-info" aria-hidden="true"></font-awesome>
</button>
</div>
</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>
<router-link
:to="{ name: 'dev-notifications' }"
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"
>
Notification Debug Panel
</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>
<UserNameDialog ref="userNameDialog" />
<ImageMethodDialog ref="imageMethodDialog" />
</template>
<script lang="ts">
import "leaflet/dist/leaflet.css";
import { Buffer } from "buffer/";
import "dexie-export-import";
import { LeafletMouseEvent } from "leaflet";
import * as L from "leaflet";
import { IIdentifier } from "@veramo/core";
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { copyToClipboard } from "../services/ClipboardService";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import { Capacitor } from "@capacitor/core";
import ChoiceButtonDialog from "../components/ChoiceButtonDialog.vue";
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 SmsVerificationDialog from "../components/SmsVerificationDialog.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,
PASSKEYS_ENABLED,
} from "../constants/app";
import {
DEFAULT_PASSKEY_EXPIRATION_MINUTES,
BoundingBox,
} 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 { NOTIFY_IMAGE_DELETE_NOT_FOUND } from "@/constants/notifications";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import { AccountSettings, isApiError } from "@/interfaces/accountView";
import {
NotificationService,
authorizeInAppNewActivity,
authorizeSmsAlertSearch,
buildDualScheduleConfig,
configureNativeFetcherIfReady,
deleteSmsPhone,
getNotificationApiBaseUrl,
listSmsPhones,
localTimeTextToUtc,
revokeInAppNewActivity,
revokeSmsAlertSearch,
smsErrorMessage,
syncStarredPlansToNativePlugin,
UnsupportedIdentityError,
} from "@/services/notifications";
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
// Profile data interface (inlined from ProfileService)
interface ProfileData {
description: string;
latitude: number;
longitude: number;
includeLocation: boolean;
}
interface UserNameDialogRef {
open: (cb: (name?: string) => void) => void;
}
interface PushNotificationPermissionRef {
open: (
title: string,
callback: (success: boolean, timeText: string, message?: string) => void,
options?: {
skipSchedule?: boolean;
rolloverIntervalMinutes?: number;
timeOnly?: boolean;
},
) => void;
hourInput?: string;
minuteInput?: string;
hourAm?: boolean;
messageInput?: string;
}
@Component({
components: {
ChoiceButtonDialog,
EntityIcon,
ImageMethodDialog,
LMap,
LMarker,
LTileLayer,
PushNotificationPermission,
QuickNav,
SmsVerificationDialog,
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;
readonly PASSKEYS_ENABLED: boolean = PASSKEYS_ENABLED;
// Server URLs section starts collapsed
showServerSettings: boolean = false;
// Read-only here; the Notification Debug screen can override it
notifyApiServer: string = getNotificationApiBaseUrl();
// Identity and settings properties
activeDid: string = "";
apiServer: string = "";
apiServerInput: string = "";
derivationPath: string = "";
givenName: string = "";
hideRegisterPromptOnNewContact: boolean = false;
isRegistered: boolean = false;
isSearchAreasSet: boolean = false;
searchBox: { name: string; bbox: BoundingBox } | null = null;
partnerApiServer: string = DEFAULT_PARTNER_API_SERVER;
partnerApiServerInput: string = DEFAULT_PARTNER_API_SERVER;
imageServer: string = DEFAULT_IMAGE_API_SERVER;
imageServerInput: string = DEFAULT_IMAGE_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 = "";
// SMS channel (opt-in), backed by the notify-api's `/notify-sms` routes.
// Settings hold what the service cannot report back: it has a route to list
// registered numbers but none to read an authorization, so the chosen time
// and the paused/active distinction live here.
/** Master switch: the SMS channel is on (requires a verified phone). */
smsEnabled: boolean = false;
/** Verified phone number; kept when smsEnabled turns off, cleared by Forget. */
notifyingNewActivitySmsPhone: string = "";
notifyingNewActivitySms: boolean = false;
notifyingNewActivitySmsTime: string = "";
/** Guard: one in-flight `/notify-sms` write at a time. */
smsRequestInProgress: boolean = false;
notifyingReminder: boolean = false;
notifyingReminderMessage: string = "";
notifyingReminderTime: string = "";
/** Guard: only one edit-reminder schedule per user action (avoids double schedule on Android). */
editReminderScheduleInProgress: boolean = false;
/** Dev/test only: use 10-minute rollover for daily reminder (plugin rolloverIntervalMinutes). */
reminderFastRolloverForTesting: boolean = false;
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;
isMapReady: boolean = false;
// Limits and validation properties
endorserLimits: EndorserRateLimits | null = null;
imageLimits: ImageRateLimits | null = null;
limitsMessage: string = "";
private notify!: ReturnType<typeof createNotifyHelpers>;
/**
* Check if running on native platform (iOS/Android)
*/
private get isNativePlatform(): boolean {
return Capacitor.isNativePlatform();
}
created() {
this.notify = createNotifyHelpers(this.$notify);
// Fix Leaflet icon issues in modern bundlers
// This prevents the "Cannot read properties of undefined (reading 'Default')" error
if (L.Icon.Default) {
// Type-safe way to handle Leaflet icon prototype
const iconDefault = L.Icon.Default.prototype as unknown as Record<
string,
unknown
>;
if ("_getIconUrl" in iconDefault) {
delete iconDefault._getIconUrl;
}
L.Icon.Default.mergeOptions({
iconRetinaUrl:
"https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon-2x.png",
iconUrl: "https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon.png",
shadowUrl:
"https://unpkg.com/leaflet@1.7.1/dist/images/marker-shadow.png",
});
}
}
/**
* 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> {
try {
await this.initializeState();
await this.processIdentity();
// Profile service logic now inlined - no need for external service
logger.debug(
"[AccountViewView] Profile logic ready with partnerApiServer:",
{
partnerApiServer: this.partnerApiServer,
},
);
if (this.isRegistered) {
try {
const profile = await this.loadProfile(this.activeDid);
if (profile) {
this.userProfileDesc = profile.description;
this.userProfileLatitude = profile.latitude;
this.userProfileLongitude = profile.longitude;
this.includeUserProfileLocation = profile.includeLocation;
// Initialize map ready state if location is included
if (profile.includeLocation) {
this.isMapReady = false; // Will be set to true when map is ready
}
} else {
// Profile not created yet; leave defaults
}
} catch (error) {
logger.error("Error loading profile:", 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;
}
// Check limits for any user with an activeDid (this will also check registration status)
if (this.activeDid) {
await this.checkLimits();
}
// 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> {
// Then get the account-specific settings
const settings: AccountSettings = await this.$accountSettings();
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.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 && settings.searchBoxes.length > 0;
this.notifyingNewActivity = !!settings.notifyingNewActivityTime;
this.notifyingNewActivityTime = settings.notifyingNewActivityTime || "";
this.notifyingNewActivitySmsPhone =
settings.notifyingNewActivitySmsPhone || "";
this.notifyingNewActivitySmsTime =
settings.notifyingNewActivitySmsTime || "";
this.notifyingNewActivitySms = !!this.notifyingNewActivitySmsTime;
this.smsEnabled =
!!settings.smsNotificationsEnabled && !!this.notifyingNewActivitySmsPhone;
this.notifyingReminder = !!settings.notifyingReminderTime;
this.notifyingReminderMessage = settings.notifyingReminderMessage || "";
this.notifyingReminderTime = settings.notifyingReminderTime || "";
this.reminderFastRolloverForTesting =
!!settings.reminderFastRolloverForTesting;
this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer;
this.partnerApiServerInput =
settings.partnerApiServer || this.partnerApiServerInput;
this.imageServer = settings.imageServer || this.imageServer;
this.imageServerInput = settings.imageServer || this.imageServerInput;
this.profileImageUrl = settings.profileImageUrl;
this.showContactGives = !!settings.showContactGivesInline;
this.passkeyExpirationMinutes =
settings.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES;
this.previousPasskeyExpirationMinutes = this.passkeyExpirationMinutes;
this.searchBox = settings.searchBoxes?.[0] || null;
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
this.showAdvanced = this.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;
if (Capacitor.isNativePlatform() && this.activeDid) {
void configureNativeFetcherIfReady(this.activeDid);
if (this.notifyingNewActivity) {
const planIds = settings?.starredPlanHandleIds ?? [];
void syncStarredPlansToNativePlugin(planIds);
}
}
if (this.notifyingNewActivitySmsPhone) {
void this.reconcileSmsPhoneFromServer();
}
}
/** True when the active identity signs through a passkey rather than a key. */
private async activeIdentityIsPasskey(): Promise<boolean> {
try {
const account = await retrieveAccountMetadata(this.activeDid);
return !account?.identity && !!account?.passkeyCredIdHex;
} catch (error) {
logger.warn("[AccountViewView] Could not read account metadata", error);
return false;
}
}
/**
* Check the stored number against the service's own list.
*
* The service can drop a verification without the app hearing about it -- a
* `STOP` text unverifies every registration of that number, whoever sent it
* -- so a number this view shows as verified may not be. Only a definite
* answer changes anything: a failed call leaves the stored state alone
* rather than presenting a network problem as an opt-out.
*/
async reconcileSmsPhoneFromServer(): Promise<void> {
if (await this.activeIdentityIsPasskey()) {
// Every signature costs a WebAuthn prompt, which nobody expects from
// merely opening this page.
return;
}
try {
const { phones } = await listSmsPhones(this.activeDid);
const stored = this.notifyingNewActivitySmsPhone;
const match = phones.find((phone) => phone.phoneNumber === stored);
if (match?.verified) {
return;
}
logger.info(
"[AccountViewView] SMS number no longer verified server-side; clearing",
);
await this.$saveSettings({
notifyingNewActivitySmsPhone: "",
notifyingNewActivitySmsTime: "",
smsNotificationsEnabled: false,
});
this.notifyingNewActivitySmsPhone = "";
this.smsEnabled = false;
this.notifyingNewActivitySms = false;
this.notifyingNewActivitySmsTime = "";
} catch (error) {
logger.warn(
"[AccountViewView] Could not check SMS registrations; keeping stored state",
error,
);
}
}
// call fn, copy text to the clipboard, then redo fn after 2 seconds
async doCopyTwoSecRedo(text: string, fn: () => void): Promise<void> {
fn();
try {
await copyToClipboard(text);
setTimeout(fn, 2000);
} catch (error) {
this.$logAndConsole(`Error copying to clipboard: ${error}`, true);
this.notify.error("Failed to copy to clipboard.");
}
}
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;
} 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;
}
}
async showNewActivityNotificationInfo(isSms: boolean): Promise<void> {
this.notify.confirm(
isSms
? ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.NEW_ACTIVITY_SMS_INFO
: 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;
if (Capacitor.isNativePlatform()) {
await this.scheduleNewActivityDualNotification(timeText);
}
await authorizeInAppNewActivity((message) => {
this.reportAlertSearchAuthorizationFailure("upload", message);
});
}
});
} else {
this.notify.notificationOff(DAILY_CHECK_TITLE, async (success) => {
if (!success) {
return;
}
// Revoke before clearing the local setting. A failed delete leaves
// the toggle on so the dialog does not claim the subscription is gone.
const revoked = await revokeInAppNewActivity((message) => {
this.reportAlertSearchAuthorizationFailure("revoke", message);
});
if (!revoked) {
return false;
}
if (
Capacitor.isNativePlatform() &&
DailyNotification?.cancelDualSchedule
) {
try {
await DailyNotification.cancelDualSchedule();
} catch (e) {
logger.error("[AccountViewView] cancelDualSchedule failed:", e);
}
}
await this.$saveSettings({
notifyingNewActivityTime: "",
});
this.notifyingNewActivity = false;
this.notifyingNewActivityTime = "";
});
}
}
/**
* Surface an AlertSearch upload or revoke failure. The message states the
* failure; callers must not follow it with a success announcement.
*/
private reportAlertSearchAuthorizationFailure(
action: "upload" | "revoke",
message: string,
): void {
logger.error(
`[AccountViewView] AlertSearch authorization ${action} failed:`,
message,
);
this.notify.error(
`AlertSearch authorization ${action} failed: ${message}`,
TIMEOUTS.LONG,
);
}
/**
* Toggle the SMS channel as a whole ("Enable Text Messages" / "SMS Text
* Message" master switch).
*
* Enabling first shows an opt-in choice (see more info / enter phone
* number / cancel) before any phone number is requested. Only "Enter
* Phone Number" proceeds to SmsVerificationDialog, where the toggle turns
* on once the texted code is confirmed.
*
* Turning the toggle off revokes the alert authorization -- which is what
* actually stops the texts -- while leaving the number verified with the
* service, so re-enabling costs nothing. "Forget Phone Number" is the
* destructive path that also deletes the registration and so forces a fresh
* code next time.
*/
async toggleSmsEnabled(): Promise<void> {
if (this.smsRequestInProgress) {
return;
}
if (this.smsEnabled) {
// Pause the channel; the verified number stays for easy re-enable.
// The revoke runs even when no alert is showing as on, in case the
// service still holds a batch this app lost track of -- but only a
// failure that leaves texts actually arriving blocks the toggle.
const hadAlerts = this.notifyingNewActivitySms;
const stopped = await this.stopSmsAlerts(!hadAlerts);
if (!stopped && hadAlerts) {
return;
}
await this.$saveSettings({
notifyingNewActivitySmsTime: "",
smsNotificationsEnabled: false,
});
this.smsEnabled = false;
this.notifyingNewActivitySms = false;
this.notifyingNewActivitySmsTime = "";
return;
}
if (this.notifyingNewActivitySmsPhone) {
// Number already verified: just switch the channel back on. Nothing is
// authorized yet -- the per-notification toggle is what asks for texts.
await this.$saveSettings({ smsNotificationsEnabled: true });
this.smsEnabled = true;
return;
}
(this.$refs.smsOptInChoiceDialog as ChoiceButtonDialog).open({
title: "Enable Text Messages",
text: ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.SMS_OPT_IN_CHOICE,
option1Text: "See More Info",
option2Text: "Enter Phone Number",
onOption1: async () => {
await (this.$router as Router).push({
name: "help-notification-types",
});
},
onOption2: () => {
(this.$refs.smsVerificationDialog as SmsVerificationDialog).open(
this.activeDid,
async (success: boolean, phoneNumber?: string) => {
if (success && phoneNumber) {
await this.$saveSettings({
notifyingNewActivitySmsPhone: phoneNumber,
smsNotificationsEnabled: true,
});
this.notifyingNewActivitySmsPhone = phoneNumber;
this.smsEnabled = true;
}
},
);
},
});
}
/**
* Forget the verified phone number entirely: the authorization is revoked,
* the registration is deleted from the notify-api, and every SMS
* notification becomes unavailable until a number is verified again.
*/
async forgetSmsPhoneNumber(): Promise<void> {
this.notify.confirm(
ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.SMS_FORGET_PHONE_CONFIRM,
async () => {
if (this.smsRequestInProgress) {
return;
}
const phoneNumber = this.notifyingNewActivitySmsPhone;
this.smsRequestInProgress = true;
try {
// Revoke first: a deleted number with a live batch would leave the
// scheduler listing an identity it can no longer text.
await revokeSmsAlertSearch(this.activeDid);
if (phoneNumber) {
await deleteSmsPhone(this.activeDid, phoneNumber);
}
} catch (error) {
logger.error(
"[AccountViewView] Could not forget SMS phone number:",
error,
);
this.notify.error(
smsErrorMessage(
error,
"Could not remove your phone number. Please try again.",
),
TIMEOUTS.LONG,
);
return;
} finally {
this.smsRequestInProgress = false;
}
await this.$saveSettings({
notifyingNewActivitySmsPhone: "",
notifyingNewActivitySmsTime: "",
smsNotificationsEnabled: false,
});
this.notifyingNewActivitySmsPhone = "";
this.smsEnabled = false;
this.notifyingNewActivitySms = false;
this.notifyingNewActivitySmsTime = "";
},
);
}
/**
* Toggle the "New Activity Text" notification (only reachable while the
* SMS channel is enabled).
*
* On: authorize the notify-api to run this identity's daily alertSearch and
* text the result. Off: revoke that authorization, which is the only thing
* that stops the texts.
*/
async showNewActivitySmsNotificationChoice(): Promise<void> {
if (this.smsRequestInProgress) {
return;
}
if (this.notifyingNewActivitySms) {
const stopped = await this.stopSmsAlerts();
if (!stopped) {
return;
}
await this.$saveSettings({ notifyingNewActivitySmsTime: "" });
this.notifyingNewActivitySms = false;
this.notifyingNewActivitySmsTime = "";
return;
}
await this.promptSmsTimeAndEnable();
}
/**
* Revoke this identity's SMS alert authorization.
*
* @param quiet when true, a failure is logged but not shown -- for the
* speculative revoke of an authorization this app does not believe exists
* @returns true when the service confirmed it, false when the caller should
* leave the toggle where it was
*/
private async stopSmsAlerts(quiet: boolean = false): Promise<boolean> {
this.smsRequestInProgress = true;
try {
await revokeSmsAlertSearch(this.activeDid);
return true;
} catch (error) {
logger.error("[AccountViewView] Could not revoke SMS alerts:", error);
if (!quiet) {
this.notify.error(
smsErrorMessage(
error,
"Could not turn text messages off. Please try again.",
),
TIMEOUTS.LONG,
);
}
return false;
} finally {
this.smsRequestInProgress = false;
}
}
/**
* Prompt for the send time and hand the notify-api a fresh authorization.
*
* The time picker is used for its clock only: nothing is scheduled on this
* device, since the texts come from the server.
*/
async promptSmsTimeAndEnable(): Promise<void> {
(this.$refs.pushNotificationPermission as PushNotificationPermission).open(
DAILY_CHECK_TITLE,
async (success: boolean, timeText: string) => {
if (!success) {
return;
}
const authorized = await this.authorizeSmsAlerts(timeText);
if (!authorized) {
return;
}
await this.$saveSettings({ notifyingNewActivitySmsTime: timeText });
this.notifyingNewActivitySms = true;
this.notifyingNewActivitySmsTime = timeText;
},
{ timeOnly: true },
);
}
/**
* Edit the time for the SMS delivery channel.
*
* The service stores the hour on the JWT batch and has no route to change it
* alone, so a new time means a new batch. That is the documented correction
* path -- it is also how a user fixes the hour after a daylight-saving
* change, since the stored time is UTC and does not follow the wall clock.
*/
async editNewActivitySmsNotification(): Promise<void> {
const dialog = this.$refs
.pushNotificationPermission as PushNotificationPermission;
dialog.open(
DAILY_CHECK_TITLE,
async (success: boolean, timeText: string) => {
if (!success) return;
const authorized = await this.authorizeSmsAlerts(timeText);
if (!authorized) {
return;
}
await this.$saveSettings({ notifyingNewActivitySmsTime: timeText });
this.notifyingNewActivitySmsTime = timeText;
this.notify.success(
"New Activity SMS time updated.",
TIMEOUTS.STANDARD,
);
},
{ timeOnly: true },
);
// Pre-populate the dialog with the current SMS time
setTimeout(() => {
const timeMatch = this.notifyingNewActivitySmsTime.match(
/(\d+):(\d+)\s*(AM|PM)/i,
);
if (timeMatch) {
let hour = parseInt(timeMatch[1], 10);
const minute = timeMatch[2];
const isAm = timeMatch[3].toUpperCase() === "AM";
if (hour === 12) {
hour = 12;
} else if (hour > 12) {
hour = hour - 12;
}
const dialogComponent =
dialog as unknown as PushNotificationPermissionRef;
if (dialogComponent) {
dialogComponent.hourInput = hour.toString();
dialogComponent.minuteInput = minute;
dialogComponent.hourAm = isAm;
}
}
}, 150);
}
/**
* Mint a delegated JWT batch and upload it with the chosen hour, converted
* to the UTC pair the notify-api schedules on.
*
* Minting 100 signatures takes a moment on a phone, so the toggle is held
* until the service has the batch.
*
* @param timeText 12-hour local reading from the time picker
* @returns true when the service accepted the authorization
*/
private async authorizeSmsAlerts(timeText: string): Promise<boolean> {
const utcTime = localTimeTextToUtc(timeText);
if (!utcTime) {
logger.error(
"[AccountViewView] Unparseable SMS notification time:",
timeText,
);
this.notify.error(
"That time did not make sense. Please pick it again.",
TIMEOUTS.STANDARD,
);
return false;
}
this.smsRequestInProgress = true;
try {
await authorizeSmsAlertSearch(
this.activeDid,
utcTime.notifyHourUtc,
utcTime.notifyMinuteUtc,
);
return true;
} catch (error) {
logger.error("[AccountViewView] Could not authorize SMS alerts:", error);
this.notify.error(
error instanceof UnsupportedIdentityError
? error.message
: smsErrorMessage(
error,
"Could not turn text messages on. Please try again.",
),
TIMEOUTS.LONG,
);
return false;
} finally {
this.smsRequestInProgress = false;
}
}
/**
* Configure native fetcher, sync starred plans, and schedule API-driven dual notification.
*/
async scheduleNewActivityDualNotification(notifyTime: string): Promise<void> {
const plugin = DailyNotification as unknown as {
scheduleDualNotification?: (opts: { config: unknown }) => Promise<void>;
};
if (!plugin.scheduleDualNotification) {
logger.warn(
"[AccountViewView] scheduleDualNotification not available on this device",
);
this.notify.error(
"New Activity scheduling is not available on this device. Please update the app.",
TIMEOUTS.STANDARD,
);
return;
}
try {
const time24h = this.parseTimeTo24Hour(notifyTime);
await configureNativeFetcherIfReady(this.activeDid);
const settings = await this.$accountSettings();
const planIds = settings?.starredPlanHandleIds ?? [];
await syncStarredPlansToNativePlugin(planIds);
const config = buildDualScheduleConfig({ notifyTime: time24h });
// Diagnostic: log what Capacitor sees at call time (helps debug UNIMPLEMENTED)
const cap = (typeof window !== "undefined" &&
(
window as unknown as {
Capacitor?: {
PluginHeaders?: Array<{
name: string;
methods?: Array<{ name: string }>;
}>;
};
}
).Capacitor) as
| {
PluginHeaders?: Array<{
name: string;
methods?: Array<{ name: string }>;
}>;
}
| undefined;
const dnHeader = cap?.PluginHeaders?.find(
(h) => h.name === "DailyNotification",
);
const methodsAtCall = dnHeader?.methods?.map((m) => m.name) ?? null;
logger.warn(
"[AccountViewView] Before scheduleDualNotification, PluginHeaders methods:",
methodsAtCall ?? "DailyNotification not in headers",
);
if (
methodsAtCall &&
!methodsAtCall.includes("scheduleDualNotification")
) {
logger.warn(
"[AccountViewView] scheduleDualNotification missing from PluginHeaders at call time – bridge may be stale for this run",
);
}
await plugin.scheduleDualNotification!({ config });
} catch (error) {
logger.error(
"[AccountViewView] scheduleNewActivityDualNotification failed:",
error,
);
const err = error as {
code?: string;
errorMessage?: string;
message?: string;
};
const code = err?.code;
const msg = err?.errorMessage ?? err?.message ?? "";
if (code === "UNIMPLEMENTED") {
this.notify.error(
"New Activity scheduling is not yet available on this device. Please update the app when support is added.",
TIMEOUTS.STANDARD,
);
} else if (
msg.includes("BGTaskSchedulerErrorDomain") ||
msg.includes("error 1")
) {
this.notify.error(
"New Activity scheduling needs a real device and Background App Refresh enabled. It does not work in Simulator.",
TIMEOUTS.STANDARD,
);
} else {
this.notify.error(
"Could not schedule New Activity notification. Please try again.",
TIMEOUTS.STANDARD,
);
}
}
}
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;
}
},
{
rolloverIntervalMinutes: this.reminderFastRolloverForTesting
? 10
: undefined,
},
);
} else {
// On native platforms, handle notification cancellation directly
// (bypassing web push unsubscribe flow which doesn't work on native)
if (this.isNativePlatform) {
// Show confirmation dialog
this.notify.confirm(
"Would you like to turn off Daily Reminder notifications?",
async () => {
try {
// Cancel the native notification
const service = NotificationService.getInstance();
await service.cancelDailyNotification();
// Update settings and state
await this.$saveSettings({
notifyingReminderMessage: "",
notifyingReminderTime: "",
});
this.notifyingReminder = false;
this.notifyingReminderMessage = "";
this.notifyingReminderTime = "";
this.notify.success(
"Daily Reminder notifications have been turned off.",
TIMEOUTS.STANDARD,
);
} catch (error) {
logger.error(
"[AccountViewView] Error canceling native notification:",
error,
);
this.notify.error(
"Failed to turn off notification. Please try again.",
TIMEOUTS.STANDARD,
);
}
},
);
} else {
// Web platform: use existing web push unsubscribe flow
this.notify.notificationOff(DIRECT_PUSH_TITLE, async (success) => {
if (success) {
await this.$saveSettings({
notifyingReminderMessage: "",
notifyingReminderTime: "",
});
this.notifyingReminder = false;
this.notifyingReminderMessage = "";
this.notifyingReminderTime = "";
}
});
}
}
}
/**
* Edit existing reminder notification
* Opens the notification dialog to allow editing time and message
*/
async editReminderNotification(): Promise<void> {
const dialog = this.$refs
.pushNotificationPermission as PushNotificationPermission;
// skipSchedule: true so only this callback schedules (dialog does not). Avoids double schedule on Android.
dialog.open(
DIRECT_PUSH_TITLE,
async (success: boolean, timeText: string, message?: string) => {
if (!success) return;
if (this.editReminderScheduleInProgress) return;
this.editReminderScheduleInProgress = true;
try {
const service = NotificationService.getInstance();
// On iOS: cancel then schedule. On Android: plugin cancels internally when scheduling with same id; skip pre-cancel to avoid double-cancel edge cases.
if (Capacitor.getPlatform() !== "android") {
await service.cancelDailyNotification();
}
// Schedule the updated notification (one call per user action)
const time24h = this.parseTimeTo24Hour(timeText);
const title = "Daily Reminder";
const body =
message ||
this.notifyingReminderMessage ||
"Click to share some gratitude with the world -- even if they're unnamed.";
const scheduleSuccess = await service.scheduleDailyNotification({
time: time24h,
title,
body,
priority: "normal",
...(this.reminderFastRolloverForTesting
? { rolloverIntervalMinutes: 10 }
: {}),
});
if (scheduleSuccess) {
await this.$saveSettings({
notifyingReminderMessage: message,
notifyingReminderTime: timeText,
});
this.notifyingReminderMessage = message || "";
this.notifyingReminderTime = timeText;
this.notify.success("Daily reminder updated.", TIMEOUTS.STANDARD);
} else {
this.notify.error(
"Failed to update reminder. Please try again.",
TIMEOUTS.STANDARD,
);
}
} catch (err) {
logger.error("[AccountViewView] Edit reminder schedule failed:", err);
this.notify.error(
"Failed to update reminder. Please try again.",
TIMEOUTS.STANDARD,
);
} finally {
this.editReminderScheduleInProgress = false;
}
},
{
skipSchedule: true,
rolloverIntervalMinutes: this.reminderFastRolloverForTesting
? 10
: undefined,
},
);
// Pre-populate the form with current values after dialog opens
// Use setTimeout to ensure the dialog is fully rendered
setTimeout(() => {
// Parse the current time string (e.g., "5:22 PM")
const timeMatch = this.notifyingReminderTime.match(
/(\d+):(\d+)\s*(AM|PM)/i,
);
if (timeMatch) {
let hour = parseInt(timeMatch[1], 10);
const minute = timeMatch[2];
const isAm = timeMatch[3].toUpperCase() === "AM";
// Convert to 12-hour format for the input
if (hour === 12) {
hour = 12;
} else if (hour > 12) {
hour = hour - 12;
}
// Set the component's properties directly
// Note: We need to access the component's internal properties
// This is a workaround but necessary to pre-populate
const dialogComponent =
dialog as unknown as PushNotificationPermissionRef;
if (dialogComponent) {
dialogComponent.hourInput = hour.toString();
dialogComponent.minuteInput = minute;
dialogComponent.hourAm = isAm;
dialogComponent.messageInput =
this.notifyingReminderMessage ||
"Click to share some gratitude with the world -- even if they're unnamed.";
}
}
}, 150);
}
/**
* Edit existing New Activity notification time.
* Opens the dialog with current time; on success updates dual schedule via
* updateDualScheduleConfig when available (plugin v2.1.0+), else scheduleDualNotification.
*/
async editNewActivityNotification(): Promise<void> {
const dialog = this.$refs
.pushNotificationPermission as PushNotificationPermission;
dialog.open(
DAILY_CHECK_TITLE,
async (success: boolean, timeText: string) => {
if (!success) return;
if (Capacitor.isNativePlatform()) {
const time24h = this.parseTimeTo24Hour(timeText);
const config = buildDualScheduleConfig({ notifyTime: time24h });
const plugin = DailyNotification as unknown as {
updateDualScheduleConfig?: (opts: {
config: unknown;
}) => Promise<void>;
scheduleDualNotification?: (opts: {
config: unknown;
}) => Promise<void>;
};
try {
if (plugin.updateDualScheduleConfig) {
await plugin.updateDualScheduleConfig({ config });
} else {
await this.scheduleNewActivityDualNotification(timeText);
}
} catch (e) {
logger.warn(
"[AccountViewView] updateDualScheduleConfig failed, falling back to scheduleDualNotification:",
e,
);
try {
await this.scheduleNewActivityDualNotification(timeText);
} catch (fallbackError) {
logger.error(
"[AccountViewView] editNewActivityNotification schedule failed:",
fallbackError,
);
this.notify.error(
"Could not update New Activity time. Please try again.",
TIMEOUTS.STANDARD,
);
return;
}
}
}
await this.$saveSettings({ notifyingNewActivityTime: timeText });
this.notifyingNewActivityTime = timeText;
this.notify.success(
"New Activity notification time updated.",
TIMEOUTS.STANDARD,
);
},
{ skipSchedule: true },
);
// Pre-populate the dialog with current New Activity time
setTimeout(() => {
const timeMatch = this.notifyingNewActivityTime.match(
/(\d+):(\d+)\s*(AM|PM)/i,
);
if (timeMatch) {
let hour = parseInt(timeMatch[1], 10);
const minute = timeMatch[2];
const isAm = timeMatch[3].toUpperCase() === "AM";
if (hour === 12) {
hour = 12;
} else if (hour > 12) {
hour = hour - 12;
}
const dialogComponent =
dialog as unknown as PushNotificationPermissionRef;
if (dialogComponent) {
dialogComponent.hourInput = hour.toString();
dialogComponent.minuteInput = minute;
dialogComponent.hourAm = isAm;
}
}
}, 150);
}
/**
* Parse time string (e.g., "5:22 PM") to 24-hour format (e.g., "17:22")
*/
private parseTimeTo24Hour(timeStr: string): string {
const timeMatch = timeStr.match(/(\d+):(\d+)\s*(AM|PM)/i);
if (!timeMatch) {
return "09:00"; // Default fallback
}
let hour = parseInt(timeMatch[1], 10);
const minute = timeMatch[2];
const isAm = timeMatch[3].toUpperCase() === "AM";
// Convert to 24-hour format
if (isAm && hour === 12) {
hour = 0;
} else if (!isAm && hour !== 12) {
hour = hour + 12;
}
return `${hour.toString().padStart(2, "0")}:${minute}`;
}
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 = "";
// The SMS channel is deliberately untouched: this runs when the browser's
// web-push subscription has gone missing, and texts do not travel over it.
}
/**
* 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 checkLimits(): Promise<void> {
this.loadingLimits = true;
const did = this.activeDid;
if (!did) {
this.limitsMessage = ACCOUNT_VIEW_CONSTANTS.LIMITS.NO_IDENTIFIER;
return;
}
try {
await this.$saveUserSettings(did, {
apiServer: this.apiServer,
partnerApiServer: this.partnerApiServer,
webPushServer: this.webPushServer,
});
const imageResp = await fetchImageRateLimits(
this.axios,
did,
this.imageServer,
);
if (imageResp && imageResp.status === 200) {
this.imageLimits = imageResp.data;
} else {
this.limitsMessage = ACCOUNT_VIEW_CONSTANTS.LIMITS.NO_IMAGE_ACCESS;
}
const endorserResp = await fetchEndorserRateLimits(
this.apiServer,
this.axios,
did,
);
if (endorserResp.status === 200) {
this.endorserLimits = endorserResp.data;
}
} catch (error) {
this.limitsMessage =
ACCOUNT_VIEW_CONSTANTS.LIMITS.ERROR_RETRIEVING_LIMITS;
// Enhanced error logging with server context
const axiosError = error as {
response?: {
data?: { error?: { code?: string; message?: string } };
status?: number;
};
};
logger.warn(
"[Server Limits] Error retrieving limits, expected for unregistered users:",
{
error: error instanceof Error ? error.message : String(error),
did: did,
apiServer: this.apiServer,
imageServer: this.imageServer,
partnerApiServer: this.partnerApiServer,
errorCode: axiosError?.response?.data?.error?.code,
errorMessage: axiosError?.response?.data?.error?.message,
httpStatus: axiosError?.response?.status,
needsUserMigration: true,
timestamp: new Date().toISOString(),
},
);
// this.notify.error(this.limitsMessage, TIMEOUTS.STANDARD);
} finally {
this.loadingLimits = false;
}
}
async onClickSaveApiServer(): Promise<void> {
// Enhanced diagnostic logging for claim URL changes
const previousApiServer = this.apiServer;
const newApiServer = this.apiServerInput;
logger.debug("[Server Switching] Claim URL change initiated:", {
did: this.activeDid,
previousServer: previousApiServer,
newServer: newApiServer,
changeType: "apiServer",
timestamp: new Date().toISOString(),
});
await this.$saveSettings({
apiServer: newApiServer,
});
this.apiServer = newApiServer;
// Add this line to save to user-specific settings
await this.$saveUserSettings(this.activeDid, {
apiServer: this.apiServer,
});
// Log successful server switch
logger.debug("[Server Switching] Claim URL change completed:", {
did: this.activeDid,
previousServer: previousApiServer,
newServer: newApiServer,
changeType: "apiServer",
settingsSaved: true,
timestamp: new Date().toISOString(),
});
}
async onClickSavePartnerServer(): Promise<void> {
// Enhanced diagnostic logging for partner server changes
const previousPartnerServer = this.partnerApiServer;
const newPartnerServer = this.partnerApiServerInput;
logger.debug("[Server Switching] Partner server change initiated:", {
did: this.activeDid,
previousServer: previousPartnerServer,
newServer: newPartnerServer,
changeType: "partnerApiServer",
timestamp: new Date().toISOString(),
});
await this.$saveSettings({
partnerApiServer: newPartnerServer,
});
this.partnerApiServer = newPartnerServer;
await this.$saveUserSettings(this.activeDid, {
partnerApiServer: this.partnerApiServer,
});
// Log successful partner server switch
logger.debug("[Server Switching] Partner server change completed:", {
did: this.activeDid,
previousServer: previousPartnerServer,
newServer: newPartnerServer,
changeType: "partnerApiServer",
settingsSaved: true,
timestamp: new Date().toISOString(),
});
}
async onClickSaveImageServer(): Promise<void> {
await this.$saveSettings({
imageServer: this.imageServerInput,
});
this.imageServer = this.imageServerInput;
await this.$saveUserSettings(this.activeDid, {
imageServer: this.imageServer,
});
}
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;
},
IMAGE_TYPE_PROFILE,
true,
);
}
confirmDeleteImage(): void {
this.notify.confirm(
ACCOUNT_VIEW_CONSTANTS.WARNINGS.IMAGE_DELETE_WARNING,
this.deleteImage,
);
}
async deleteImage(): Promise<void> {
try {
if (!this.profileImageUrl) {
this.notify.error("Invalid image URL");
return;
}
// the image server looks the image up by its full URL
const response = await this.axios.delete(
this.imageServer + "/image/" + encodeURIComponent(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 profileImageUrl so the user can try again if they want
}
} catch (error) {
if (isApiError(error) && error.response?.status === 404) {
// still drop it from the profile, which is what the user asked for
this.profileImageUrl = "";
await this.$saveSettings({
profileImageUrl: "",
});
this.notify.error(NOTIFY_IMAGE_DELETE_NOT_FOUND.message, TIMEOUTS.LONG);
} else {
this.notify.error(
ACCOUNT_VIEW_CONSTANTS.ERRORS.IMAGE_DELETE_ERROR,
TIMEOUTS.STANDARD,
);
}
}
}
onMapReady(map: L.Map): void {
try {
// 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;
const lat = this.userProfileLatitude || 0;
const lng = this.userProfileLongitude || 0;
map.setView([lat, lng], zoom);
this.isMapReady = true;
logger.debug(
"Map ready state set to true, coordinates:",
[lat, lng],
"zoom:",
zoom,
);
} catch (error) {
logger.error("Error in onMapReady:", error);
this.isMapReady = true; // Set to true even on error to prevent infinite loading
}
}
onMapMounted(): void {
logger.debug("Map component mounted");
// Check if map ref is available
const mapRef = this.$refs.profileMap;
logger.debug("Map ref:", mapRef);
// Try to set map ready after component is mounted
setTimeout(() => {
this.isMapReady = true;
}, 500);
}
// Fallback method to handle map initialization failures
private handleMapInitFailure(): void {
setTimeout(() => {
if (!this.isMapReady) {
logger.warn("Map failed to initialize, forcing ready state");
this.isMapReady = true;
}
}, 5000); // 5 second timeout
}
showProfileInfo(): void {
this.notify.info(
ACCOUNT_VIEW_CONSTANTS.INFO.PROFILE_INFO,
TIMEOUTS.VERY_LONG,
);
}
showNotifyServerInfo(): void {
this.notify.info(
ACCOUNT_VIEW_CONSTANTS.INFO.NOTIFY_SERVER_INFO,
TIMEOUTS.VERY_LONG,
);
}
async saveProfile(): Promise<void> {
this.savingProfile = true;
try {
const profileData: ProfileData = {
description: this.userProfileDesc,
latitude: this.userProfileLatitude,
longitude: this.userProfileLongitude,
includeLocation: this.includeUserProfileLocation,
};
logger.debug("Saving profile data:", profileData);
const success = await this.saveProfileToServer(
this.activeDid,
profileData,
);
if (success) {
this.notify.success(ACCOUNT_VIEW_CONSTANTS.SUCCESS.PROFILE_SAVED);
// Show seed phrase backup reminder if needed
try {
const settings = await this.$accountSettings();
showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify);
} catch (error) {
logger.error("Error checking seed backup status:", error);
}
} else {
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_SAVE_ERROR);
}
} catch (error) {
logger.error("Error saving profile:", error);
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_SAVE_ERROR);
} finally {
this.savingProfile = false;
}
}
toggleUserProfileLocation(): void {
try {
const updated = this.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;
// Reset map ready state when toggling location
if (!updated.includeLocation) {
this.isMapReady = false;
}
} catch (error) {
logger.error("Error in toggleUserProfileLocation:", error);
this.notify.error("Failed to toggle location setting");
}
}
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.deleteProfileFromServer(this.activeDid);
if (success) {
this.notify.success(ACCOUNT_VIEW_CONSTANTS.SUCCESS.PROFILE_DELETED);
this.userProfileDesc = "";
this.userProfileLatitude = 0;
this.userProfileLongitude = 0;
this.includeUserProfileLocation = false;
this.isMapReady = false; // Reset map state
} else {
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_DELETE_ERROR);
}
} catch (error) {
logger.error("Error in deleteProfile component method:", error);
// Show more specific error message if available
if (error instanceof Error) {
this.notify.error(error.message);
} else {
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) {
try {
if (event && event.latlng) {
this.userProfileLatitude = event.latlng.lat;
this.userProfileLongitude = event.latlng.lng;
}
} catch (error) {
logger.error("Error in onProfileMapClick:", error);
}
}
onLocationCheckboxChange(): void {
try {
logger.debug(
"Location checkbox changed, new value:",
this.includeUserProfileLocation,
);
if (!this.includeUserProfileLocation) {
// Location checkbox was unchecked, clean up map state
this.isMapReady = false;
this.userProfileLatitude = 0;
this.userProfileLongitude = 0;
} else {
// Location checkbox was checked, start map initialization timeout
this.isMapReady = false;
logger.debug("Location checked, starting map initialization timeout");
// Try to set map ready after a short delay to allow Vue to render
setTimeout(() => {
if (!this.isMapReady) {
this.isMapReady = true;
}
}, 1000); // 1 second delay
this.handleMapInitFailure();
}
} catch (error) {
logger.error("Error in onLocationCheckboxChange:", error);
}
}
// IdentitySection event handlers
onEditName() {
const dialog = this.$refs.userNameDialog as UserNameDialogRef | undefined;
if (dialog && typeof dialog.open === "function") {
dialog.open((name?: string) => {
if (name) this.givenName = name;
});
} else {
this.notify.error("Name dialog not available.");
logger.error(
"UserNameDialog ref is missing or open() is not a function",
dialog,
);
}
}
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));
}
onRecheckLimits() {
this.checkLimits();
}
// Inlined profile methods (previously in ProfileService)
/**
* Load user profile from the partner API
*/
private async loadProfile(did: string): Promise<ProfileData | null> {
try {
const requestId = `profile_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
logger.debug("[AccountViewView] Loading profile:", {
requestId,
did,
partnerApiServer: this.partnerApiServer,
});
// Get authentication headers
const headers = await getHeaders(did);
const fullUrl = `${this.partnerApiServer}/api/partner/userProfileForIssuer/${did}`;
logger.debug("[AccountViewView] Making API request:", {
requestId,
did,
fullUrl,
hasAuthHeader: !!headers.Authorization,
});
const response = await this.axios.get(fullUrl, { headers });
logger.debug("[AccountViewView] Profile loaded successfully:", {
requestId,
status: response.status,
hasData: !!response.data,
});
if (response.data && response.data.data) {
const profileData = response.data.data;
logger.debug("[AccountViewView] Parsing profile data:", {
requestId,
locLat: profileData.locLat,
locLon: profileData.locLon,
description: profileData.description,
});
const result = {
description: profileData.description || "",
latitude: profileData.locLat || 0,
longitude: profileData.locLon || 0,
includeLocation: !!(profileData.locLat && profileData.locLon),
};
logger.debug("[AccountViewView] Parsed profile result:", {
requestId,
result,
hasLocation: result.includeLocation,
});
if (response.data.userMessage) {
this.notify.info(response.data.userMessage);
}
return result;
} else {
logger.debug("[AccountViewView] No profile data found in response:", {
requestId,
hasData: !!response.data,
hasDataData: !!(response.data && response.data.data),
});
if (response.data?.userMessage) {
this.notify.info(response.data.userMessage);
}
}
return null;
} catch (error: unknown) {
// Handle specific HTTP status codes cleanly to suppress console spam
if (error && typeof error === "object" && "response" in error) {
const axiosError = error as { response?: { status?: number } };
if (axiosError.response?.status === 404) {
logger.info(
"[Profile] No profile found - this is normal for new users",
{
did,
server: this.partnerApiServer,
status: 404,
timestamp: new Date().toISOString(),
},
);
return null;
}
if (axiosError.response?.status === 400) {
logger.warn("[Profile] Bad request - user may not be registered", {
did,
server: this.partnerApiServer,
status: 400,
timestamp: new Date().toISOString(),
});
return null;
}
if (
axiosError.response?.status === 401 ||
axiosError.response?.status === 403
) {
logger.warn("[Profile] Authentication/authorization issue", {
did,
server: this.partnerApiServer,
status: axiosError.response.status,
timestamp: new Date().toISOString(),
});
return null;
}
}
// Only log full errors for unexpected issues (5xx, network errors, etc.)
logger.error("[Profile] Unexpected error loading profile:", {
did,
server: this.partnerApiServer,
error: error instanceof Error ? error.message : String(error),
timestamp: new Date().toISOString(),
});
return null;
}
}
/**
* Save user profile to the partner API
*/
private async saveProfileToServer(
did: string,
profileData: ProfileData,
): Promise<boolean> {
try {
const requestId = `profile_save_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
logger.debug("[AccountViewView] Saving profile:", {
requestId,
did,
profileData,
});
// Get authentication headers
const headers = await getHeaders(did);
// Prepare payload in the format expected by the partner API
const payload = {
description: profileData.description,
issuerDid: did,
...(profileData.includeLocation &&
profileData.latitude &&
profileData.longitude
? {
locLat: profileData.latitude,
locLon: profileData.longitude,
}
: {}),
};
logger.debug("[AccountViewView] Sending payload to server:", {
requestId,
payload,
hasLocation: profileData.includeLocation,
});
const response = await this.axios.post(
`${this.partnerApiServer}/api/partner/userProfile`,
payload,
{ headers },
);
logger.debug("[AccountViewView] Profile saved successfully:", {
requestId,
status: response.status,
});
if (response.data?.userMessage) {
this.notify.info(response.data.userMessage);
}
return true;
} catch (error: unknown) {
// Handle specific HTTP status codes cleanly to suppress console spam
if (error && typeof error === "object" && "response" in error) {
const axiosError = error as { response?: { status?: number } };
if (axiosError.response?.status === 400) {
logger.warn("[Profile] Bad request saving profile", {
did,
server: this.partnerApiServer,
status: 400,
timestamp: new Date().toISOString(),
});
throw new Error("Invalid profile data");
}
if (
axiosError.response?.status === 401 ||
axiosError.response?.status === 403
) {
logger.warn(
"[Profile] Authentication/authorization issue saving profile",
{
did,
server: this.partnerApiServer,
status: axiosError.response.status,
timestamp: new Date().toISOString(),
},
);
throw new Error("Authentication required");
}
if (axiosError.response?.status === 409) {
logger.warn("[Profile] Profile conflict - may already exist", {
did,
server: this.partnerApiServer,
status: 409,
timestamp: new Date().toISOString(),
});
throw new Error("Profile already exists");
}
}
// Only log full errors for unexpected issues
logger.error("[Profile] Unexpected error saving profile:", {
did,
server: this.partnerApiServer,
error: error instanceof Error ? error.message : String(error),
timestamp: new Date().toISOString(),
});
throw new Error("Failed to save profile");
}
}
/**
* Toggle profile location visibility
*/
private toggleProfileLocation(profileData: ProfileData): ProfileData {
const includeLocation = !profileData.includeLocation;
return {
...profileData,
latitude: includeLocation ? profileData.latitude : 0,
longitude: includeLocation ? profileData.longitude : 0,
includeLocation,
};
}
/**
* Delete user profile from the partner API
*/
private async deleteProfileFromServer(did: string): Promise<boolean> {
try {
const requestId = `profile_delete_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
logger.debug("[AccountViewView] Deleting profile:", {
requestId,
did,
});
// Get authentication headers
const headers = await getHeaders(did);
const response = await this.axios.delete(
`${this.partnerApiServer}/api/partner/userProfile/${did}`,
{ headers },
);
logger.debug("[AccountViewView] Profile deleted successfully:", {
requestId,
status: response.status,
});
if (response.data?.userMessage) {
this.notify.info(response.data.userMessage);
}
return true;
} catch (error: unknown) {
// Handle specific HTTP status codes cleanly to suppress console spam
if (error && typeof error === "object" && "response" in error) {
const axiosError = error as { response?: { status?: number } };
if (axiosError.response?.status === 404) {
logger.info(
"[Profile] Profile not found for deletion - may already be deleted",
{
did,
server: this.partnerApiServer,
status: 404,
timestamp: new Date().toISOString(),
},
);
return true; // Consider it successful if already deleted
}
if (
axiosError.response?.status === 401 ||
axiosError.response?.status === 403
) {
logger.warn(
"[Profile] Authentication/authorization issue deleting profile",
{
did,
server: this.partnerApiServer,
status: axiosError.response.status,
timestamp: new Date().toISOString(),
},
);
return false;
}
}
// Only log full errors for unexpected issues
logger.error("[Profile] Unexpected error deleting profile:", {
did,
server: this.partnerApiServer,
error: error instanceof Error ? error.message : String(error),
timestamp: new Date().toISOString(),
});
return false;
}
}
}
</script>