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.
|
# iOS doesn't like spaces in the app title.
|
||||||
TIME_SAFARI_APP_TITLE="TimeSafari_Dev"
|
TIME_SAFARI_APP_TITLE="TimeSafari_Dev"
|
||||||
VITE_APP_SERVER=http://localhost:8080
|
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
|
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
|
VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000
|
||||||
# Using shared server by default to ease setup, which works for shared test users.
|
# 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
|
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 QuickNav from "../components/QuickNav.vue";
|
||||||
import EntityIcon from "../components/EntityIcon.vue";
|
import EntityIcon from "../components/EntityIcon.vue";
|
||||||
import OfferDialog from "../components/OfferDialog.vue";
|
import OfferDialog from "../components/OfferDialog.vue";
|
||||||
import { APP_SERVER, AppString, NotificationIface } from "../constants/app";
|
import { AppString, NotificationIface } from "../constants/app";
|
||||||
import {
|
import {
|
||||||
Contact,
|
Contact,
|
||||||
ContactWithLabels,
|
ContactWithLabels,
|
||||||
@@ -303,8 +303,7 @@ import {
|
|||||||
errorStringForLog,
|
errorStringForLog,
|
||||||
setVisibilityUtil,
|
setVisibilityUtil,
|
||||||
} from "../libs/endorserServer";
|
} from "../libs/endorserServer";
|
||||||
import { getContactJwtFromJwtUrl } from "../libs/crypto";
|
import { parseContactImportInput } from "../libs/contactImportPayload";
|
||||||
import { decodeEndorserJwt } from "../libs/crypto/vc";
|
|
||||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||||
import { ContactLabel } from "@/db/tables/contactLabels";
|
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
|
* Processes JWT from URL path and handles different JWT formats
|
||||||
*/
|
*/
|
||||||
private async processJwtFromPath() {
|
private async processJwtFromPath() {
|
||||||
// JWT tokens always start with 'ey' (base64url encoded header)
|
const parsedImport = parseContactImportInput(window.location.pathname);
|
||||||
const JWT_PATTERN = /\/contact-import\/(ey.+)$/;
|
if (parsedImport.kind === "error") {
|
||||||
const jwt = window.location.pathname.match(JWT_PATTERN)?.[1];
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (jwt) {
|
if (parsedImport.kind === "single") {
|
||||||
const parsedJwt = decodeEndorserJwt(jwt);
|
|
||||||
const contacts: Array<Contact> =
|
|
||||||
parsedJwt.payload.contacts ||
|
|
||||||
(Array.isArray(parsedJwt.payload) ? parsedJwt.payload : undefined);
|
|
||||||
|
|
||||||
if (!contacts && parsedJwt.payload.own) {
|
|
||||||
this.$router.push({
|
this.$router.push({
|
||||||
name: "contacts",
|
name: "contacts",
|
||||||
query: { contactJwt: jwt },
|
query: { contactJwt: parsedImport.jwt },
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contacts) {
|
if (parsedImport.contacts.length === 1) {
|
||||||
await this.setContactsSelected(contacts);
|
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
|
* @param jwtInput JWT string to validate
|
||||||
*/
|
*/
|
||||||
async checkContactJwt(jwtInput: string) {
|
async checkContactJwt(jwtInput: string) {
|
||||||
|
const parsedImport = parseContactImportInput(jwtInput);
|
||||||
if (
|
if (
|
||||||
jwtInput.endsWith(APP_SERVER) ||
|
parsedImport.kind === "error" &&
|
||||||
jwtInput.endsWith(APP_SERVER + "/") ||
|
parsedImport.code === "truncated_data"
|
||||||
jwtInput.endsWith("contact-import") ||
|
|
||||||
jwtInput.endsWith("contact-import/")
|
|
||||||
) {
|
) {
|
||||||
this.notify.error(
|
this.notify.error(parsedImport.message, TIMEOUTS.LONG);
|
||||||
"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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,14 +613,29 @@ export default class ContactImportView extends Vue {
|
|||||||
this.checkingImports = true;
|
this.checkingImports = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const jwt: string = getContactJwtFromJwtUrl(jwtInput) || "";
|
const parsedImport = parseContactImportInput(jwtInput);
|
||||||
const payload = decodeEndorserJwt(jwt).payload;
|
if (parsedImport.kind === "error") {
|
||||||
|
this.notify.error(parsedImport.message, TIMEOUTS.STANDARD);
|
||||||
if (Array.isArray(payload.contacts)) {
|
return;
|
||||||
await this.setContactsSelected(payload.contacts);
|
|
||||||
} else {
|
|
||||||
throw new Error("Invalid contact-import JWT or URL: " + jwtInput);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
const fullError = "Error importing contacts: " + errorStringForLog(error);
|
const fullError = "Error importing contacts: " + errorStringForLog(error);
|
||||||
this.$logAndConsole(fullError, true);
|
this.$logAndConsole(fullError, true);
|
||||||
|
|||||||
@@ -223,8 +223,11 @@ import LargeIdenticonModal from "../components/LargeIdenticonModal.vue";
|
|||||||
import { AppString, NotificationIface } from "../constants/app";
|
import { AppString, NotificationIface } from "../constants/app";
|
||||||
// Legacy logging import removed - using PlatformServiceMixin methods
|
// Legacy logging import removed - using PlatformServiceMixin methods
|
||||||
import { Contact } from "../db/tables/contacts";
|
import { Contact } from "../db/tables/contacts";
|
||||||
import { getContactJwtFromJwtUrl } from "../libs/crypto";
|
|
||||||
import { decodeEndorserJwt } from "../libs/crypto/vc";
|
import { decodeEndorserJwt } from "../libs/crypto/vc";
|
||||||
|
import {
|
||||||
|
parseContactImportInput,
|
||||||
|
ContactImportParseResult,
|
||||||
|
} from "../libs/contactImportPayload";
|
||||||
import {
|
import {
|
||||||
CONTACT_CSV_HEADER,
|
CONTACT_CSV_HEADER,
|
||||||
createEndorserJwtForDid,
|
createEndorserJwtForDid,
|
||||||
@@ -233,15 +236,8 @@ import {
|
|||||||
isDid,
|
isDid,
|
||||||
register,
|
register,
|
||||||
setVisibilityUtil,
|
setVisibilityUtil,
|
||||||
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
|
|
||||||
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
|
|
||||||
CONTACT_URL_PATH_ENDORSER_CH_OLD,
|
|
||||||
} from "../libs/endorserServer";
|
} from "../libs/endorserServer";
|
||||||
import {
|
import { GiveSummaryRecord, VerifiableCredential } from "@/interfaces";
|
||||||
GiveSummaryRecord,
|
|
||||||
UserInfo,
|
|
||||||
VerifiableCredential,
|
|
||||||
} from "@/interfaces";
|
|
||||||
import * as libsUtil from "../libs/util";
|
import * as libsUtil from "../libs/util";
|
||||||
import { generateSaveAndActivateIdentity } from "../libs/util";
|
import { generateSaveAndActivateIdentity } from "../libs/util";
|
||||||
import { logger } from "../utils/logger";
|
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.
|
// because that will do better error checking for things like missing data on iOS platforms.
|
||||||
const importedContactJwt = this.$route.query["contactJwt"] as string;
|
const importedContactJwt = this.$route.query["contactJwt"] as string;
|
||||||
if (importedContactJwt) {
|
if (importedContactJwt) {
|
||||||
// really should fully verify contents
|
const parsedImport = parseContactImportInput(importedContactJwt);
|
||||||
const { payload } = decodeEndorserJwt(importedContactJwt);
|
await this.handleParsedContactImport(parsedImport);
|
||||||
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" });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -787,7 +771,7 @@ export default class ContactsView extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try different parsing methods in order
|
// 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.tryParseCsvContacts(contactInput)) return;
|
||||||
if (await this.tryParseDidContact(contactInput)) return;
|
if (await this.tryParseDidContact(contactInput)) return;
|
||||||
if (await this.tryParseJsonContacts(contactInput)) return;
|
if (await this.tryParseJsonContacts(contactInput)) return;
|
||||||
@@ -799,29 +783,51 @@ export default class ContactsView extends Vue {
|
|||||||
/**
|
/**
|
||||||
* Parse contact from JWT URL format
|
* 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 (
|
if (
|
||||||
contactInput.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI) ||
|
parsedImport.kind === "error" &&
|
||||||
contactInput.includes(CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI) ||
|
parsedImport.code === "not_contact_import_format"
|
||||||
contactInput.includes(CONTACT_URL_PATH_ENDORSER_CH_OLD)
|
|
||||||
) {
|
) {
|
||||||
const jwt = getContactJwtFromJwtUrl(contactInput);
|
return false;
|
||||||
if (jwt) {
|
}
|
||||||
const { payload } = decodeEndorserJwt(jwt);
|
await this.handleParsedContactImport(parsedImport);
|
||||||
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 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 },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user