forked from trent_larson/crowd-funder-for-time-pwa
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.
This commit is contained in:
@@ -58,6 +58,7 @@ export interface PlanSummaryRecord {
|
|||||||
name?: string;
|
name?: string;
|
||||||
startTime?: string;
|
startTime?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
jwtId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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.
|
await this.validateAndRedirect(jwt);
|
||||||
// Send them to the contacts page to finish, with inviteJwt in the query string.
|
|
||||||
this.$router.push({
|
|
||||||
name: "contacts",
|
|
||||||
query: { inviteJwt: 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",
|
|
||||||
text: "There was an error processing that invite.",
|
|
||||||
},
|
|
||||||
3000,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this.checkingInvite = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// check the invite JWT
|
/**
|
||||||
|
* Extracts JWT from various input formats
|
||||||
|
* @param input Raw input text
|
||||||
|
* @returns Extracted JWT or empty string
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) ||
|
||||||
|
|||||||
@@ -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();
|
||||||
|
this.apiServer = settings.apiServer ?? "";
|
||||||
|
this.activeDid = settings.activeDid ?? "";
|
||||||
|
this.showGeneralAdvanced = settings.showGeneralAdvanced ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.recipientDid && !this.recipientName) {
|
/**
|
||||||
const allContacts = await db.contacts.toArray();
|
* Loads recipient information if recipient DID exists
|
||||||
const allMyDids = await retrieveAccountDids();
|
*/
|
||||||
this.recipientName = didInfo(
|
private async loadRecipientInfo() {
|
||||||
this.recipientDid,
|
if (this.recipientDid && !this.recipientName) {
|
||||||
this.activeDid,
|
const allContacts = await db.contacts.toArray();
|
||||||
allMyDids,
|
const allMyDids = await retrieveAccountDids();
|
||||||
allContacts,
|
this.recipientName = didInfo(
|
||||||
);
|
this.recipientDid,
|
||||||
}
|
this.activeDid,
|
||||||
// these should be functions but something's wrong with the syntax in the <> conditional
|
allMyDids,
|
||||||
this.offeredToProject = !!this.projectId;
|
allContacts,
|
||||||
this.offeredToRecipient = !this.offeredToProject && !!this.recipientDid;
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error("Error retrieving settings from database:", err);
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: err.message || "There was an error retrieving your settings.",
|
|
||||||
},
|
|
||||||
5000,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// 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
|
||||||
*
|
*
|
||||||
* @param offererDid may be null
|
* Workflow:
|
||||||
* @param description may be an empty string
|
* 1. Determines if editing existing or creating new
|
||||||
* @param amountInput may be 0
|
* 2. Prepares offer data with assignments
|
||||||
* @param unitCode may be omitted, defaults to "HUR"
|
* 3. Submits to server via appropriate method
|
||||||
|
* 4. Handles success/error responses
|
||||||
|
* 5. Navigates on success
|
||||||
|
*
|
||||||
|
* @throws Will not throw but shows notifications
|
||||||
|
* @emits Notifications for success/failure
|
||||||
|
* @emits Router navigation on success
|
||||||
*/
|
*/
|
||||||
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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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 "======================================"
|
||||||
Reference in New Issue
Block a user