You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
613 lines
19 KiB
613 lines
19 KiB
<template>
|
|
<QuickNav selected="Contacts"></QuickNav>
|
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
|
<!-- Back -->
|
|
<div class="text-lg text-center font-light relative px-7">
|
|
<h1
|
|
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
|
@click="$router.back()"
|
|
>
|
|
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
|
|
</h1>
|
|
</div>
|
|
|
|
<!-- Heading -->
|
|
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
|
Contact Import
|
|
</h1>
|
|
|
|
<div v-if="checkingImports" class="text-center">
|
|
<font-awesome icon="spinner" class="animate-spin" />
|
|
</div>
|
|
<div v-else>
|
|
<span
|
|
v-if="contactsImporting.length > sameCount"
|
|
class="flex justify-center"
|
|
>
|
|
<input v-model="makeVisible" type="checkbox" class="mr-2" />
|
|
Make my activity visible to these contacts.
|
|
</span>
|
|
|
|
<div v-if="sameCount > 0">
|
|
<span v-if="sameCount == 1"
|
|
>One contact is the same as an existing contact</span
|
|
>
|
|
<span v-else
|
|
>{{ sameCount }} contacts are the same as existing contacts</span
|
|
>
|
|
</div>
|
|
|
|
<!-- Results List -->
|
|
<ul
|
|
v-if="contactsImporting.length > sameCount"
|
|
class="border-t border-slate-300"
|
|
>
|
|
<li v-for="(contact, index) in contactsImporting" :key="contact.did">
|
|
<div
|
|
v-if="
|
|
!contactsExisting[contact.did] ||
|
|
!R.isEmpty(contactDifferences[contact.did])
|
|
"
|
|
class="grow overflow-hidden border-b border-slate-300 pt-2.5 pb-4"
|
|
>
|
|
<h2 class="text-base font-semibold">
|
|
<input v-model="contactsSelected[index]" type="checkbox" />
|
|
{{ contact.name || AppString.NO_CONTACT_NAME }}
|
|
-
|
|
<span v-if="contactsExisting[contact.did]" class="text-orange-500"
|
|
>Existing</span
|
|
>
|
|
<span v-else class="text-green-500">New</span>
|
|
</h2>
|
|
<div class="text-sm truncate">
|
|
{{ contact.did }}
|
|
</div>
|
|
<div v-if="contactDifferences[contact.did]">
|
|
<div>
|
|
<div class="grid grid-cols-3 gap-2">
|
|
<div></div>
|
|
<div class="font-bold">Old Value</div>
|
|
<div class="font-bold">New Value</div>
|
|
</div>
|
|
<div
|
|
v-for="(value, contactField) in contactDifferences[
|
|
contact.did
|
|
]"
|
|
:key="contactField"
|
|
class="grid grid-cols-3 border"
|
|
>
|
|
<div class="border font-bold p-1">
|
|
{{ capitalizeAndInsertSpacesBeforeCaps(contactField) }}
|
|
</div>
|
|
<div class="border p-1">{{ value.old }}</div>
|
|
<div class="border p-1">{{ value.new }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
<button
|
|
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-sm text-white mt-2 px-2 py-1.5 rounded"
|
|
@click="importContacts"
|
|
>
|
|
Import Selected Contacts
|
|
</button>
|
|
</ul>
|
|
<p v-else-if="contactsImporting.length > 0">
|
|
All those contacts are already in your list with the same information.
|
|
</p>
|
|
<div v-else>
|
|
There are no contacts in that import. If some were sent, try again to
|
|
get the full text and paste it. (Note that iOS cuts off data in text
|
|
messages.) Ask the person to send the data a different way, eg. email.
|
|
<div class="mt-4 text-center">
|
|
<textarea
|
|
v-model="inputJwt"
|
|
placeholder="Contact-import data"
|
|
class="mt-4 border-2 border-gray-300 p-2 rounded"
|
|
cols="30"
|
|
@input="() => checkContactJwt(inputJwt)"
|
|
/>
|
|
<br />
|
|
<button
|
|
class="ml-2 p-2 bg-blue-500 text-white rounded"
|
|
@click="() => processContactJwt(inputJwt)"
|
|
>
|
|
Check Import
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
/**
|
|
* @file Contact Import View Component
|
|
* @author Matthew Raymer
|
|
*
|
|
* This component handles the import of contacts into the TimeSafari app.
|
|
* It supports multiple import methods and handles duplicate detection,
|
|
* contact validation, and visibility settings.
|
|
*
|
|
* Import Methods:
|
|
* 1. Direct URL Query Parameters:
|
|
* Example: /contact-import?contacts=[{"did":"did:example:123","name":"Alice"}]
|
|
*
|
|
* 2. JWT in URL Path:
|
|
* Example: /contact-import/eyJhbGciOiJFUzI1NksifQ...
|
|
* - Supports both single and bulk imports
|
|
* - JWT payload can be either:
|
|
* a) Array format: { contacts: [{did: "...", name: "..."}, ...] }
|
|
* b) Single contact: { own: true, did: "...", name: "..." }
|
|
*
|
|
* 3. Manual JWT Input:
|
|
* - Accepts pasted JWT strings
|
|
* - Validates format and content before processing
|
|
*
|
|
* URL Examples:
|
|
* ```
|
|
* # Bulk import via query params
|
|
* /contact-import?contacts=[
|
|
* {"did":"did:example:123","name":"Alice"},
|
|
* {"did":"did:example:456","name":"Bob"}
|
|
* ]
|
|
*
|
|
* # Single contact via JWT
|
|
* /contact-import/eyJhbGciOiJFUzI1NksifQ.eyJvd24iOnRydWUsImRpZCI6ImRpZDpleGFtcGxlOjEyMyJ9...
|
|
*
|
|
* # Bulk import via JWT
|
|
* /contact-import/eyJhbGciOiJFUzI1NksifQ.eyJjb250YWN0cyI6W3siZGlkIjoiZGlkOmV4YW1wbGU6MTIzIn1dfQ...
|
|
*
|
|
* # Redirect to contacts page (single contact)
|
|
* /contacts?contactJwt=eyJhbGciOiJFUzI1NksifQ...
|
|
* ```
|
|
*
|
|
* Features:
|
|
* - Automatic duplicate detection
|
|
* - Field-by-field comparison for existing contacts
|
|
* - Batch visibility settings
|
|
* - Auto-import for single new contacts
|
|
* - Error handling and validation
|
|
*
|
|
* State Management:
|
|
* - Tracks existing contacts
|
|
* - Maintains selection state for bulk imports
|
|
* - Records differences for duplicate contacts
|
|
* - Manages visibility settings
|
|
*
|
|
* Security Considerations:
|
|
* - JWT validation for imported contacts
|
|
* - Visibility control per contact
|
|
* - Error handling for malformed data
|
|
*
|
|
* @example
|
|
* // Component usage in router
|
|
* {
|
|
* path: "/contact-import/:jwt?",
|
|
* name: "contact-import",
|
|
* component: ContactImportView
|
|
* }
|
|
*
|
|
* @see {@link Contact} for contact data structure
|
|
* @see {@link setVisibilityUtil} for visibility management
|
|
*/
|
|
|
|
import * as R from "ramda";
|
|
import { Component, Vue } from "vue-facing-decorator";
|
|
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,
|
|
USE_DEXIE_DB,
|
|
} from "../constants/app";
|
|
import {
|
|
db,
|
|
logConsoleAndDb,
|
|
retrieveSettingsForActiveAccount,
|
|
} from "../db/index";
|
|
import { Contact, ContactMethod } from "../db/tables/contacts";
|
|
import * as databaseUtil from "../db/databaseUtil";
|
|
import * as libsUtil from "../libs/util";
|
|
import {
|
|
capitalizeAndInsertSpacesBeforeCaps,
|
|
errorStringForLog,
|
|
setVisibilityUtil,
|
|
} from "../libs/endorserServer";
|
|
import { getContactJwtFromJwtUrl } from "../libs/crypto";
|
|
import { decodeEndorserJwt } from "../libs/crypto/vc";
|
|
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
|
|
|
/**
|
|
* Contact Import View Component
|
|
* @author Matthew Raymer
|
|
*
|
|
* This component handles the secure import of contacts into TimeSafari via JWT tokens.
|
|
* It supports both single and multiple contact imports with validation and duplicate detection.
|
|
*
|
|
* Import Workflows:
|
|
* 1. JWT in URL Path (/contact-import/[JWT])
|
|
* - Extracts JWT from path
|
|
* - Decodes and validates contact data
|
|
* - Handles both single and multiple contacts
|
|
*
|
|
* 2. JWT in Query Parameter (/contacts?contactJwt=[JWT])
|
|
* - Used for single contact redirects
|
|
* - Processes JWT from query parameter
|
|
* - Redirects to appropriate view
|
|
*
|
|
* JWT Payload Structure:
|
|
* ```json
|
|
* {
|
|
* "iat": 1740740453,
|
|
* "contacts": [{
|
|
* "did": "did:ethr:0x...",
|
|
* "name": "Optional Name",
|
|
* "nextPubKeyHashB64": "base64 string",
|
|
* "publicKeyBase64": "base64 string"
|
|
* }],
|
|
* "iss": "did:ethr:0x..."
|
|
* }
|
|
* ```
|
|
*
|
|
* Security Features:
|
|
* - JWT validation
|
|
* - Issuer verification
|
|
* - Duplicate detection
|
|
* - Contact data validation
|
|
*
|
|
* @component
|
|
*/
|
|
@Component({
|
|
components: { EntityIcon, OfferDialog, QuickNav },
|
|
})
|
|
export default class ContactImportView extends Vue {
|
|
/** Notification function injected by Vue */
|
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
|
/** Current route instance */
|
|
$route!: RouteLocationNormalizedLoaded;
|
|
/** Router instance for navigation */
|
|
$router!: Router;
|
|
|
|
// Constants
|
|
AppString = AppString;
|
|
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
|
|
libsUtil = libsUtil;
|
|
R = R;
|
|
|
|
// Component state
|
|
/** Active user's DID for authentication and visibility settings */
|
|
activeDid = "";
|
|
/** API server URL for backend communication */
|
|
apiServer = "";
|
|
/** Map of existing contacts keyed by DID for duplicate detection */
|
|
contactsExisting: Record<string, Contact> = {};
|
|
/** Array of contacts being imported from JWT */
|
|
contactsImporting: Array<Contact> = [];
|
|
/** Selection state for each importing contact */
|
|
contactsSelected: Array<boolean> = [];
|
|
/** Differences between existing and importing contacts */
|
|
contactDifferences: Record<
|
|
string,
|
|
Record<
|
|
string,
|
|
{
|
|
new: string | boolean | Array<ContactMethod> | undefined;
|
|
old: string | boolean | Array<ContactMethod> | undefined;
|
|
}
|
|
>
|
|
> = {};
|
|
/** Loading state for import operations */
|
|
checkingImports = false;
|
|
/** JWT input for manual contact import */
|
|
inputJwt: string = "";
|
|
/** Visibility setting for imported contacts */
|
|
makeVisible = true;
|
|
/** Count of duplicate contacts found */
|
|
sameCount = 0;
|
|
|
|
/**
|
|
* Component lifecycle hook that initializes the contact import process
|
|
*
|
|
* This method handles three distinct import scenarios:
|
|
* 1. Query Parameter Import:
|
|
* - Checks for contacts in URL query parameters
|
|
* - Parses JSON array of contacts if present
|
|
*
|
|
* 2. JWT URL Import:
|
|
* - Extracts JWT from URL path using regex pattern '/contact-import/(ey.+)$'
|
|
* - Decodes JWT without validation (supports future-dated QR codes)
|
|
* - Handles two JWT payload formats:
|
|
* a. Array format: payload.contacts or direct array
|
|
* b. Single contact format: redirects to contacts page with JWT
|
|
*
|
|
* 3. Auto-Import Logic:
|
|
* - Automatically imports if exactly one new contact is present
|
|
* - Only triggers if no existing contacts match
|
|
*
|
|
* @throws Will not throw but logs errors during JWT processing
|
|
* @emits router.push when redirecting for single contact import
|
|
*/
|
|
async created() {
|
|
await this.initializeSettings();
|
|
await this.processQueryParams();
|
|
await this.processJwtFromPath();
|
|
await this.handleAutoImport();
|
|
}
|
|
|
|
/**
|
|
* Initializes component settings from active account
|
|
*/
|
|
private async initializeSettings() {
|
|
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
|
if (USE_DEXIE_DB) {
|
|
settings = await retrieveSettingsForActiveAccount();
|
|
}
|
|
this.activeDid = settings.activeDid || "";
|
|
this.apiServer = settings.apiServer || "";
|
|
}
|
|
|
|
/**
|
|
* Processes contacts from URL query parameters
|
|
*/
|
|
private async processQueryParams() {
|
|
const importedContacts = this.$route.query["contacts"] as string;
|
|
if (importedContacts) {
|
|
await this.setContactsSelected(JSON.parse(importedContacts));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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];
|
|
|
|
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) {
|
|
this.$router.push({
|
|
name: "contacts",
|
|
query: { contactJwt: jwt },
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (contacts) {
|
|
await this.setContactsSelected(contacts);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles automatic import for single new contacts
|
|
*/
|
|
private async handleAutoImport() {
|
|
if (
|
|
this.contactsImporting.length === 1 &&
|
|
R.isEmpty(this.contactsExisting)
|
|
) {
|
|
this.contactsSelected[0] = true;
|
|
await this.importContacts();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Processes contacts for import and checks for duplicates
|
|
* @param contacts Array of contacts to process
|
|
*/
|
|
async setContactsSelected(contacts: Array<Contact>) {
|
|
this.contactsImporting = contacts;
|
|
this.contactsSelected = new Array(this.contactsImporting.length).fill(true);
|
|
|
|
const platformService = PlatformServiceFactory.getInstance();
|
|
const dbAllContacts = await platformService.dbQuery(
|
|
"SELECT * FROM contacts",
|
|
);
|
|
let baseContacts = databaseUtil.mapQueryResultToValues(
|
|
dbAllContacts,
|
|
) as unknown as Contact[];
|
|
if (USE_DEXIE_DB) {
|
|
await db.open();
|
|
baseContacts = await db.contacts.toArray();
|
|
}
|
|
|
|
// Check for existing contacts and differences
|
|
for (let i = 0; i < this.contactsImporting.length; i++) {
|
|
const contactIn = this.contactsImporting[i];
|
|
const existingContact = baseContacts.find(
|
|
(contact) => contact.did === contactIn.did,
|
|
);
|
|
if (existingContact) {
|
|
this.contactsExisting[contactIn.did] = existingContact;
|
|
|
|
// Compare contact fields for differences
|
|
const differences: Record<
|
|
string,
|
|
{
|
|
new: string | boolean | Array<ContactMethod> | undefined;
|
|
old: string | boolean | Array<ContactMethod> | undefined;
|
|
}
|
|
> = {};
|
|
Object.keys(contactIn).forEach((key) => {
|
|
if (
|
|
!R.equals(
|
|
contactIn[key as keyof Contact],
|
|
existingContact[key as keyof Contact],
|
|
)
|
|
) {
|
|
differences[key] = {
|
|
old: existingContact[key as keyof Contact],
|
|
new: contactIn[key as keyof Contact],
|
|
};
|
|
}
|
|
});
|
|
this.contactDifferences[contactIn.did] = differences;
|
|
if (R.isEmpty(differences)) {
|
|
this.sameCount++;
|
|
}
|
|
|
|
// Don't auto-select duplicates
|
|
this.contactsSelected[i] = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates contact import JWT format
|
|
* @param jwtInput JWT string to validate
|
|
*/
|
|
async checkContactJwt(jwtInput: string) {
|
|
if (
|
|
jwtInput.endsWith(APP_SERVER) ||
|
|
jwtInput.endsWith(APP_SERVER + "/") ||
|
|
jwtInput.endsWith("contact-import") ||
|
|
jwtInput.endsWith("contact-import/")
|
|
) {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "That is only part of the contact-import data; it's missing data at the end. Try another way to get the full data.",
|
|
},
|
|
5000,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Processes contact import JWT and updates contacts
|
|
* @param jwtInput JWT string containing contact data
|
|
*/
|
|
async processContactJwt(jwtInput: string) {
|
|
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);
|
|
}
|
|
} catch (error) {
|
|
const fullError = "Error importing contacts: " + errorStringForLog(error);
|
|
logConsoleAndDb(fullError, true);
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "There was an error processing the contact-import data.",
|
|
},
|
|
3000,
|
|
);
|
|
}
|
|
this.checkingImports = false;
|
|
}
|
|
|
|
/**
|
|
* Imports selected contacts and sets visibility if requested
|
|
* Updates existing contacts or adds new ones
|
|
*/
|
|
async importContacts() {
|
|
this.checkingImports = true;
|
|
let importedCount = 0,
|
|
updatedCount = 0;
|
|
|
|
// Process selected contacts
|
|
for (let i = 0; i < this.contactsImporting.length; i++) {
|
|
if (this.contactsSelected[i]) {
|
|
const contact = this.contactsImporting[i];
|
|
const existingContact = this.contactsExisting[contact.did];
|
|
if (existingContact) {
|
|
const platformService = PlatformServiceFactory.getInstance();
|
|
// @ts-expect-error because we're just using the value to store to the DB
|
|
contact.contactMethods = JSON.stringify(contact.contactMethods);
|
|
const { sql, params } = databaseUtil.generateUpdateStatement(
|
|
contact as unknown as Record<string, unknown>,
|
|
"contacts",
|
|
"did = ?",
|
|
[contact.did],
|
|
);
|
|
await platformService.dbExec(sql, params);
|
|
if (USE_DEXIE_DB) {
|
|
await db.contacts.update(contact.did, contact);
|
|
}
|
|
updatedCount++;
|
|
} else {
|
|
// without explicit clone on the Proxy, we get: DataCloneError: Failed to execute 'add' on 'IDBObjectStore': #<Object> could not be cloned.
|
|
// DataError: Failed to execute 'add' on 'IDBObjectStore': Evaluating the object store's key path yielded a value that is not a valid key.
|
|
await db.contacts.add(R.clone(contact));
|
|
importedCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set visibility if requested
|
|
if (this.makeVisible) {
|
|
const failedVisibileToContacts = [];
|
|
for (let i = 0; i < this.contactsImporting.length; i++) {
|
|
if (this.contactsSelected[i]) {
|
|
const contact = this.contactsImporting[i];
|
|
if (contact) {
|
|
const visResult = await setVisibilityUtil(
|
|
this.activeDid,
|
|
this.apiServer,
|
|
this.axios,
|
|
db,
|
|
contact,
|
|
true,
|
|
);
|
|
if (!visResult.success) {
|
|
failedVisibileToContacts.push(contact);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (failedVisibileToContacts.length > 0) {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Visibility Error",
|
|
text: `Failed to set visibility for ${failedVisibileToContacts.length} contact${
|
|
failedVisibileToContacts.length == 1 ? "" : "s"
|
|
}. You must set them individually: ${failedVisibileToContacts.map((c) => c.name).join(", ")}`,
|
|
},
|
|
-1,
|
|
);
|
|
}
|
|
}
|
|
|
|
this.checkingImports = false;
|
|
|
|
// Show success notification
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "success",
|
|
title: "Imported",
|
|
text:
|
|
`${importedCount} contact${importedCount == 1 ? "" : "s"} imported.` +
|
|
(updatedCount ? ` ${updatedCount} updated.` : ""),
|
|
},
|
|
3000,
|
|
);
|
|
this.$router.push({ name: "contacts" });
|
|
}
|
|
}
|
|
</script>
|
|
|