You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1124 lines
34 KiB
1124 lines
34 KiB
<template>
|
|
<QuickNav selected="Profile"></QuickNav>
|
|
<TopMessage />
|
|
|
|
<!-- CONTENT -->
|
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
|
<!-- Heading -->
|
|
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-4">
|
|
Your Identity
|
|
</h1>
|
|
|
|
<div class="flex justify-between">
|
|
<span />
|
|
<span class="whitespace-nowrap">
|
|
<router-link
|
|
:to="{ name: 'contact-qr' }"
|
|
class="text-xs uppercase bg-slate-500 text-white px-1.5 py-1 rounded-md"
|
|
>
|
|
<fa icon="qrcode" class="fa-fw"></fa>
|
|
</router-link>
|
|
</span>
|
|
<span />
|
|
</div>
|
|
|
|
<div class="flex justify-between py-2">
|
|
<span />
|
|
<span>
|
|
<router-link
|
|
:to="{ name: 'help' }"
|
|
class="text-xs uppercase bg-blue-500 text-white px-1.5 py-1 rounded-md ml-1"
|
|
>
|
|
Help
|
|
</router-link>
|
|
</span>
|
|
</div>
|
|
|
|
<!-- ID notice -->
|
|
<div
|
|
v-if="!activeDid"
|
|
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mb-4"
|
|
>
|
|
<p class="mb-4">
|
|
<b>Note:</b> Before you can take any action, you need an ID.
|
|
</p>
|
|
<router-link
|
|
:to="{ name: 'start' }"
|
|
class="inline-block text-md uppercase bg-amber-600 text-white px-4 py-2 rounded-md"
|
|
>
|
|
Generate Identity
|
|
</router-link>
|
|
</div>
|
|
|
|
<!-- Registration notice -->
|
|
<!-- We won't show any loading indicator because it usually doesn't change anything. We'll just pop the message in only if we discover that they need it. -->
|
|
<div
|
|
v-if="!loadingLimits && !limits?.nextWeekBeginDateTime"
|
|
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mb-4"
|
|
>
|
|
<p class="mb-4">
|
|
<b>Note:</b> Before you can publicly announce a new project or time
|
|
commitment, a friend needs to register you.
|
|
</p>
|
|
<router-link
|
|
:to="{ name: 'contact-qr' }"
|
|
class="inline-block text-md uppercase bg-amber-600 text-white px-4 py-2 rounded-md"
|
|
>
|
|
Share Your Info
|
|
</router-link>
|
|
</div>
|
|
|
|
<!-- Identity Details -->
|
|
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
|
<h2 v-if="givenName" class="text-xl font-semibold mb-2">
|
|
{{ givenName }}
|
|
<router-link :to="{ name: 'new-edit-account' }">
|
|
<fa icon="pen" class="text-xs text-blue-500 mb-1"></fa>
|
|
</router-link>
|
|
</h2>
|
|
<span v-else>
|
|
<router-link
|
|
:to="{ name: 'new-edit-account' }"
|
|
class="block w-full text-center text-md text-blue-500 uppercase border border-dashed border-slate-400 px-1.5 py-2 rounded-md mb-2"
|
|
>
|
|
(Set Your Name)
|
|
</router-link>
|
|
</span>
|
|
|
|
<div class="text-slate-500 text-sm font-bold">ID</div>
|
|
<div class="text-sm text-slate-500 flex justify-start items-center mb-1">
|
|
<code class="truncate">{{ activeDid }}</code>
|
|
<button
|
|
@click="
|
|
doCopyTwoSecRedo(activeDid, () => (showDidCopy = !showDidCopy))
|
|
"
|
|
class="ml-2"
|
|
>
|
|
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
|
|
</button>
|
|
<span v-show="showDidCopy">Copied!</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8">
|
|
<div
|
|
v-if="!notificationMaybeChanged"
|
|
class="flex items-center justify-between cursor-pointer"
|
|
@click="showNotificationChoice()"
|
|
>
|
|
<!-- label -->
|
|
<div>App Notifications</div>
|
|
<!-- toggle -->
|
|
<div class="relative ml-2">
|
|
<!-- input -->
|
|
<input
|
|
type="checkbox"
|
|
v-model="isSubscribed"
|
|
name="toggleNotificationsInput"
|
|
class="sr-only"
|
|
/>
|
|
<!-- line -->
|
|
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
|
<!-- dot -->
|
|
<div
|
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
<div v-else>
|
|
Notification status may have changed. Refresh this page to see the
|
|
latest setting.
|
|
</div>
|
|
<router-link class="px-4 text-sm text-blue-500" to="/help-notifications">
|
|
Troubleshoot your notification setup.
|
|
</router-link>
|
|
</div>
|
|
|
|
<h3 class="text-sm uppercase font-semibold mb-3">Data Export</h3>
|
|
|
|
<router-link
|
|
:to="{ name: 'seed-backup' }"
|
|
v-if="activeDid"
|
|
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-2"
|
|
>
|
|
Backup Identifier Seed
|
|
</router-link>
|
|
|
|
<button
|
|
v-bind:class="computedStartDownloadLinkClassNames()"
|
|
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-6"
|
|
@click="exportDatabase()"
|
|
>
|
|
Download Settings & Contacts
|
|
<br />
|
|
(excluding Identifier Data)
|
|
</button>
|
|
<a
|
|
ref="downloadLink"
|
|
v-bind:class="computedDownloadLinkClassNames()"
|
|
class="block w-full text-center text-md uppercase bg-green-600 text-white px-1.5 py-2 rounded-md mb-6"
|
|
>
|
|
If no download happened yet, click again here to download now.
|
|
</a>
|
|
|
|
<div v-if="activeDid" class="flex mt-8 py-2">
|
|
<h3 class="text-sm uppercase font-semibold">Rate Limits</h3>
|
|
<button
|
|
class="block w-fit text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md ml-2 mr-2 mb-2"
|
|
@click="checkLimits()"
|
|
>
|
|
Check Limits
|
|
</button>
|
|
<!-- show spinner if loading limits -->
|
|
<div v-if="loadingLimits" class="text-center">
|
|
Checking… <fa icon="spinner" class="fa-spin"></fa>
|
|
</div>
|
|
<div>
|
|
{{ limitsMessage }}
|
|
</div>
|
|
<div v-if="!!limits?.nextWeekBeginDateTime">
|
|
<p class="mb-3 text-sm">
|
|
You have done <b>{{ limits.doneClaimsThisWeek }}</b> claims out of
|
|
<b>{{ limits.maxClaimsPerWeek }}</b> for this week. Your claims
|
|
counter resets at
|
|
<b class="whitespace-nowrap">{{
|
|
readableTime(limits.nextWeekBeginDateTime)
|
|
}}</b>
|
|
</p>
|
|
<p class="text-sm">
|
|
You have done
|
|
<b>{{ limits.doneRegistrationsThisMonth }}</b> registrations out of
|
|
<b>{{ limits.maxRegistrationsPerMonth }}</b> for this month.
|
|
<i
|
|
>(You can register nobody on your first day, and after that only one
|
|
a day in your first month.)</i
|
|
>
|
|
Your registration counter resets at
|
|
<b class="whitespace-nowrap">
|
|
{{ readableTime(limits.nextMonthBeginDateTime) }}
|
|
</b>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- id used by puppeteer test script -->
|
|
<h3
|
|
id="advanced"
|
|
class="text-sm uppercase font-semibold mb-3"
|
|
@click="showAdvanced = !showAdvanced"
|
|
>
|
|
Advanced
|
|
</h3>
|
|
<div v-if="showAdvanced">
|
|
<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 -->
|
|
<h2 class="text-sm uppercase font-semibold mb-3">
|
|
Deep Identity Details
|
|
</h2>
|
|
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
|
<div class="text-slate-500 text-sm font-bold">Public Key (base 64)</div>
|
|
<div
|
|
class="text-sm text-slate-500 flex justify-start items-center mb-1"
|
|
>
|
|
<code class="truncate">{{ publicBase64 }}</code>
|
|
<button
|
|
@click="
|
|
doCopyTwoSecRedo(publicBase64, () => (showB64Copy = !showB64Copy))
|
|
"
|
|
class="ml-2"
|
|
>
|
|
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
|
|
</button>
|
|
<span v-show="showB64Copy">Copied!</span>
|
|
</div>
|
|
|
|
<div class="text-slate-500 text-sm font-bold">Public Key (hex)</div>
|
|
<div
|
|
class="text-sm text-slate-500 flex justify-start items-center mb-1"
|
|
>
|
|
<code class="truncate">{{ publicHex }}</code>
|
|
<button
|
|
@click="
|
|
doCopyTwoSecRedo(publicHex, () => (showPubCopy = !showPubCopy))
|
|
"
|
|
class="ml-2"
|
|
>
|
|
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
|
|
</button>
|
|
<span v-show="showPubCopy">Copied!</span>
|
|
</div>
|
|
|
|
<div class="text-slate-500 text-sm font-bold">Derivation Path</div>
|
|
<div
|
|
class="text-sm text-slate-500 flex justify-start items-center mb-1"
|
|
>
|
|
<code class="truncate">{{ derivationPath }}</code>
|
|
<button
|
|
@click="
|
|
doCopyTwoSecRedo(
|
|
derivationPath,
|
|
() => (showDerCopy = !showDerCopy),
|
|
)
|
|
"
|
|
class="ml-2"
|
|
>
|
|
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
|
|
</button>
|
|
<span v-show="showDerCopy">Copied!</span>
|
|
</div>
|
|
</div>
|
|
|
|
<label
|
|
for="toggleShowAmounts"
|
|
class="flex items-center justify-between cursor-pointer my-4"
|
|
@click="handleChange"
|
|
>
|
|
<!-- label -->
|
|
<h2>Show amounts given with contacts</h2>
|
|
<!-- toggle -->
|
|
<div class="relative ml-2">
|
|
<!-- input -->
|
|
<input
|
|
type="checkbox"
|
|
v-model="showContactGives"
|
|
name="showContactGives"
|
|
class="sr-only"
|
|
/>
|
|
<!-- line -->
|
|
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
|
<!-- dot -->
|
|
<div
|
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
|
></div>
|
|
</div>
|
|
</label>
|
|
|
|
<div class="grid-cols-2 mb-4">
|
|
<span class="text-slate-500 text-sm font-bold mb-2">Data Import</span>
|
|
<input type="file" @change="uploadFile" class="ml-2" />
|
|
<div v-if="showContactImport()">
|
|
<button
|
|
class="block text-center text-md uppercase bg-green-600 text-white px-1.5 py-2 rounded-md mb-6"
|
|
@click="submitFile()"
|
|
>
|
|
Import Settings & Contacts
|
|
<br />
|
|
(excluding Identifier Data)
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex py-2">
|
|
<button>
|
|
<router-link
|
|
:to="{ name: 'statistics' }"
|
|
class="block w-fit text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-2"
|
|
>
|
|
See Global Animated History of Giving
|
|
</router-link>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- id used by puppeteer test script -->
|
|
<router-link
|
|
id="switch-identity-link"
|
|
:to="{ name: 'identity-switcher' }"
|
|
class="block w-fit text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-2"
|
|
>
|
|
Switch Identity
|
|
</router-link>
|
|
|
|
<div class="flex py-4">
|
|
<h2 class="text-slate-500 text-sm font-bold mb-2">Claim Server</h2>
|
|
<input
|
|
type="text"
|
|
class="block w-full rounded border border-slate-400 px-3 py-2"
|
|
v-model="apiServerInput"
|
|
/>
|
|
<button
|
|
v-if="apiServerInput != apiServer"
|
|
class="px-4 rounded bg-red-500 border border-slate-400"
|
|
@click="onClickSaveApiServer()"
|
|
>
|
|
<fa icon="floppy-disk" class="fa-fw" color="white"></fa>
|
|
</button>
|
|
<button
|
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
|
@click="apiServerInput = AppConstants.PROD_ENDORSER_API_SERVER"
|
|
>
|
|
Use Prod
|
|
</button>
|
|
<button
|
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
|
@click="apiServerInput = AppConstants.TEST_ENDORSER_API_SERVER"
|
|
>
|
|
Use Test
|
|
</button>
|
|
<button
|
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
|
@click="apiServerInput = AppConstants.LOCAL_ENDORSER_API_SERVER"
|
|
>
|
|
Use Local
|
|
</button>
|
|
</div>
|
|
|
|
<label
|
|
for="toggleProdWarningMessage"
|
|
class="flex items-center justify-between cursor-pointer my-4"
|
|
@click="toggleProdWarning"
|
|
>
|
|
<!-- label -->
|
|
<h2>Show warning if on prod server</h2>
|
|
<!-- toggle -->
|
|
<div class="relative ml-2">
|
|
<!-- input -->
|
|
<input type="checkbox" v-model="warnIfProdServer" class="sr-only" />
|
|
<!-- line -->
|
|
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
|
<!-- dot -->
|
|
<div
|
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
|
></div>
|
|
</div>
|
|
</label>
|
|
|
|
<label
|
|
for="toggleTestWarningMessage"
|
|
class="flex items-center justify-between cursor-pointer my-4"
|
|
@click="toggleTestWarning"
|
|
>
|
|
<!-- label -->
|
|
<h2>Show warning if on non-prod server</h2>
|
|
<!-- toggle -->
|
|
<div class="relative ml-2">
|
|
<!-- input -->
|
|
<input type="checkbox" v-model="warnIfTestServer" class="sr-only" />
|
|
<!-- line -->
|
|
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
|
<!-- dot -->
|
|
<div
|
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
|
></div>
|
|
</div>
|
|
</label>
|
|
|
|
<div class="flex py-4">
|
|
<h2 class="text-slate-500 text-sm font-bold mb-2">
|
|
Notification Push Server
|
|
</h2>
|
|
<input
|
|
type="text"
|
|
class="block w-full rounded border border-slate-400 px-3 py-2"
|
|
v-model="webPushServerInput"
|
|
/>
|
|
<button
|
|
v-if="webPushServerInput != webPushServer"
|
|
class="px-4 rounded bg-red-500 border border-slate-400"
|
|
@click="onClickSavePushServer()"
|
|
>
|
|
<fa icon="floppy-disk" class="fa-fw" color="white"></fa>
|
|
</button>
|
|
<button
|
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
|
@click="webPushServerInput = AppConstants.PROD_PUSH_SERVER"
|
|
>
|
|
Use Prod
|
|
</button>
|
|
<button
|
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
|
@click="webPushServerInput = AppConstants.TEST1_PUSH_SERVER"
|
|
>
|
|
Use Test 1
|
|
</button>
|
|
<button
|
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
|
@click="webPushServerInput = AppConstants.TEST2_PUSH_SERVER"
|
|
>
|
|
Use Test 2
|
|
</button>
|
|
</div>
|
|
<span class="px-4 text-sm" v-if="!webPushServerInput">
|
|
When that setting is blank, this app will use the default web push
|
|
server URL:
|
|
{{ AppConstants.DEFAULT_PUSH_SERVER }}
|
|
</span>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { AxiosError, AxiosRequestConfig } from "axios";
|
|
import Dexie from "dexie";
|
|
import "dexie-export-import";
|
|
import { ref } from "vue";
|
|
import { Component, Vue } from "vue-facing-decorator";
|
|
import { useClipboard } from "@vueuse/core";
|
|
|
|
import QuickNav from "@/components/QuickNav.vue";
|
|
import TopMessage from "@/components/TopMessage.vue";
|
|
import { AppString } from "@/constants/app";
|
|
import { db, accountsDB } from "@/db/index";
|
|
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
|
import { accessToken } from "@/libs/crypto";
|
|
import { IIdentifier } from "@veramo/core";
|
|
import { ErrorResponse, RateLimits } from "@/libs/endorserServer";
|
|
import { ImportProgress } from "dexie-export-import/dist/import";
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
const Buffer = require("buffer/").Buffer;
|
|
|
|
interface Notification {
|
|
group: string;
|
|
type: string;
|
|
title: string;
|
|
text: string;
|
|
}
|
|
|
|
interface IAccount {
|
|
did: string;
|
|
publicKeyHex: string;
|
|
privateHex?: string;
|
|
derivationPath: string;
|
|
}
|
|
|
|
const inputFileNameRef = ref<Blob>();
|
|
|
|
@Component({ components: { QuickNav, TopMessage } })
|
|
export default class AccountViewView extends Vue {
|
|
$notify!: (notification: Notification, timeout?: number) => void;
|
|
|
|
AppConstants = AppString;
|
|
|
|
activeDid = "";
|
|
apiServer = "";
|
|
apiServerInput = "";
|
|
derivationPath = "";
|
|
downloadUrl = ""; // because DuckDuckGo doesn't download on automated call to "click" on the anchor
|
|
givenName = "";
|
|
isRegistered = false;
|
|
isSubscribed = false;
|
|
notificationMaybeChanged = false;
|
|
numAccounts = 0;
|
|
publicHex = "";
|
|
publicBase64 = "";
|
|
webPushServer = "";
|
|
webPushServerInput = "";
|
|
limits: RateLimits | null = null;
|
|
limitsMessage = "";
|
|
loadingLimits = true; // might as well now that we do it on mount, to avoid flashing the registration message
|
|
showContactGives = false;
|
|
|
|
showDidCopy = false;
|
|
showDerCopy = false;
|
|
showB64Copy = false;
|
|
showPubCopy = false;
|
|
|
|
showAdvanced = false;
|
|
|
|
subscription: PushSubscription | null = null;
|
|
warnIfProdServer = false;
|
|
warnIfTestServer = false;
|
|
|
|
async beforeCreate() {
|
|
await accountsDB.open();
|
|
this.numAccounts = await accountsDB.accounts.count();
|
|
}
|
|
|
|
/**
|
|
* Async function executed when the component is created.
|
|
* 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 created() {
|
|
try {
|
|
await db.open();
|
|
|
|
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
|
|
|
// Initialize component state with values from the database or defaults
|
|
this.initializeState(settings);
|
|
|
|
// Get and process the identity
|
|
const identity = await this.getIdentity(this.activeDid);
|
|
if (identity) {
|
|
this.processIdentity(identity);
|
|
}
|
|
} catch (err: unknown) {
|
|
this.handleError(err);
|
|
}
|
|
}
|
|
|
|
async mounted() {
|
|
try {
|
|
const registration = await navigator.serviceWorker.ready;
|
|
this.subscription = await registration.pushManager.getSubscription();
|
|
this.isSubscribed = !!this.subscription;
|
|
} catch (error) {
|
|
console.error("Mount error:", error);
|
|
}
|
|
}
|
|
|
|
beforeUnmount() {
|
|
if (this.downloadUrl) {
|
|
URL.revokeObjectURL(this.downloadUrl);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initializes component state with values from the database or defaults.
|
|
* @param {SettingsType} settings - Object containing settings from the database.
|
|
*/
|
|
initializeState(settings: Settings | undefined) {
|
|
this.activeDid = (settings?.activeDid as string) || "";
|
|
this.apiServer = (settings?.apiServer as string) || "";
|
|
this.apiServerInput = (settings?.apiServer as string) || "";
|
|
this.givenName =
|
|
(settings?.firstName || "") +
|
|
(settings?.lastName ? ` ${settings.lastName}` : ""); // pre v 0.1.3
|
|
this.isRegistered = !!settings?.isRegistered;
|
|
this.showContactGives = !!settings?.showContactGivesInline;
|
|
this.warnIfProdServer = !!settings?.warnIfProdServer;
|
|
this.warnIfTestServer = !!settings?.warnIfTestServer;
|
|
this.webPushServer = (settings?.webPushServer as string) || "";
|
|
this.webPushServerInput = (settings?.webPushServer as string) || "";
|
|
}
|
|
|
|
public async getIdentity(activeDid: string): Promise<IIdentifier | null> {
|
|
try {
|
|
// Open the accounts database
|
|
await accountsDB.open();
|
|
|
|
// Search for the account with the matching DID (decentralized identifier)
|
|
const account: { identity?: string } | undefined =
|
|
await accountsDB.accounts.where("did").equals(activeDid).first();
|
|
|
|
// Return parsed identity or null if not found
|
|
return JSON.parse((account?.identity as string) || "null");
|
|
} catch (error) {
|
|
console.error("Failed to find account:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Asynchronously retrieves headers for HTTP requests.
|
|
*
|
|
* @param {IIdentifier} identity - The identity object for which to generate the headers.
|
|
* @returns {Promise<Record<string,string>>} A Promise that resolves to an object containing the headers.
|
|
*
|
|
* @throws Will throw an error if unable to generate an access token.
|
|
*/
|
|
public async getHeaders(
|
|
identity: IIdentifier,
|
|
): Promise<Record<string, string>> {
|
|
try {
|
|
const token = await accessToken(identity);
|
|
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
};
|
|
|
|
return headers;
|
|
} catch (error) {
|
|
console.error("Failed to get headers:", error);
|
|
return Promise.reject(error);
|
|
}
|
|
}
|
|
|
|
// call fn, copy text to the clipboard, then redo fn after 2 seconds
|
|
doCopyTwoSecRedo(text: string, fn: () => void) {
|
|
fn();
|
|
useClipboard()
|
|
.copy(text)
|
|
.then(() => setTimeout(fn, 2000));
|
|
}
|
|
|
|
handleChange() {
|
|
this.showContactGives = !this.showContactGives;
|
|
this.updateShowContactAmounts();
|
|
}
|
|
|
|
toggleProdWarning() {
|
|
this.warnIfProdServer = !this.warnIfProdServer;
|
|
this.updateWarnIfProdServer(this.warnIfProdServer);
|
|
}
|
|
|
|
toggleTestWarning() {
|
|
this.warnIfTestServer = !this.warnIfTestServer;
|
|
this.updateWarnIfTestServer(this.warnIfTestServer);
|
|
}
|
|
|
|
readableTime(timeStr: string) {
|
|
return timeStr.substring(0, timeStr.indexOf("T"));
|
|
}
|
|
|
|
/**
|
|
* Processes the identity and updates the component's state.
|
|
* @param {IdentityType} identity - Object containing identity information.
|
|
*/
|
|
processIdentity(identity: IIdentifier) {
|
|
if (
|
|
identity &&
|
|
identity.keys &&
|
|
identity.keys.length > 0 &&
|
|
identity.keys[0].meta
|
|
) {
|
|
this.publicHex = identity.keys[0].publicKeyHex;
|
|
this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64");
|
|
this.derivationPath = identity.keys[0].meta.derivationPath as string;
|
|
|
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
|
activeDid: identity.did,
|
|
});
|
|
this.checkLimitsFor(identity);
|
|
} else {
|
|
// Handle the case where any of these are null or undefined
|
|
}
|
|
}
|
|
|
|
async showNotificationChoice() {
|
|
if (!this.subscription) {
|
|
this.$notify(
|
|
{
|
|
group: "modal",
|
|
type: "notification-permission",
|
|
title: "", // unused, only here to satisfy type check
|
|
text: "", // unused, only here to satisfy type check
|
|
},
|
|
-1,
|
|
);
|
|
} else {
|
|
this.$notify(
|
|
{
|
|
group: "modal",
|
|
type: "notification-off",
|
|
title: "", // unused, only here to satisfy type check
|
|
text: "", // unused, only here to satisfy type check
|
|
},
|
|
-1,
|
|
);
|
|
}
|
|
this.notificationMaybeChanged = true;
|
|
}
|
|
|
|
/**
|
|
* Handles errors and updates the component's state accordingly.
|
|
* @param {Error} err - The error object.
|
|
*/
|
|
handleError(err: unknown) {
|
|
if (
|
|
err instanceof Error &&
|
|
err.message ===
|
|
"Attempted to load account records with no identity available."
|
|
) {
|
|
this.limitsMessage = "No identity.";
|
|
this.loadingLimits = false;
|
|
} else {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error Creating Account",
|
|
text: "Clear your cache and start over (after data backup).",
|
|
},
|
|
-1,
|
|
);
|
|
console.error("Telling user to clear cache at page create because:", err);
|
|
}
|
|
}
|
|
|
|
public async updateShowContactAmounts() {
|
|
try {
|
|
await db.open();
|
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
|
showContactGivesInline: this.showContactGives,
|
|
});
|
|
} catch (err) {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error Updating Contact Setting",
|
|
text: "The setting may not have saved. Try again, maybe after restarting the app.",
|
|
},
|
|
-1,
|
|
);
|
|
console.error(
|
|
"Telling user to try again after contact setting update because:",
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
|
|
public async updateWarnIfProdServer(newSetting: boolean) {
|
|
try {
|
|
await db.open();
|
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
|
warnIfProdServer: newSetting,
|
|
});
|
|
} catch (err) {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error Updating Prod Warning",
|
|
text: "The setting may not have saved. Try again, maybe after restarting the app.",
|
|
},
|
|
-1,
|
|
);
|
|
console.error(
|
|
"Telling user to try again after setting update because:",
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
|
|
public async updateWarnIfTestServer(newSetting: boolean) {
|
|
try {
|
|
await db.open();
|
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
|
warnIfTestServer: newSetting,
|
|
});
|
|
} catch (err) {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error Updating Test Warning",
|
|
text: "The setting may not have saved. Try again, maybe after restarting the app.",
|
|
},
|
|
-1,
|
|
);
|
|
console.error(
|
|
"Telling user to try again after setting update because:",
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
// Revoke the temporary URL -- not yet because of DuckDuckGo download failure
|
|
//URL.revokeObjectURL(this.downloadUrl);
|
|
|
|
// Notify the user that the download has started
|
|
this.notifyDownloadStarted();
|
|
} catch (error) {
|
|
this.handleExportError(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generates a blob object representing the database.
|
|
*
|
|
* @returns {Promise<Blob>} The generated blob object.
|
|
*/
|
|
private async generateDatabaseBlob(): Promise<Blob> {
|
|
return await db.export({ prettyJson: true });
|
|
}
|
|
|
|
/**
|
|
* Creates a temporary URL for a blob object.
|
|
*
|
|
* @param {Blob} blob - The blob object.
|
|
* @returns {string} The temporary URL for the blob.
|
|
*/
|
|
private createBlobURL(blob: Blob): string {
|
|
return URL.createObjectURL(blob);
|
|
}
|
|
|
|
/**
|
|
* Triggers the download of the database backup.
|
|
*
|
|
* @param {string} url - The temporary URL for the blob.
|
|
*/
|
|
private downloadDatabaseBackup(url: string) {
|
|
const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement;
|
|
downloadAnchor.href = url;
|
|
downloadAnchor.download = `${db.name}-backup.json`;
|
|
downloadAnchor.click(); // doesn't work for some browsers, eg. DuckDuckGo
|
|
}
|
|
|
|
public computedStartDownloadLinkClassNames() {
|
|
return {
|
|
invisible: this.downloadUrl,
|
|
};
|
|
}
|
|
|
|
public computedDownloadLinkClassNames() {
|
|
return {
|
|
invisible: !this.downloadUrl,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Notifies the user that the download has started.
|
|
*/
|
|
private notifyDownloadStarted() {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "success",
|
|
title: "Download Started",
|
|
text: "See your downloads directory for the backup. It is in the Dexie format.",
|
|
},
|
|
-1,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handles errors during the database export process.
|
|
*
|
|
* @param {Error} error - The error object.
|
|
*/
|
|
private handleExportError(error: unknown) {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Export Error",
|
|
text: "See console logs for more info.",
|
|
},
|
|
-1,
|
|
);
|
|
console.error("Export Error:", error);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
async uploadFile(event: any) {
|
|
inputFileNameRef.value = event.target.files[0];
|
|
}
|
|
|
|
showContactImport() {
|
|
return !!inputFileNameRef.value;
|
|
}
|
|
|
|
/**
|
|
* Asynchronously imports the database from a downloadable JSON file.
|
|
*
|
|
* @throws Will notify the user if there is an export error.
|
|
*/
|
|
async submitFile() {
|
|
if (inputFileNameRef.value != null) {
|
|
if (
|
|
confirm(
|
|
"This will replace all settings and contacts, so we recommend you first do the backup step above." +
|
|
" Are you sure you want to import and replace all contacts and settings?",
|
|
)
|
|
) {
|
|
await db.delete();
|
|
await Dexie.import(inputFileNameRef.value, {
|
|
progressCallback: this.progressCallback,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
private progressCallback(progress: ImportProgress) {
|
|
console.log(
|
|
`Import progress: ${progress.completedRows} of ${progress.totalRows} rows completed.`,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
async checkLimits() {
|
|
const identity = await this.getIdentity(this.activeDid);
|
|
if (identity) {
|
|
this.checkLimitsFor(identity);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Asynchronously checks rate limits for the given identity.
|
|
*
|
|
* Updates component state variables `limits`, `limitsMessage`, and `loadingLimits`.
|
|
*/
|
|
public async checkLimitsFor(identity: IIdentifier) {
|
|
this.loadingLimits = true;
|
|
this.limitsMessage = "";
|
|
|
|
try {
|
|
const resp = await this.fetchRateLimits(identity);
|
|
if (resp.status === 200) {
|
|
this.limits = resp.data;
|
|
if (!this.isRegistered) {
|
|
// the user is not known to be registered, but they are so let's record it
|
|
try {
|
|
await db.open();
|
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
|
isRegistered: true,
|
|
});
|
|
this.isRegistered = true;
|
|
} catch (err) {
|
|
console.error("Got an error updating settings:", err);
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "warning",
|
|
title: "Update Error",
|
|
text: "Unable to update your settings. Check claim limits again.",
|
|
},
|
|
-1,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
this.handleRateLimitsError(error);
|
|
}
|
|
|
|
this.loadingLimits = false;
|
|
}
|
|
|
|
/**
|
|
* Fetches rate limits from the server.
|
|
*
|
|
* @param {IIdentifier} identity - The identity object to check rate limits for.
|
|
* @returns {Promise<AxiosResponse>} The Axios response object.
|
|
*/
|
|
private async fetchRateLimits(identity: IIdentifier) {
|
|
const url = `${this.apiServer}/api/report/rateLimits`;
|
|
const headers = await this.getHeaders(identity);
|
|
return await this.axios.get(url, { headers } as AxiosRequestConfig);
|
|
}
|
|
|
|
/**
|
|
* Handles errors that occur while fetching rate limits.
|
|
*
|
|
* @param {AxiosError | Error} error - The error object.
|
|
*/
|
|
private handleRateLimitsError(error: unknown) {
|
|
if (error instanceof AxiosError) {
|
|
const data = error.response?.data as ErrorResponse;
|
|
this.limitsMessage =
|
|
(data?.error?.message as string) || "Bad server response.";
|
|
console.error(
|
|
"Got bad response retrieving limits, which usually means user isn't registered. Server says:",
|
|
this.limitsMessage,
|
|
);
|
|
} else if (
|
|
error instanceof Error &&
|
|
error.message ===
|
|
"Attempted to load Give records with no identity available."
|
|
) {
|
|
this.limitsMessage = "No identity.";
|
|
} else {
|
|
// Handle other unknown errors
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Asynchronously switches the active account based on the provided account number.
|
|
*
|
|
* @param {number} accountNum - The account number to switch to. 0 means none.
|
|
*/
|
|
public async switchAccount(accountNum: number) {
|
|
await db.open(); // Assumes db needs to be open for both cases
|
|
|
|
if (accountNum === 0) {
|
|
this.switchToNoAccount();
|
|
} else {
|
|
await this.switchToAccountNumber(accountNum);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Switches to no active account and clears relevant properties.
|
|
*/
|
|
private async switchToNoAccount() {
|
|
await db.settings.update(MASTER_SETTINGS_KEY, { activeDid: undefined });
|
|
this.clearActiveAccountProperties();
|
|
}
|
|
|
|
/**
|
|
* Clears properties related to the active account.
|
|
*/
|
|
private clearActiveAccountProperties() {
|
|
this.activeDid = "";
|
|
this.derivationPath = "";
|
|
this.publicHex = "";
|
|
this.publicBase64 = "";
|
|
}
|
|
|
|
/**
|
|
* Switches to an account based on its number in the list.
|
|
*
|
|
* @param {number} accountNum - The account number to switch to.
|
|
*/
|
|
private async switchToAccountNumber(accountNum: number) {
|
|
await accountsDB.open();
|
|
const accounts = await accountsDB.accounts.toArray();
|
|
const account = accounts[accountNum - 1];
|
|
|
|
await db.open();
|
|
await db.settings.update(MASTER_SETTINGS_KEY, { activeDid: account.did });
|
|
|
|
this.updateActiveAccountProperties(account);
|
|
}
|
|
|
|
/**
|
|
* Updates properties related to the active account.
|
|
*
|
|
* @param {AccountType} account - The account object.
|
|
*/
|
|
private updateActiveAccountProperties(account: IAccount) {
|
|
this.activeDid = account.did;
|
|
this.derivationPath = account.derivationPath;
|
|
this.publicHex = account.publicKeyHex;
|
|
this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64");
|
|
}
|
|
|
|
public showContactGivesClassNames() {
|
|
return {
|
|
"bg-slate-900": !this.showContactGives,
|
|
"bg-green-600": this.showContactGives,
|
|
};
|
|
}
|
|
|
|
async onClickSaveApiServer() {
|
|
await db.open();
|
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
|
apiServer: this.apiServerInput,
|
|
});
|
|
this.apiServer = this.apiServerInput;
|
|
}
|
|
|
|
async onClickSavePushServer() {
|
|
await db.open();
|
|
db.settings.update(MASTER_SETTINGS_KEY, {
|
|
webPushServer: this.webPushServerInput,
|
|
});
|
|
this.webPushServer = this.webPushServerInput;
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "warning",
|
|
title: "Reload",
|
|
text: "Now reload the app to get a new VAPID to use with this push server.",
|
|
},
|
|
-1,
|
|
);
|
|
}
|
|
}
|
|
</script>
|
|
|