@ -3,10 +3,7 @@
< 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()"
>
< 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" > < / f o n t - a w e s o m e >
< / h1 >
< / div >
@ -20,43 +17,28 @@
< font -awesome icon = "spinner" class = "animate-spin" / >
< / div >
< div v-else >
< span
v - if = "contactsImporting.length > sameCount"
class = "flex justify-center"
>
< 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 < / s p a n
>
< span v -else
> { { sameCount } } contacts are the same as existing contacts < / s p a n
>
< 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"
>
< 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 = "
< 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"
>
" 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 < / s p a n
>
< 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" >
@ -69,13 +51,9 @@
< div class = "font-bold" > Old Value < / div >
< div class = "font-bold" > New Value < / div >
< / div >
< div
v - for = " ( value , contactField ) in contactDifferences [
< div v -for = " ( value , contactField ) in contactDifferences [
contact . did
] "
: key = "contactField"
class = "grid grid-cols-3 border"
>
] " :key=" contactField " class=" grid grid - cols - 3 border " >
< div class = "border font-bold p-1" >
{ { capitalizeAndInsertSpacesBeforeCaps ( contactField ) } }
< / div >
@ -88,8 +66,7 @@
< / 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"
>
@ click = "importContacts" >
Import Selected Contacts
< / button >
< / ul >
@ -101,18 +78,10 @@
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)"
/ >
< 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)"
>
< button class = "ml-2 p-2 bg-blue-500 text-white rounded" @ click = "() => processContactJwt(inputJwt)" >
Check Import
< / button >
< / div >
@ -122,6 +91,77 @@
< / 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 : / c o n t a c t - i m p o r t ? c o n t a c t s = [ { " d i d " : " d i d : e x a m p l e : 1 2 3 " , " n a m e " : " A l i c e " } ]
*
* 2. JWT in URL Path :
* Example : / c o n t a c t - i m p o r t / e y J h b G c i O i J F U z I 1 N k s i f Q . . .
* - 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
* / c o n t a c t - i m p o r t ? c o n t a c t s = [
* { "did" : "did:example:123" , "name" : "Alice" } ,
* { "did" : "did:example:456" , "name" : "Bob" }
* ]
*
* # Single contact via JWT
* / c o n t a c t - i m p o r t / e y J h b G c i O i J F U z I 1 N k s i f Q . e y J v d 2 4 i O n R y d W U s I m R p Z C I 6 I m R p Z D p l e G F t c G x l O j E y M y J 9 . . .
*
* # Bulk import via JWT
* / c o n t a c t - i m p o r t / e y J h b G c i O i J F U z I 1 N k s i f Q . e y J j b 2 5 0 Y W N 0 c y I 6 W 3 s i Z G l k I j o i Z G l k O m V 4 Y W 1 w b G U 6 M T I z I n 1 d f Q . . .
*
* # Redirect to contacts page ( single contact )
* / c o n t a c t s ? c o n t a c t J w t = e y J h b G c i O i J F U z I 1 N k s i f Q . . .
* ` ` `
*
* 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
* / / C o m p o n e n t u s a g e i n r o u t e r
* {
* 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" ;
@ -145,24 +185,75 @@ import {
import { getContactJwtFromJwtUrl } from "../libs/crypto" ;
import { decodeEndorserJwt } from "../libs/crypto/vc" ;
/ * *
* 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 ( / c o n t a c t s ? c o n t a c t J w t = [ J W T ] )
* - 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 ;
/ / C o n s t a n t s
AppString = AppString ;
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps ;
libsUtil = libsUtil ;
R = R ;
/ / C o m p o n e n t s t a t e
/** Active user's DID for authentication and visibility settings */
activeDid = "" ;
/** API server URL for backend communication */
apiServer = "" ;
contactsExisting : Record < string , Contact > = { } ; / / u s e r ' s c o n t a c t s a l r e a d y i n t h e s y s t e m , k e y e d b y D I D
contactsImporting : Array < Contact > = [ ] ; / / c o n t a c t s f r o m t h e i m p o r t
contactsSelected : Array < boolean > = [ ] ; / / w h e t h e r e a c h c o n t a c t i n c o n t a c t s I m p o r t i n g i s s e l e c t e d
/** 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 <
@ -172,68 +263,117 @@ export default class ContactImportView extends Vue {
old : string | boolean | Array < ContactMethod > | undefined ;
}
>
> = { } ; / / f o r e x i s t i n g c o n t a c t s , i t s h o w s t h e d i f f e r e n c e b e t w e e n i m p o r t e d a n d e x i s t i n g c o n t a c t s f o r e a c h k e y
> = { } ;
/** 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 ( ) {
const settings = await retrieveSettingsForActiveAccount ( ) ;
this . activeDid = settings . activeDid || "" ;
this . apiServer = settings . apiServer || "" ;
}
/ / l o o k f o r a n y i m p o r t e d c o n t a c t a r r a y f r o m t h e q u e r y p a r a m e t e r
/ * *
* 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 ( ) {
/ / J W T t o k e n s a l w a y s s t a r t w i t h ' e y ' ( b a s e 6 4 u r l e n c o d e d h e a d e r )
const JWT_PATTERN = /\/contact-import\/(ey.+)$/ ;
const jwt = window . location . pathname . match ( JWT_PATTERN ) ? . [ 1 ] ;
/ / l o o k f o r a J W T a f t e r / c o n t a c t - i m p o r t / i n t h e w i n d o w . l o c a t i o n . p a t h n a m e
const jwt = window . location . pathname . match (
/\/contact-import\/(ey.+)$/ ,
) ? . [ 1 ] ;
if ( jwt ) {
/ / w o u l d p r e f e r t o v a l i d a t e b u t w e ' v e g o t a n e r r o r w i t h J W T s o n Q R c o d e s g e n e r a t e d i n t h e f u t u r e
/ / e s l i n t - d i s a b l e - n e x t - l i n e p r e t t i e r / p r e t t i e r
/ / c o n s t p a r s e d J w t : O m i t < J W T V e r i f i e d , " d i d R e s o l u t i o n R e s u l t " | " s i g n e r " | " j w t " > = a w a i t d e c o d e A n d V e r i f y J w t ( j w t ) ;
/ / d e c o d e t h e J W T
const parsedJwt = decodeEndorserJwt ( jwt ) ;
const contacts : Array < Contact > =
parsedJwt . payload . contacts || / / s o m e d a y t h i s w i l l b e t h e o n l y p a y l o a d s e n t t o t h i s p a g e
parsedJwt . payload . contacts ||
( Array . isArray ( parsedJwt . payload ) ? parsedJwt . payload : undefined ) ;
if ( ! contacts && parsedJwt . payload . own ) {
/ / h a n d l e t h i s s i n g l e - c o n t a c t J W T i n t h e c o n t a c t s p a g e , b e t t e r s u i t e d t o s i n g l e a d d i t i o n s
this . $router . push ( {
name : "contacts" ,
query : { contactJwt : jwt } ,
} ) ;
return ;
}
if ( contacts ) {
await this . setContactsSelected ( contacts ) ;
} else {
/ / n o c o n t a c t s f o u n d s o d e f a u l t m e s s a g e s h o u l d b e O K
}
}
}
/ * *
* Handles automatic import for single new contacts
* /
private async handleAutoImport ( ) {
if (
this . contactsImporting . length === 1 &&
R . isEmpty ( this . contactsExisting )
) {
/ / i f t h e r e i s o n l y o n e c o n t a c t a n d i t ' s n e w , t h e n w e w i l l a u t o m a t i c a l l y i m p o r t i t
this . contactsSelected [ 0 ] = true ;
this . importContacts ( ) ; / / . . . w h i c h r o u t e s t o t h e c o n t a c t s l i s t
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 ) ;
await db . open ( ) ;
const baseContacts = await db . contacts . toArray ( ) ;
/ / s e t t h e e x i s t i n g c o n t a c t s , k e y e d b y D I D , i f t h e y e x i s t i n c o n t a c t s I m p o r t i n g
/ / C h e c k f o r e x i s t i n g c o n t a c t s a n d d i f f e r e n c e s
for ( let i = 0 ; i < this . contactsImporting . length ; i ++ ) {
const contactIn = this . contactsImporting [ i ] ;
const existingContact = baseContacts . find (
@ -242,6 +382,7 @@ export default class ContactImportView extends Vue {
if ( existingContact ) {
this . contactsExisting [ contactIn . did ] = existingContact ;
/ / C o m p a r e c o n t a c t f i e l d s f o r d i f f e r e n c e s
const differences : Record <
string ,
{
@ -250,7 +391,6 @@ export default class ContactImportView extends Vue {
}
> = { } ;
Object . keys ( contactIn ) . forEach ( ( key ) => {
/ / e s l i n t - d i s a b l e - n e x t - l i n e p r e t t i e r / p r e t t i e r
if ( ! R . equals ( contactIn [ key as keyof Contact ] , existingContact [ key as keyof Contact ] ) ) {
differences [ key ] = {
old : existingContact [ key as keyof Contact ] ,
@ -263,13 +403,16 @@ export default class ContactImportView extends Vue {
this . sameCount ++ ;
}
/ / d o n ' t a u t o m a t i c a l l y i m p o r t p r e v i o u s d a t a
/ / D o n ' t a u t o - s e l e c t d u p l i c a t e s
this . contactsSelected [ i ] = false ;
}
}
}
/ / c h e c k t h e c o n t a c t - i m p o r t J W T
/ * *
* Validates contact import JWT format
* @ param jwtInput JWT string to validate
* /
async checkContactJwt ( jwtInput : string ) {
if (
jwtInput . endsWith ( APP_SERVER ) ||
@ -289,14 +432,15 @@ export default class ContactImportView extends Vue {
}
}
/ / p r o c e s s t h e i n v i t e J W T a n d / o r t e x t m e s s a g e c o n t a i n i n g t h e U R L w i t h t h e J W T
/ * *
* Processes contact import JWT and updates contacts
* @ param jwtInput JWT string containing contact data
* /
async processContactJwt ( jwtInput : string ) {
this . checkingImports = true ;
try {
/ / ( F o r a n o t h e r a p p r o a c h u s e d w i t h i n v i t e s , s e e I n v i t e O n e A c c e p t V i e w . p r o c e s s I n v i t e )
const jwt : string = getContactJwtFromJwtUrl ( jwtInput ) ;
/ / J W T f o r m a t : { h e a d e r , p a y l o a d , s i g n a t u r e , d a t a }
const payload = decodeEndorserJwt ( jwt ) . payload ;
if ( Array . isArray ( payload . contacts ) ) {
@ -320,10 +464,16 @@ export default class ContactImportView extends Vue {
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 ;
/ / P r o c e s s s e l e c t e d c o n t a c t s
for ( let i = 0 ; i < this . contactsImporting . length ; i ++ ) {
if ( this . contactsSelected [ i ] ) {
const contact = this . contactsImporting [ i ] ;
@ -339,6 +489,8 @@ export default class ContactImportView extends Vue {
}
}
}
/ / S e t v i s i b i l i t y i f r e q u e s t e d
if ( this . makeVisible ) {
const failedVisibileToContacts = [ ] ;
for ( let i = 0 ; i < this . contactsImporting . length ; i ++ ) {
@ -365,8 +517,7 @@ export default class ContactImportView extends Vue {
group : "alert" ,
type : "danger" ,
title : "Visibility Error" ,
text : ` Failed to set visibility for ${ failedVisibileToContacts . length } contact ${
failedVisibileToContacts . length == 1 ? "" : "s"
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 ,
@ -376,6 +527,7 @@ export default class ContactImportView extends Vue {
this . checkingImports = false ;
/ / S h o w s u c c e s s n o t i f i c a t i o n
this . $notify (
{
group : "alert" ,