Browse Source

docs: add comprehensive JSDoc documentation to views

Changes:
- Add detailed JSDoc headers to ContactImportView
- Add component-level documentation to ProjectViewView
- Document state management and data flow
- Add security considerations and usage examples
- Improve test script documentation and organization
- Add interface documentation for deep linking

This improves code maintainability by documenting component
architecture, workflows and integration points.
side_step
Matthew Raymer 2 weeks ago
parent
commit
02bf0b3f1a
  1. 103
      src/views/ContactEditView.vue
  2. 308
      src/views/ContactImportView.vue
  3. 193
      src/views/ProjectViewView.vue
  4. 59
      test-deeplinks.sh

103
src/views/ContactEditView.vue

@ -142,6 +142,37 @@ import { AppString, NotificationIface } from "../constants/app";
import { db } from "../db/index"; import { db } from "../db/index";
import { Contact, ContactMethod } from "../db/tables/contacts"; import { Contact, ContactMethod } from "../db/tables/contacts";
/**
* Contact Edit View Component
* @author Matthew Raymer
*
* This component provides a full-featured contact editing interface with support for:
* - Basic contact information (name, notes)
* - Multiple contact methods with type selection
* - Data validation and persistence
*
* Workflow:
* 1. Component loads with DID from route params
* 2. Fetches existing contact data from IndexedDB
* 3. Presents editable form with current values
* 4. Validates and saves updates back to database
*
* Contact Method Types:
* - CELL: Mobile phone numbers
* - EMAIL: Email addresses
* - WHATSAPP: WhatsApp contact info
*
* State Management:
* - Maintains separate state for form fields to prevent direct mutation
* - Handles array cloning for contact methods to prevent reference issues
* - Manages dropdown state for method type selection
*
* Navigation:
* - Back button returns to previous view
* - Save redirects to contact detail view
* - Cancel returns to previous view
* - Invalid DID redirects to contacts list
*/
@Component({ @Component({
components: { components: {
QuickNav, QuickNav,
@ -149,22 +180,44 @@ import { Contact, ContactMethod } from "../db/tables/contacts";
}, },
}) })
export default class ContactEditView extends Vue { export default class ContactEditView extends Vue {
/** Notification function injected by Vue */
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
/** Current route instance */
$route!: RouteLocationNormalizedLoaded; $route!: RouteLocationNormalizedLoaded;
/** Router instance for navigation */
$router!: Router; $router!: Router;
/** Current contact data */
contact: Contact = { contact: Contact = {
did: "", did: "",
name: "", name: "",
notes: "", notes: "",
}; };
/** Editable contact name field */
contactName = ""; contactName = "";
/** Editable contact notes field */
contactNotes = ""; contactNotes = "";
/** Array of editable contact methods */
contactMethods: Array<ContactMethod> = []; contactMethods: Array<ContactMethod> = [];
/** Currently open dropdown index, null if none open */
dropdownIndex: number | null = null; dropdownIndex: number | null = null;
/** App string constants */
AppString = AppString; AppString = AppString;
/**
* Component lifecycle hook that initializes the contact edit form
*
* Workflow:
* 1. Extracts DID from route parameters
* 2. Queries database for existing contact
* 3. Populates form fields with contact data
* 4. Handles missing contact error case
*
* @throws Will not throw but redirects on error
* @emits Notification on contact not found
* @emits Router navigation on error
*/
async created() { async created() {
const contactDid = this.$route.params.did; const contactDid = this.$route.params.did;
const contact = await db.contacts.get(contactDid || ""); const contact = await db.contacts.get(contactDid || "");
@ -185,29 +238,75 @@ export default class ContactEditView extends Vue {
} }
} }
/**
* Adds a new empty contact method to the methods array
*
* Creates a new method object with empty fields for:
* - label: Custom label for the method
* - type: Communication type (CELL, EMAIL, WHATSAPP)
* - value: The contact information value
*/
addContactMethod() { addContactMethod() {
this.contactMethods.push({ label: "", type: "", value: "" }); this.contactMethods.push({ label: "", type: "", value: "" });
} }
/**
* Removes a contact method at the specified index
*
* @param index The array index of the method to remove
*/
removeContactMethod(index: number) { removeContactMethod(index: number) {
this.contactMethods.splice(index, 1); this.contactMethods.splice(index, 1);
} }
/**
* Toggles the type selection dropdown for a contact method
*
* If the clicked dropdown is already open, closes it.
* If another dropdown is open, closes it and opens the clicked one.
*
* @param index The array index of the method whose dropdown to toggle
*/
toggleDropdown(index: number) { toggleDropdown(index: number) {
this.dropdownIndex = this.dropdownIndex === index ? null : index; this.dropdownIndex = this.dropdownIndex === index ? null : index;
} }
/**
* Sets the type for a contact method and closes the dropdown
*
* @param index The array index of the method to update
* @param type The new type value (CELL, EMAIL, WHATSAPP)
*/
setMethodType(index: number, type: string) { setMethodType(index: number, type: string) {
this.contactMethods[index].type = type; this.contactMethods[index].type = type;
this.dropdownIndex = null; this.dropdownIndex = null;
} }
/**
* Saves the edited contact information to the database
*
* Workflow:
* 1. Clones contact methods array to prevent reference issues
* 2. Normalizes method types to uppercase
* 3. Checks for changes in method types
* 4. Updates database with new values
* 5. Notifies user of success
* 6. Redirects to contact detail view
*
* @throws Will not throw but notifies on validation errors
* @emits Notification on type changes or success
* @emits Router navigation on success
*/
async saveEdit() { async saveEdit() {
// without this conversion, "Failed to execute 'put' on 'IDBObjectStore': [object Array] could not be cloned." // without this conversion, "Failed to execute 'put' on 'IDBObjectStore': [object Array] could not be cloned."
const contactMethodsObj = JSON.parse(JSON.stringify(this.contactMethods)); const contactMethodsObj = JSON.parse(JSON.stringify(this.contactMethods));
// Normalize method types to uppercase
const contactMethods = contactMethodsObj.map((method: ContactMethod) => const contactMethods = contactMethodsObj.map((method: ContactMethod) =>
R.set(R.lensProp("type"), method.type.toUpperCase(), method), R.set(R.lensProp("type"), method.type.toUpperCase(), method),
); );
// Check for type changes
if (!R.equals(contactMethodsObj, contactMethods)) { if (!R.equals(contactMethodsObj, contactMethods)) {
this.contactMethods = contactMethods; this.contactMethods = contactMethods;
this.$notify( this.$notify(
@ -221,11 +320,15 @@ export default class ContactEditView extends Vue {
); );
return; return;
} }
// Save to database
await db.contacts.update(this.contact.did, { await db.contacts.update(this.contact.did, {
name: this.contactName, name: this.contactName,
notes: this.contactNotes, notes: this.contactNotes,
contactMethods: contactMethods, contactMethods: contactMethods,
}); });
// Notify success and redirect
this.$notify({ this.$notify({
group: "alert", group: "alert",
type: "success", type: "success",

308
src/views/ContactImportView.vue

@ -3,10 +3,7 @@
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back --> <!-- Back -->
<div class="text-lg text-center font-light relative px-7"> <div class="text-lg text-center font-light relative px-7">
<h1 <h1 class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" @click="$router.back()">
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> <font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1> </h1>
</div> </div>
@ -20,43 +17,28 @@
<font-awesome icon="spinner" class="animate-spin" /> <font-awesome icon="spinner" class="animate-spin" />
</div> </div>
<div v-else> <div v-else>
<span <span v-if="contactsImporting.length > sameCount" class="flex justify-center">
v-if="contactsImporting.length > sameCount"
class="flex justify-center"
>
<input v-model="makeVisible" type="checkbox" class="mr-2" /> <input v-model="makeVisible" type="checkbox" class="mr-2" />
Make my activity visible to these contacts. Make my activity visible to these contacts.
</span> </span>
<div v-if="sameCount > 0"> <div v-if="sameCount > 0">
<span v-if="sameCount == 1" <span v-if="sameCount == 1">One contact is the same as an existing contact</span>
>One contact is the same as an existing contact</span <span v-else>{{ sameCount }} contacts are the same as existing contacts</span>
>
<span v-else
>{{ sameCount }} contacts are the same as existing contacts</span
>
</div> </div>
<!-- Results List --> <!-- Results List -->
<ul <ul v-if="contactsImporting.length > sameCount" class="border-t border-slate-300">
v-if="contactsImporting.length > sameCount"
class="border-t border-slate-300"
>
<li v-for="(contact, index) in contactsImporting" :key="contact.did"> <li v-for="(contact, index) in contactsImporting" :key="contact.did">
<div <div v-if="
v-if=" !contactsExisting[contact.did] ||
!contactsExisting[contact.did] || !R.isEmpty(contactDifferences[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"> <h2 class="text-base font-semibold">
<input v-model="contactsSelected[index]" type="checkbox" /> <input v-model="contactsSelected[index]" type="checkbox" />
{{ contact.name || AppString.NO_CONTACT_NAME }} {{ contact.name || AppString.NO_CONTACT_NAME }}
- -
<span v-if="contactsExisting[contact.did]" class="text-orange-500" <span v-if="contactsExisting[contact.did]" class="text-orange-500">Existing</span>
>Existing</span
>
<span v-else class="text-green-500">New</span> <span v-else class="text-green-500">New</span>
</h2> </h2>
<div class="text-sm truncate"> <div class="text-sm truncate">
@ -69,13 +51,9 @@
<div class="font-bold">Old Value</div> <div class="font-bold">Old Value</div>
<div class="font-bold">New Value</div> <div class="font-bold">New Value</div>
</div> </div>
<div <div v-for="(value, contactField) in contactDifferences[
v-for="(value, contactField) in contactDifferences[ contact.did
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"> <div class="border font-bold p-1">
{{ capitalizeAndInsertSpacesBeforeCaps(contactField) }} {{ capitalizeAndInsertSpacesBeforeCaps(contactField) }}
</div> </div>
@ -88,8 +66,7 @@
</li> </li>
<button <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" 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 Import Selected Contacts
</button> </button>
</ul> </ul>
@ -101,18 +78,10 @@
get the full text and paste it. (Note that iOS cuts off data in text 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. messages.) Ask the person to send the data a different way, eg. email.
<div class="mt-4 text-center"> <div class="mt-4 text-center">
<textarea <textarea v-model="inputJwt" placeholder="Contact-import data"
v-model="inputJwt" class="mt-4 border-2 border-gray-300 p-2 rounded" cols="30" @input="() => checkContactJwt(inputJwt)" />
placeholder="Contact-import data"
class="mt-4 border-2 border-gray-300 p-2 rounded"
cols="30"
@input="() => checkContactJwt(inputJwt)"
/>
<br /> <br />
<button <button class="ml-2 p-2 bg-blue-500 text-white rounded" @click="() => processContactJwt(inputJwt)">
class="ml-2 p-2 bg-blue-500 text-white rounded"
@click="() => processContactJwt(inputJwt)"
>
Check Import Check Import
</button> </button>
</div> </div>
@ -122,6 +91,77 @@
</template> </template>
<script lang="ts"> <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 * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router"; import { RouteLocationNormalizedLoaded, Router } from "vue-router";
@ -145,24 +185,75 @@ import {
import { getContactJwtFromJwtUrl } from "../libs/crypto"; import { getContactJwtFromJwtUrl } from "../libs/crypto";
import { decodeEndorserJwt } from "../libs/crypto/vc"; 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 (/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({ @Component({
components: { EntityIcon, OfferDialog, QuickNav }, components: { EntityIcon, OfferDialog, QuickNav },
}) })
export default class ContactImportView extends Vue { export default class ContactImportView extends Vue {
/** Notification function injected by Vue */
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
/** Current route instance */
$route!: RouteLocationNormalizedLoaded; $route!: RouteLocationNormalizedLoaded;
/** Router instance for navigation */
$router!: Router; $router!: Router;
// Constants
AppString = AppString; AppString = AppString;
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps; capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
libsUtil = libsUtil; libsUtil = libsUtil;
R = R; R = R;
// Component state
/** Active user's DID for authentication and visibility settings */
activeDid = ""; activeDid = "";
/** API server URL for backend communication */
apiServer = ""; apiServer = "";
contactsExisting: Record<string, Contact> = {}; // user's contacts already in the system, keyed by DID /** Map of existing contacts keyed by DID for duplicate detection */
contactsImporting: Array<Contact> = []; // contacts from the import contactsExisting: Record<string, Contact> = {};
contactsSelected: Array<boolean> = []; // whether each contact in contactsImporting is selected /** 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< contactDifferences: Record<
string, string,
Record< Record<
@ -172,68 +263,117 @@ export default class ContactImportView extends Vue {
old: string | boolean | Array<ContactMethod> | undefined; old: string | boolean | Array<ContactMethod> | undefined;
} }
> >
> = {}; // for existing contacts, it shows the difference between imported and existing contacts for each key > = {};
/** Loading state for import operations */
checkingImports = false; checkingImports = false;
/** JWT input for manual contact import */
inputJwt: string = ""; inputJwt: string = "";
/** Visibility setting for imported contacts */
makeVisible = true; makeVisible = true;
/** Count of duplicate contacts found */
sameCount = 0; 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() { 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(); const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
}
// look for any imported contact array from the query parameter /**
* Processes contacts from URL query parameters
*/
private async processQueryParams() {
const importedContacts = this.$route.query["contacts"] as string; const importedContacts = this.$route.query["contacts"] as string;
if (importedContacts) { if (importedContacts) {
await this.setContactsSelected(JSON.parse(importedContacts)); await this.setContactsSelected(JSON.parse(importedContacts));
} }
}
// look for a JWT after /contact-import/ in the window.location.pathname /**
const jwt = window.location.pathname.match( * Processes JWT from URL path and handles different JWT formats
/\/contact-import\/(ey.+)$/, */
)?.[1]; 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) { if (jwt) {
// would prefer to validate but we've got an error with JWTs on QR codes generated in the future
// eslint-disable-next-line prettier/prettier
// const parsedJwt: Omit<JWTVerified, "didResolutionResult" | "signer" | "jwt"> = await decodeAndVerifyJwt(jwt);
// decode the JWT
const parsedJwt = decodeEndorserJwt(jwt); const parsedJwt = decodeEndorserJwt(jwt);
const contacts: Array<Contact> = const contacts: Array<Contact> =
parsedJwt.payload.contacts || // someday this will be the only payload sent to this page parsedJwt.payload.contacts ||
(Array.isArray(parsedJwt.payload) ? parsedJwt.payload : undefined); (Array.isArray(parsedJwt.payload) ? parsedJwt.payload : undefined);
if (!contacts && parsedJwt.payload.own) { if (!contacts && parsedJwt.payload.own) {
// handle this single-contact JWT in the contacts page, better suited to single additions
this.$router.push({ this.$router.push({
name: "contacts", name: "contacts",
query: { contactJwt: jwt }, query: { contactJwt: jwt },
}); });
return;
} }
if (contacts) { if (contacts) {
await this.setContactsSelected(contacts); await this.setContactsSelected(contacts);
} else {
// no contacts found so default message should be OK
} }
} }
}
/**
* Handles automatic import for single new contacts
*/
private async handleAutoImport() {
if ( if (
this.contactsImporting.length === 1 && this.contactsImporting.length === 1 &&
R.isEmpty(this.contactsExisting) R.isEmpty(this.contactsExisting)
) { ) {
// if there is only one contact and it's new, then we will automatically import it
this.contactsSelected[0] = true; this.contactsSelected[0] = true;
this.importContacts(); // ... which routes to the contacts list await this.importContacts();
} }
} }
/**
* Processes contacts for import and checks for duplicates
* @param contacts Array of contacts to process
*/
async setContactsSelected(contacts: Array<Contact>) { async setContactsSelected(contacts: Array<Contact>) {
this.contactsImporting = contacts; this.contactsImporting = contacts;
this.contactsSelected = new Array(this.contactsImporting.length).fill(true); this.contactsSelected = new Array(this.contactsImporting.length).fill(true);
await db.open(); await db.open();
const baseContacts = await db.contacts.toArray(); const baseContacts = await db.contacts.toArray();
// set the existing contacts, keyed by DID, if they exist in contactsImporting
// Check for existing contacts and differences
for (let i = 0; i < this.contactsImporting.length; i++) { for (let i = 0; i < this.contactsImporting.length; i++) {
const contactIn = this.contactsImporting[i]; const contactIn = this.contactsImporting[i];
const existingContact = baseContacts.find( const existingContact = baseContacts.find(
@ -242,6 +382,7 @@ export default class ContactImportView extends Vue {
if (existingContact) { if (existingContact) {
this.contactsExisting[contactIn.did] = existingContact; this.contactsExisting[contactIn.did] = existingContact;
// Compare contact fields for differences
const differences: Record< const differences: Record<
string, string,
{ {
@ -250,7 +391,6 @@ export default class ContactImportView extends Vue {
} }
> = {}; > = {};
Object.keys(contactIn).forEach((key) => { Object.keys(contactIn).forEach((key) => {
// eslint-disable-next-line prettier/prettier
if (!R.equals(contactIn[key as keyof Contact], existingContact[key as keyof Contact])) { if (!R.equals(contactIn[key as keyof Contact], existingContact[key as keyof Contact])) {
differences[key] = { differences[key] = {
old: existingContact[key as keyof Contact], old: existingContact[key as keyof Contact],
@ -263,13 +403,16 @@ export default class ContactImportView extends Vue {
this.sameCount++; this.sameCount++;
} }
// don't automatically import previous data // Don't auto-select duplicates
this.contactsSelected[i] = false; this.contactsSelected[i] = false;
} }
} }
} }
// check the contact-import JWT /**
* Validates contact import JWT format
* @param jwtInput JWT string to validate
*/
async checkContactJwt(jwtInput: string) { async checkContactJwt(jwtInput: string) {
if ( if (
jwtInput.endsWith(APP_SERVER) || jwtInput.endsWith(APP_SERVER) ||
@ -289,14 +432,15 @@ export default class ContactImportView extends Vue {
} }
} }
// process the invite JWT and/or text message containing the URL with the JWT /**
* Processes contact import JWT and updates contacts
* @param jwtInput JWT string containing contact data
*/
async processContactJwt(jwtInput: string) { async processContactJwt(jwtInput: string) {
this.checkingImports = true; this.checkingImports = true;
try { try {
// (For another approach used with invites, see InviteOneAcceptView.processInvite)
const jwt: string = getContactJwtFromJwtUrl(jwtInput); const jwt: string = getContactJwtFromJwtUrl(jwtInput);
// JWT format: { header, payload, signature, data }
const payload = decodeEndorserJwt(jwt).payload; const payload = decodeEndorserJwt(jwt).payload;
if (Array.isArray(payload.contacts)) { if (Array.isArray(payload.contacts)) {
@ -320,10 +464,16 @@ export default class ContactImportView extends Vue {
this.checkingImports = false; this.checkingImports = false;
} }
/**
* Imports selected contacts and sets visibility if requested
* Updates existing contacts or adds new ones
*/
async importContacts() { async importContacts() {
this.checkingImports = true; this.checkingImports = true;
let importedCount = 0, let importedCount = 0,
updatedCount = 0; updatedCount = 0;
// Process selected contacts
for (let i = 0; i < this.contactsImporting.length; i++) { for (let i = 0; i < this.contactsImporting.length; i++) {
if (this.contactsSelected[i]) { if (this.contactsSelected[i]) {
const contact = this.contactsImporting[i]; const contact = this.contactsImporting[i];
@ -339,6 +489,8 @@ export default class ContactImportView extends Vue {
} }
} }
} }
// Set visibility if requested
if (this.makeVisible) { if (this.makeVisible) {
const failedVisibileToContacts = []; const failedVisibileToContacts = [];
for (let i = 0; i < this.contactsImporting.length; i++) { for (let i = 0; i < this.contactsImporting.length; i++) {
@ -365,9 +517,8 @@ export default class ContactImportView extends Vue {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Visibility Error", title: "Visibility Error",
text: `Failed to set visibility for ${failedVisibileToContacts.length} contact${ text: `Failed to set visibility for ${failedVisibileToContacts.length} contact${failedVisibileToContacts.length == 1 ? "" : "s"
failedVisibileToContacts.length == 1 ? "" : "s" }. You must set them individually: ${failedVisibileToContacts.map((c) => c.name).join(", ")}`,
}. You must set them individually: ${failedVisibileToContacts.map((c) => c.name).join(", ")}`,
}, },
-1, -1,
); );
@ -376,6 +527,7 @@ export default class ContactImportView extends Vue {
this.checkingImports = false; this.checkingImports = false;
// Show success notification
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",

193
src/views/ProjectViewView.vue

@ -568,6 +568,37 @@ import * as serverUtil from "../libs/endorserServer";
import { retrieveAccountDids } from "../libs/util"; import { retrieveAccountDids } from "../libs/util";
import HiddenDidDialog from "../components/HiddenDidDialog.vue"; import HiddenDidDialog from "../components/HiddenDidDialog.vue";
/**
* Project View Component
* @author Matthew Raymer
*
* This component displays detailed project information and manages interactions including:
* - Project metadata (name, description, dates, location)
* - Issuer information and verification
* - Project contributions and fulfillments
* - Offers and gifts tracking
* - Contact interactions
*
* Data Flow:
* 1. Component loads with project ID from route
* 2. Fetches project data, contacts, and account settings
* 3. Loads related data (offers, gifts, fulfillments)
* 4. Updates UI with paginated results
*
* Security Features:
* - DID visibility controls
* - JWT validation for imports
* - Permission checks for actions
*
* State Management:
* - Maintains separate loading states for different data types
* - Handles pagination limits
* - Tracks confirmation states
*
* @see GiftedDialog for gift creation
* @see OfferDialog for offer creation
* @see HiddenDidDialog for DID privacy explanations
*/
@Component({ @Component({
components: { components: {
EntityIcon, EntityIcon,
@ -580,49 +611,103 @@ import HiddenDidDialog from "../components/HiddenDidDialog.vue";
}, },
}) })
export default class ProjectViewView extends Vue { export default class ProjectViewView extends Vue {
/** Notification function injected by Vue */
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
/** Router instance for navigation */
$router!: Router; $router!: Router;
// Account and Settings State
/** Currently active DID */
activeDid = ""; activeDid = "";
/** Project agent DID */
agentDid = ""; agentDid = "";
/** DIDs that can see the agent DID */
agentDidVisibleToDids: Array<string> = []; agentDidVisibleToDids: Array<string> = [];
/** All DIDs associated with current account */
allMyDids: Array<string> = []; allMyDids: Array<string> = [];
/** All known contacts */
allContacts: Array<Contact> = []; allContacts: Array<Contact> = [];
/** API server endpoint */
apiServer = ""; apiServer = "";
checkingConfirmationForJwtId = ""; /** Registration status of current user */
isRegistered = false;
// Project Data
/** Project description */
description = ""; description = "";
/** Project end time */
endTime = ""; endTime = "";
/** Text expansion state */
expanded = false; expanded = false;
/** Project fulfilled by this project */
fulfilledByThis: PlanSummaryRecord | null = null; fulfilledByThis: PlanSummaryRecord | null = null;
/** Projects fulfilling this project */
fulfillersToThis: Array<PlanSummaryRecord> = []; fulfillersToThis: Array<PlanSummaryRecord> = [];
/** Flag for fulfiller pagination */
fulfillersToHitLimit = false; fulfillersToHitLimit = false;
givesToThis: Array<GiveSummaryRecord> = []; /** Project image URL */
givesHitLimit = false;
givesProvidedByThis: Array<GiveSummaryRecord> = [];
givesProvidedByHitLimit = false;
imageUrl = ""; imageUrl = "";
isRegistered = false; /** Project issuer DID */
issuer = ""; issuer = "";
/** Cached issuer information */
issuerInfoObject: { issuerInfoObject: {
known: boolean; known: boolean;
displayName: string; displayName: string;
profileImageUrl?: string; profileImageUrl?: string;
} | null = null; } | null = null;
/** DIDs that can see issuer information */
issuerVisibleToDids: Array<string> = []; issuerVisibleToDids: Array<string> = [];
/** Project location data */
latitude = 0; latitude = 0;
longitude = 0; longitude = 0;
/** Project name */
name = ""; name = "";
/** Project ID (handle) */
projectId = "";
/** Project start time */
startTime = "";
/** Project URL */
url = "";
// Interaction Data
/** Gifts to this project */
givesToThis: Array<GiveSummaryRecord> = [];
/** Flag for gifts pagination */
givesHitLimit = false;
/** Gifts from this project */
givesProvidedByThis: Array<GiveSummaryRecord> = [];
/** Flag for provided gifts pagination */
givesProvidedByHitLimit = false;
/** Offers to this project */
offersToThis: Array<OfferSummaryRecord> = []; offersToThis: Array<OfferSummaryRecord> = [];
/** Flag for offers pagination */
offersHitLimit = false; offersHitLimit = false;
projectId = ""; // handle ID
// UI State
/** JWT being checked for confirmation */
checkingConfirmationForJwtId = "";
/** Recently checked unconfirmable JWTs */
recentlyCheckedAndUnconfirmableJwts: string[] = []; recentlyCheckedAndUnconfirmableJwts: string[] = [];
startTime = "";
truncatedDesc = ""; truncatedDesc = "";
/** Truncation length */
truncateLength = 40; truncateLength = 40;
url = "";
// Utility References
libsUtil = libsUtil; libsUtil = libsUtil;
serverUtil = serverUtil; serverUtil = serverUtil;
/**
* Component lifecycle hook that initializes the project view
*
* Workflow:
* 1. Loads account settings and contacts
* 2. Retrieves all account DIDs
* 3. Extracts project ID from URL
* 4. Initializes project data loading
*
* @throws Logs errors but continues loading
* @emits Notification on profile loading errors
*/
async created() { async created() {
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
@ -656,23 +741,19 @@ export default class ProjectViewView extends Vue {
this.loadProject(this.projectId, this.activeDid); this.loadProject(this.projectId, this.activeDid);
} }
onEditClick() { /**
const route = { * Loads project data and related information
name: "new-edit-project", *
query: { projectId: this.projectId }, * Workflow:
}; * 1. Fetches project details from API
this.$router.push(route); * 2. Updates component state with project data
} * 3. Initializes related data loading (gifts, offers, fulfillments)
*
// Isn't there a better way to make this available to the template? * @param projectId Project handle ID
expandText() { * @param userDid Active user's DID
this.expanded = true; * @throws Logs errors and notifies user
} * @emits Notification on loading errors
*/
collapseText() {
this.expanded = false;
}
async loadProject(projectId: string, userDid: string) { async loadProject(projectId: string, userDid: string) {
this.projectId = projectId; this.projectId = projectId;
@ -759,6 +840,15 @@ export default class ProjectViewView extends Vue {
this.loadPlanFulfilledBy(); this.loadPlanFulfilledBy();
} }
/**
* Loads gifts made to this project
*
* Handles pagination and updates component state with results.
* Uses beforeId for pagination based on last loaded gift.
*
* @throws Logs errors and notifies user
* @emits Notification on loading errors
*/
async loadGives() { async loadGives() {
const givesUrl = const givesUrl =
this.apiServer + this.apiServer +
@ -806,6 +896,15 @@ export default class ProjectViewView extends Vue {
} }
} }
/**
* Loads gifts provided by this project
*
* Similar to loadGives but for outgoing gifts.
* Maintains separate pagination state.
*
* @throws Logs errors and notifies user
* @emits Notification on loading errors
*/
async loadGivesProvidedBy() { async loadGivesProvidedBy() {
const providedByUrl = const providedByUrl =
this.apiServer + this.apiServer +
@ -856,6 +955,15 @@ export default class ProjectViewView extends Vue {
} }
} }
/**
* Loads offers made to this project
*
* Handles pagination and filtering of valid offers.
* Updates component state with results.
*
* @throws Logs errors and notifies user
* @emits Notification on loading errors
*/
async loadOffers() { async loadOffers() {
const offersUrl = const offersUrl =
this.apiServer + this.apiServer +
@ -903,6 +1011,14 @@ export default class ProjectViewView extends Vue {
} }
} }
/**
* Loads projects that fulfill this project
*
* Manages pagination state and updates component with results.
*
* @throws Logs errors and notifies user
* @emits Notification on loading errors
*/
async loadPlanFulfillersTo() { async loadPlanFulfillersTo() {
const fulfillsUrl = const fulfillsUrl =
this.apiServer + this.apiServer +
@ -951,6 +1067,14 @@ export default class ProjectViewView extends Vue {
} }
} }
/**
* Loads project that this project fulfills
*
* Updates fulfilledByThis state with result.
*
* @throws Logs errors and notifies user
* @emits Notification on loading errors
*/
async loadPlanFulfilledBy() { async loadPlanFulfilledBy() {
const fulfilledByUrl = const fulfilledByUrl =
this.apiServer + this.apiServer +
@ -990,6 +1114,23 @@ export default class ProjectViewView extends Vue {
} }
} }
onEditClick() {
const route = {
name: "new-edit-project",
query: { projectId: this.projectId },
};
this.$router.push(route);
}
// Isn't there a better way to make this available to the template?
expandText() {
this.expanded = true;
}
collapseText() {
this.expanded = false;
}
/** /**
* Handle clicking on a project entry found in the list * Handle clicking on a project entry found in the list
* @param id of the project * @param id of the project

59
test-deeplinks.sh

@ -8,9 +8,10 @@ MANUAL_CONTINUE=true
test_link() { test_link() {
echo "----------------------------------------" echo "----------------------------------------"
echo "Testing: $1" echo "Testing: $1"
echo "Description: $2"
echo "----------------------------------------" echo "----------------------------------------"
adb shell am start -W -a android.intent.action.VIEW -d "$1" app.timesafari.app adb shell am start -W -a android.intent.action.VIEW -d "$1" app.timesafari.app
if [ "$MANUAL_CONTINUE" = true ]; then if [ "$MANUAL_CONTINUE" = true ]; then
read -p "Press Enter to continue to next test..." read -p "Press Enter to continue to next test..."
else else
@ -31,20 +32,55 @@ echo "======================================"
echo "Pause duration: $PAUSE_DURATION seconds" echo "Pause duration: $PAUSE_DURATION seconds"
echo "Manual continue: $MANUAL_CONTINUE" echo "Manual continue: $MANUAL_CONTINUE"
# Contact Import Routes
echo "\nTesting Contact Import Routes:"
# 1. Direct Query Parameter Import (URL-encoded JSON)
QUERY_CONTACTS='[{
"did":"did:ethr:0xf969A5DeE7a5806d1C37f4Ec49A555Ab97911089"
},{
"did":"did:ethr:0xFEd3b416946b23F3F472799053144B4E34155B5b",
"name":"Jordan",
"nextPubKeyHashB64":"IBfRZfwdzeKOzqCx8b+WlLpMJHOAT9ZknIDJo7F3rZE=",
"publicKeyBase64":"A1eIndfaxgMpVwyD5dYe74DgjuIo5SwPZFCcLdOemjf"
}]'
ENCODED_CONTACTS=$(echo $QUERY_CONTACTS | jq -c | python3 -c "import urllib.parse; print(urllib.parse.quote(input()))")
test_link "timesafari://contact-import?contacts=$ENCODED_CONTACTS" "Bulk import via query parameters"
# 2. JWT Path Imports
# Original JWT with multiple contacts
BULK_JWT="eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.eyJpYXQiOjE3NDA3NDA0NTMsImNvbnRhY3RzIjpbeyJkaWQiOiJkaWQ6ZXRocjoweGY5NjlBNURlRTdhNTgwNmQxQzM3ZjRFYzQ5QTU1NUFiOTc5MTEwODkifSx7ImRpZCI6ImRpZDpldGhyOjB4RkVkM2I0MTY5NDZiMjNGM0Y0NzI3OTkwNTMxNDRCNEUzNDE1NUI1YiIsIm5hbWUiOiJKb3JkYW4iLCJuZXh0UHViS2V5SGFzaEI2NCI6IklCZlJaZndkemVLT3pxQ3g4YitXbExwTUpIT0FUOVprbklESm83RjNyWkU9IiwicHVibGljS2V5QmFzZTY0IjoiQTFlSW5kZmF4Z01wVnd5RDVkWWU3NERnanQ5SW81U3dQWkZDY0xkT2VtamYifV0sImlzcyI6ImRpZDpldGhyOjB4RDUzMTE0ODMwRDRhNUQ5MDQxNkI0M0ZjOTlhMjViMGRGOGJiMUJBZCJ9.yKEFounxUGU9-grAMFHA12dif9BKYkftg8F3wAIcFYh0H_k1tevjEYyD1fvAyIxYxK5xR0E8moqMhi78ipJXcg"
test_link "timesafari://contact-import/$BULK_JWT" "Multiple contacts via JWT"
# 3. Contact Page JWT Redirect
test_link "timesafari://contacts?contactJwt=$BULK_JWT" "Multiple contacts redirect"
# Contact Management Routes
test_link "timesafari://contact-edit/did:ethr:0xf969A5DeE7a5806d1C37f4Ec49A555Ab97911089" \
"Edit first contact"
# Error Cases
echo "\nTesting Contact Import Error Cases:"
test_link "timesafari://contact-import/eyJJTlZBTElEIn0" "Invalid JWT format"
test_link "timesafari://contact-import?contacts=[{invalid:data}]" "Invalid contact data"
# Original Routes (preserved from previous version)
echo "\nTesting Other Routes:"
# Test claim routes # Test claim routes
echo "\nTesting Claim Routes:" echo "\nTesting Claim Routes:"
test_link "timesafari://claim/01JMAAFZRNSRTQ0EBSD70A8E1H" test_link "timesafari://claim/01JMAAFZRNSRTQ0EBSD70A8E1H"
test_link "timesafari://claim/01JMAAFZRNSRTQ0EBSD70A8E1H?view=details" test_link "timesafari://claim/01JMAAFZRNSRTQ0EBSD70A8E1H?view=details"
test_link "timesafari://claim-cert/01JMAAFZRNSRTQ0EBSD70A8E1H" test_link "timesafari://claim-cert/01JMAAFZRNSRTQ0EBSD70A8E1H"
# Test contact routes echo "\nTesting Additional Claim Routes:"
echo "\nTesting Contact Routes:" test_link "timesafari://claim/123?view=certificate"
test_link "timesafari://contact-import/eyJhbGciOiJFUzI1NksifQ" test_link "timesafari://claim/123?view=raw"
test_link "timesafari://contact-edit/did:example:123" test_link "timesafari://claim-add-raw/123?claimJwtId=jwt123"
# Test project routes # Test project routes
echo "\nTesting Project Routes:" echo "\nTesting Project Routes:"
test_link "timesafari://project/456?view=details" test_link "timesafari://project/https%3A%2F%2Fendorser.ch%2Fentity%2F01JKW0QZX1XVCVZV85VXAMB31R"
# Test invite routes # Test invite routes
echo "\nTesting Invite Routes:" echo "\nTesting Invite Routes:"
@ -67,11 +103,6 @@ echo "\nTesting DID Routes:"
test_link "timesafari://did/did:example:123" test_link "timesafari://did/did:example:123"
test_link "timesafari://did/did:example:456?view=details" test_link "timesafari://did/did:example:456?view=details"
echo "\nTesting Additional Claim Routes:"
test_link "timesafari://claim/123?view=certificate"
test_link "timesafari://claim/123?view=raw"
test_link "timesafari://claim-add-raw/123?claimJwtId=jwt123"
echo "\nTesting Additional Contact Routes:" echo "\nTesting Additional Contact Routes:"
test_link "timesafari://contact-import/jwt?contacts=%5B%7B%22did%22%3A%22did%3Aexample%3A123%22%7D%5D" test_link "timesafari://contact-import/jwt?contacts=%5B%7B%22did%22%3A%22did%3Aexample%3A123%22%7D%5D"
test_link "timesafari://contact-edit/did:example:123?action=edit" test_link "timesafari://contact-edit/did:example:123?action=edit"
@ -81,9 +112,5 @@ test_link "timesafari://invalid-route/123"
test_link "timesafari://claim/123?view=invalid" test_link "timesafari://claim/123?view=invalid"
test_link "timesafari://did/invalid-did" test_link "timesafari://did/invalid-did"
# Test contact import one route
echo "\nTesting Contact Import One Route:"
test_link "timesafari://contacts?contactJwt=eyJhbGciOiJFUzI1NksifQ"
echo "\nDeep link testing complete" echo "\nDeep link testing complete"
echo "======================================" echo "======================================"
Loading…
Cancel
Save