From b2ebc2992bbb921597d8900350d7b6d4cb9c6b6d Mon Sep 17 00:00:00 2001
From: Trent Larson <trent@trentlarson.com>
Date: Fri, 19 Jul 2024 12:44:54 -0600
Subject: [PATCH 1/2] cache the passkey JWANT access token for multiple
 signatures

---
 src/db/tables/settings.ts        |   5 +-
 src/libs/crypto/index.ts         |   2 +-
 src/libs/endorserServer.ts       |  52 ++++++-
 src/libs/util.ts                 |  16 +-
 src/views/AccountViewView.vue    | 250 +++++++++++++------------------
 src/views/ContactAmountsView.vue |  15 +-
 src/views/GiftedDetails.vue      |  10 +-
 src/views/NewEditProjectView.vue |  24 +--
 src/views/ProjectViewView.vue    |   6 -
 src/views/ProjectsView.vue       |  28 ++--
 src/views/SharedPhotoView.vue    |   7 +-
 11 files changed, 196 insertions(+), 219 deletions(-)

diff --git a/src/db/tables/settings.ts b/src/db/tables/settings.ts
index ac5cde9..35c428f 100644
--- a/src/db/tables/settings.ts
+++ b/src/db/tables/settings.ts
@@ -26,6 +26,7 @@ export type Settings = {
   lastName?: string; // deprecated - put all names in firstName
   lastNotifiedClaimId?: string;
   lastViewedClaimId?: string;
+  passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes
   profileImageUrl?: string;
   reminderTime?: number; // Time in milliseconds since UNIX epoch for reminders
   reminderOn?: boolean; // Toggle to enable or disable reminders
@@ -46,7 +47,7 @@ export type Settings = {
 };
 
 export function isAnyFeedFilterOn(settings: Settings): boolean {
-  return !!(settings.filterFeedByNearby || settings.filterFeedByVisible);
+  return !!(settings?.filterFeedByNearby || settings?.filterFeedByVisible);
 }
 
 /**
@@ -60,3 +61,5 @@ export const SettingsSchema = {
  * Constants.
  */
 export const MASTER_SETTINGS_KEY = 1;
+
+export const DEFAULT_PASSKEY_EXPIRATION_MINUTES = 15;
diff --git a/src/libs/crypto/index.ts b/src/libs/crypto/index.ts
index 862ed54..2ac8aef 100644
--- a/src/libs/crypto/index.ts
+++ b/src/libs/crypto/index.ts
@@ -85,7 +85,7 @@ export const generateSeed = (): string => {
 };
 
 /**
- * Retreive an access token
+ * Retrieve an access token, or "" if no DID is provided.
  *
  * @return {*}
  */
diff --git a/src/libs/endorserServer.ts b/src/libs/endorserServer.ts
index 26954cd..754766b 100644
--- a/src/libs/endorserServer.ts
+++ b/src/libs/endorserServer.ts
@@ -5,9 +5,10 @@ import * as R from "ramda";
 import { DEFAULT_IMAGE_API_SERVER } from "@/constants/app";
 import { Contact } from "@/db/tables/contacts";
 import { accessToken } from "@/libs/crypto";
-import { NonsensitiveDexie } from "@/db/index";
-import { getAccount } from "@/libs/util";
+import { db, NonsensitiveDexie } from "@/db/index";
+import { getAccount, getPasskeyExpirationSeconds } from "@/libs/util";
 import { createEndorserJwtForKey, KeyMeta } from "@/libs/crypto/vc";
+import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
 
 export const SCHEMA_ORG_CONTEXT = "https://schema.org";
 // the object in RegisterAction claims
@@ -447,12 +448,57 @@ export function didInfo(
   return didInfoForContact(did, activeDid, contact, allMyDids).displayName;
 }
 
+let passkeyAccessToken: string = "";
+let passkeyTokenExpirationEpochSeconds: number = 0;
+
+export function clearPasskeyToken() {
+  passkeyAccessToken = "";
+  passkeyTokenExpirationEpochSeconds = 0;
+}
+
+export function tokenExpiryTimeDescription() {
+  if (
+    !passkeyAccessToken ||
+    passkeyTokenExpirationEpochSeconds < new Date().getTime() / 1000
+  ) {
+    return "Token has expired";
+  } else {
+    return (
+      "Token expires at " +
+      new Date(passkeyTokenExpirationEpochSeconds * 1000).toLocaleString()
+    );
+  }
+}
+
+/**
+ * Get the headers for a request, potentially including Authorization
+ */
 export async function getHeaders(did?: string) {
   const headers: { "Content-Type": string; Authorization?: string } = {
     "Content-Type": "application/json",
   };
   if (did) {
-    const token = await accessToken(did);
+    let token;
+    const account = await getAccount(did);
+    if (account?.passkeyCredIdHex) {
+      if (
+        passkeyAccessToken &&
+        passkeyTokenExpirationEpochSeconds > Date.now() / 1000
+      ) {
+        // there's an active current passkey token
+        token = passkeyAccessToken;
+      } else {
+        // there's no current passkey token or it's expired
+        token = await accessToken(did);
+
+        passkeyAccessToken = token;
+        const passkeyExpirationSeconds = await getPasskeyExpirationSeconds();
+        passkeyTokenExpirationEpochSeconds =
+          Date.now() / 1000 + passkeyExpirationSeconds;
+      }
+    } else {
+      token = await accessToken(did);
+    }
     headers["Authorization"] = "Bearer " + token;
   } else {
     // it's often OK to request without auth; we assume necessary checks are done earlier
diff --git a/src/libs/util.ts b/src/libs/util.ts
index c0292ac..c652683 100644
--- a/src/libs/util.ts
+++ b/src/libs/util.ts
@@ -1,21 +1,20 @@
 // many of these are also found in endorser-mobile utility.ts
 
 import axios, { AxiosResponse } from "axios";
-import { IIdentifier } from "@veramo/core";
 import { useClipboard } from "@vueuse/core";
 
 import { DEFAULT_PUSH_SERVER } from "@/constants/app";
 import { accountsDB, db } from "@/db/index";
 import { Account } from "@/db/tables/accounts";
-import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
+import {DEFAULT_PASSKEY_EXPIRATION_MINUTES, MASTER_SETTINGS_KEY} from "@/db/tables/settings";
 import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto";
 import { GenericCredWrapper, containsHiddenDid } from "@/libs/endorserServer";
 import * as serverUtil from "@/libs/endorserServer";
 import { registerCredential } from "@/libs/crypto/vc/passkeyDidPeer";
 
 import { Buffer } from "buffer";
-import {KeyMeta} from "@/libs/crypto/vc";
-import {createPeerDid} from "@/libs/crypto/vc/didPeer";
+import { KeyMeta } from "@/libs/crypto/vc";
+import { createPeerDid } from "@/libs/crypto/vc/didPeer";
 
 export const PRIVACY_MESSAGE =
   "The data you send will be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to them and others you explicitly allow.";
@@ -273,6 +272,15 @@ export const registerSaveAndActivatePasskey = async (
   return account;
 };
 
+export const getPasskeyExpirationSeconds = async (): Promise<number> => {
+  await db.open();
+  const settings = await db.settings.get(MASTER_SETTINGS_KEY);
+  const passkeyExpirationSeconds =
+    (settings?.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES) *
+    60;
+  return passkeyExpirationSeconds;
+};
+
 export const sendTestThroughPushServer = async (
   subscriptionJSON: PushSubscriptionJSON,
   skipFilter: boolean,
diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue
index d32267e..2f99791 100644
--- a/src/views/AccountViewView.vue
+++ b/src/views/AccountViewView.vue
@@ -152,7 +152,7 @@
 
       <div class="text-blue-500 text-sm font-bold">
         <router-link :to="{ path: '/did/' + encodeURIComponent(activeDid) }">
-          Activity
+          Your Activity
         </router-link>
       </div>
     </div>
@@ -216,7 +216,6 @@
       <div class="mb-2 font-bold">Location</div>
       <router-link
         :to="{ name: 'search-area' }"
-        v-if="activeDid"
         class="block w-full text-center text-m 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-1.5 py-2 rounded-md mb-2 mt-6"
       >
         Set Search Area…
@@ -622,6 +621,26 @@
         </button>
       </div>
 
+      <div class="flex justify-between">
+        <span>
+          <span class="text-slate-500 text-sm font-bold mb-2">
+            Passkey Expiration Minutes
+          </span>
+          <br />
+          <span class="text-sm ml-2">
+            {{ passkeyExpirationDescription }}
+          </span>
+        </span>
+        <div class="relative ml-2">
+          <input
+            type="number"
+            class="border border-slate-400 rounded px-2 py-2 text-center w-20"
+            v-model="passkeyExpirationMinutes"
+            @change="updatePasskeyExpiration"
+          />
+        </div>
+      </div>
+
       <label
         for="toggleShowGeneralAdvanced"
         class="flex items-center justify-between cursor-pointer mt-4"
@@ -667,7 +686,7 @@ import ImageMethodDialog from "@/components/ImageMethodDialog.vue";
 import QuickNav from "@/components/QuickNav.vue";
 import TopMessage from "@/components/TopMessage.vue";
 import {
-  AppString,
+  AppString, DEFAULT_ENDORSER_API_SERVER,
   DEFAULT_IMAGE_API_SERVER,
   DEFAULT_PUSH_SERVER,
   IMAGE_TYPE_PROFILE,
@@ -675,14 +694,20 @@ import {
 } from "@/constants/app";
 import { db, accountsDB } from "@/db/index";
 import { Account } from "@/db/tables/accounts";
-import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
-import { accessToken } from "@/libs/crypto";
 import {
+  DEFAULT_PASSKEY_EXPIRATION_MINUTES,
+  MASTER_SETTINGS_KEY,
+  Settings,
+} from "@/db/tables/settings";
+import {
+  clearPasskeyToken,
   ErrorResponse,
   EndorserRateLimits,
-  ImageRateLimits,
   fetchEndorserRateLimits,
   fetchImageRateLimits,
+  getHeaders,
+  ImageRateLimits,
+  tokenExpiryTimeDescription,
 } from "@/libs/endorserServer";
 import { getAccount } from "@/libs/util";
 
@@ -713,6 +738,9 @@ export default class AccountViewView extends Vue {
   limitsMessage = "";
   loadingLimits = false;
   notificationMaybeChanged = false;
+  passkeyExpirationDescription = "";
+  passkeyExpirationMinutes = DEFAULT_PASSKEY_EXPIRATION_MINUTES;
+  previousPasskeyExpirationMinutes = DEFAULT_PASSKEY_EXPIRATION_MINUTES;
   profileImageUrl?: string;
   publicHex = "";
   publicBase64 = "";
@@ -745,12 +773,32 @@ export default class AccountViewView extends Vue {
       await this.initializeState();
       await this.processIdentity();
 
+      this.passkeyExpirationDescription = tokenExpiryTimeDescription();
+
+      /**
+       * Beware! I've seen where this "ready" never resolves.
+       */
       const registration = await navigator.serviceWorker.ready;
       this.subscription = await registration.pushManager.getSubscription();
       this.isSubscribed = !!this.subscription;
+      console.log("Got to the end of 'mounted' call.");
+      /**
+       * Beware! I've seen where we never get to this point because "ready" never resolves.
+       */
     } catch (error) {
-      console.error("Mount error:", error);
-      this.handleError(error);
+      console.error(
+        "Telling user to clear cache at page create because:",
+        error,
+      );
+      this.$notify(
+        {
+          group: "alert",
+          type: "danger",
+          title: "Error Loading Account",
+          text: "Clear your cache and start over (after data backup).",
+        },
+        -1,
+      );
     }
   }
 
@@ -780,6 +828,10 @@ export default class AccountViewView extends Vue {
     this.showContactGives = !!settings?.showContactGivesInline;
     this.hideRegisterPromptOnNewContact =
       !!settings?.hideRegisterPromptOnNewContact;
+    this.passkeyExpirationMinutes =
+      (settings?.passkeyExpirationMinutes as number) ??
+      DEFAULT_PASSKEY_EXPIRATION_MINUTES;
+    this.previousPasskeyExpirationMinutes = this.passkeyExpirationMinutes;
     this.showGeneralAdvanced = !!settings?.showGeneralAdvanced;
     this.showShortcutBvc = !!settings?.showShortcutBvc;
     this.warnIfProdServer = !!settings?.warnIfProdServer;
@@ -835,11 +887,11 @@ export default class AccountViewView extends Vue {
       this.publicHex = identity.keys[0].publicKeyHex;
       this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64");
       this.derivationPath = identity.keys[0].meta?.derivationPath as string;
-      this.checkLimitsFor(this.activeDid);
+      await this.checkLimitsFor(this.activeDid);
     } else if (account?.publicKeyHex) {
       this.publicHex = account.publicKeyHex as string;
       this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64");
-      this.checkLimitsFor(this.activeDid);
+      await this.checkLimitsFor(this.activeDid);
     }
   }
 
@@ -868,75 +920,18 @@ export default class AccountViewView extends Vue {
     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 identifier available."
-    ) {
-      this.limitsMessage = "No identifier.";
-    } else {
-      console.error("Telling user to clear cache at page create because:", err);
-      this.$notify(
-        {
-          group: "alert",
-          type: "danger",
-          title: "Error Loading Account",
-          text: "Clear your cache and start over (after data backup).",
-        },
-        -1,
-      );
-    }
-  }
-
   public async updateShowContactAmounts() {
-    try {
-      await db.open();
-      await 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-amounts setting update because:",
-        err,
-      );
-    }
+    await db.open();
+    await db.settings.update(MASTER_SETTINGS_KEY, {
+      showContactGivesInline: this.showContactGives,
+    });
   }
 
   public async updateShowGeneralAdvanced() {
-    try {
-      await db.open();
-      await db.settings.update(MASTER_SETTINGS_KEY, {
-        showGeneralAdvanced: this.showGeneralAdvanced,
-      });
-    } catch (err) {
-      this.$notify(
-        {
-          group: "alert",
-          type: "danger",
-          title: "Error Updating Advanced Setting",
-          text: "The setting may not have saved. Try again, maybe after restarting the app.",
-        },
-        -1,
-      );
-      console.error(
-        "Telling user to try again after general-advanced setting update because:",
-        err,
-      );
-    }
+    await db.open();
+    await db.settings.update(MASTER_SETTINGS_KEY, {
+      showGeneralAdvanced: this.showGeneralAdvanced,
+    });
   }
 
   public async updateWarnIfProdServer(newSetting: boolean) {
@@ -963,71 +958,35 @@ export default class AccountViewView extends Vue {
   }
 
   public async updateWarnIfTestServer(newSetting: boolean) {
-    try {
-      await db.open();
-      await 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 test-server-warning setting update because:",
-        err,
-      );
-    }
+    await db.open();
+    await db.settings.update(MASTER_SETTINGS_KEY, {
+      warnIfTestServer: newSetting,
+    });
   }
 
   public async toggleHideRegisterPromptOnNewContact() {
     const newSetting = !this.hideRegisterPromptOnNewContact;
-    try {
-      await db.open();
-      await db.settings.update(MASTER_SETTINGS_KEY, {
-        hideRegisterPromptOnNewContact: newSetting,
-      });
-      this.hideRegisterPromptOnNewContact = newSetting;
-    } catch (err) {
-      this.$notify(
-        {
-          group: "alert",
-          type: "danger",
-          title: "Error Updating Setting",
-          text: "The setting may not have saved. Try again, maybe after restarting the app.",
-        },
-        -1,
-      );
-      console.error("Telling user to try again because:", err);
-    }
+    await db.open();
+    await db.settings.update(MASTER_SETTINGS_KEY, {
+      hideRegisterPromptOnNewContact: newSetting,
+    });
+    this.hideRegisterPromptOnNewContact = newSetting;
+  }
+
+  public async updatePasskeyExpiration() {
+    await db.open();
+    await db.settings.update(MASTER_SETTINGS_KEY, {
+      passkeyExpirationMinutes: this.passkeyExpirationMinutes,
+    });
+    clearPasskeyToken();
+    this.passkeyExpirationDescription = tokenExpiryTimeDescription();
   }
 
   public async updateShowShortcutBvc(newSetting: boolean) {
-    try {
-      await db.open();
-      await db.settings.update(MASTER_SETTINGS_KEY, {
-        showShortcutBvc: newSetting,
-      });
-    } catch (err) {
-      this.$notify(
-        {
-          group: "alert",
-          type: "danger",
-          title: "Error Updating BVC Shortcut Setting",
-          text: "The setting may not have saved. Try again, maybe after restarting the app.",
-        },
-        -1,
-      );
-      console.error(
-        "Telling user to try again after BVC-shortcut setting update because:",
-        err,
-      );
-    }
+    await db.open();
+    await db.settings.update(MASTER_SETTINGS_KEY, {
+      showShortcutBvc: newSetting,
+    });
   }
 
   /**
@@ -1220,7 +1179,7 @@ export default class AccountViewView extends Vue {
           // the user was not known to be registered, but now they are (because we got no error) so let's record it
           try {
             await db.open();
-            db.settings.update(MASTER_SETTINGS_KEY, {
+            await db.settings.update(MASTER_SETTINGS_KEY, {
               isRegistered: true,
             });
             this.isRegistered = true;
@@ -1247,7 +1206,7 @@ export default class AccountViewView extends Vue {
 
       try {
         await db.open();
-        db.settings.update(MASTER_SETTINGS_KEY, {
+        await db.settings.update(MASTER_SETTINGS_KEY, {
           isRegistered: false,
         });
         this.isRegistered = false;
@@ -1272,8 +1231,8 @@ export default class AccountViewView extends Vue {
         (data?.error?.message as string) || "Bad server response.";
       console.error(
         "Got bad response retrieving limits, which usually means user isn't registered.",
+        error,
       );
-      //console.error(error);
     } else {
       this.limitsMessage = "Got an error retrieving limits.";
       console.error("Got some error retrieving limits:", error);
@@ -1350,7 +1309,7 @@ export default class AccountViewView extends Vue {
 
   async onClickSaveApiServer() {
     await db.open();
-    db.settings.update(MASTER_SETTINGS_KEY, {
+    await db.settings.update(MASTER_SETTINGS_KEY, {
       apiServer: this.apiServerInput,
     });
     this.apiServer = this.apiServerInput;
@@ -1358,7 +1317,7 @@ export default class AccountViewView extends Vue {
 
   async onClickSavePushServer() {
     await db.open();
-    db.settings.update(MASTER_SETTINGS_KEY, {
+    await db.settings.update(MASTER_SETTINGS_KEY, {
       webPushServer: this.webPushServerInput,
     });
     this.webPushServer = this.webPushServerInput;
@@ -1377,7 +1336,7 @@ export default class AccountViewView extends Vue {
     (this.$refs.imageMethodDialog as ImageMethodDialog).open(
       async (imgUrl) => {
         await db.open();
-        db.settings.update(MASTER_SETTINGS_KEY, {
+        await db.settings.update(MASTER_SETTINGS_KEY, {
           profileImageUrl: imgUrl,
         });
         this.profileImageUrl = imgUrl;
@@ -1407,16 +1366,13 @@ export default class AccountViewView extends Vue {
       return;
     }
     try {
-      const token = await accessToken(this.activeDid);
+      const headers = await getHeaders(this.activeDid);
+      this.passkeyExpirationDescription = tokenExpiryTimeDescription();
       const response = await this.axios.delete(
         DEFAULT_IMAGE_API_SERVER +
           "/image/" +
           encodeURIComponent(this.profileImageUrl),
-        {
-          headers: {
-            Authorization: `Bearer ${token}`,
-          },
-        },
+        { headers },
       );
       if (response.status === 204) {
         // don't bother with a notification
@@ -1436,7 +1392,7 @@ export default class AccountViewView extends Vue {
       }
 
       await db.open();
-      db.settings.update(MASTER_SETTINGS_KEY, {
+      await db.settings.update(MASTER_SETTINGS_KEY, {
         profileImageUrl: undefined,
       });
 
@@ -1448,7 +1404,7 @@ export default class AccountViewView extends Vue {
         console.error("The image was already deleted:", error);
 
         await db.open();
-        db.settings.update(MASTER_SETTINGS_KEY, {
+        await db.settings.update(MASTER_SETTINGS_KEY, {
           profileImageUrl: undefined,
         });
 
diff --git a/src/views/ContactAmountsView.vue b/src/views/ContactAmountsView.vue
index 157f243..f54ad16 100644
--- a/src/views/ContactAmountsView.vue
+++ b/src/views/ContactAmountsView.vue
@@ -55,7 +55,7 @@
             {{ new Date(record.issuedAt).toLocaleString() }}
           </td>
           <td class="p-1">
-            <span v-if="record.agentDid == contact.did">
+            <span v-if="record.agentDid == contact?.did">
               <div class="font-bold">
                 {{ displayAmount(record.unit, record.amount) }}
                 <span v-if="record.amountConfirmed" title="Confirmed">
@@ -71,7 +71,7 @@
             </span>
           </td>
           <td class="p-1">
-            <span v-if="record.agentDid == contact.did">
+            <span v-if="record.agentDid == contact?.did">
               <fa icon="arrow-left" class="text-slate-400 fa-fw" />
             </span>
             <span v-else>
@@ -79,7 +79,7 @@
             </span>
           </td>
           <td class="p-1">
-            <span v-if="record.agentDid != contact.did">
+            <span v-if="record.agentDid != contact?.did">
               <div class="font-bold">
                 {{ displayAmount(record.unit, record.amount) }}
                 <span v-if="record.amountConfirmed" title="Confirmed">
@@ -105,7 +105,7 @@
 </template>
 
 <script lang="ts">
-import { AxiosError } from "axios";
+import { AxiosError, AxiosRequestHeaders } from "axios";
 import * as R from "ramda";
 import { Component, Vue } from "vue-facing-decorator";
 
@@ -114,7 +114,6 @@ import { NotificationIface } from "@/constants/app";
 import { accountsDB, db } from "@/db/index";
 import { Contact } from "@/db/tables/contacts";
 import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
-import { accessToken } from "@/libs/crypto";
 import {
   AgreeVerifiableCredential,
   createEndorserJwtVcFromClaim,
@@ -271,11 +270,7 @@ export default class ContactAmountssView extends Vue {
     // Make the xhr request payload
     const payload = JSON.stringify({ jwtEncoded: vcJwt });
     const url = this.apiServer + "/api/v2/claim";
-    const token = await accessToken(this.activeDid);
-    const headers = {
-      "Content-Type": "application/json",
-      Authorization: "Bearer " + token,
-    };
+    const headers = getHeaders(this.activeDid) as AxiosRequestHeaders;
 
     try {
       const resp = await this.axios.post(url, payload, { headers });
diff --git a/src/views/GiftedDetails.vue b/src/views/GiftedDetails.vue
index 626a0c8..5f4a9f4 100644
--- a/src/views/GiftedDetails.vue
+++ b/src/views/GiftedDetails.vue
@@ -186,10 +186,10 @@ import {
   constructGive,
   createAndSubmitGive,
   didInfo,
+  getHeaders,
   getPlanFromCache,
 } from "@/libs/endorserServer";
 import * as libsUtil from "@/libs/util";
-import { accessToken } from "@/libs/crypto";
 import { Contact } from "@/db/tables/contacts";
 
 @Component({
@@ -380,16 +380,12 @@ export default class GiftedDetails extends Vue {
       return;
     }
     try {
-      const token = await accessToken(this.activeDid);
+      const headers = await getHeaders(this.activeDid);
       const response = await this.axios.delete(
         DEFAULT_IMAGE_API_SERVER +
           "/image/" +
           encodeURIComponent(this.imageUrl),
-        {
-          headers: {
-            Authorization: `Bearer ${token}`,
-          },
-        },
+        { headers },
       );
       if (response.status === 204) {
         // don't bother with a notification
diff --git a/src/views/NewEditProjectView.vue b/src/views/NewEditProjectView.vue
index 0ebf62a..e4f6c88 100644
--- a/src/views/NewEditProjectView.vue
+++ b/src/views/NewEditProjectView.vue
@@ -173,7 +173,7 @@
 
 <script lang="ts">
 import "leaflet/dist/leaflet.css";
-import { AxiosError } from "axios";
+import { AxiosError, AxiosRequestHeaders } from "axios";
 import { DateTime } from "luxon";
 import { Component, Vue } from "vue-facing-decorator";
 import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
@@ -183,9 +183,9 @@ import QuickNav from "@/components/QuickNav.vue";
 import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
 import { accountsDB, db } from "@/db/index";
 import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
-import { accessToken } from "@/libs/crypto";
 import {
   createEndorserJwtVcFromClaim,
+  getHeaders,
   PlanVerifiableCredential,
 } from "@/libs/endorserServer";
 import { useAppStore } from "@/store/app";
@@ -250,11 +250,7 @@ export default class NewEditProjectView extends Vue {
       this.apiServer +
       "/api/claim/byHandle/" +
       encodeURIComponent(this.projectId);
-    const token = await accessToken(userDid);
-    const headers = {
-      "Content-Type": "application/json",
-      Authorization: "Bearer " + token,
-    };
+    const headers = await getHeaders(userDid);
 
     try {
       const resp = await this.axios.get(url, { headers });
@@ -309,16 +305,12 @@ export default class NewEditProjectView extends Vue {
       return;
     }
     try {
-      const token = await accessToken(this.activeDid);
+      const headers = getHeaders(this.activeDid) as AxiosRequestHeaders;
       const response = await this.axios.delete(
         DEFAULT_IMAGE_API_SERVER +
           "/image/" +
           encodeURIComponent(this.imageUrl),
-        {
-          headers: {
-            Authorization: `Bearer ${token}`,
-          },
-        },
+        { headers },
       );
       if (response.status === 204) {
         // don't bother with a notification
@@ -418,11 +410,7 @@ export default class NewEditProjectView extends Vue {
 
     const payload = JSON.stringify({ jwtEncoded: vcJwt });
     const url = this.apiServer + "/api/v2/claim";
-    const token = await accessToken(issuerDid);
-    const headers = {
-      "Content-Type": "application/json",
-      Authorization: "Bearer " + token,
-    };
+    const headers = await getHeaders(issuerDid);
 
     try {
       const resp = await this.axios.post(url, payload, { headers });
diff --git a/src/views/ProjectViewView.vue b/src/views/ProjectViewView.vue
index dc37032..6843d19 100644
--- a/src/views/ProjectViewView.vue
+++ b/src/views/ProjectViewView.vue
@@ -416,7 +416,6 @@ import { accountsDB, db } from "@/db/index";
 import { Account } from "@/db/tables/accounts";
 import { Contact } from "@/db/tables/contacts";
 import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
-import { accessToken } from "@/libs/crypto";
 import * as libsUtil from "@/libs/util";
 import {
   BLANK_GENERIC_SERVER_RECORD,
@@ -583,11 +582,6 @@ export default class ProjectViewView extends Vue {
 
     this.loadPlanFulfillersTo();
 
-    // now load fulfilled-by, a single project
-    if (this.activeDid) {
-      const token = await accessToken(this.activeDid);
-      headers["Authorization"] = "Bearer " + token;
-    }
     const fulfilledByUrl =
       this.apiServer +
       "/api/v2/report/planFulfilledByPlan?planHandleId=" +
diff --git a/src/views/ProjectsView.vue b/src/views/ProjectsView.vue
index d8a1f96..c967e2b 100644
--- a/src/views/ProjectsView.vue
+++ b/src/views/ProjectsView.vue
@@ -233,13 +233,16 @@ import { Component, Vue } from "vue-facing-decorator";
 import { NotificationIface } from "@/constants/app";
 import { accountsDB, db } from "@/db/index";
 import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
-import { accessToken } from "@/libs/crypto";
 import * as libsUtil from "@/libs/util";
 import InfiniteScroll from "@/components/InfiniteScroll.vue";
 import QuickNav from "@/components/QuickNav.vue";
 import ProjectIcon from "@/components/ProjectIcon.vue";
 import TopMessage from "@/components/TopMessage.vue";
-import { OfferSummaryRecord, PlanData } from "@/libs/endorserServer";
+import {
+  getHeaders,
+  OfferSummaryRecord,
+  PlanData,
+} from "@/libs/endorserServer";
 import EntityIcon from "@/components/EntityIcon.vue";
 
 @Component({
@@ -293,13 +296,9 @@ export default class ProjectsView extends Vue {
    * @param url the url used to fetch the data
    * @param token Authorization token
    **/
-  async projectDataLoader(url: string, token: string) {
-    const headers: { [key: string]: string } = {
-      "Content-Type": "application/json",
-      Authorization: `Bearer ${token}`,
-    };
-
+  async projectDataLoader(url: string) {
     try {
+      const headers = await getHeaders(this.activeDid);
       this.isLoading = true;
       const resp = await this.axios.get(url, { headers } as AxiosRequestConfig);
       if (resp.status === 200 && resp.data.data) {
@@ -353,8 +352,7 @@ export default class ProjectsView extends Vue {
    **/
   async loadProjects(activeDid?: string, urlExtra: string = "") {
     const url = `${this.apiServer}/api/v2/report/plansByIssuer?${urlExtra}`;
-    const token: string = await accessToken(activeDid);
-    await this.projectDataLoader(url, token);
+    await this.projectDataLoader(url);
   }
 
   /**
@@ -392,11 +390,8 @@ export default class ProjectsView extends Vue {
    * @param url the url used to fetch the data
    * @param token Authorization token
    **/
-  async offerDataLoader(url: string, token: string) {
-    const headers: { [key: string]: string } = {
-      "Content-Type": "application/json",
-      Authorization: `Bearer ${token}`,
-    };
+  async offerDataLoader(url: string) {
+    const headers = getHeaders(this.activeDid);
 
     try {
       this.isLoading = true;
@@ -454,8 +449,7 @@ export default class ProjectsView extends Vue {
    **/
   async loadOffers(issuerDid?: string, urlExtra: string = "") {
     const url = `${this.apiServer}/api/v2/report/offers?offeredByDid=${issuerDid}${urlExtra}`;
-    const token: string = await accessToken(issuerDid);
-    await this.offerDataLoader(url, token);
+    await this.offerDataLoader(url);
   }
 
   public computedOfferTabClassNames() {
diff --git a/src/views/SharedPhotoView.vue b/src/views/SharedPhotoView.vue
index 5c427a6..1f58aa0 100644
--- a/src/views/SharedPhotoView.vue
+++ b/src/views/SharedPhotoView.vue
@@ -65,7 +65,7 @@ import {
 } from "@/constants/app";
 import { db } from "@/db/index";
 import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
-import { accessToken } from "@/libs/crypto";
+import { getHeaders } from "@/libs/endorserServer";
 
 @Component({ components: { PhotoDialog, QuickNav } })
 export default class SharedPhotoView extends Vue {
@@ -151,10 +151,7 @@ export default class SharedPhotoView extends Vue {
     let result;
     try {
       // send the image to the server
-      const token = await accessToken(this.activeDid);
-      const headers = {
-        Authorization: "Bearer " + token,
-      };
+      const headers = await getHeaders(this.activeDid);
       const formData = new FormData();
       formData.append(
         "image",

From 4270374a674a83251df5c78a007e83718e5d6edd Mon Sep 17 00:00:00 2001
From: Trent Larson <trent@trentlarson.com>
Date: Fri, 19 Jul 2024 20:49:43 -0600
Subject: [PATCH 2/2] create an identifier by default, while letting them
 choose if passkeys are enabled

---
 src/libs/crypto/vc/passkeyDidPeer.ts | 10 +++-
 src/views/HelpView.vue               |  9 ++--
 src/views/HomeView.vue               | 77 +++++++++++-----------------
 src/views/StartView.vue              |  7 ++-
 4 files changed, 49 insertions(+), 54 deletions(-)

diff --git a/src/libs/crypto/vc/passkeyDidPeer.ts b/src/libs/crypto/vc/passkeyDidPeer.ts
index 920d751..5efc372 100644
--- a/src/libs/crypto/vc/passkeyDidPeer.ts
+++ b/src/libs/crypto/vc/passkeyDidPeer.ts
@@ -411,13 +411,21 @@ async function peerDidToDidDocument(did: string): Promise<DIDResolutionResult> {
   }
   // this is basically hard-coded from https://www.w3.org/TR/did-core/#example-various-verification-method-types
   // (another reference is the @aviarytech/did-peer resolver)
+
+  /**
+   * Looks like JsonWebKey2020 isn't too difficult:
+   * - change context security/suites link to jws-2020/v1
+   * - change publicKeyMultibase to publicKeyJwk generated with cborToKeys
+   * - change type to JsonWebKey2020
+   */
+
   const id = did.split(":")[2];
   const multibase = id.slice(1);
   const encnumbasis = multibase.slice(1);
   const didDocument = {
     "@context": [
       "https://www.w3.org/ns/did/v1",
-      "https://w3id.org/security/suites/jws-2020/v1",
+      "https://w3id.org/security/suites/secp256k1-2019/v1",
     ],
     assertionMethod: [did + "#" + encnumbasis],
     authentication: [did + "#" + encnumbasis],
diff --git a/src/views/HelpView.vue b/src/views/HelpView.vue
index ea6d3b5..02327fa 100644
--- a/src/views/HelpView.vue
+++ b/src/views/HelpView.vue
@@ -24,16 +24,15 @@
     <!-- eslint-disable prettier/prettier -->
     <div>
       <p>
-        This app is a window into data that you and your friends own, focused on
-        gifts and collaboration.
+        This app focuses on gifts & gratitude, using them to build cool things with your network.
       </p>
 
       <h2 class="text-xl font-semibold">What is the idea here?</h2>
       <p>
         We are building networks of people who want to grow a giving society.
-        First of all, you can see what people have given, and also recognize
-        gifts you've seen, in a way that leaves a permanent record -- one that
-        came from you, and the recipient can prove it was for them. This is
+        First of all, let's build gratitude: see what people have given, and recognize
+        gifts you've seen. This is done in a way that leaves a permanent record -- one that
+        came from you, and that the recipient can prove it was for them. This is
         personally gratifying, but it extends to broader work: volunteers get
         confirmation of activity, and selectively show off their contributions
         and network.
diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue
index 53ac742..25e4ead 100644
--- a/src/views/HomeView.vue
+++ b/src/views/HomeView.vue
@@ -77,58 +77,28 @@
 
       <div v-else>
         <!-- !isCreatingIdentifier -->
-        <div
-          v-if="!activeDid"
-          class="bg-amber-200 rounded-md text-center px-4 py-3 mb-4"
-        >
-          <div v-if="PASSKEYS_ENABLED">
-            <p class="text-lg mb-3">
-              Choose how to see info from your contacts or share contributions:
-            </p>
-            <div class="flex justify-between">
-              <button
-                class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
-                @click="generateIdentifier()"
-              >
-                Let me start the easiest (with a passkey).
-              </button>
-              <router-link
-                :to="{ name: 'start' }"
-                class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
-              >
-                Give me all the options.
-              </router-link>
-            </div>
-          </div>
-          <div v-else>
-            <p class="text-lg mb-3">
-              To recognize giving or collaborate, have someone register you:
-            </p>
-            <router-link
-              :to="{ name: 'contact-qr' }"
-              class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
-            >
-              Share your contact info.
-            </router-link>
-          </div>
-        </div>
-
-        <div v-else class="mb-4">
-          <!-- activeDid -->
-
+        <!-- They should have an identifier, even if it's an auto-generated one that they'll never use. -->
+        <div class="mb-4">
           <div
             v-if="!isRegistered"
             class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
           >
             <!-- activeDid && !isRegistered -->
-            Someone must register you before you can give kudos or make offers
-            or create projects... basically before doing anything.
+            To share, someone must register you.
             <router-link
               :to="{ name: 'contact-qr' }"
               class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
             >
-              Show Them Your Identifier Info
+              Show Them Default Identifier Info
             </router-link>
+            <div v-if="PASSKEYS_ENABLED" class="flex justify-end w-full">
+              <router-link
+                :to="{ name: 'start' }"
+                class="block text-right text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
+              >
+                See all your options first
+              </router-link>
+            </div>
           </div>
 
           <div v-else>
@@ -340,7 +310,11 @@ import FeedFilters from "@/components/FeedFilters.vue";
 import InfiniteScroll from "@/components/InfiniteScroll.vue";
 import QuickNav from "@/components/QuickNav.vue";
 import TopMessage from "@/components/TopMessage.vue";
-import { AppString, NotificationIface, PASSKEYS_ENABLED } from "@/constants/app";
+import {
+  AppString,
+  NotificationIface,
+  PASSKEYS_ENABLED,
+} from "@/constants/app";
 import { db, accountsDB } from "@/db/index";
 import { Contact } from "@/db/tables/contacts";
 import {
@@ -359,7 +333,10 @@ import {
   GiverReceiverInputInfo,
   GiveSummaryRecord,
 } from "@/libs/endorserServer";
-import { registerSaveAndActivatePasskey } from "@/libs/util";
+import {
+  generateSaveAndActivateIdentity,
+  registerSaveAndActivatePasskey,
+} from "@/libs/util";
 
 interface GiveRecordWithContactInfo extends GiveSummaryRecord {
   giver: {
@@ -423,7 +400,14 @@ export default class HomeView extends Vue {
     try {
       await accountsDB.open();
       const allAccounts = await accountsDB.accounts.toArray();
-      this.allMyDids = allAccounts.map((acc) => acc.did);
+      if (allAccounts.length > 0) {
+        this.allMyDids = allAccounts.map((acc) => acc.did);
+      } else {
+        this.isCreatingIdentifier = true;
+        const newDid = await generateSaveAndActivateIdentity();
+        this.isCreatingIdentifier = false;
+        this.allMyDids = [newDid];
+      }
 
       await db.open();
       const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
@@ -440,6 +424,7 @@ export default class HomeView extends Vue {
 
       this.isAnyFeedFilterOn = isAnyFeedFilterOn(settings);
 
+
       // someone may have have registered after sharing contact info, so recheck
       if (!this.isRegistered && this.activeDid) {
         try {
@@ -481,7 +466,7 @@ export default class HomeView extends Vue {
     }
   }
 
-  async generateIdentifier() {
+  async generatePasskeyIdentifier() {
     this.isCreatingIdentifier = true;
     const account = await registerSaveAndActivatePasskey(
       AppString.APP_NAME + (this.givenName ? " - " + this.givenName : ""),
diff --git a/src/views/StartView.vue b/src/views/StartView.vue
index 54587d9..002db61 100644
--- a/src/views/StartView.vue
+++ b/src/views/StartView.vue
@@ -27,7 +27,7 @@
         <p class="text-center text-xl font-light">
           How do you want to create this identifier?
         </p>
-        <p class="text-center font-light mt-6">
+        <p v-if="PASSKEYS_ENABLED" class="text-center font-light mt-6">
           A <strong>passkey</strong> is easy to manage, though it is less
           interoperable with other systems for advanced uses.
           <a
@@ -49,6 +49,7 @@
         </p>
         <div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-4">
           <a
+            v-if="PASSKEYS_ENABLED"
             @click="onClickNewPasskey()"
             class="block w-full text-center text-lg uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2 cursor-pointer"
           >
@@ -88,7 +89,7 @@
 <script lang="ts">
 import { Component, Vue } from "vue-facing-decorator";
 
-import { AppString } from "@/constants/app";
+import { AppString, PASSKEYS_ENABLED } from "@/constants/app";
 import { accountsDB, db } from "@/db/index";
 import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
 import { registerSaveAndActivatePasskey } from "@/libs/util";
@@ -97,6 +98,8 @@ import { registerSaveAndActivatePasskey } from "@/libs/util";
   components: {},
 })
 export default class StartView extends Vue {
+  PASSKEYS_ENABLED = PASSKEYS_ENABLED;
+
   givenName = "";
   numAccounts = 0;