Browse Source

test: enhance deep link testing with real JWT examples

Changes:
- Add real JWT example for invite testing
- Add detailed JWT payload documentation
- Update test-deeplinks.sh with valid claim IDs
- Add test case for single contact invite
- Improve test descriptions and organization

This improves test coverage by using real-world JWT examples
and valid claim identifiers.
pull/127/head
Matthew Raymer 2 weeks ago
parent
commit
f4c5567471
  1. 1
      src/interfaces/records.ts
  2. 2
      src/lib/capacitor/app.ts
  3. 26
      src/libs/endorserServer.ts
  4. 30
      src/views/ContactEditView.vue
  5. 125
      src/views/ContactImportView.vue
  6. 254
      src/views/InviteOneAcceptView.vue
  7. 279
      src/views/OfferDetailsView.vue
  8. 47
      src/views/ProjectViewView.vue
  9. 23
      test-deeplinks.sh

1
src/interfaces/records.ts

@ -58,6 +58,7 @@ export interface PlanSummaryRecord {
name?: string; name?: string;
startTime?: string; startTime?: string;
url?: string; url?: string;
jwtId?: string;
} }
/** /**

2
src/lib/capacitor/app.ts

@ -4,7 +4,7 @@ import {
App as CapacitorApp, App as CapacitorApp,
AppLaunchUrl, AppLaunchUrl,
BackButtonListener, BackButtonListener,
} from '../../../node_modules/@capacitor/app'; } from "../../../node_modules/@capacitor/app";
import type { PluginListenerHandle } from "@capacitor/core"; import type { PluginListenerHandle } from "@capacitor/core";
/** /**

26
src/libs/endorserServer.ts

@ -1,18 +1,18 @@
/** /**
* @fileoverview Endorser Server Interface and Utilities * @fileoverview Endorser Server Interface and Utilities
* @author Matthew Raymer * @author Matthew Raymer
* *
* This module provides the interface and utilities for interacting with the Endorser server. * This module provides the interface and utilities for interacting with the Endorser server.
* It handles authentication, data validation, and server communication for claims, contacts, * It handles authentication, data validation, and server communication for claims, contacts,
* and other core functionality. * and other core functionality.
* *
* Key Features: * Key Features:
* - Deep link URL path constants * - Deep link URL path constants
* - DID validation and handling * - DID validation and handling
* - Contact management utilities * - Contact management utilities
* - Server authentication * - Server authentication
* - Plan caching * - Plan caching
* *
* @module endorserServer * @module endorserServer
*/ */
@ -136,35 +136,35 @@ export function isEmptyOrHiddenDid(did?: string): boolean {
* @param {Function} func - Test function to apply to strings * @param {Function} func - Test function to apply to strings
* @param {any} input - Object/array to recursively test * @param {any} input - Object/array to recursively test
* @returns {boolean} True if any string passes the test function * @returns {boolean} True if any string passes the test function
* *
* @example * @example
* testRecursivelyOnStrings(isDid, { user: { id: "did:example:123" } }) * testRecursivelyOnStrings(isDid, { user: { id: "did:example:123" } })
* // Returns: true * // Returns: true
*/ */
/** /**
* Recursively tests strings within a nested object/array structure against a test function * Recursively tests strings within a nested object/array structure against a test function
* *
* This function traverses through objects and arrays to find all string values and applies * This function traverses through objects and arrays to find all string values and applies
* a test function to each string found. It handles: * a test function to each string found. It handles:
* - Direct string values * - Direct string values
* - Strings in objects (at any depth) * - Strings in objects (at any depth)
* - Strings in arrays (at any depth) * - Strings in arrays (at any depth)
* - Mixed nested structures (objects containing arrays containing objects, etc) * - Mixed nested structures (objects containing arrays containing objects, etc)
* *
* @param {Function} func - Test function that takes a string and returns boolean * @param {Function} func - Test function that takes a string and returns boolean
* @param {any} input - Value to recursively search (can be string, object, array, or other) * @param {any} input - Value to recursively search (can be string, object, array, or other)
* @returns {boolean} True if any string in the structure passes the test function * @returns {boolean} True if any string in the structure passes the test function
* *
* @example * @example
* // Test if any string is a DID * // Test if any string is a DID
* const obj = { * const obj = {
* user: { * user: {
* id: "did:example:123", * id: "did:example:123",
* details: ["name", "did:example:456"] * details: ["name", "did:example:456"]
* } * }
* }; * };
* testRecursivelyOnStrings(isDid, obj); // Returns: true * testRecursivelyOnStrings(isDid, obj); // Returns: true
* *
* @example * @example
* // Test for hidden DIDs * // Test for hidden DIDs
* const obj = { * const obj = {
@ -175,12 +175,12 @@ export function isEmptyOrHiddenDid(did?: string): boolean {
*/ */
function testRecursivelyOnStrings( function testRecursivelyOnStrings(
func: (arg0: any) => boolean, func: (arg0: any) => boolean,
input: any input: any,
): boolean { ): boolean {
// Test direct string values // Test direct string values
if (Object.prototype.toString.call(input) === "[object String]") { if (Object.prototype.toString.call(input) === "[object String]") {
return func(input); return func(input);
} }
// Recursively test objects and arrays // Recursively test objects and arrays
else if (input instanceof Object) { else if (input instanceof Object) {
if (!Array.isArray(input)) { if (!Array.isArray(input)) {
@ -482,7 +482,7 @@ const planCache: LRUCache<string, PlanSummaryRecord> = new LRUCache({
* @param {string} apiServer - API server URL * @param {string} apiServer - API server URL
* @param {string} [requesterDid] - Optional requester DID for private info * @param {string} [requesterDid] - Optional requester DID for private info
* @returns {Promise<PlanSummaryRecord|undefined>} Plan data or undefined if not found * @returns {Promise<PlanSummaryRecord|undefined>} Plan data or undefined if not found
* *
* @throws {Error} If server request fails * @throws {Error} If server request fails
*/ */
export async function getPlanFromCache( export async function getPlanFromCache(

30
src/views/ContactEditView.vue

@ -145,28 +145,28 @@ import { Contact, ContactMethod } from "../db/tables/contacts";
/** /**
* Contact Edit View Component * Contact Edit View Component
* @author Matthew Raymer * @author Matthew Raymer
* *
* This component provides a full-featured contact editing interface with support for: * This component provides a full-featured contact editing interface with support for:
* - Basic contact information (name, notes) * - Basic contact information (name, notes)
* - Multiple contact methods with type selection * - Multiple contact methods with type selection
* - Data validation and persistence * - Data validation and persistence
* *
* Workflow: * Workflow:
* 1. Component loads with DID from route params * 1. Component loads with DID from route params
* 2. Fetches existing contact data from IndexedDB * 2. Fetches existing contact data from IndexedDB
* 3. Presents editable form with current values * 3. Presents editable form with current values
* 4. Validates and saves updates back to database * 4. Validates and saves updates back to database
* *
* Contact Method Types: * Contact Method Types:
* - CELL: Mobile phone numbers * - CELL: Mobile phone numbers
* - EMAIL: Email addresses * - EMAIL: Email addresses
* - WHATSAPP: WhatsApp contact info * - WHATSAPP: WhatsApp contact info
* *
* State Management: * State Management:
* - Maintains separate state for form fields to prevent direct mutation * - Maintains separate state for form fields to prevent direct mutation
* - Handles array cloning for contact methods to prevent reference issues * - Handles array cloning for contact methods to prevent reference issues
* - Manages dropdown state for method type selection * - Manages dropdown state for method type selection
* *
* Navigation: * Navigation:
* - Back button returns to previous view * - Back button returns to previous view
* - Save redirects to contact detail view * - Save redirects to contact detail view
@ -207,13 +207,13 @@ export default class ContactEditView extends Vue {
/** /**
* Component lifecycle hook that initializes the contact edit form * Component lifecycle hook that initializes the contact edit form
* *
* Workflow: * Workflow:
* 1. Extracts DID from route parameters * 1. Extracts DID from route parameters
* 2. Queries database for existing contact * 2. Queries database for existing contact
* 3. Populates form fields with contact data * 3. Populates form fields with contact data
* 4. Handles missing contact error case * 4. Handles missing contact error case
* *
* @throws Will not throw but redirects on error * @throws Will not throw but redirects on error
* @emits Notification on contact not found * @emits Notification on contact not found
* @emits Router navigation on error * @emits Router navigation on error
@ -240,7 +240,7 @@ export default class ContactEditView extends Vue {
/** /**
* Adds a new empty contact method to the methods array * Adds a new empty contact method to the methods array
* *
* Creates a new method object with empty fields for: * Creates a new method object with empty fields for:
* - label: Custom label for the method * - label: Custom label for the method
* - type: Communication type (CELL, EMAIL, WHATSAPP) * - type: Communication type (CELL, EMAIL, WHATSAPP)
@ -252,7 +252,7 @@ export default class ContactEditView extends Vue {
/** /**
* Removes a contact method at the specified index * Removes a contact method at the specified index
* *
* @param index The array index of the method to remove * @param index The array index of the method to remove
*/ */
removeContactMethod(index: number) { removeContactMethod(index: number) {
@ -261,10 +261,10 @@ export default class ContactEditView extends Vue {
/** /**
* Toggles the type selection dropdown for a contact method * Toggles the type selection dropdown for a contact method
* *
* If the clicked dropdown is already open, closes it. * If the clicked dropdown is already open, closes it.
* If another dropdown is open, closes it and opens the clicked one. * If another dropdown is open, closes it and opens the clicked one.
* *
* @param index The array index of the method whose dropdown to toggle * @param index The array index of the method whose dropdown to toggle
*/ */
toggleDropdown(index: number) { toggleDropdown(index: number) {
@ -273,7 +273,7 @@ export default class ContactEditView extends Vue {
/** /**
* Sets the type for a contact method and closes the dropdown * Sets the type for a contact method and closes the dropdown
* *
* @param index The array index of the method to update * @param index The array index of the method to update
* @param type The new type value (CELL, EMAIL, WHATSAPP) * @param type The new type value (CELL, EMAIL, WHATSAPP)
*/ */
@ -284,7 +284,7 @@ export default class ContactEditView extends Vue {
/** /**
* Saves the edited contact information to the database * Saves the edited contact information to the database
* *
* Workflow: * Workflow:
* 1. Clones contact methods array to prevent reference issues * 1. Clones contact methods array to prevent reference issues
* 2. Normalizes method types to uppercase * 2. Normalizes method types to uppercase
@ -292,7 +292,7 @@ export default class ContactEditView extends Vue {
* 4. Updates database with new values * 4. Updates database with new values
* 5. Notifies user of success * 5. Notifies user of success
* 6. Redirects to contact detail view * 6. Redirects to contact detail view
* *
* @throws Will not throw but notifies on validation errors * @throws Will not throw but notifies on validation errors
* @emits Notification on type changes or success * @emits Notification on type changes or success
* @emits Router navigation on success * @emits Router navigation on success
@ -300,7 +300,7 @@ export default class ContactEditView extends Vue {
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 // 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),

125
src/views/ContactImportView.vue

@ -3,7 +3,10 @@
<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 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> <font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1> </h1>
</div> </div>
@ -17,28 +20,43 @@
<font-awesome icon="spinner" class="animate-spin" /> <font-awesome icon="spinner" class="animate-spin" />
</div> </div>
<div v-else> <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" /> <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">One contact is the same as an existing contact</span> <span v-if="sameCount == 1"
<span v-else>{{ sameCount }} contacts are the same as existing contacts</span> >One contact is the same as an existing contact</span
>
<span v-else
>{{ sameCount }} contacts are the same as existing contacts</span
>
</div> </div>
<!-- Results List --> <!-- 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"> <li v-for="(contact, index) in contactsImporting" :key="contact.did">
<div v-if=" <div
!contactsExisting[contact.did] || v-if="
!R.isEmpty(contactDifferences[contact.did]) !contactsExisting[contact.did] ||
" class="grow overflow-hidden border-b border-slate-300 pt-2.5 pb-4"> !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"> <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">Existing</span> <span v-if="contactsExisting[contact.did]" class="text-orange-500"
>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">
@ -51,9 +69,13 @@
<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 v-for="(value, contactField) in contactDifferences[ <div
contact.did v-for="(value, contactField) in contactDifferences[
]" :key="contactField" class="grid grid-cols-3 border"> contact.did
]"
: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>
@ -66,7 +88,8 @@
</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>
@ -78,10 +101,18 @@
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 v-model="inputJwt" placeholder="Contact-import data" <textarea
class="mt-4 border-2 border-gray-300 p-2 rounded" cols="30" @input="() => checkContactJwt(inputJwt)" /> v-model="inputJwt"
placeholder="Contact-import data"
class="mt-4 border-2 border-gray-300 p-2 rounded"
cols="30"
@input="() => checkContactJwt(inputJwt)"
/>
<br /> <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 Check Import
</button> </button>
</div> </div>
@ -94,26 +125,26 @@
/** /**
* @file Contact Import View Component * @file Contact Import View Component
* @author Matthew Raymer * @author Matthew Raymer
* *
* This component handles the import of contacts into the TimeSafari app. * This component handles the import of contacts into the TimeSafari app.
* It supports multiple import methods and handles duplicate detection, * It supports multiple import methods and handles duplicate detection,
* contact validation, and visibility settings. * contact validation, and visibility settings.
* *
* Import Methods: * Import Methods:
* 1. Direct URL Query Parameters: * 1. Direct URL Query Parameters:
* Example: /contact-import?contacts=[{"did":"did:example:123","name":"Alice"}] * Example: /contact-import?contacts=[{"did":"did:example:123","name":"Alice"}]
* *
* 2. JWT in URL Path: * 2. JWT in URL Path:
* Example: /contact-import/eyJhbGciOiJFUzI1NksifQ... * Example: /contact-import/eyJhbGciOiJFUzI1NksifQ...
* - Supports both single and bulk imports * - Supports both single and bulk imports
* - JWT payload can be either: * - JWT payload can be either:
* a) Array format: { contacts: [{did: "...", name: "..."}, ...] } * a) Array format: { contacts: [{did: "...", name: "..."}, ...] }
* b) Single contact: { own: true, did: "...", name: "..." } * b) Single contact: { own: true, did: "...", name: "..." }
* *
* 3. Manual JWT Input: * 3. Manual JWT Input:
* - Accepts pasted JWT strings * - Accepts pasted JWT strings
* - Validates format and content before processing * - Validates format and content before processing
* *
* URL Examples: * URL Examples:
* ``` * ```
* # Bulk import via query params * # Bulk import via query params
@ -121,35 +152,35 @@
* {"did":"did:example:123","name":"Alice"}, * {"did":"did:example:123","name":"Alice"},
* {"did":"did:example:456","name":"Bob"} * {"did":"did:example:456","name":"Bob"}
* ] * ]
* *
* # Single contact via JWT * # Single contact via JWT
* /contact-import/eyJhbGciOiJFUzI1NksifQ.eyJvd24iOnRydWUsImRpZCI6ImRpZDpleGFtcGxlOjEyMyJ9... * /contact-import/eyJhbGciOiJFUzI1NksifQ.eyJvd24iOnRydWUsImRpZCI6ImRpZDpleGFtcGxlOjEyMyJ9...
* *
* # Bulk import via JWT * # Bulk import via JWT
* /contact-import/eyJhbGciOiJFUzI1NksifQ.eyJjb250YWN0cyI6W3siZGlkIjoiZGlkOmV4YW1wbGU6MTIzIn1dfQ... * /contact-import/eyJhbGciOiJFUzI1NksifQ.eyJjb250YWN0cyI6W3siZGlkIjoiZGlkOmV4YW1wbGU6MTIzIn1dfQ...
* *
* # Redirect to contacts page (single contact) * # Redirect to contacts page (single contact)
* /contacts?contactJwt=eyJhbGciOiJFUzI1NksifQ... * /contacts?contactJwt=eyJhbGciOiJFUzI1NksifQ...
* ``` * ```
* *
* Features: * Features:
* - Automatic duplicate detection * - Automatic duplicate detection
* - Field-by-field comparison for existing contacts * - Field-by-field comparison for existing contacts
* - Batch visibility settings * - Batch visibility settings
* - Auto-import for single new contacts * - Auto-import for single new contacts
* - Error handling and validation * - Error handling and validation
* *
* State Management: * State Management:
* - Tracks existing contacts * - Tracks existing contacts
* - Maintains selection state for bulk imports * - Maintains selection state for bulk imports
* - Records differences for duplicate contacts * - Records differences for duplicate contacts
* - Manages visibility settings * - Manages visibility settings
* *
* Security Considerations: * Security Considerations:
* - JWT validation for imported contacts * - JWT validation for imported contacts
* - Visibility control per contact * - Visibility control per contact
* - Error handling for malformed data * - Error handling for malformed data
* *
* @example * @example
* // Component usage in router * // Component usage in router
* { * {
@ -157,7 +188,7 @@
* name: "contact-import", * name: "contact-import",
* component: ContactImportView * component: ContactImportView
* } * }
* *
* @see {@link Contact} for contact data structure * @see {@link Contact} for contact data structure
* @see {@link setVisibilityUtil} for visibility management * @see {@link setVisibilityUtil} for visibility management
*/ */
@ -188,21 +219,21 @@ import { decodeEndorserJwt } from "../libs/crypto/vc";
/** /**
* Contact Import View Component * Contact Import View Component
* @author Matthew Raymer * @author Matthew Raymer
* *
* This component handles the secure import of contacts into TimeSafari via JWT tokens. * 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. * It supports both single and multiple contact imports with validation and duplicate detection.
* *
* Import Workflows: * Import Workflows:
* 1. JWT in URL Path (/contact-import/[JWT]) * 1. JWT in URL Path (/contact-import/[JWT])
* - Extracts JWT from path * - Extracts JWT from path
* - Decodes and validates contact data * - Decodes and validates contact data
* - Handles both single and multiple contacts * - Handles both single and multiple contacts
* *
* 2. JWT in Query Parameter (/contacts?contactJwt=[JWT]) * 2. JWT in Query Parameter (/contacts?contactJwt=[JWT])
* - Used for single contact redirects * - Used for single contact redirects
* - Processes JWT from query parameter * - Processes JWT from query parameter
* - Redirects to appropriate view * - Redirects to appropriate view
* *
* JWT Payload Structure: * JWT Payload Structure:
* ```json * ```json
* { * {
@ -216,13 +247,13 @@ import { decodeEndorserJwt } from "../libs/crypto/vc";
* "iss": "did:ethr:0x..." * "iss": "did:ethr:0x..."
* } * }
* ``` * ```
* *
* Security Features: * Security Features:
* - JWT validation * - JWT validation
* - Issuer verification * - Issuer verification
* - Duplicate detection * - Duplicate detection
* - Contact data validation * - Contact data validation
* *
* @component * @component
*/ */
@Component({ @Component({
@ -275,23 +306,23 @@ export default class ContactImportView extends Vue {
/** /**
* Component lifecycle hook that initializes the contact import process * Component lifecycle hook that initializes the contact import process
* *
* This method handles three distinct import scenarios: * This method handles three distinct import scenarios:
* 1. Query Parameter Import: * 1. Query Parameter Import:
* - Checks for contacts in URL query parameters * - Checks for contacts in URL query parameters
* - Parses JSON array of contacts if present * - Parses JSON array of contacts if present
* *
* 2. JWT URL Import: * 2. JWT URL Import:
* - Extracts JWT from URL path using regex pattern '/contact-import/(ey.+)$' * - Extracts JWT from URL path using regex pattern '/contact-import/(ey.+)$'
* - Decodes JWT without validation (supports future-dated QR codes) * - Decodes JWT without validation (supports future-dated QR codes)
* - Handles two JWT payload formats: * - Handles two JWT payload formats:
* a. Array format: payload.contacts or direct array * a. Array format: payload.contacts or direct array
* b. Single contact format: redirects to contacts page with JWT * b. Single contact format: redirects to contacts page with JWT
* *
* 3. Auto-Import Logic: * 3. Auto-Import Logic:
* - Automatically imports if exactly one new contact is present * - Automatically imports if exactly one new contact is present
* - Only triggers if no existing contacts match * - Only triggers if no existing contacts match
* *
* @throws Will not throw but logs errors during JWT processing * @throws Will not throw but logs errors during JWT processing
* @emits router.push when redirecting for single contact import * @emits router.push when redirecting for single contact import
*/ */
@ -328,7 +359,7 @@ export default class ContactImportView extends Vue {
// JWT tokens always start with 'ey' (base64url encoded header) // JWT tokens always start with 'ey' (base64url encoded header)
const JWT_PATTERN = /\/contact-import\/(ey.+)$/; const JWT_PATTERN = /\/contact-import\/(ey.+)$/;
const jwt = window.location.pathname.match(JWT_PATTERN)?.[1]; const jwt = window.location.pathname.match(JWT_PATTERN)?.[1];
if (jwt) { if (jwt) {
const parsedJwt = decodeEndorserJwt(jwt); const parsedJwt = decodeEndorserJwt(jwt);
const contacts: Array<Contact> = const contacts: Array<Contact> =
@ -391,7 +422,12 @@ export default class ContactImportView extends Vue {
} }
> = {}; > = {};
Object.keys(contactIn).forEach((key) => { Object.keys(contactIn).forEach((key) => {
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],
new: contactIn[key as keyof Contact], new: contactIn[key as keyof Contact],
@ -517,8 +553,9 @@ 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${failedVisibileToContacts.length == 1 ? "" : "s" text: `Failed to set visibility for ${failedVisibileToContacts.length} contact${
}. You must set them individually: ${failedVisibileToContacts.map((c) => c.name).join(", ")}`, failedVisibileToContacts.length == 1 ? "" : "s"
}. You must set them individually: ${failedVisibileToContacts.map((c) => c.name).join(", ")}`,
}, },
-1, -1,
); );

254
src/views/InviteOneAcceptView.vue

@ -39,7 +39,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router"; import { Router, RouteLocationNormalized } from "vue-router";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import { APP_SERVER, NotificationIface } from "../constants/app"; import { APP_SERVER, NotificationIface } from "../constants/app";
@ -52,19 +52,69 @@ import { decodeEndorserJwt } from "../libs/crypto/vc";
import { errorStringForLog } from "../libs/endorserServer"; import { errorStringForLog } from "../libs/endorserServer";
import { generateSaveAndActivateIdentity } from "../libs/util"; import { generateSaveAndActivateIdentity } from "../libs/util";
@Component({ components: { QuickNav } }) /**
* Invite One Accept View Component
* @author Matthew Raymer
*
* This component handles accepting single-use invitations to join the platform.
* It supports multiple invitation formats and provides user feedback during the process.
*
* Workflow:
* 1. Component loads with JWT from route or user input
* 2. Validates JWT format and signature
* 3. Processes invite data and redirects to contacts page
* 4. Handles errors with user feedback
*
* Supported Invite Formats:
* 1. Direct JWT in URL path: /invite-one-accept/{jwt}
* 2. JWT in text message URL: https://app.example.com/invite-one-accept/{jwt}
* 3. JWT surrounded by other text: "Your invite code is {jwt}"
*
* Security Features:
* - JWT validation
* - Identity generation if needed
* - Error handling for invalid/expired invites
*
* @see ContactsView for completion of invite process
*/
@Component({
components: { QuickNav },
})
export default class InviteOneAcceptView extends Vue { export default class InviteOneAcceptView 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;
/** Route instance for current route */
$route!: RouteLocationNormalized;
activeDid: string = ""; /** Active user's DID */
apiServer: string = ""; activeDid = "";
checkingInvite: boolean = true; /** API server endpoint */
inputJwt: string = ""; apiServer = "";
/** Loading state for invite processing */
checkingInvite = true;
/** User input for manual JWT entry */
inputJwt = "";
/**
* Component lifecycle hook that initializes invite processing
*
* Workflow:
* 1. Opens database connection
* 2. Retrieves account settings
* 3. Ensures active DID exists or generates one
* 4. Extracts JWT from URL path
* 5. Processes invite automatically
*
* @throws Will not throw but logs errors
* @emits Notifications on errors
*/
async mounted() { async mounted() {
this.checkingInvite = true; this.checkingInvite = true;
await db.open(); await db.open();
// Load or generate identity
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
@ -73,81 +123,155 @@ export default class InviteOneAcceptView extends Vue {
this.activeDid = await generateSaveAndActivateIdentity(); this.activeDid = await generateSaveAndActivateIdentity();
} }
const jwt = window.location.pathname.substring( // Extract JWT from route path
"/invite-one-accept/".length, const jwt = (this.$route.params.jwt as string) || "";
);
await this.processInvite(jwt, false); await this.processInvite(jwt, false);
this.checkingInvite = false; this.checkingInvite = false;
} }
// process the invite JWT and/or text message containing the URL with the JWT /**
* Processes an invite JWT and/or text containing the invite
*
* Handles multiple input formats:
* 1. Direct JWT:
* - Raw JWT string starting with "ey"
* - Example: eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ...
*
* 2. URL containing JWT:
* - Full URL with JWT in path
* - Pattern: /invite-one-accept/{jwt}
* - Example: https://app.example.com/invite-one-accept/eyJ0eXAiOiJKV1Q...
*
* 3. Text with embedded JWT:
* - JWT surrounded by other text
* - Uses regex to extract JWT pattern
* - Example: "Your invite code is eyJ0eXAiOiJKV1Q... Click to accept"
*
* Extraction Process:
* 1. First attempts URL pattern match
* 2. If no URL found, looks for JWT pattern (ey...)
* 3. Validates extracted JWT format
* 4. Redirects to contacts page on success
*
* Error Handling:
* - Missing JWT: Shows "Missing Invite" notification
* - Invalid JWT: Logs error and shows generic error message
* - Network Issues: Captured in try/catch block
*
* @param jwtInput Raw input that may contain a JWT
* @param notifyOnFailure Whether to show error notifications
* - true: Shows UI notifications for errors
* - false: Silently logs errors (used for auto-processing)
* @throws Will not throw but logs errors
* @emits Notifications on errors if notifyOnFailure is true
* @emits Router navigation on success to /contacts?inviteJwt={jwt}
*/
async processInvite(jwtInput: string, notifyOnFailure: boolean) { async processInvite(jwtInput: string, notifyOnFailure: boolean) {
this.checkingInvite = true; this.checkingInvite = true;
try { try {
let jwt: string = jwtInput ?? ""; const jwt = this.extractJwtFromInput(jwtInput);
// parse the string: extract the URL or JWT if surrounded by spaces
// and then extract the JWT from the URL
// (For another approach used with contacts, see getContactPayloadFromJwtUrl)
const urlMatch = jwtInput.match(/(https?:\/\/[^\s]+)/);
if (urlMatch && urlMatch[1]) {
// extract the JWT from the URL, meaning any character except "?"
const internalMatch = urlMatch[1].match(/\/invite-one-accept\/([^?]+)/);
if (internalMatch && internalMatch[1]) {
jwt = internalMatch[1];
}
} else {
// extract the JWT (which starts with "ey") if it is surrounded by other input
const spaceMatch = jwtInput.match(/(ey[\w.-]+)/);
if (spaceMatch && spaceMatch[1]) {
jwt = spaceMatch[1];
}
}
if (!jwt) { if (!jwt) {
if (notifyOnFailure) { this.handleMissingJwt(notifyOnFailure);
this.$notify( return;
{
group: "alert",
type: "danger",
title: "Missing Invite",
text: "There was no invite. Paste the entire text that has the data.",
},
5000,
);
}
} else {
//const payload: JWTPayload =
decodeEndorserJwt(jwt);
// That's good enough for an initial check.
// Send them to the contacts page to finish, with inviteJwt in the query string.
this.$router.push({
name: "contacts",
query: { inviteJwt: jwt },
});
} }
await this.validateAndRedirect(jwt);
} catch (error) { } catch (error) {
const fullError = "Error accepting invite: " + errorStringForLog(error); this.handleError(error, notifyOnFailure);
logConsoleAndDb(fullError, true); } finally {
if (notifyOnFailure) { this.checkingInvite = false;
this.$notify( }
{ }
group: "alert",
type: "danger", /**
title: "Error", * Extracts JWT from various input formats
text: "There was an error processing that invite.", * @param input Raw input text
}, * @returns Extracted JWT or empty string
3000, */
); private extractJwtFromInput(input: string): string {
} const jwtInput = input ?? "";
// Try URL format first
const urlMatch = jwtInput.match(/(https?:\/\/[^\s]+)/);
if (urlMatch?.[1]) {
const internalMatch = urlMatch[1].match(/\/invite-one-accept\/([^?]+)/);
if (internalMatch?.[1]) return internalMatch[1];
}
// Try direct JWT format
const spaceMatch = jwtInput.match(/(ey[\w.-]+)/);
if (spaceMatch?.[1]) return spaceMatch[1];
return "";
}
/**
* Validates JWT and redirects to contacts page
* @param jwt JWT to validate
*/
private async validateAndRedirect(jwt: string) {
decodeEndorserJwt(jwt);
this.$router.push({
name: "contacts",
query: { inviteJwt: jwt },
});
}
/**
* Handles missing JWT error
* @param notify Whether to show notification
*/
private handleMissingJwt(notify: boolean) {
if (notify) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Missing Invite",
text: "There was no invite. Paste the entire text that has the data.",
},
5000,
);
}
}
/**
* Handles processing errors
* @param error Error that occurred
* @param notify Whether to show notification
*/
private handleError(error: unknown, notify: boolean) {
const fullError = "Error accepting invite: " + errorStringForLog(error);
logConsoleAndDb(fullError, true);
if (notify) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error processing that invite.",
},
3000,
);
} }
this.checkingInvite = false;
} }
// check the invite JWT /**
* Validates invite data format
*
* Checks for common error cases:
* - Truncated URLs
* - Missing JWT data
* - Invalid URL formats
*
* @param jwtInput Raw input to validate
* @throws Will not throw but shows notifications
* @emits Notifications on validation errors
*/
async checkInvite(jwtInput: string) { async checkInvite(jwtInput: string) {
if ( if (
jwtInput.endsWith(APP_SERVER) || jwtInput.endsWith(APP_SERVER) ||

279
src/views/OfferDetailsView.vue

@ -193,6 +193,38 @@ import {
import * as libsUtil from "../libs/util"; import * as libsUtil from "../libs/util";
import { retrieveAccountDids } from "../libs/util"; import { retrieveAccountDids } from "../libs/util";
/**
* Offer Details View Component
* @author Matthew Raymer
*
* This component handles the creation and editing of offers within the platform.
* It supports both new offers and editing existing ones, with validation and
* submission handling.
*
* Features:
* - Offer amount and unit selection
* - Item description
* - Conditional requirements
* - Expiration date setting
* - Project or recipient targeting
* - Raw claim editing option
*
* Data Flow:
* 1. Component loads with optional previous offer data
* 2. Retrieves account settings and contact information
* 3. Populates form with existing or default values
* 4. Validates and submits offer to server
* 5. Redirects on success or shows error
*
* Security Features:
* - DID validation
* - JWT handling for edits
* - Server-side validation
* - Privacy controls for data sharing
*
* @see GiftedDialog for related gift creation
* @see ClaimAddRawView for raw claim editing
*/
@Component({ @Component({
components: { components: {
QuickNav, QuickNav,
@ -200,35 +232,96 @@ import { retrieveAccountDids } from "../libs/util";
}, },
}) })
export default class OfferDetailsView extends Vue { export default class OfferDetailsView 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;
/** Currently active DID */
activeDid = ""; activeDid = "";
/** API server endpoint */
apiServer = ""; apiServer = "";
/** Offer amount input field */
amountInput = "0"; amountInput = "0";
/** Conditions for the offer */
descriptionOfCondition = ""; descriptionOfCondition = "";
/** Description of offered item */
descriptionOfItem = ""; descriptionOfItem = "";
/** Path to redirect after completion */
destinationPathAfter = ""; destinationPathAfter = "";
/** Controls back button visibility */
hideBackButton = false; hideBackButton = false;
/** Additional message to display */
message = ""; message = "";
/** Flag for project assignment */
offeredToProject = false; offeredToProject = false;
/** Flag for recipient assignment */
offeredToRecipient = false; offeredToRecipient = false;
/** DID of offer creator */
offererDid: string | undefined; offererDid: string | undefined;
/** Offer ID for editing */
offerId = ""; offerId = "";
/** Previous offer data for editing */
prevCredToEdit?: GenericCredWrapper<OfferVerifiableCredential>; prevCredToEdit?: GenericCredWrapper<OfferVerifiableCredential>;
/** Project ID if offer is for project */
projectId = ""; projectId = "";
/** Project name display */
projectName = "a project"; projectName = "a project";
/** Recipient DID if offer is for person */
recipientDid = ""; recipientDid = "";
/** Recipient name display */
recipientName = ""; recipientName = "";
/** Advanced features visibility flag */
showGeneralAdvanced = false; showGeneralAdvanced = false;
/** Unit type for offer amount */
unitCode = "HUR"; unitCode = "HUR";
/** Expiration date input */
validThroughDateInput = ""; validThroughDateInput = "";
/** Utility library reference */
libsUtil = libsUtil; libsUtil = libsUtil;
/**
* Component lifecycle hook that initializes the offer form
*
* Workflow:
* 1. Extracts previous offer data if editing
* 2. Sets initial form values from route or previous offer
* 3. Loads account settings and contacts
* 4. Retrieves project information if needed
* 5. Sets offer assignment flags
*
* @throws Will not throw but shows notifications
* @emits Notifications on loading errors
*/
async mounted() { async mounted() {
try {
await this.loadPreviousOffer();
await this.initializeFormValues();
await this.loadAccountSettings();
await this.loadRecipientInfo();
await this.loadProjectInfo();
} catch (err: any) {
console.error("Error in mounted:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: err.message || "There was an error loading the offer details.",
},
5000,
);
}
}
/**
* Loads previous offer data if editing an existing offer
* @throws Will not throw but shows notifications
*/
private async loadPreviousOffer() {
try { try {
this.prevCredToEdit = (this.$route.query["prevCredToEdit"] as string) this.prevCredToEdit = (this.$route.query["prevCredToEdit"] as string)
? (JSON.parse( ? (JSON.parse(
@ -246,34 +339,38 @@ export default class OfferDetailsView extends Vue {
5000, 5000,
); );
} }
}
/**
* Initializes form values from route params or previous offer
*/
private async initializeFormValues() {
const prevAmount = const prevAmount =
this.prevCredToEdit?.claim?.includesObject?.amountOfThisGood; this.prevCredToEdit?.claim?.includesObject?.amountOfThisGood;
this.amountInput = this.amountInput =
(this.$route.query["amountInput"] as string) || (this.$route.query["amountInput"] as string) ||
(prevAmount ? String(prevAmount) : "") || (prevAmount ? String(prevAmount) : "") ||
this.amountInput; this.amountInput;
this.unitCode = ((this.$route.query["unitCode"] as string) || this.unitCode = ((this.$route.query["unitCode"] as string) ||
this.prevCredToEdit?.claim?.includesObject?.unitCode || this.prevCredToEdit?.claim?.includesObject?.unitCode ||
this.unitCode) as string; this.unitCode) as string;
this.descriptionOfCondition = this.descriptionOfCondition =
this.prevCredToEdit?.claim?.description || this.descriptionOfCondition; this.prevCredToEdit?.claim?.description || this.descriptionOfCondition;
this.descriptionOfItem = this.descriptionOfItem =
(this.$route.query["description"] as string) || (this.$route.query["description"] as string) ||
this.prevCredToEdit?.claim?.itemOffered?.description || this.prevCredToEdit?.claim?.itemOffered?.description ||
this.descriptionOfItem; this.descriptionOfItem;
this.destinationPathAfter = this.destinationPathAfter =
(this.$route.query["destinationPathAfter"] as string) || ""; (this.$route.query["destinationPathAfter"] as string) || "";
this.offererDid = ((this.$route.query["offererDid"] as string) ||
(this.prevCredToEdit?.claim?.agent as unknown as { identifier: string })
?.identifier ||
this.offererDid) as string;
this.hideBackButton = this.hideBackButton =
(this.$route.query["hideBackButton"] as string) === "true"; (this.$route.query["hideBackButton"] as string) === "true";
this.message = (this.$route.query["message"] as string) || ""; this.message = (this.$route.query["message"] as string) || "";
// find any project ID // Set project info from previous offer or route
let project; let project;
if ( if (
this.prevCredToEdit?.claim?.itemOffered?.isPartOf?.["@type"] === this.prevCredToEdit?.claim?.itemOffered?.isPartOf?.["@type"] ===
@ -294,43 +391,43 @@ export default class OfferDetailsView extends Vue {
this.validThroughDateInput = this.validThroughDateInput =
this.prevCredToEdit?.claim?.validThrough || this.validThroughDateInput; this.prevCredToEdit?.claim?.validThrough || this.validThroughDateInput;
}
try { /**
const settings = await retrieveSettingsForActiveAccount(); * Loads account settings and updates component state
this.apiServer = settings.apiServer ?? ""; * @throws Will not throw but logs errors
this.activeDid = settings.activeDid ?? ""; */
this.showGeneralAdvanced = settings.showGeneralAdvanced ?? false; private async loadAccountSettings() {
const settings = await retrieveSettingsForActiveAccount();
if (this.recipientDid && !this.recipientName) { this.apiServer = settings.apiServer ?? "";
const allContacts = await db.contacts.toArray(); this.activeDid = settings.activeDid ?? "";
const allMyDids = await retrieveAccountDids(); this.showGeneralAdvanced = settings.showGeneralAdvanced ?? false;
this.recipientName = didInfo( }
this.recipientDid,
this.activeDid,
allMyDids,
allContacts,
);
}
// these should be functions but something's wrong with the syntax in the <> conditional
this.offeredToProject = !!this.projectId;
this.offeredToRecipient = !this.offeredToProject && !!this.recipientDid;
// eslint-disable-next-line @typescript-eslint/no-explicit-any /**
} catch (err: any) { * Loads recipient information if recipient DID exists
console.error("Error retrieving settings from database:", err); */
this.$notify( private async loadRecipientInfo() {
{ if (this.recipientDid && !this.recipientName) {
group: "alert", const allContacts = await db.contacts.toArray();
type: "danger", const allMyDids = await retrieveAccountDids();
title: "Error", this.recipientName = didInfo(
text: err.message || "There was an error retrieving your settings.", this.recipientDid,
}, this.activeDid,
5000, allMyDids,
allContacts,
); );
} }
// Set assignment flags
this.offeredToProject = !!this.projectId;
this.offeredToRecipient = !this.offeredToProject && !!this.recipientDid;
}
/**
* Loads project information if project ID exists
*/
private async loadProjectInfo() {
if (this.projectId && !this.projectName) { if (this.projectId && !this.projectName) {
// console.log("Getting project name from cache", this.projectId);
const project = await getPlanFromCache( const project = await getPlanFromCache(
this.projectId, this.projectId,
this.axios, this.axios,
@ -343,16 +440,32 @@ export default class OfferDetailsView extends Vue {
} }
} }
/**
* Changes the unit type for the offer amount
*
* Cycles through available unit types in UNIT_SHORT.
* Updates display and internal state.
*/
changeUnitCode() { changeUnitCode() {
const units = Object.keys(this.libsUtil.UNIT_SHORT); const units = Object.keys(this.libsUtil.UNIT_SHORT);
const index = units.indexOf(this.unitCode); const index = units.indexOf(this.unitCode);
this.unitCode = units[(index + 1) % units.length]; this.unitCode = units[(index + 1) % units.length];
} }
/**
* Increments the offer amount by 1
*
* Handles string to number conversion and updates display.
*/
increment() { increment() {
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`; this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
} }
/**
* Decrements the offer amount by 1
*
* Prevents negative values and handles string to number conversion.
*/
decrement() { decrement() {
this.amountInput = `${Math.max( this.amountInput = `${Math.max(
0, 0,
@ -360,6 +473,15 @@ export default class OfferDetailsView extends Vue {
)}`; )}`;
} }
/**
* Handles cancellation of offer creation/editing
*
* Workflow:
* 1. Checks for destination path
* 2. Navigates to destination or previous page
*
* @emits Router navigation
*/
cancel() { cancel() {
if (this.destinationPathAfter) { if (this.destinationPathAfter) {
(this.$router as Router).push({ path: this.destinationPathAfter }); (this.$router as Router).push({ path: this.destinationPathAfter });
@ -368,10 +490,28 @@ export default class OfferDetailsView extends Vue {
} }
} }
/**
* Handles back button navigation
*
* @emits Router navigation to previous page
*/
cancelBack() { cancelBack() {
(this.$router as Router).back(); (this.$router as Router).back();
} }
/**
* Validates and initiates offer submission
*
* Workflow:
* 1. Validates active DID exists
* 2. Checks for negative amounts
* 3. Ensures description or amount exists
* 4. Shows processing notification
* 5. Calls recordOffer for submission
*
* @throws Will not throw but shows notifications
* @emits Notifications for validation errors or processing
*/
async confirm() { async confirm() {
if (!this.activeDid) { if (!this.activeDid) {
this.$notify( this.$notify(
@ -426,6 +566,15 @@ export default class OfferDetailsView extends Vue {
await this.recordOffer(); await this.recordOffer();
} }
/**
* Notifies user about project assignment restrictions
*
* Shows appropriate error message based on:
* - Missing project ID
* - Conflict with recipient assignment
*
* @emits Notification with error message
*/
notifyUserOfProject() { notifyUserOfProject() {
if (!this.projectId) { if (!this.projectId) {
this.$notify( this.$notify(
@ -451,6 +600,15 @@ export default class OfferDetailsView extends Vue {
} }
} }
/**
* Notifies user about recipient assignment restrictions
*
* Shows appropriate error message based on:
* - Missing recipient DID
* - Conflict with project assignment
*
* @emits Notification with error message
*/
notifyUserOfRecipient() { notifyUserOfRecipient() {
if (!this.recipientDid) { if (!this.recipientDid) {
this.$notify( this.$notify(
@ -477,11 +635,18 @@ export default class OfferDetailsView extends Vue {
} }
/** /**
* Records the offer to the server
*
* Workflow:
* 1. Determines if editing existing or creating new
* 2. Prepares offer data with assignments
* 3. Submits to server via appropriate method
* 4. Handles success/error responses
* 5. Navigates on success
* *
* @param offererDid may be null * @throws Will not throw but shows notifications
* @param description may be an empty string * @emits Notifications for success/failure
* @param amountInput may be 0 * @emits Router navigation on success
* @param unitCode may be omitted, defaults to "HUR"
*/ */
public async recordOffer() { public async recordOffer() {
try { try {
@ -568,6 +733,17 @@ export default class OfferDetailsView extends Vue {
} }
} }
/**
* Constructs offer parameters for raw editing
*
* Creates a JSON string containing:
* - Offer details
* - Assignments
* - Conditions
* - Expiration
*
* @returns JSON string of offer parameters
*/
constructOfferParam() { constructOfferParam() {
const recipientDid = this.offeredToRecipient const recipientDid = this.offeredToRecipient
? this.recipientDid ? this.recipientDid
@ -589,11 +765,11 @@ export default class OfferDetailsView extends Vue {
return claimStr; return claimStr;
} }
// Helper functions for readability
/** /**
* @param result response "data" from the server * Checks if server response indicates an error
* @returns true if the result indicates an error *
* @param result Response data from server
* @returns true if response indicates error
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
isCreationError(result: any) { isCreationError(result: any) {
@ -601,8 +777,10 @@ export default class OfferDetailsView extends Vue {
} }
/** /**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data") * Extracts error message from server response
* @returns best guess at an error message *
* @param result Server response object
* @returns Best available error message
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
getCreationErrorMessage(result: any) { getCreationErrorMessage(result: any) {
@ -613,6 +791,13 @@ export default class OfferDetailsView extends Vue {
); );
} }
/**
* Shows privacy information dialog
*
* Displays standard privacy message about data sharing.
*
* @emits Notification with privacy message
*/
explainData() { explainData() {
this.$notify( this.$notify(
{ {

47
src/views/ProjectViewView.vue

@ -285,7 +285,7 @@
<ul v-else class="text-sm border-t border-slate-300"> <ul v-else class="text-sm border-t border-slate-300">
<li <li
v-for="offer in offersToThis" v-for="offer in offersToThis"
:key="offer.id" :key="offer.jwtId"
class="py-1.5 border-b border-slate-300" class="py-1.5 border-b border-slate-300"
> >
<div class="flex justify-between gap-4"> <div class="flex justify-between gap-4">
@ -365,7 +365,7 @@
<ul v-else class="text-sm border-t border-slate-300"> <ul v-else class="text-sm border-t border-slate-300">
<li <li
v-for="give in givesToThis" v-for="give in givesToThis"
:key="give.id" :key="give.jwtId"
class="py-1.5 border-b border-slate-300" class="py-1.5 border-b border-slate-300"
> >
<div class="flex justify-between gap-4"> <div class="flex justify-between gap-4">
@ -461,7 +461,7 @@
<ul v-else class="text-sm border-t border-slate-300"> <ul v-else class="text-sm border-t border-slate-300">
<li <li
v-for="give in givesProvidedByThis" v-for="give in givesProvidedByThis"
:key="give.id" :key="give.jwtId"
class="py-1.5 border-b border-slate-300" class="py-1.5 border-b border-slate-300"
> >
<div class="flex justify-between gap-4"> <div class="flex justify-between gap-4">
@ -571,30 +571,31 @@ import HiddenDidDialog from "../components/HiddenDidDialog.vue";
/** /**
* Project View Component * Project View Component
* @author Matthew Raymer * @author Matthew Raymer
* *
* This component displays detailed project information and manages interactions including: * This component displays and manages detailed project information. It handles:
* - Project loading and display from URL-encoded project handles
* - Project metadata (name, description, dates, location) * - Project metadata (name, description, dates, location)
* - Issuer information and verification * - Issuer information and verification
* - Project contributions and fulfillments * - Project contributions and fulfillments
* - Offers and gifts tracking * - Offers and gifts tracking
* - Contact interactions * - Contact interactions
* *
* Data Flow: * Data Flow:
* 1. Component loads with project ID from route * 1. Component loads with project ID from route
* 2. Fetches project data, contacts, and account settings * 2. Fetches project data, contacts, and account settings
* 3. Loads related data (offers, gifts, fulfillments) * 3. Loads related data (offers, gifts, fulfillments)
* 4. Updates UI with paginated results * 4. Updates UI with paginated results
* *
* Security Features: * Security Features:
* - DID visibility controls * - DID visibility controls
* - JWT validation for imports * - JWT validation for imports
* - Permission checks for actions * - Permission checks for actions
* *
* State Management: * State Management:
* - Maintains separate loading states for different data types * - Maintains separate loading states for different data types
* - Handles pagination limits * - Handles pagination limits
* - Tracks confirmation states * - Tracks confirmation states
* *
* @see GiftedDialog for gift creation * @see GiftedDialog for gift creation
* @see OfferDialog for offer creation * @see OfferDialog for offer creation
* @see HiddenDidDialog for DID privacy explanations * @see HiddenDidDialog for DID privacy explanations
@ -698,13 +699,13 @@ export default class ProjectViewView extends Vue {
/** /**
* Component lifecycle hook that initializes the project view * Component lifecycle hook that initializes the project view
* *
* Workflow: * Workflow:
* 1. Loads account settings and contacts * 1. Loads account settings and contacts
* 2. Retrieves all account DIDs * 2. Retrieves all account DIDs
* 3. Extracts project ID from URL * 3. Extracts project ID from URL
* 4. Initializes project data loading * 4. Initializes project data loading
* *
* @throws Logs errors but continues loading * @throws Logs errors but continues loading
* @emits Notification on profile loading errors * @emits Notification on profile loading errors
*/ */
@ -743,12 +744,12 @@ export default class ProjectViewView extends Vue {
/** /**
* Loads project data and related information * Loads project data and related information
* *
* Workflow: * Workflow:
* 1. Fetches project details from API * 1. Fetches project details from API
* 2. Updates component state with project data * 2. Updates component state with project data
* 3. Initializes related data loading (gifts, offers, fulfillments) * 3. Initializes related data loading (gifts, offers, fulfillments)
* *
* @param projectId Project handle ID * @param projectId Project handle ID
* @param userDid Active user's DID * @param userDid Active user's DID
* @throws Logs errors and notifies user * @throws Logs errors and notifies user
@ -842,10 +843,10 @@ export default class ProjectViewView extends Vue {
/** /**
* Loads gifts made to this project * Loads gifts made to this project
* *
* Handles pagination and updates component state with results. * Handles pagination and updates component state with results.
* Uses beforeId for pagination based on last loaded gift. * Uses beforeId for pagination based on last loaded gift.
* *
* @throws Logs errors and notifies user * @throws Logs errors and notifies user
* @emits Notification on loading errors * @emits Notification on loading errors
*/ */
@ -898,10 +899,10 @@ export default class ProjectViewView extends Vue {
/** /**
* Loads gifts provided by this project * Loads gifts provided by this project
* *
* Similar to loadGives but for outgoing gifts. * Similar to loadGives but for outgoing gifts.
* Maintains separate pagination state. * Maintains separate pagination state.
* *
* @throws Logs errors and notifies user * @throws Logs errors and notifies user
* @emits Notification on loading errors * @emits Notification on loading errors
*/ */
@ -957,10 +958,10 @@ export default class ProjectViewView extends Vue {
/** /**
* Loads offers made to this project * Loads offers made to this project
* *
* Handles pagination and filtering of valid offers. * Handles pagination and filtering of valid offers.
* Updates component state with results. * Updates component state with results.
* *
* @throws Logs errors and notifies user * @throws Logs errors and notifies user
* @emits Notification on loading errors * @emits Notification on loading errors
*/ */
@ -1013,9 +1014,9 @@ export default class ProjectViewView extends Vue {
/** /**
* Loads projects that fulfill this project * Loads projects that fulfill this project
* *
* Manages pagination state and updates component with results. * Manages pagination state and updates component with results.
* *
* @throws Logs errors and notifies user * @throws Logs errors and notifies user
* @emits Notification on loading errors * @emits Notification on loading errors
*/ */
@ -1069,9 +1070,9 @@ export default class ProjectViewView extends Vue {
/** /**
* Loads project that this project fulfills * Loads project that this project fulfills
* *
* Updates fulfilledByThis state with result. * Updates fulfilledByThis state with result.
* *
* @throws Logs errors and notifies user * @throws Logs errors and notifies user
* @emits Notification on loading errors * @emits Notification on loading errors
*/ */

23
test-deeplinks.sh

@ -82,13 +82,9 @@ test_link "timesafari://claim-add-raw/123?claimJwtId=jwt123"
echo "\nTesting Project Routes:" echo "\nTesting Project Routes:"
test_link "timesafari://project/https%3A%2F%2Fendorser.ch%2Fentity%2F01JKW0QZX1XVCVZV85VXAMB31R" test_link "timesafari://project/https%3A%2F%2Fendorser.ch%2Fentity%2F01JKW0QZX1XVCVZV85VXAMB31R"
# Test invite routes
echo "\nTesting Invite Routes:"
test_link "timesafari://invite-one-accept/eyJhbGciOiJFUzI1NksifQ"
# Test gift routes # Test gift routes
echo "\nTesting Gift Routes:" echo "\nTesting Gift Routes:"
test_link "timesafari://confirm-gift/789" test_link "timesafari://confirm-gift/01JMTC8T961KFPP2N8ZB92ER4K"
# Test offer routes # Test offer routes
echo "\nTesting Offer Routes:" echo "\nTesting Offer Routes:"
@ -112,5 +108,22 @@ 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"
# Single invite JWT test
# Header: {"typ":"JWT","alg":"ES256K"}
# Payload: {
# "iat": 1740740453,
# "contact": {
# "did": "did:ethr:0xFEd3b416946b23F3F472799053144B4E34155B5b",
# "name": "Jordan",
# "nextPubKeyHashB64": "IBfRZfwdzeKOzqCx8b+WlLpMJHOAT9ZknIDJo7F3rZE=",
# "publicKeyBase64": "A1eIndfaxgMpVwyD5dYe74DgjuIo5SwPZFCcLdOemjf"
# },
# "iss": "did:ethr:0xD53114830D4a5D90416B43Fc99a25b0dF8bb1BAd"
# }
SINGLE_INVITE_JWT="eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.eyJpYXQiOjE3NDA3NDA0NTMsImNvbnRhY3QiOnsiZGlkIjoiZGlkOmV0aHI6MHhGRWQzYjQxNjk0NmIyM0YzRjQ3Mjc5OTA1MzE0NEI0RTM0MTU1QjViIiwibmFtZSI6IkpvcmRhbiIsIm5leHRQdWJLZXlIYXNoQjY0IjoiSUJmUlpmd2R6ZUtPenFDeDhiK1dsTHBNSkhPQVQ5WmtuSURKbzdGM3JaRT0iLCJwdWJsaWNLZXlCYXNlNjQiOiJBMWVJbmRmYXhnTXBWd3lENWRZZTc0RGdqdUlvNVN3UFpGQ2NMZEtlbWpmIn0sImlzcyI6ImRpZDpldGhyOjB4RDUzMTE0ODMwRDRhNUQ5MDQxNkI0M0ZjOTlhMjViMGRGOGJiMUJBZCJ9.yKEFounxUGU9-grAMFHA12dif9BKYkftg8F3wAIcFYh0H_k1tevjEYyD1fvAyIxYxK5xR0E8moqMhi78ipJXcg"
test_link "timesafari://invite-one-accept/$SINGLE_INVITE_JWT" "Single contact invite via JWT"
echo "\nDeep link testing complete" echo "\nDeep link testing complete"
echo "======================================" echo "======================================"
Loading…
Cancel
Save