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. 2
      src/libs/endorserServer.ts
  4. 69
      src/views/ContactImportView.vue
  5. 202
      src/views/InviteOneAcceptView.vue
  6. 249
      src/views/OfferDetailsView.vue
  7. 9
      src/views/ProjectViewView.vue
  8. 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";
/** /**

2
src/libs/endorserServer.ts

@ -175,7 +175,7 @@ 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]") {

69
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
v-if="
!contactsExisting[contact.did] || !contactsExisting[contact.did] ||
!R.isEmpty(contactDifferences[contact.did]) !R.isEmpty(contactDifferences[contact.did])
" class="grow overflow-hidden border-b border-slate-300 pt-2.5 pb-4"> "
class="grow overflow-hidden border-b border-slate-300 pt-2.5 pb-4"
>
<h2 class="text-base font-semibold"> <h2 class="text-base font-semibold">
<input v-model="contactsSelected[index]" type="checkbox" /> <input v-model="contactsSelected[index]" type="checkbox" />
{{ contact.name || AppString.NO_CONTACT_NAME }} {{ contact.name || AppString.NO_CONTACT_NAME }}
- -
<span v-if="contactsExisting[contact.did]" class="text-orange-500">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
v-for="(value, contactField) in contactDifferences[
contact.did contact.did
]" :key="contactField" class="grid grid-cols-3 border"> ]"
:key="contactField"
class="grid grid-cols-3 border"
>
<div class="border font-bold p-1"> <div class="border font-bold p-1">
{{ capitalizeAndInsertSpacesBeforeCaps(contactField) }} {{ capitalizeAndInsertSpacesBeforeCaps(contactField) }}
</div> </div>
@ -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>
@ -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,7 +553,8 @@ export default class ContactImportView extends Vue {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Visibility Error", title: "Visibility Error",
text: `Failed to set visibility for ${failedVisibileToContacts.length} contact${failedVisibileToContacts.length == 1 ? "" : "s" text: `Failed to set visibility for ${failedVisibileToContacts.length} contact${
failedVisibileToContacts.length == 1 ? "" : "s"
}. You must set them individually: ${failedVisibileToContacts.map((c) => c.name).join(", ")}`, }. You must set them individually: ${failedVisibileToContacts.map((c) => c.name).join(", ")}`,
}, },
-1, -1,

202
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,41 +123,109 @@ 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);
if (!jwt) {
this.handleMissingJwt(notifyOnFailure);
return;
}
await this.validateAndRedirect(jwt);
} catch (error) {
this.handleError(error, notifyOnFailure);
} finally {
this.checkingInvite = false;
}
}
// parse the string: extract the URL or JWT if surrounded by spaces /**
// and then extract the JWT from the URL * Extracts JWT from various input formats
// (For another approach used with contacts, see getContactPayloadFromJwtUrl) * @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]+)/); const urlMatch = jwtInput.match(/(https?:\/\/[^\s]+)/);
if (urlMatch && urlMatch[1]) { if (urlMatch?.[1]) {
// extract the JWT from the URL, meaning any character except "?"
const internalMatch = urlMatch[1].match(/\/invite-one-accept\/([^?]+)/); const internalMatch = urlMatch[1].match(/\/invite-one-accept\/([^?]+)/);
if (internalMatch && internalMatch[1]) { if (internalMatch?.[1]) return internalMatch[1];
jwt = internalMatch[1];
} }
} else {
// extract the JWT (which starts with "ey") if it is surrounded by other input // Try direct JWT format
const spaceMatch = jwtInput.match(/(ey[\w.-]+)/); const spaceMatch = jwtInput.match(/(ey[\w.-]+)/);
if (spaceMatch && spaceMatch[1]) { if (spaceMatch?.[1]) return spaceMatch[1];
jwt = 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 },
});
} }
if (!jwt) { /**
if (notifyOnFailure) { * Handles missing JWT error
* @param notify Whether to show notification
*/
private handleMissingJwt(notify: boolean) {
if (notify) {
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@ -118,21 +236,18 @@ export default class InviteOneAcceptView extends Vue {
5000, 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 },
});
} }
} catch (error) {
/**
* 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); const fullError = "Error accepting invite: " + errorStringForLog(error);
logConsoleAndDb(fullError, true); logConsoleAndDb(fullError, true);
if (notifyOnFailure) {
if (notify) {
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@ -144,10 +259,19 @@ export default class InviteOneAcceptView extends Vue {
); );
} }
} }
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) ||

249
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,13 +391,23 @@ export default class OfferDetailsView extends Vue {
this.validThroughDateInput = this.validThroughDateInput =
this.prevCredToEdit?.claim?.validThrough || this.validThroughDateInput; this.prevCredToEdit?.claim?.validThrough || this.validThroughDateInput;
}
try { /**
* Loads account settings and updates component state
* @throws Will not throw but logs errors
*/
private async loadAccountSettings() {
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount();
this.apiServer = settings.apiServer ?? ""; this.apiServer = settings.apiServer ?? "";
this.activeDid = settings.activeDid ?? ""; this.activeDid = settings.activeDid ?? "";
this.showGeneralAdvanced = settings.showGeneralAdvanced ?? false; this.showGeneralAdvanced = settings.showGeneralAdvanced ?? false;
}
/**
* Loads recipient information if recipient DID exists
*/
private async loadRecipientInfo() {
if (this.recipientDid && !this.recipientName) { if (this.recipientDid && !this.recipientName) {
const allContacts = await db.contacts.toArray(); const allContacts = await db.contacts.toArray();
const allMyDids = await retrieveAccountDids(); const allMyDids = await retrieveAccountDids();
@ -311,26 +418,16 @@ export default class OfferDetailsView extends Vue {
allContacts, allContacts,
); );
} }
// these should be functions but something's wrong with the syntax in the <> conditional // Set assignment flags
this.offeredToProject = !!this.projectId; this.offeredToProject = !!this.projectId;
this.offeredToRecipient = !this.offeredToProject && !!this.recipientDid; 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,
);
} }
/**
* 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(
{ {

9
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">
@ -572,7 +572,8 @@ 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

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