ensure contact import from deep-link or paste all act consistently
This commit is contained in:
@@ -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
|
||||
|
||||
155
src/libs/contactImportPayload.ts
Normal file
155
src/libs/contactImportPayload.ts
Normal 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.",
|
||||
};
|
||||
}
|
||||
@@ -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 (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);
|
||||
|
||||
@@ -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 true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user