ensure contact import from deep-link or paste all act consistently

This commit is contained in:
2026-03-02 18:57:53 -07:00
parent 3c657848c5
commit a45f605c5f
4 changed files with 258 additions and 82 deletions

View File

@@ -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

View File

@@ -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<string, unknown>): Contact | null {
const own = payload.own as Record<string, unknown> | 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<string, unknown>;
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.",
};
}

View File

@@ -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<Contact> =
parsedJwt.payload.contacts ||
(Array.isArray(parsedJwt.payload) ? parsedJwt.payload : undefined);
if (!contacts && parsedJwt.payload.own) {
if (parsedImport.kind === "single") {
this.$router.push({
name: "contacts",
query: { contactJwt: jwt },
query: { contactJwt: parsedImport.jwt },
});
return;
}
if (contacts) {
await this.setContactsSelected(contacts);
if (parsedImport.contacts.length === 1) {
this.$router.push({
name: "contacts",
query: { contactJwt: parsedImport.jwt },
});
return;
}
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);

View File

@@ -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<boolean> {
private async tryParseContactImport(contactInput: string): Promise<boolean> {
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 false;
}
await this.handleParsedContactImport(parsedImport);
return true;
}
private async handleParsedContactImport(
parsedImport: ContactImportParseResult,
): Promise<void> {
if (parsedImport.kind === "error") {
this.notify.error(parsedImport.message, TIMEOUTS.LONG);
return;
}
return false;
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<void> {
const existingContact = await this.$getContact(contact.did);
if (!existingContact) {
await this.addContact(contact);
}
await this.$router.push({
name: "contact-edit",
params: { did: contact.did },
});
}
/**