diff --git a/src/App.vue b/src/App.vue index 49d0356c9..e1a43ca66 100644 --- a/src/App.vue +++ b/src/App.vue @@ -180,8 +180,9 @@ " class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2" > - Yes - {{ notification.yesText ? ", " + notification.yesText : "" }} + Yes{{ + notification.yesText ? ", " + notification.yesText : "" + }} - No {{ notification.noText ? ", " + notification.noText : "" }} + No{{ notification.noText ? ", " + notification.noText : "" }} - Camera or Other? + Add Photo + + + Set Your Name + + Note that this is not sent to servers. It is only shared with people when + you choose to send it to them. + + + + + + Save + + + + Cancel + + + + + + + + + + diff --git a/src/constants/app.ts b/src/constants/app.ts index f42f922d1..d936d4ffa 100644 --- a/src/constants/app.ts +++ b/src/constants/app.ts @@ -49,8 +49,8 @@ export interface NotificationIface { title: string; text?: string; noText?: string; - onCancel?: (stopAsking: boolean) => Promise; - onNo?: (stopAsking: boolean) => Promise; + onCancel?: (stopAsking?: boolean) => Promise; + onNo?: (stopAsking?: boolean) => Promise; onYes?: () => Promise; promptToStopAsking?: boolean; yesText?: string; diff --git a/src/libs/endorserServer.ts b/src/libs/endorserServer.ts index 608735bb2..0145f596f 100644 --- a/src/libs/endorserServer.ts +++ b/src/libs/endorserServer.ts @@ -1,13 +1,16 @@ import { Axios, AxiosRequestConfig, AxiosResponse } from "axios"; +import { Buffer } from "buffer"; +import { sha256 } from "ethereum-cryptography/sha256"; import { LRUCache } from "lru-cache"; 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 { accessToken, deriveAddress, nextDerivationPath } from "@/libs/crypto"; import { NonsensitiveDexie } from "@/db/index"; import { getAccount, getPasskeyExpirationSeconds } from "@/libs/util"; import { createEndorserJwtForKey, KeyMeta } from "@/libs/crypto/vc"; +import { Account } from "@/db/tables/accounts"; export const SCHEMA_ORG_CONTEXT = "https://schema.org"; // the object in RegisterAction claims @@ -925,6 +928,53 @@ export async function createAndSubmitClaim( } } +export async function generateEndorserJwtForAccount( + account: Account, + isRegistered?: boolean, + name?: string, + profileImageUrl?: string, +) { + const publicKeyHex = account.publicKeyHex; + const publicEncKey = Buffer.from(publicKeyHex, "hex").toString("base64"); + + interface UserInfo { + name: string; + publicEncKey: string; + registered: boolean; + profileImageUrl?: string; + nextPublicEncKeyHash?: string; + } + const contactInfo = { + iat: Date.now(), + iss: account.did, + own: { + name: name ?? "", + publicEncKey, + registered: !!isRegistered, + } as UserInfo, + }; + if (profileImageUrl) { + contactInfo.own.profileImageUrl = profileImageUrl; + } + + if (account?.mnemonic && account?.derivationPath) { + const newDerivPath = nextDerivationPath(account.derivationPath as string); + const nextPublicHex = deriveAddress( + account.mnemonic as string, + newDerivPath, + )[2]; + const nextPublicEncKey = Buffer.from(nextPublicHex, "hex"); + const nextPublicEncKeyHash = sha256(nextPublicEncKey); + const nextPublicEncKeyHashBase64 = + Buffer.from(nextPublicEncKeyHash).toString("base64"); + contactInfo.own.nextPublicEncKeyHash = nextPublicEncKeyHashBase64; + } + const vcJwt = await createEndorserJwtForDid(account.did, contactInfo); + + const viewPrefix = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION; + return viewPrefix + vcJwt; +} + export async function createEndorserJwtForDid( issuerDid: string, payload: object, diff --git a/src/router/index.ts b/src/router/index.ts index 3ca92cea3..b19aa51b2 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -189,6 +189,11 @@ const routes: Array = [ name: "seed-backup", component: () => import("../views/SeedBackupView.vue"), }, + { + path: "/share-my-contact-info", + name: "share-my-contact-info", + component: () => import("@/views/ShareMyContactInfoView.vue"), + }, { path: "/shared-photo", name: "shared-photo", diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue index ea1a389a8..28acc3e88 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -5,11 +5,11 @@ - + Your Identity - + - + @@ -109,6 +109,7 @@ import { CONTACT_URL_PREFIX, createEndorserJwtForDid, ENDORSER_JWT_URL_LOCATION, + generateEndorserJwtForAccount, isDid, register, setVisibilityUtil, @@ -157,7 +158,7 @@ export default class ContactQRScanShow extends Vue { own: { name: (settings?.firstName || "") + - (settings?.lastName ? ` ${settings.lastName}` : ""), // deprecated, pre v 0.1.3 + (settings?.lastName ? ` ${settings.lastName}` : ""), // lastName is deprecated, pre v 0.1.3 publicEncKey, profileImageUrl: settings?.profileImageUrl, registered: settings?.isRegistered, @@ -182,7 +183,18 @@ export default class ContactQRScanShow extends Vue { const vcJwt = await createEndorserJwtForDid(this.activeDid, contactInfo); const viewPrefix = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION; - this.qrValue = viewPrefix + vcJwt; + viewPrefix + vcJwt; + + const name = + (settings?.firstName || "") + + (settings?.lastName ? ` ${settings.lastName}` : ""); // lastName is deprecated, pre v 0.1.3 + + this.qrValue = await generateEndorserJwtForAccount( + account, + !!settings?.isRegistered, + name, + settings?.profileImageUrl as string, + ); } } diff --git a/src/views/ContactsView.vue b/src/views/ContactsView.vue index 63c8cd7b9..fab730620 100644 --- a/src/views/ContactsView.vue +++ b/src/views/ContactsView.vue @@ -4,11 +4,11 @@ - + Your Contacts - + - + - + Discover Projects - + - + {{ AppString.APP_NAME }} - - + + To share, someone must register you. - Show Them {{ PASSKEYS_ENABLED ? "Default" : "Your" }} Identifier Info - + + - + Record Something Given By: - + Latest Activity @@ -313,6 +314,7 @@ 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 UserNameDialog from "@/components/UserNameDialog.vue"; import { AppString, NotificationIface, @@ -370,6 +372,7 @@ interface GiveRecordWithContactInfo extends GiveSummaryRecord { EntityIcon, InfiniteScroll, TopMessage, + UserNameDialog, }, }) export default class HomeView extends Vue { @@ -426,6 +429,7 @@ export default class HomeView extends Vue { this.showShortcutBvc = !!settings?.showShortcutBvc; this.isAnyFeedFilterOn = isAnyFeedFilterOn(settings); + console.log("getting through mounted"); // someone may have have registered after sharing contact info, so recheck if (!this.isRegistered && this.activeDid) { @@ -449,7 +453,7 @@ export default class HomeView extends Vue { } // this returns a Promise but we don't need to wait for it - await this.updateAllFeed(); + this.updateAllFeed(); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { @@ -495,7 +499,7 @@ export default class HomeView extends Vue { this.feedData = []; this.feedPreviousOldestId = undefined; - this.updateAllFeed(); + await this.updateAllFeed(); } /** @@ -507,7 +511,7 @@ export default class HomeView extends Vue { // and the InfiniteScroll component triggers a load before finished. // One alternative is to totally separate the project link loading. if (payload && !this.isFeedLoading) { - this.updateAllFeed(); + await this.updateAllFeed(); } } @@ -527,6 +531,7 @@ export default class HomeView extends Vue { async updateAllFeed() { this.isFeedLoading = true; let endOfResults = true; + console.log("about to retrieveGives"); await this.retrieveGives(this.apiServer, this.feedPreviousOldestId) .then(async (results) => { if (results.data.length > 0) { @@ -620,7 +625,7 @@ export default class HomeView extends Vue { }); if (this.feedData.length === 0 && !endOfResults) { // repeat until there's at least some data - this.updateAllFeed(); + await this.updateAllFeed(); } this.isFeedLoading = false; } @@ -770,5 +775,36 @@ export default class HomeView extends Vue { computeKnownPersonIconStyleClassNames(known: boolean) { return known ? "text-slate-500" : "text-slate-100"; } + + showNameDialog() { + if (!this.givenName) { + (this.$refs.userNameDialog as UserNameDialog).open(() => { + this.promptForShareMethod(); + }); + } else { + this.promptForShareMethod(); + } + } + + promptForShareMethod() { + this.$notify( + { + group: "modal", + type: "confirm", + title: "Are you nearby with cameras?", + text: "If so, we'll use those with QR codes to share.", + onCancel: async () => {}, + onNo: async () => { + (this.$router as Router).push({ name: "share-my-contact-info" }); + }, + onYes: async () => { + (this.$router as Router).push({ name: "contact-qr" }); + }, + noText: "we will share another way", + yesText: "we are nearby with cameras", + }, + -1, + ); + } } diff --git a/src/views/ProjectsView.vue b/src/views/ProjectsView.vue index d9394775c..4c3dea13c 100644 --- a/src/views/ProjectsView.vue +++ b/src/views/ProjectsView.vue @@ -1,15 +1,13 @@ - + - - Your Ideas - + Your Ideas - + + + + + + + + + + + + + + + + + + Share Your Contact Info + + + + + + Copy to Clipboard + + + Click to copy your info, then send it to them. + + They will paste it in the input box on the Contacts + screen. + + + + +