forked from trent_larson/crowd-funder-for-time-pwa
- Add notification channel creation in TimeSafariApplication for Android 8.0+ Required for daily notifications to display properly. Channel ID matches plugin's 'timesafari.daily' channel. - Convert localhost to 10.0.2.2 in CapacitorPlatformService for Android emulators Android emulators cannot reach localhost - they need 10.0.2.2 to access the host machine's API server. - Refresh native fetcher configuration when API server changes in AccountViewView Ensures background notification prefetch uses the updated endpoint when user changes API server URL in settings. - Add directive for fixing notification dismiss cancellation in plugin Documents the fix needed in plugin source to cancel notification from NotificationManager when dismiss button is clicked. These changes ensure daily notifications work correctly on Android, including proper channel setup, emulator network connectivity, and configuration refresh.
2264 lines
72 KiB
Vue
2264 lines
72 KiB
Vue
<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 -->
|
|
<!-- Currently disabled because it doesn't work, even on Chrome.
|
|
If restored, make sure it works or doesn't show on mobile/electron. -->
|
|
<section
|
|
v-if="false"
|
|
id="sectionNotifications"
|
|
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
|
|
aria-labelledby="notificationsHeading"
|
|
>
|
|
<h2 id="notificationsHeading" class="mb-2 font-bold">Notifications</h2>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
Reminder Notification
|
|
<button
|
|
class="text-slate-400 fa-fw cursor-pointer"
|
|
aria-label="Learn more about reminder notifications"
|
|
@click.stop="showReminderNotificationInfo"
|
|
>
|
|
<font-awesome
|
|
icon="question-circle"
|
|
aria-hidden="true"
|
|
></font-awesome>
|
|
</button>
|
|
</div>
|
|
<div
|
|
class="relative ml-2 cursor-pointer"
|
|
role="switch"
|
|
:aria-checked="notifyingReminder"
|
|
aria-label="Toggle reminder notifications"
|
|
tabindex="0"
|
|
@click="showReminderNotificationChoice()"
|
|
>
|
|
<!-- input -->
|
|
<input v-model="notifyingReminder" type="checkbox" class="sr-only" />
|
|
<!-- line -->
|
|
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
|
<!-- dot -->
|
|
<div
|
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
<div v-if="notifyingReminder" class="w-full flex justify-between">
|
|
<span class="ml-8 mr-8">Message: "{{ notifyingReminderMessage }}"</span>
|
|
<span>{{ notifyingReminderTime.replace(" ", " ") }}</span>
|
|
</div>
|
|
<div class="mt-2 flex items-center justify-between">
|
|
<!-- label -->
|
|
<div>
|
|
New Activity Notification
|
|
<font-awesome
|
|
icon="question-circle"
|
|
class="text-slate-400 fa-fw cursor-pointer"
|
|
@click.stop="showNewActivityNotificationInfo"
|
|
/>
|
|
</div>
|
|
<!-- toggle -->
|
|
<div
|
|
class="relative ml-2 cursor-pointer"
|
|
@click="showNewActivityNotificationChoice()"
|
|
>
|
|
<!-- input -->
|
|
<input
|
|
v-model="notifyingNewActivity"
|
|
type="checkbox"
|
|
class="sr-only"
|
|
/>
|
|
<!-- line -->
|
|
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
|
<!-- dot -->
|
|
<div
|
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
<div v-if="notifyingNewActivityTime" class="w-full text-right">
|
|
{{ notifyingNewActivityTime.replace(" ", " ") }}
|
|
</div>
|
|
<div class="mt-2 text-center">
|
|
<router-link class="text-sm text-blue-500" to="/help-notifications">
|
|
Troubleshoot your notifications…
|
|
</router-link>
|
|
</div>
|
|
</section>
|
|
<PushNotificationPermission ref="pushNotificationPermission" />
|
|
|
|
<!-- Daily Notifications (Native) -->
|
|
<DailyNotificationSection />
|
|
|
|
<!-- 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="toggleShowGeneralAdvanced"
|
|
>
|
|
{{
|
|
showGeneralAdvanced
|
|
? "Hide Advanced Settings"
|
|
: "Show Advanced Settings"
|
|
}}
|
|
</h3>
|
|
<section
|
|
v-if="showGeneralAdvanced"
|
|
id="sectionAdvanced"
|
|
aria-labelledby="advancedHeading"
|
|
>
|
|
<h2 id="advancedHeading" class="sr-only">Advanced Settings</h2>
|
|
<p class="text-rose-600 mb-8">
|
|
Beware: the features here can be confusing and even change data in ways
|
|
you do not expect. But we support your freedom!
|
|
</p>
|
|
|
|
<!-- Deep Identity Details -->
|
|
<span class="text-slate-500 text-sm font-bold mb-2">
|
|
Identifier Details
|
|
</span>
|
|
<div
|
|
id="sectionDeepIdentifier"
|
|
class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4"
|
|
>
|
|
<div class="text-slate-500 text-sm font-bold">Public Key (base 64)</div>
|
|
<div
|
|
class="text-sm text-slate-500 flex justify-start items-center mb-1"
|
|
>
|
|
<code class="truncate">{{ publicBase64 }}</code>
|
|
<button
|
|
class="ml-2"
|
|
@click="
|
|
doCopyTwoSecRedo(publicBase64, () => (showB64Copy = !showB64Copy))
|
|
"
|
|
>
|
|
<font-awesome
|
|
icon="copy"
|
|
class="text-slate-400 fa-fw"
|
|
></font-awesome>
|
|
</button>
|
|
<span v-show="showB64Copy">Copied</span>
|
|
</div>
|
|
|
|
<div class="text-slate-500 text-sm font-bold">Public Key (hex)</div>
|
|
<div
|
|
class="text-sm text-slate-500 flex justify-start items-center mb-1"
|
|
>
|
|
<code class="truncate">{{ publicHex }}</code>
|
|
<button
|
|
class="ml-2"
|
|
@click="
|
|
doCopyTwoSecRedo(publicHex, () => (showPubCopy = !showPubCopy))
|
|
"
|
|
>
|
|
<font-awesome
|
|
icon="copy"
|
|
class="text-slate-400 fa-fw"
|
|
></font-awesome>
|
|
</button>
|
|
<span v-show="showPubCopy">Copied</span>
|
|
</div>
|
|
|
|
<div class="text-slate-500 text-sm font-bold">Derivation Path</div>
|
|
<div
|
|
v-if="derivationPath"
|
|
class="text-sm text-slate-500 flex justify-start items-center mb-1"
|
|
>
|
|
<code class="truncate">{{ derivationPath }}</code>
|
|
<button
|
|
class="ml-2"
|
|
@click="
|
|
doCopyTwoSecRedo(
|
|
derivationPath,
|
|
() => (showDerCopy = !showDerCopy),
|
|
)
|
|
"
|
|
>
|
|
<font-awesome
|
|
icon="copy"
|
|
class="text-slate-400 fa-fw"
|
|
></font-awesome>
|
|
</button>
|
|
<span v-show="showDerCopy">Copied</span>
|
|
</div>
|
|
<div
|
|
v-else
|
|
class="text-sm text-slate-500 flex justify-start items-center mb-1"
|
|
>
|
|
(none)
|
|
</div>
|
|
</div>
|
|
|
|
<!-- id used by puppeteer test script -->
|
|
<router-link
|
|
id="switch-identity-link"
|
|
:to="{ name: 'identity-switcher' }"
|
|
class="block w-fit text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md mb-2"
|
|
>
|
|
Switch Identifier
|
|
</router-link>
|
|
|
|
<div id="sectionImportContactsSettings" class="mt-4">
|
|
<h2 class="text-slate-500 text-sm font-bold">Import Contacts</h2>
|
|
|
|
<div class="ml-4 mt-2">
|
|
<input type="file" class="ml-2" @change="uploadImportFile" />
|
|
<transition
|
|
enter-active-class="transform ease-out duration-300 transition"
|
|
enter-from-class="translate-y-2 opacity-0 sm:translate-y-4"
|
|
enter-to-class="translate-y-0 opacity-100 sm:translate-y-0"
|
|
leave-active-class="transition ease-in duration-500"
|
|
leave-from-class="opacity-100"
|
|
leave-to-class="opacity-0"
|
|
>
|
|
<div v-if="showContactImport()" class="mt-4">
|
|
<!-- Bulk import has an error
|
|
<div class="flex justify-center">
|
|
<button
|
|
class="block text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
|
|
@click="confirmSubmitImportFile()"
|
|
>
|
|
Overwrite Settings & Contacts
|
|
<br />
|
|
(which doesn't include Identifier Data)
|
|
</button>
|
|
</div>
|
|
-->
|
|
<div class="flex justify-center">
|
|
<button
|
|
class="block text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
|
|
@click="checkContactImports()"
|
|
>
|
|
Import Contacts
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
</div>
|
|
|
|
<label
|
|
for="toggleShowAmounts"
|
|
class="flex items-center justify-between cursor-pointer my-4"
|
|
@click="toggleShowContactAmounts"
|
|
>
|
|
<!-- label -->
|
|
<span class="text-slate-500 text-sm font-bold">Contacts Display</span>
|
|
<span class="ml-2">Show hours given & received</span>
|
|
<!-- toggle -->
|
|
<div class="relative ml-2">
|
|
<!-- input -->
|
|
<input
|
|
v-model="showContactGives"
|
|
type="checkbox"
|
|
name="showContactGives"
|
|
class="sr-only"
|
|
/>
|
|
<!-- line -->
|
|
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
|
<!-- dot -->
|
|
<div
|
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
|
></div>
|
|
</div>
|
|
</label>
|
|
|
|
<div id="sectionClaimServer">
|
|
<h2 class="text-slate-500 text-sm font-bold mt-4">Claim Server</h2>
|
|
<div
|
|
class="px-4 py-4"
|
|
role="group"
|
|
aria-labelledby="claimServerHeading"
|
|
>
|
|
<h3 id="claimServerHeading" class="sr-only">
|
|
Claim Server Configuration
|
|
</h3>
|
|
<label for="apiServerInput" class="sr-only">API Server URL</label>
|
|
<input
|
|
id="apiServerInput"
|
|
v-model="apiServerInput"
|
|
type="text"
|
|
class="block w-full rounded border border-slate-400 px-4 py-2"
|
|
aria-describedby="apiServerDescription"
|
|
placeholder="Enter API server URL"
|
|
/>
|
|
<div id="apiServerDescription" class="sr-only" role="tooltip">
|
|
Enter the URL for the claim server. You can use the buttons below to
|
|
quickly set common server URLs.
|
|
</div>
|
|
<button
|
|
v-if="apiServerInput != apiServer"
|
|
class="w-full px-4 rounded bg-yellow-500 border border-slate-400"
|
|
aria-label="Save API server URL"
|
|
@click="onClickSaveApiServer()"
|
|
>
|
|
<font-awesome
|
|
icon="floppy-disk"
|
|
class="fa-fw"
|
|
color="white"
|
|
aria-hidden="true"
|
|
></font-awesome>
|
|
</button>
|
|
<div class="mt-2" role="group" aria-label="Quick server selection">
|
|
<button
|
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
|
aria-label="Use production server URL"
|
|
@click="apiServerInput = AppConstants.PROD_ENDORSER_API_SERVER"
|
|
>
|
|
Use Prod
|
|
</button>
|
|
<button
|
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
|
aria-label="Use test server URL"
|
|
@click="apiServerInput = AppConstants.TEST_ENDORSER_API_SERVER"
|
|
>
|
|
Use Test
|
|
</button>
|
|
<button
|
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
|
aria-label="Use local server URL"
|
|
@click="apiServerInput = AppConstants.LOCAL_ENDORSER_API_SERVER"
|
|
>
|
|
Use Local
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<label
|
|
for="toggleProdWarningMessage"
|
|
class="flex items-center justify-between cursor-pointer px-4 py-4"
|
|
@click="toggleProdWarning"
|
|
>
|
|
<!-- label -->
|
|
<h2>Show warning if on prod server</h2>
|
|
<!-- toggle -->
|
|
<div class="relative ml-2">
|
|
<!-- input -->
|
|
<input v-model="warnIfProdServer" type="checkbox" class="sr-only" />
|
|
<!-- line -->
|
|
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
|
<!-- dot -->
|
|
<div
|
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
|
></div>
|
|
</div>
|
|
</label>
|
|
|
|
<label
|
|
for="toggleTestWarningMessage"
|
|
class="flex items-center justify-between cursor-pointer px-4 py-4"
|
|
@click="toggleTestWarning"
|
|
>
|
|
<!-- label -->
|
|
<h2>Show warning if on non-prod server</h2>
|
|
<!-- toggle -->
|
|
<div class="relative ml-2">
|
|
<!-- input -->
|
|
<input v-model="warnIfTestServer" type="checkbox" class="sr-only" />
|
|
<!-- line -->
|
|
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
|
<!-- dot -->
|
|
<div
|
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
|
></div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<h2 class="text-slate-500 text-sm font-bold mb-2">
|
|
Notification Push Server
|
|
</h2>
|
|
<div class="px-3 py-4">
|
|
<input
|
|
v-model="webPushServerInput"
|
|
type="text"
|
|
class="block w-full rounded border border-slate-400 px-3 py-2"
|
|
/>
|
|
<button
|
|
v-if="webPushServerInput != webPushServer"
|
|
class="w-full px-4 rounded bg-yellow-500 border border-slate-400"
|
|
@click="onClickSavePushServer()"
|
|
>
|
|
<font-awesome
|
|
icon="floppy-disk"
|
|
class="fa-fw"
|
|
color="white"
|
|
></font-awesome>
|
|
</button>
|
|
<button
|
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
|
@click="webPushServerInput = AppConstants.PROD_PUSH_SERVER"
|
|
>
|
|
Use Prod
|
|
</button>
|
|
<button
|
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
|
@click="webPushServerInput = AppConstants.TEST1_PUSH_SERVER"
|
|
>
|
|
Use Test 1
|
|
</button>
|
|
<button
|
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
|
@click="webPushServerInput = AppConstants.TEST2_PUSH_SERVER"
|
|
>
|
|
Use Test 2
|
|
</button>
|
|
</div>
|
|
<span v-if="!webPushServerInput" class="px-4 text-sm">
|
|
When that setting is blank, this app will use the default web push
|
|
server URL:
|
|
{{ DEFAULT_PUSH_SERVER }}
|
|
</span>
|
|
|
|
<h2 class="text-slate-500 text-sm font-bold mb-2">Partner Server URL</h2>
|
|
<div class="px-3 py-4">
|
|
<input
|
|
v-model="partnerApiServerInput"
|
|
type="text"
|
|
class="block w-full rounded border border-slate-400 px-3 py-2"
|
|
/>
|
|
<button
|
|
v-if="partnerApiServerInput != partnerApiServer"
|
|
class="w-full px-4 rounded bg-yellow-500 border border-slate-400"
|
|
@click="onClickSavePartnerServer()"
|
|
>
|
|
<font-awesome
|
|
icon="floppy-disk"
|
|
class="fa-fw"
|
|
color="white"
|
|
></font-awesome>
|
|
</button>
|
|
<button
|
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
|
@click="partnerApiServerInput = AppConstants.PROD_PARTNER_API_SERVER"
|
|
>
|
|
Use Prod
|
|
</button>
|
|
<button
|
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
|
@click="partnerApiServerInput = AppConstants.TEST_PARTNER_API_SERVER"
|
|
>
|
|
Use Test
|
|
</button>
|
|
<button
|
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
|
@click="partnerApiServerInput = AppConstants.LOCAL_PARTNER_API_SERVER"
|
|
>
|
|
Use Local
|
|
</button>
|
|
</div>
|
|
<span v-if="!partnerApiServerInput" class="px-4 text-sm">
|
|
When that setting is blank, this app will use the default partner server
|
|
URL:
|
|
{{ DEFAULT_PARTNER_API_SERVER }}
|
|
</span>
|
|
|
|
<div class="mt-2">
|
|
<span class="text-slate-500 text-sm font-bold">Image Server URL</span>
|
|
|
|
<span class="text-sm">{{ DEFAULT_IMAGE_API_SERVER }}</span>
|
|
</div>
|
|
|
|
<label
|
|
for="toggleHideRegisterPromptOnNewContact"
|
|
class="flex items-center justify-between cursor-pointer mt-4"
|
|
@click="toggleHideRegisterPromptOnNewContact()"
|
|
>
|
|
<!-- label -->
|
|
<span class="text-slate-500 text-sm font-bold">
|
|
Hide Register Prompt on New Contact
|
|
</span>
|
|
<!-- toggle -->
|
|
<div class="relative ml-2">
|
|
<!-- input -->
|
|
<input
|
|
v-model="hideRegisterPromptOnNewContact"
|
|
type="checkbox"
|
|
class="sr-only"
|
|
/>
|
|
<!-- line -->
|
|
<div class="block bg-slate-500 w-14 h-8 rounded-full" />
|
|
<!-- dot -->
|
|
<div
|
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
|
/>
|
|
</div>
|
|
</label>
|
|
|
|
<label
|
|
for="toggleShowShortcutBvc"
|
|
class="flex items-center justify-between cursor-pointer mt-4"
|
|
@click="toggleShowShortcutBvc"
|
|
>
|
|
<!-- label -->
|
|
<span class="text-slate-500 text-sm font-bold">
|
|
Show BVC Shortcut on Home Page
|
|
</span>
|
|
<!-- toggle -->
|
|
<div class="relative ml-2">
|
|
<!-- input -->
|
|
<input v-model="showShortcutBvc" type="checkbox" class="sr-only" />
|
|
<!-- line -->
|
|
<div class="block bg-slate-500 w-14 h-8 rounded-full" />
|
|
<!-- dot -->
|
|
<div
|
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
|
/>
|
|
</div>
|
|
</label>
|
|
|
|
<div id="sectionPasskeyExpiration" class="flex mt-4 justify-between">
|
|
<span>
|
|
<span class="text-slate-500 text-sm font-bold">
|
|
Passkey Expiration Minutes
|
|
</span>
|
|
<br />
|
|
<span class="text-sm ml-2">
|
|
{{ passkeyExpirationDescription }}
|
|
</span>
|
|
</span>
|
|
<div class="relative ml-2">
|
|
<input
|
|
v-model="passkeyExpirationMinutes"
|
|
type="number"
|
|
class="border border-slate-400 rounded px-2 py-2 text-center w-20"
|
|
@change="updatePasskeyExpiration"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<label
|
|
for="toggleShowGeneralAdvanced"
|
|
class="flex items-center justify-between cursor-pointer mt-4"
|
|
@click="toggleShowGeneralAdvanced"
|
|
>
|
|
<!-- label -->
|
|
<span class="text-slate-500 text-sm font-bold">
|
|
Show All General Advanced Functions
|
|
</span>
|
|
<!-- toggle -->
|
|
<div class="relative ml-2">
|
|
<!-- input -->
|
|
<input
|
|
v-model="showGeneralAdvanced"
|
|
type="checkbox"
|
|
class="sr-only"
|
|
/>
|
|
<!-- line -->
|
|
<div class="block bg-slate-500 w-14 h-8 rounded-full" />
|
|
<!-- dot -->
|
|
<div
|
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
|
/>
|
|
</div>
|
|
</label>
|
|
|
|
<router-link
|
|
:to="{ name: 'logs' }"
|
|
class="block w-fit text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md mt-2"
|
|
>
|
|
Logs
|
|
</router-link>
|
|
<router-link
|
|
:to="{ name: 'test' }"
|
|
class="block w-fit text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md mt-2"
|
|
>
|
|
Test Page
|
|
</router-link>
|
|
|
|
<div class="flex mt-2">
|
|
<button>
|
|
<router-link
|
|
:to="{ name: 'statistics' }"
|
|
class="block w-fit text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
|
>
|
|
See Global Animated History of Giving
|
|
</router-link>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<UserNameDialog ref="userNameDialog" />
|
|
<ImageMethodDialog ref="imageMethodDialog" />
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import "leaflet/dist/leaflet.css";
|
|
|
|
import { Buffer } from "buffer/";
|
|
import "dexie-export-import";
|
|
|
|
// @ts-expect-error - they aren't exporting it but it's there
|
|
import { ImportProgress } from "dexie-export-import";
|
|
import { LeafletMouseEvent } from "leaflet";
|
|
import * as L from "leaflet";
|
|
import * as R from "ramda";
|
|
import { IIdentifier } from "@veramo/core";
|
|
import { ref } from "vue";
|
|
import { Component, Vue } from "vue-facing-decorator";
|
|
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
|
import { copyToClipboard } from "../services/ClipboardService";
|
|
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
|
import { Capacitor } from "@capacitor/core";
|
|
|
|
import EntityIcon from "../components/EntityIcon.vue";
|
|
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
|
|
import PushNotificationPermission from "../components/PushNotificationPermission.vue";
|
|
import QuickNav from "../components/QuickNav.vue";
|
|
import TopMessage from "../components/TopMessage.vue";
|
|
import UserNameDialog from "../components/UserNameDialog.vue";
|
|
import DataExportSection from "../components/DataExportSection.vue";
|
|
import IdentitySection from "@/components/IdentitySection.vue";
|
|
import RegistrationNotice from "@/components/RegistrationNotice.vue";
|
|
import LocationSearchSection from "@/components/LocationSearchSection.vue";
|
|
import UsageLimitsSection from "@/components/UsageLimitsSection.vue";
|
|
import DailyNotificationSection from "@/components/notifications/DailyNotificationSection.vue";
|
|
import {
|
|
AppString,
|
|
DEFAULT_IMAGE_API_SERVER,
|
|
DEFAULT_PARTNER_API_SERVER,
|
|
DEFAULT_PUSH_SERVER,
|
|
IMAGE_TYPE_PROFILE,
|
|
NotificationIface,
|
|
PASSKEYS_ENABLED,
|
|
} from "../constants/app";
|
|
import { Contact } from "../db/tables/contacts";
|
|
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 { PlatformServiceFactory } from "../services/PlatformServiceFactory";
|
|
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
|
|
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
|
|
import {
|
|
AccountSettings,
|
|
isApiError,
|
|
ImportContent,
|
|
} from "@/interfaces/accountView";
|
|
// Profile data interface (inlined from ProfileService)
|
|
interface ProfileData {
|
|
description: string;
|
|
latitude: number;
|
|
longitude: number;
|
|
includeLocation: boolean;
|
|
}
|
|
|
|
const inputImportFileNameRef = ref<Blob>();
|
|
|
|
interface UserNameDialogRef {
|
|
open: (cb: (name?: string) => void) => void;
|
|
}
|
|
|
|
@Component({
|
|
components: {
|
|
EntityIcon,
|
|
ImageMethodDialog,
|
|
LMap,
|
|
LMarker,
|
|
LTileLayer,
|
|
PushNotificationPermission,
|
|
QuickNav,
|
|
TopMessage,
|
|
UserNameDialog,
|
|
DataExportSection,
|
|
IdentitySection,
|
|
RegistrationNotice,
|
|
LocationSearchSection,
|
|
UsageLimitsSection,
|
|
DailyNotificationSection,
|
|
},
|
|
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;
|
|
|
|
// 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;
|
|
passkeyExpirationDescription: string = "";
|
|
passkeyExpirationMinutes: number = DEFAULT_PASSKEY_EXPIRATION_MINUTES;
|
|
previousPasskeyExpirationMinutes: number = DEFAULT_PASSKEY_EXPIRATION_MINUTES;
|
|
profileImageUrl?: string;
|
|
publicHex: string = "";
|
|
publicBase64: string = "";
|
|
webPushServer: string = DEFAULT_PUSH_SERVER;
|
|
webPushServerInput: string = DEFAULT_PUSH_SERVER;
|
|
|
|
// Profile properties
|
|
userProfileDesc: string = "";
|
|
userProfileLatitude: number = 0;
|
|
userProfileLongitude: number = 0;
|
|
includeUserProfileLocation: boolean = false;
|
|
savingProfile: boolean = false;
|
|
|
|
// Notification properties
|
|
notifyingNewActivity: boolean = false;
|
|
notifyingNewActivityTime: string = "";
|
|
notifyingReminder: boolean = false;
|
|
notifyingReminderMessage: string = "";
|
|
notifyingReminderTime: string = "";
|
|
subscription: PushSubscription | null = null;
|
|
|
|
// UI state properties
|
|
downloadUrl: string = ""; // because DuckDuckGo doesn't download on automated call to "click" on the anchor
|
|
loadingLimits: boolean = false;
|
|
loadingProfile: boolean = true;
|
|
showAdvanced: boolean = false;
|
|
showB64Copy: boolean = false;
|
|
showContactGives: boolean = false;
|
|
showDidCopy: boolean = false;
|
|
showDerCopy: boolean = false;
|
|
showGeneralAdvanced: boolean = false;
|
|
showLargeIdenticonId?: string;
|
|
showLargeIdenticonUrl?: string;
|
|
showPubCopy: boolean = false;
|
|
showShortcutBvc: boolean = false;
|
|
warnIfProdServer: boolean = false;
|
|
warnIfTestServer: boolean = false;
|
|
zoom: number = 2;
|
|
isMapReady: boolean = false;
|
|
|
|
// Limits and validation properties
|
|
endorserLimits: EndorserRateLimits | null = null;
|
|
imageLimits: ImageRateLimits | null = null;
|
|
limitsMessage: string = "";
|
|
|
|
private notify!: ReturnType<typeof createNotifyHelpers>;
|
|
|
|
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.notifyingReminder = !!settings.notifyingReminderTime;
|
|
this.notifyingReminderMessage = settings.notifyingReminderMessage || "";
|
|
this.notifyingReminderTime = settings.notifyingReminderTime || "";
|
|
this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer;
|
|
this.partnerApiServerInput =
|
|
settings.partnerApiServer || this.partnerApiServerInput;
|
|
this.profileImageUrl = settings.profileImageUrl;
|
|
this.showContactGives = !!settings.showContactGivesInline;
|
|
this.passkeyExpirationMinutes =
|
|
settings.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES;
|
|
this.previousPasskeyExpirationMinutes = this.passkeyExpirationMinutes;
|
|
this.searchBox = settings.searchBoxes?.[0] || null;
|
|
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
|
|
this.showShortcutBvc = !!settings.showShortcutBvc;
|
|
this.warnIfProdServer = !!settings.warnIfProdServer;
|
|
this.warnIfTestServer = !!settings.warnIfTestServer;
|
|
this.webPushServer = settings.webPushServer || this.webPushServer;
|
|
this.webPushServerInput = settings.webPushServer || this.webPushServerInput;
|
|
}
|
|
|
|
// call fn, copy text to the clipboard, then redo fn after 2 seconds
|
|
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(): Promise<void> {
|
|
this.notify.confirm(
|
|
ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.NEW_ACTIVITY_INFO,
|
|
async () => {
|
|
await (this.$router as Router).push({
|
|
name: "help-notification-types",
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
async showNewActivityNotificationChoice(): Promise<void> {
|
|
if (!this.notifyingNewActivity) {
|
|
(
|
|
this.$refs.pushNotificationPermission as PushNotificationPermission
|
|
).open(DAILY_CHECK_TITLE, async (success: boolean, timeText: string) => {
|
|
if (success) {
|
|
await this.$saveSettings({
|
|
notifyingNewActivityTime: timeText,
|
|
});
|
|
this.notifyingNewActivity = true;
|
|
this.notifyingNewActivityTime = timeText;
|
|
}
|
|
});
|
|
} else {
|
|
this.notify.notificationOff(DAILY_CHECK_TITLE, async (success) => {
|
|
if (success) {
|
|
await this.$saveSettings({
|
|
notifyingNewActivityTime: "",
|
|
});
|
|
this.notifyingNewActivity = false;
|
|
this.notifyingNewActivityTime = "";
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
async showReminderNotificationInfo(): Promise<void> {
|
|
this.notify.confirm(
|
|
ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.REMINDER_INFO,
|
|
async () => {
|
|
await (this.$router as Router).push({
|
|
name: "help-notification-types",
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
async showReminderNotificationChoice(): Promise<void> {
|
|
if (!this.notifyingReminder) {
|
|
(
|
|
this.$refs.pushNotificationPermission as PushNotificationPermission
|
|
).open(
|
|
DIRECT_PUSH_TITLE,
|
|
async (success: boolean, timeText: string, message?: string) => {
|
|
if (success) {
|
|
await this.$saveSettings({
|
|
notifyingReminderMessage: message,
|
|
notifyingReminderTime: timeText,
|
|
});
|
|
this.notifyingReminder = true;
|
|
this.notifyingReminderMessage = message || "";
|
|
this.notifyingReminderTime = timeText;
|
|
}
|
|
},
|
|
);
|
|
} else {
|
|
this.notify.notificationOff(DIRECT_PUSH_TITLE, async (success) => {
|
|
if (success) {
|
|
await this.$saveSettings({
|
|
notifyingReminderMessage: "",
|
|
notifyingReminderTime: "",
|
|
});
|
|
this.notifyingReminder = false;
|
|
this.notifyingReminderMessage = "";
|
|
this.notifyingReminderTime = "";
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
public async toggleHideRegisterPromptOnNewContact(): Promise<void> {
|
|
const newSetting = !this.hideRegisterPromptOnNewContact;
|
|
await this.$saveSettings({
|
|
hideRegisterPromptOnNewContact: newSetting,
|
|
});
|
|
this.hideRegisterPromptOnNewContact = newSetting;
|
|
}
|
|
|
|
public async updatePasskeyExpiration(): Promise<void> {
|
|
await this.$saveSettings({
|
|
passkeyExpirationMinutes: this.passkeyExpirationMinutes,
|
|
});
|
|
clearPasskeyToken();
|
|
this.passkeyExpirationDescription = tokenExpiryTimeDescription();
|
|
}
|
|
|
|
public async turnOffNotifyingFlags(): Promise<void> {
|
|
// should tell the push server as well
|
|
await this.$saveSettings({
|
|
notifyingNewActivityTime: "",
|
|
notifyingReminderMessage: "",
|
|
notifyingReminderTime: "",
|
|
});
|
|
this.notifyingNewActivity = false;
|
|
this.notifyingNewActivityTime = "";
|
|
this.notifyingReminder = false;
|
|
this.notifyingReminderMessage = "";
|
|
this.notifyingReminderTime = "";
|
|
}
|
|
|
|
/**
|
|
* Asynchronously exports the database into a downloadable JSON file.
|
|
*
|
|
* @throws Will notify the user if there is an export error.
|
|
*/
|
|
public async exportDatabase() {
|
|
try {
|
|
// Generate the blob from the database
|
|
const blob = await this.generateDatabaseBlob();
|
|
|
|
// Create a temporary URL for the blob
|
|
this.downloadUrl = this.createBlobURL(blob);
|
|
|
|
// Trigger the download
|
|
this.downloadDatabaseBackup(this.downloadUrl);
|
|
|
|
// Notify the user that the download has started
|
|
this.notifyDownloadStarted();
|
|
|
|
// Revoke the temporary URL -- after a pause to avoid DuckDuckGo download failure
|
|
setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000);
|
|
} catch (error) {
|
|
this.handleExportError(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generates a blob object representing the database.
|
|
*
|
|
* @returns {Promise<Blob>} The generated blob object.
|
|
*/
|
|
private async generateDatabaseBlob(): Promise<Blob> {
|
|
// TODO: Implement this for SQLite
|
|
throw new Error("Not implemented");
|
|
}
|
|
|
|
/**
|
|
* Creates a temporary URL for a blob object.
|
|
*
|
|
* @param {Blob} blob - The blob object.
|
|
* @returns {string} The temporary URL for the blob.
|
|
*/
|
|
private createBlobURL(blob: Blob): string {
|
|
return URL.createObjectURL(blob);
|
|
}
|
|
|
|
/**
|
|
* Triggers the download of the database backup.
|
|
*
|
|
* @param {string} url - The temporary URL for the blob.
|
|
*/
|
|
private downloadDatabaseBackup(url: string) {
|
|
const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement;
|
|
downloadAnchor.href = url;
|
|
downloadAnchor.download = `${AppString.APP_NAME_NO_SPACES}-backup.json`;
|
|
downloadAnchor.click(); // doesn't work for some browsers, eg. DuckDuckGo
|
|
}
|
|
|
|
public computedStartDownloadLinkClassNames() {
|
|
return {
|
|
hidden: this.downloadUrl,
|
|
};
|
|
}
|
|
|
|
public computedDownloadLinkClassNames() {
|
|
return {
|
|
hidden: !this.downloadUrl,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Notifies the user that the download has started.
|
|
*/
|
|
private notifyDownloadStarted() {
|
|
this.notify.downloadStarted();
|
|
}
|
|
|
|
/**
|
|
* Handles errors during the database export process.
|
|
*
|
|
* @param {Error} error - The error object.
|
|
*/
|
|
private handleExportError(error: unknown): void {
|
|
logger.error("Export Error:", error);
|
|
this.notify.error(
|
|
ACCOUNT_VIEW_CONSTANTS.ERRORS.EXPORT_ERROR,
|
|
TIMEOUTS.STANDARD,
|
|
);
|
|
}
|
|
|
|
async uploadImportFile(event: Event): Promise<void> {
|
|
inputImportFileNameRef.value = (
|
|
event.target as HTMLInputElement
|
|
).files?.[0];
|
|
}
|
|
|
|
showContactImport(): boolean {
|
|
return !!inputImportFileNameRef.value;
|
|
}
|
|
|
|
confirmSubmitImportFile(): void {
|
|
if (inputImportFileNameRef.value != null) {
|
|
this.notify.confirm(
|
|
ACCOUNT_VIEW_CONSTANTS.WARNINGS.IMPORT_REPLACE_WARNING,
|
|
this.submitImportFile,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Asynchronously imports the database from a downloadable JSON file.
|
|
*
|
|
* @throws Will notify the user if there is an export error.
|
|
*/
|
|
async submitImportFile(): Promise<void> {
|
|
if (inputImportFileNameRef.value != null) {
|
|
// TODO: implement this for SQLite
|
|
}
|
|
}
|
|
|
|
async checkContactImports(): Promise<void> {
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => {
|
|
const fileContent: string = (event.target?.result as string) || "{}";
|
|
try {
|
|
const contents: ImportContent = JSON.parse(fileContent);
|
|
const contactTableRows: Array<Contact> = (
|
|
contents.data?.data as [{ tableName: string; rows: Array<Contact> }]
|
|
)?.find((table) => table.tableName === "contacts")
|
|
?.rows as Array<Contact>;
|
|
const contactRows = contactTableRows.map(
|
|
// @ts-expect-error for omitting this field that is found in the Dexie format
|
|
(contact) => R.omit(["$types"], contact) as Contact,
|
|
);
|
|
(this.$router as Router).push({
|
|
name: "contact-import",
|
|
query: { contacts: JSON.stringify(contactRows) },
|
|
});
|
|
} catch (error) {
|
|
logger.error("Error checking contact imports:", error);
|
|
this.notify.error(
|
|
ACCOUNT_VIEW_CONSTANTS.ERRORS.IMPORT_ERROR,
|
|
TIMEOUTS.STANDARD,
|
|
);
|
|
}
|
|
};
|
|
reader.readAsText(inputImportFileNameRef.value as Blob);
|
|
}
|
|
|
|
private progressCallback(progress: ImportProgress): boolean {
|
|
logger.log(
|
|
`Import progress: ${progress.completedRows} of ${progress.totalRows} rows completed.`,
|
|
);
|
|
if (progress.done) {
|
|
this.notify.success(
|
|
ACCOUNT_VIEW_CONSTANTS.SUCCESS.IMPORT_COMPLETE,
|
|
TIMEOUTS.LONG,
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
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.DEFAULT_IMAGE_API_SERVER,
|
|
);
|
|
|
|
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.DEFAULT_IMAGE_API_SERVER,
|
|
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(),
|
|
});
|
|
|
|
// Refresh native fetcher configuration with new API server
|
|
// This ensures background notification prefetch uses the updated endpoint
|
|
try {
|
|
const platformService = PlatformServiceFactory.getInstance();
|
|
const settings = await this.$accountSettings();
|
|
const starredPlanHandleIds = settings.starredPlanHandleIds || [];
|
|
|
|
await platformService.configureNativeFetcher({
|
|
apiServer: newApiServer,
|
|
jwt: "", // Will be generated automatically by configureNativeFetcher
|
|
starredPlanHandleIds,
|
|
});
|
|
|
|
logger.info(
|
|
"[AccountViewView] Native fetcher configuration refreshed after API server change",
|
|
{
|
|
newApiServer,
|
|
},
|
|
);
|
|
} catch (error) {
|
|
logger.error(
|
|
"[AccountViewView] Failed to refresh native fetcher config after API server change:",
|
|
error,
|
|
);
|
|
// Don't throw - API server change should still succeed even if native fetcher refresh fails
|
|
}
|
|
}
|
|
|
|
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 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 {
|
|
// Extract the image ID from the full URL
|
|
const imageId = this.profileImageUrl?.split("/").pop();
|
|
if (!imageId) {
|
|
this.notify.error("Invalid image URL");
|
|
return;
|
|
}
|
|
|
|
const response = await this.axios.delete(
|
|
this.apiServer + "/api/image/" + imageId,
|
|
{ headers: await getHeaders(this.activeDid) },
|
|
);
|
|
if (response.status === 204) {
|
|
this.profileImageUrl = "";
|
|
await this.$saveSettings({
|
|
profileImageUrl: "",
|
|
});
|
|
this.notify.success("Image deleted successfully.");
|
|
} else {
|
|
logger.error("Non-success deleting image:", response);
|
|
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.IMAGE_DELETE_PROBLEM);
|
|
// keep the imageUrl in localStorage so the user can try again if they want
|
|
}
|
|
} catch (error) {
|
|
if (isApiError(error) && error.response?.status === 404) {
|
|
// it already doesn't exist so we won't say anything to the user
|
|
// Clear the local reference since the image is gone
|
|
this.profileImageUrl = "";
|
|
await this.$saveSettings({
|
|
profileImageUrl: "",
|
|
});
|
|
} 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,
|
|
);
|
|
}
|
|
|
|
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,
|
|
});
|
|
|
|
return result;
|
|
} else {
|
|
logger.debug("[AccountViewView] No profile data found in response:", {
|
|
requestId,
|
|
hasData: !!response.data,
|
|
hasDataData: !!(response.data && response.data.data),
|
|
});
|
|
}
|
|
|
|
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,
|
|
});
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clear profile location
|
|
*/
|
|
private clearProfileLocation(profileData: ProfileData): ProfileData {
|
|
return {
|
|
...profileData,
|
|
latitude: 0,
|
|
longitude: 0,
|
|
includeLocation: false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get default profile data
|
|
*/
|
|
private getDefaultProfile(): ProfileData {
|
|
return {
|
|
description: "",
|
|
latitude: 0,
|
|
longitude: 0,
|
|
includeLocation: false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
});
|
|
|
|
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>
|