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.
This commit is contained in:
Matthew Raymer
2025-02-28 12:45:21 +00:00
parent 651bab8853
commit 7432e56ead
4 changed files with 552 additions and 129 deletions

View File

@@ -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"></font-awesome>
</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</span
>
<span v-else
>{{ sameCount }} contacts are the same as existing contacts</span
>
<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="
!contactsExisting[contact.did] ||
!R.isEmpty(contactDifferences[contact.did])
"
class="grow overflow-hidden border-b border-slate-300 pt-2.5 pb-4"
>
<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-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[
contact.did
]"
:key="contactField"
class="grid grid-cols-3 border"
>
<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>
@@ -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: /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";
@@ -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 (/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 = "";
contactsExisting: Record<string, Contact> = {}; // user's contacts already in the system, keyed by DID
contactsImporting: Array<Contact> = []; // contacts from the import
contactsSelected: Array<boolean> = []; // whether each contact in contactsImporting is selected
/** 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;
}
>
> = {}; // for existing contacts, it shows the difference between imported and existing contacts for each key
> = {};
/** 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 || "";
}
// 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;
if (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(
/\/contact-import\/(ey.+)$/,
)?.[1];
/**
* 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) {
// 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 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);
if (!contacts && parsedJwt.payload.own) {
// handle this single-contact JWT in the contacts page, better suited to single additions
this.$router.push({
name: "contacts",
query: { contactJwt: jwt },
});
return;
}
if (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 (
this.contactsImporting.length === 1 &&
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.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>) {
this.contactsImporting = contacts;
this.contactsSelected = new Array(this.contactsImporting.length).fill(true);
await db.open();
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++) {
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;
// Compare contact fields for differences
const differences: Record<
string,
{
@@ -250,7 +391,6 @@ export default class ContactImportView extends Vue {
}
> = {};
Object.keys(contactIn).forEach((key) => {
// eslint-disable-next-line prettier/prettier
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++;
}
// don't automatically import previous data
// Don't auto-select duplicates
this.contactsSelected[i] = false;
}
}
}
// check the contact-import JWT
/**
* 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 {
}
}
// 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) {
this.checkingImports = true;
try {
// (For another approach used with invites, see InviteOneAcceptView.processInvite)
const jwt: string = getContactJwtFromJwtUrl(jwtInput);
// JWT format: { header, payload, signature, data }
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;
// Process selected contacts
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 {
}
}
}
// Set visibility if requested
if (this.makeVisible) {
const failedVisibileToContacts = [];
for (let i = 0; i < this.contactsImporting.length; i++) {
@@ -365,9 +517,8 @@ 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"
}. You must set them individually: ${failedVisibileToContacts.map((c) => c.name).join(", ")}`,
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;
// Show success notification
this.$notify(
{
group: "alert",