From a45f605c5fe0b4ea0173a05841bb60f6ba9e25bb Mon Sep 17 00:00:00 2001 From: Trent Larson Date: Mon, 2 Mar 2026 18:57:53 -0700 Subject: [PATCH] ensure contact import from deep-link or paste all act consistently --- .env.development | 7 +- src/libs/contactImportPayload.ts | 155 +++++++++++++++++++++++++++++++ src/views/ContactImportView.vue | 84 ++++++++++------- src/views/ContactsView.vue | 94 ++++++++++--------- 4 files changed, 258 insertions(+), 82 deletions(-) create mode 100644 src/libs/contactImportPayload.ts diff --git a/.env.development b/.env.development index 726f3b7a..40b221fe 100644 --- a/.env.development +++ b/.env.development @@ -6,10 +6,13 @@ VITE_LOG_LEVEL=debug # iOS doesn't like spaces in the app title. TIME_SAFARI_APP_TITLE="TimeSafari_Dev" VITE_APP_SERVER=http://localhost:8080 -# This is the claim ID for actions in the BVC project, with the JWT ID on this environment (not - +# This is the claim ID for actions in the BVC project, with the JWT ID on the environment +# test server VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F +# production server +#VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H + VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 # Using shared server by default to ease setup, which works for shared test users. VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app diff --git a/src/libs/contactImportPayload.ts b/src/libs/contactImportPayload.ts new file mode 100644 index 00000000..54b56965 --- /dev/null +++ b/src/libs/contactImportPayload.ts @@ -0,0 +1,155 @@ +import { Contact } from "../db/tables/contacts"; +import { getContactJwtFromJwtUrl } from "./crypto"; +import { decodeEndorserJwt } from "./crypto/vc"; + +export type ContactImportParseErrorCode = + | "not_contact_import_format" + | "truncated_data" + | "invalid_jwt" + | "unsupported_payload"; + +export type ContactImportParseResult = + | { + kind: "single"; + jwt: string; + contact: Contact; + } + | { + kind: "multi"; + jwt: string; + contacts: Contact[]; + } + | { + kind: "error"; + code: ContactImportParseErrorCode; + message: string; + }; + +function mapSingleContact(payload: Record): Contact | null { + const own = payload.own as Record | undefined; + if (!own || typeof own !== "object") { + return null; + } + + const didFromOwn = typeof own.did === "string" ? own.did : undefined; + const didFromIss = typeof payload.iss === "string" ? payload.iss : undefined; + const did = didFromOwn || didFromIss; + if (!did) { + return null; + } + + const contact: Contact = { + did, + name: typeof own.name === "string" ? own.name : undefined, + registered: Boolean(own.registered), + }; + + const nextPubKeyHashB64 = + (typeof own.nextPublicEncKeyHash === "string" && + own.nextPublicEncKeyHash) || + (typeof own.nextPubKeyHashB64 === "string" && own.nextPubKeyHashB64) || + undefined; + if (nextPubKeyHashB64) { + contact.nextPubKeyHashB64 = nextPubKeyHashB64; + } + + const publicKeyBase64 = + (typeof own.publicEncKey === "string" && own.publicEncKey) || + (typeof own.publicKeyBase64 === "string" && own.publicKeyBase64) || + undefined; + if (publicKeyBase64) { + contact.publicKeyBase64 = publicKeyBase64; + } + + if (typeof own.profileImageUrl === "string" && own.profileImageUrl) { + contact.profileImageUrl = own.profileImageUrl; + } + + return contact; +} + +export function parseContactImportInput( + rawInput: string, +): ContactImportParseResult { + const input = rawInput.trim(); + if (!input) { + return { + kind: "error", + code: "not_contact_import_format", + message: "No contact import data was found in that input.", + }; + } + + const looksLikeJwt = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/.test( + input, + ); + const hasContactHint = + input.includes("/contact-import") || + input.includes("contactJwt=") || + input.includes("/contact?jwt="); + + if ( + input.endsWith("contact-import") || + input.endsWith("contact-import/") || + input.endsWith("/deep-link") + ) { + return { + kind: "error", + code: "truncated_data", + message: + "That is only part of the contact-import data; it's missing data at the end. Try another way to get the full data.", + }; + } + + if (!hasContactHint && !looksLikeJwt) { + return { + kind: "error", + code: "not_contact_import_format", + message: "No contact import data was found in that input.", + }; + } + + const jwt = getContactJwtFromJwtUrl(input); + if (!jwt) { + return { + kind: "error", + code: "invalid_jwt", + message: + "That contact-import data could not be decoded. Ask for a fresh link or QR code.", + }; + } + + try { + const payload = decodeEndorserJwt(jwt).payload as Record; + if (Array.isArray(payload.contacts)) { + return { + kind: "multi", + jwt, + contacts: payload.contacts as Contact[], + }; + } + + const singleContact = mapSingleContact(payload); + if (singleContact) { + return { + kind: "single", + jwt, + contact: singleContact, + }; + } + } catch (_error) { + return { + kind: "error", + code: "invalid_jwt", + message: + "That contact-import data could not be decoded. Ask for a fresh link or QR code.", + }; + } + + return { + kind: "error", + code: "unsupported_payload", + message: + "That contact-import format is not supported yet. Ask the sender to share again.", + }; +} diff --git a/src/views/ContactImportView.vue b/src/views/ContactImportView.vue index 9164f87f..79f83fb0 100644 --- a/src/views/ContactImportView.vue +++ b/src/views/ContactImportView.vue @@ -291,7 +291,7 @@ import { RouteLocationNormalizedLoaded, Router } from "vue-router"; import QuickNav from "../components/QuickNav.vue"; import EntityIcon from "../components/EntityIcon.vue"; import OfferDialog from "../components/OfferDialog.vue"; -import { APP_SERVER, AppString, NotificationIface } from "../constants/app"; +import { AppString, NotificationIface } from "../constants/app"; import { Contact, ContactWithLabels, @@ -303,8 +303,7 @@ import { errorStringForLog, setVisibilityUtil, } from "../libs/endorserServer"; -import { getContactJwtFromJwtUrl } from "../libs/crypto"; -import { decodeEndorserJwt } from "../libs/crypto/vc"; +import { parseContactImportInput } from "../libs/contactImportPayload"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; import { ContactLabel } from "@/db/tables/contactLabels"; @@ -495,27 +494,29 @@ export default class ContactImportView extends Vue { * Processes JWT from URL path and handles different JWT formats */ private async processJwtFromPath() { - // JWT tokens always start with 'ey' (base64url encoded header) - const JWT_PATTERN = /\/contact-import\/(ey.+)$/; - const jwt = window.location.pathname.match(JWT_PATTERN)?.[1]; + const parsedImport = parseContactImportInput(window.location.pathname); + if (parsedImport.kind === "error") { + return; + } - if (jwt) { - const parsedJwt = decodeEndorserJwt(jwt); - const contacts: Array = - parsedJwt.payload.contacts || - (Array.isArray(parsedJwt.payload) ? parsedJwt.payload : undefined); + if (parsedImport.kind === "single") { + this.$router.push({ + name: "contacts", + query: { contactJwt: parsedImport.jwt }, + }); + return; + } - if (!contacts && parsedJwt.payload.own) { - this.$router.push({ - name: "contacts", - query: { contactJwt: jwt }, - }); - return; - } + if (parsedImport.contacts.length === 1) { + this.$router.push({ + name: "contacts", + query: { contactJwt: parsedImport.jwt }, + }); + return; + } - if (contacts) { - await this.setContactsSelected(contacts); - } + if (parsedImport.contacts.length > 0) { + await this.setContactsSelected(parsedImport.contacts); } } @@ -595,16 +596,12 @@ export default class ContactImportView extends Vue { * @param jwtInput JWT string to validate */ async checkContactJwt(jwtInput: string) { + const parsedImport = parseContactImportInput(jwtInput); if ( - jwtInput.endsWith(APP_SERVER) || - jwtInput.endsWith(APP_SERVER + "/") || - jwtInput.endsWith("contact-import") || - jwtInput.endsWith("contact-import/") + parsedImport.kind === "error" && + parsedImport.code === "truncated_data" ) { - this.notify.error( - "That is only part of the contact-import data; it's missing data at the end. Try another way to get the full data.", - TIMEOUTS.LONG, - ); + this.notify.error(parsedImport.message, TIMEOUTS.LONG); } } @@ -616,14 +613,29 @@ export default class ContactImportView extends Vue { this.checkingImports = true; try { - const jwt: string = getContactJwtFromJwtUrl(jwtInput) || ""; - const payload = decodeEndorserJwt(jwt).payload; - - if (Array.isArray(payload.contacts)) { - await this.setContactsSelected(payload.contacts); - } else { - throw new Error("Invalid contact-import JWT or URL: " + jwtInput); + const parsedImport = parseContactImportInput(jwtInput); + if (parsedImport.kind === "error") { + this.notify.error(parsedImport.message, TIMEOUTS.STANDARD); + return; } + + if (parsedImport.kind === "single") { + await this.$router.push({ + name: "contacts", + query: { contactJwt: parsedImport.jwt }, + }); + return; + } + + if (parsedImport.contacts.length === 1) { + await this.$router.push({ + name: "contacts", + query: { contactJwt: parsedImport.jwt }, + }); + return; + } + + await this.setContactsSelected(parsedImport.contacts); } catch (error) { const fullError = "Error importing contacts: " + errorStringForLog(error); this.$logAndConsole(fullError, true); diff --git a/src/views/ContactsView.vue b/src/views/ContactsView.vue index c4f3773d..87cf90e9 100644 --- a/src/views/ContactsView.vue +++ b/src/views/ContactsView.vue @@ -223,8 +223,11 @@ import LargeIdenticonModal from "../components/LargeIdenticonModal.vue"; import { AppString, NotificationIface } from "../constants/app"; // Legacy logging import removed - using PlatformServiceMixin methods import { Contact } from "../db/tables/contacts"; -import { getContactJwtFromJwtUrl } from "../libs/crypto"; import { decodeEndorserJwt } from "../libs/crypto/vc"; +import { + parseContactImportInput, + ContactImportParseResult, +} from "../libs/contactImportPayload"; import { CONTACT_CSV_HEADER, createEndorserJwtForDid, @@ -233,15 +236,8 @@ import { isDid, register, setVisibilityUtil, - CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI, - CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI, - CONTACT_URL_PATH_ENDORSER_CH_OLD, } from "../libs/endorserServer"; -import { - GiveSummaryRecord, - UserInfo, - VerifiableCredential, -} from "@/interfaces"; +import { GiveSummaryRecord, VerifiableCredential } from "@/interfaces"; import * as libsUtil from "../libs/util"; import { generateSaveAndActivateIdentity } from "../libs/util"; import { logger } from "../utils/logger"; @@ -416,20 +412,8 @@ export default class ContactsView extends Vue { // because that will do better error checking for things like missing data on iOS platforms. const importedContactJwt = this.$route.query["contactJwt"] as string; if (importedContactJwt) { - // really should fully verify contents - const { payload } = decodeEndorserJwt(importedContactJwt); - const userInfo = payload["own"] as UserInfo; - const newContact = { - did: userInfo.did || payload["iss"], // ".did" is reliable as of v 0.3.49 - name: userInfo.name, - nextPubKeyHashB64: userInfo.nextPublicEncKeyHash, - profileImageUrl: userInfo.profileImageUrl, - publicKeyBase64: userInfo.publicEncKey, - registered: userInfo.registered, - } as Contact; - await this.addContact(newContact); - // if we're here, they haven't redirected anywhere, so we'll redirect here without a query parameter - this.$router.push({ path: "/contacts" }); + const parsedImport = parseContactImportInput(importedContactJwt); + await this.handleParsedContactImport(parsedImport); } } @@ -787,7 +771,7 @@ export default class ContactsView extends Vue { } // Try different parsing methods in order - if (await this.tryParseJwtContact(contactInput)) return; + if (await this.tryParseContactImport(contactInput)) return; if (await this.tryParseCsvContacts(contactInput)) return; if (await this.tryParseDidContact(contactInput)) return; if (await this.tryParseJsonContacts(contactInput)) return; @@ -799,29 +783,51 @@ export default class ContactsView extends Vue { /** * Parse contact from JWT URL format */ - private async tryParseJwtContact(contactInput: string): Promise { + private async tryParseContactImport(contactInput: string): Promise { + const parsedImport = parseContactImportInput(contactInput); if ( - contactInput.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI) || - contactInput.includes(CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI) || - contactInput.includes(CONTACT_URL_PATH_ENDORSER_CH_OLD) + parsedImport.kind === "error" && + parsedImport.code === "not_contact_import_format" ) { - const jwt = getContactJwtFromJwtUrl(contactInput); - if (jwt) { - const { payload } = decodeEndorserJwt(jwt); - const userInfo = payload["own"] as UserInfo; - const newContact = { - did: userInfo.did || payload["iss"], - name: userInfo.name, - nextPubKeyHashB64: userInfo.nextPublicEncKeyHash, - profileImageUrl: userInfo.profileImageUrl, - publicKeyBase64: userInfo.publicEncKey, - registered: userInfo.registered, - } as Contact; - await this.addContact(newContact); - return true; - } + return false; } - return false; + await this.handleParsedContactImport(parsedImport); + return true; + } + + private async handleParsedContactImport( + parsedImport: ContactImportParseResult, + ): Promise { + if (parsedImport.kind === "error") { + this.notify.error(parsedImport.message, TIMEOUTS.LONG); + return; + } + + if (parsedImport.kind === "single") { + await this.addOrEditImportedContact(parsedImport.contact); + return; + } + + if (parsedImport.contacts.length === 1) { + await this.addOrEditImportedContact(parsedImport.contacts[0]); + return; + } + + await this.$router.push({ + name: "contact-import", + params: { jwt: parsedImport.jwt }, + }); + } + + private async addOrEditImportedContact(contact: Contact): Promise { + const existingContact = await this.$getContact(contact.did); + if (!existingContact) { + await this.addContact(contact); + } + await this.$router.push({ + name: "contact-edit", + params: { did: contact.did }, + }); } /**