Browse Source

(chore): merge mostly pathway changes

split_build_process
Matthew Raymer 2 days ago
parent
commit
836bdce501
  1. 2
      README.md
  2. 152
      src/components/ChoiceButtonDialog.vue
  3. 4
      src/components/GiftedDialog.vue
  4. 481
      src/components/MembersList.vue
  5. 4
      src/components/OfferDialog.vue
  6. 158
      src/libs/crypto/index.ts
  7. 87
      src/libs/endorserServer.ts
  8. 15
      src/libs/util.ts
  9. 4
      src/main.ts
  10. 15
      src/router/index.ts
  11. 190
      src/views/ClaimReportCertificateView.vue
  12. 146
      src/views/ContactsView.vue
  13. 48
      src/views/HomeView.vue
  14. 6
      src/views/NewEditProjectView.vue
  15. 340
      src/views/OnboardMeetingListView.vue
  16. 72
      src/views/OnboardMeetingMembersView.vue
  17. 672
      src/views/OnboardMeetingSetupView.vue
  18. 2
      src/views/QuickActionBvcEndView.vue
  19. 109
      src/views/TestView.vue
  20. 4
      test-playwright/00-noid-tests.spec.ts

2
README.md

@ -10,7 +10,7 @@ See [project.task.yaml](project.task.yaml) for current priorities.
## Setup
We like pkgx: `sh <(curl https://pkgx.sh) +npm sh`
We like pkgx: `sh <(curl https://pkgx.sh) +vite sh`
```
npm install

152
src/components/ChoiceButtonDialog.vue

@ -0,0 +1,152 @@
<template>
<NotificationGroup group="customModal">
<div class="fixed z-[100] top-0 inset-x-0 w-full">
<Notification
v-slot="{ notifications, close }"
enter="transform ease-out duration-300 transition"
enter-from="translate-y-2 opacity-0 sm:translate-y-4"
enter-to="translate-y-0 opacity-100 sm:translate-y-0"
leave="transition ease-in duration-500"
leave-from="opacity-100"
leave-to="opacity-0"
move="transition duration-500"
move-delay="delay-300"
>
<div
v-for="notification in notifications"
:key="notification.id"
class="w-full"
role="alert"
>
<div
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
>
<div
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
>
<div class="w-full px-6 py-6 text-slate-900 text-center">
<span class="font-semibold text-lg">{{ title }}</span>
<p class="text-sm mb-2">{{ text }}</p>
<button
@click="handleOption1(close)"
class="block w-full text-center text-md font-bold capitalize bg-blue-800 text-white px-2 py-2 rounded-md mb-2"
>
{{ option1Text }}
</button>
<button
@click="handleOption2(close)"
class="block w-full text-center text-md font-bold capitalize bg-blue-700 text-white px-2 py-2 rounded-md mb-2"
>
{{ option2Text }}
</button>
<button
@click="handleOption3(close)"
class="block w-full text-center text-md font-bold capitalize bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
>
{{ option3Text }}
</button>
<button
@click="handleCancel(close)"
class="block w-full text-center text-md font-bold capitalize bg-slate-600 text-white px-2 py-2 rounded-md"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</Notification>
</div>
</NotificationGroup>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app";
@Component
export default class PromptDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
title = "";
text = "";
option1Text = "";
option2Text = "";
option3Text = "";
onOption1?: () => void;
onOption2?: () => void;
onOption3?: () => void;
onCancel?: () => Promise<void>;
open(options: {
title: string;
text: string;
option1Text?: string;
option2Text?: string;
option3Text?: string;
onOption1?: () => void;
onOption2?: () => void;
onOption3?: () => void;
onCancel?: () => Promise<void>;
}) {
this.title = options.title;
this.text = options.text;
this.option1Text = options.option1Text || "";
this.option2Text = options.option2Text || "";
this.option3Text = options.option3Text || "";
this.onOption1 = options.onOption1;
this.onOption2 = options.onOption2;
this.onOption3 = options.onOption3;
this.onCancel = options.onCancel;
this.$notify(
{
group: "customModal",
type: "confirm",
title: this.title,
text: this.text,
option1Text: this.option1Text,
option2Text: this.option2Text,
option3Text: this.option3Text,
onOption1: this.onOption1,
onOption2: this.onOption2,
onOption3: this.onOption3,
onCancel: this.onCancel,
} as NotificationIface,
-1,
);
}
handleOption1(close: (id: string) => void) {
if (this.onOption1) {
this.onOption1();
}
close("string that does not matter");
}
handleOption2(close: (id: string) => void) {
if (this.onOption2) {
this.onOption2();
}
close("string that does not matter");
}
handleOption3(close: (id: string) => void) {
if (this.onOption3) {
this.onOption3();
}
close("string that does not matter");
}
handleCancel(close: (id: string) => void) {
if (this.onCancel) {
this.onCancel();
}
close("string that does not matter");
}
}
</script>

4
src/components/GiftedDialog.vue

@ -90,7 +90,7 @@
import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "../constants/app";
import { createAndSubmitGive, didInfo } from "../libs/endorserServer";
import { createAndSubmitGive, didInfo, serverMessageForUser } from "../libs/endorserServer";
import * as libsUtil from "../libs/util";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts";
@ -336,7 +336,7 @@ export default class GiftedDialog extends Vue {
console.error("Error with give recordation caught:", error);
const errorMessage =
error.userMessage ||
error.response?.data?.error?.message ||
serverMessageForUser(error) ||
"There was an error recording the give.";
this.$notify(
{

481
src/components/MembersList.vue

@ -0,0 +1,481 @@
<template>
<div class="space-y-4">
<!-- Loading State -->
<div v-if="isLoading" class="flex justify-center items-center py-8">
<fa icon="spinner" class="fa-spin-pulse" />
</div>
<!-- Members List -->
<p
v-if="decryptedMembers.length < members.length"
class="text-center text-red-600 py-4"
>
{{
decryptFailureMessage ||
"Your password failed. Please go back and try again."
}}
</p>
<div v-else class="space-y-4">
<div v-if="missingMyself" class="py-4 text-red-600">
You are not admitted. The organizer will admit you.
</div>
<div>
<span
v-if="showOrganizerTools && isOrganizer"
class="inline-flex items-center flex-wrap"
>
<span class="inline-flex items-center">
Use
<span
class="mx-2 min-w-[24px] min-h-[24px] w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600"
>
<fa icon="plus" class="text-sm" />
</span>
and
<span
class="mx-2 min-w-[24px] min-h-[24px] w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600"
>
<fa icon="minus" class="text-sm" />
</span>
to add/remove them to/from the meeting.
</span>
</span>
</div>
<div>
<span class="inline-flex items-center">
Use
<span
class="mx-2 w-8 h-8 flex items-center justify-center rounded-full bg-green-100 text-green-600"
>
<fa icon="circle-user" class="text-xl" />
</span>
to add them to your contacts.
</span>
</div>
<div v-if="members.length > 0" class="flex justify-center">
<button
@click="fetchMembers"
class="w-8 h-8 flex items-center justify-center rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800 transition-colors"
title="Refresh members list"
>
<fa icon="rotate" :class="{ 'fa-spin': isLoading }" />
</button>
</div>
<div
v-for="member in membersToShow()"
:key="member.member.memberId"
class="p-4 bg-gray-50 rounded-lg"
>
<div class="flex items-center justify-between">
<div class="flex items-center">
<h3 class="text-lg font-medium">{{ member.name }}</h3>
<div
v-if="!getContactFor(member.did) && member.did !== activeDid"
class="flex justify-end"
>
<button
@click="addAsContact(member)"
class="ml-2 w-8 h-8 flex items-center justify-center rounded-full bg-green-100 text-green-600 hover:bg-green-200 hover:text-green-800 transition-colors"
title="Add as contact"
>
<fa icon="circle-user" class="text-xl" />
</button>
</div>
<button
v-if="member.did !== activeDid"
@click="
informAboutAddingContact(
getContactFor(member.did) !== undefined,
)
"
class="ml-2 mb-2 w-6 h-6 flex items-center justify-center rounded-full bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-800 transition-colors"
title="Contact info"
>
<fa icon="circle-info" class="text-base" />
</button>
</div>
<div class="flex">
<span
v-if="
showOrganizerTools && isOrganizer && member.did !== activeDid
"
class="flex items-center"
>
<button
@click="checkWhetherContactBeforeAdmitting(member)"
class="mr-2 w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800 transition-colors"
:title="
member.member.admitted ? 'Remove member' : 'Admit member'
"
>
<fa
:icon="member.member.admitted ? 'minus' : 'plus'"
class="text-sm"
/>
</button>
<button
@click="informAboutAdmission()"
class="mr-2 mb-2 w-6 h-6 flex items-center justify-center rounded-full bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-800 transition-colors"
title="Admission info"
>
<fa icon="circle-info" class="text-base" />
</button>
</span>
</div>
</div>
<p class="text-sm text-gray-600 truncate">
{{ member.did }}
</p>
</div>
<div v-if="members.length > 0" class="flex justify-center mt-4">
<button
@click="fetchMembers"
class="w-8 h-8 flex items-center justify-center rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800 transition-colors"
title="Refresh members list"
>
<fa icon="rotate" :class="{ 'fa-spin': isLoading }" />
</button>
</div>
<p v-if="members.length === 0" class="text-gray-500 py-4">
No members have joined this meeting yet
</p>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-facing-decorator";
import {
logConsoleAndDb,
retrieveSettingsForActiveAccount,
db,
} from "@/db/index";
import {
errorStringForLog,
getHeaders,
register,
serverMessageForUser,
} from "@/libs/endorserServer";
import { decryptMessage } from "@/libs/crypto";
import { Contact } from "@/db/tables/contacts";
import * as libsUtil from "@/libs/util";
import { NotificationIface } from "@/constants/app";
interface Member {
admitted: boolean;
content: string;
memberId: number;
registered: boolean;
}
interface DecryptedMember {
member: Member;
name: string;
did: string;
}
@Component
export default class MembersList extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
libsUtil = libsUtil;
@Prop({ required: true }) password!: string;
@Prop({ default: "Your password failed. Please go back and try again." })
decryptFailureMessage!: string;
@Prop({ default: false }) showOrganizerTools!: boolean;
decryptedMembers: DecryptedMember[] = [];
missingPassword = false;
missingMyself = false;
isLoading = false;
isOrganizer = false;
members: Member[] = [];
activeDid = "";
apiServer = "";
contacts: Array<Contact> = [];
async created() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
await this.fetchMembers();
await this.loadContacts();
}
async fetchMembers() {
this.isLoading = true;
try {
const headers = await getHeaders(this.activeDid);
const response = await this.axios.get(
`${this.apiServer}/api/partner/groupOnboardMembers`,
{ headers },
);
if (response.data && response.data.data) {
this.members = response.data.data;
await this.decryptMemberContents();
}
} catch (error) {
logConsoleAndDb(
"Error fetching members: " + errorStringForLog(error),
true,
);
this.$emit(
"error",
serverMessageForUser(error) || "Failed to fetch members.",
);
} finally {
this.isLoading = false;
}
}
async decryptMemberContents() {
this.decryptedMembers = [];
if (!this.password) {
this.missingPassword = true;
return;
}
let isFirstEntry = true,
foundMyself = false;
for (const member of this.members) {
try {
const decryptedContent = await decryptMessage(
member.content,
this.password,
);
const content = JSON.parse(decryptedContent);
this.decryptedMembers.push({
member: member,
name: content.name,
did: content.did,
});
if (isFirstEntry && content.did === this.activeDid) {
this.isOrganizer = true;
}
if (content.did === this.activeDid) {
foundMyself = true;
}
} catch (error) {
// do nothing, relying on the count of members to determine if there was an error
}
isFirstEntry = false;
}
this.missingMyself = !foundMyself;
}
membersToShow(): DecryptedMember[] {
if (this.isOrganizer) {
if (this.showOrganizerTools) {
return this.decryptedMembers;
} else {
return this.decryptedMembers.filter(
(member: DecryptedMember) => member.member.admitted,
);
}
}
// non-organizers only get visible members from server
return this.decryptedMembers;
}
informAboutAdmission() {
this.$notify(
{
group: "alert",
type: "info",
title: "Admission info",
text: "This is to register people and admit them to the meeting. A '+' symbol means they are not yet admitted and you can register and admit them. A '-' means you can remove them, but they will stay registered.",
},
10000,
);
}
informAboutAddingContact(contactImportedAlready: boolean) {
if (contactImportedAlready) {
this.$notify(
{
group: "alert",
type: "info",
title: "Contact Exists",
text: "They are in your contacts. If you want to remove them, you must do that from the contacts screen.",
},
10000,
);
} else {
this.$notify(
{
group: "alert",
type: "info",
title: "Contact Available",
text: "This is to add them to your contacts. If you want to remove them later, you must do that from the contacts screen.",
},
10000,
);
}
}
async loadContacts() {
this.contacts = await db.contacts.toArray();
}
getContactFor(did: string): Contact | undefined {
return this.contacts.find((contact) => contact.did === did);
}
checkWhetherContactBeforeAdmitting(member: DecryptedMember) {
const contact = this.getContactFor(member.did);
if (!member.member.admitted && !contact) {
// If not a contact, show confirmation dialog
this.$notify({
group: "modal",
type: "confirm",
title: "Add as Contact First?",
text: "This person is not in your contacts. Would you like to add them as a contact first?",
yesText: "Add as Contact",
noText: "Skip Adding Contact",
onYes: async () => {
await this.addAsContact(member);
// After adding as contact, proceed with admission
await this.toggleAdmission(member);
},
onNo: async () => {
// If they choose not to add as contact, show second confirmation
this.$notify({
group: "modal",
type: "confirm",
title: "Continue Without Adding?",
text: "Are you sure you want to proceed with admission even though they are not a contact?",
yesText: "Continue",
onYes: async () => {
await this.toggleAdmission(member);
},
onCancel: async () => {
// Do nothing, effectively canceling the operation
},
},
-1,
);
},
},
-1,
);
} else {
// If already a contact, proceed directly with admission
this.toggleAdmission(member);
}
}
async toggleAdmission(member: DecryptedMember) {
try {
const headers = await getHeaders(this.activeDid);
await this.axios.put(
`${this.apiServer}/api/partner/groupOnboardMember/${member.member.memberId}`,
{ admitted: !member.member.admitted },
{ headers },
);
// Update local state
member.member.admitted = !member.member.admitted;
const oldContact = this.getContactFor(member.did);
// if admitted, now register that user if they are not registered
if (member.member.admitted && !oldContact?.registered) {
const contactOldOrNew: Contact = oldContact || {
did: member.did,
name: member.name,
}
const result = await register(
this.activeDid,
this.apiServer,
this.axios,
contactOldOrNew,
);
if (result.success) {
member.member.registered = true;
if (oldContact) {
await db.contacts.update(member.did, { registered: true });
oldContact.registered = true;
}
this.$notify(
{
group: "alert",
type: "success",
title: "Registered",
text: "Besides being admitted, they were also registered.",
},
3000,
);
} else {
const additionalInfo = result.error || "";
this.$notify(
{
group: "alert",
type: "danger",
title: "Registration failed",
text:
"They were admitted, but registration failed. You can try again, or register from your contacts screen. " +
additionalInfo,
},
10000,
);
}
}
} catch (error) {
logConsoleAndDb(
"Error toggling admission: " + errorStringForLog(error),
true,
);
this.$emit(
"error",
serverMessageForUser(error) ||
"Failed to update member admission status.",
);
}
}
async addAsContact(member: DecryptedMember) {
try {
const newContact = {
did: member.did,
name: member.name,
};
await db.contacts.add(newContact);
this.contacts.push(newContact);
this.$notify(
{
group: "alert",
type: "success",
title: "Contact Added",
text: "They were added to your contacts.",
},
3000,
);
} catch (err) {
logConsoleAndDb("Error adding contact: " + errorStringForLog(err), true);
let message = "An error prevented adding this contact.";
if (err instanceof Error && err.message?.indexOf("already exists") > -1) {
message = "This person is already in your contact list.";
}
this.$notify(
{
group: "alert",
type: "danger",
title: "Contact Not Added",
text: message,
},
5000,
);
}
}
}
</script>

4
src/components/OfferDialog.vue

@ -304,9 +304,9 @@ export default class OfferDialog extends Vue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getOfferCreationErrorMessage(result: any) {
return (
serverMessageForUser(result) ||
result.error?.userMessage ||
result.error?.error ||
result.response?.data?.error?.message
result.error?.error
);
}
}

158
src/libs/crypto/index.ts

@ -52,7 +52,7 @@ export const newIdentifier = (
*
*
* @param {string} mnemonic
* @return {*} {[string, string, string, string]}
* @return {[string, string, string, string]} address, privateHex, publicHex, derivationPath
*/
export const deriveAddress = (
mnemonic: string,
@ -88,7 +88,8 @@ export const generateSeed = (): string => {
/**
* Retrieve an access token, or "" if no DID is provided.
*
* @return {*}
* @param {string} did
* @return {string} JWT with basic payload
*/
export const accessToken = async (did?: string) => {
if (did) {
@ -147,3 +148,156 @@ export const nextDerivationPath = (origDerivPath: string) => {
.join("/");
return newDerivPath;
};
// Base64 encoding/decoding utilities for browser
function base64ToArrayBuffer(base64: string): Uint8Array {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const binary = String.fromCharCode(...new Uint8Array(buffer));
return btoa(binary);
}
const SALT_LENGTH = 16;
const IV_LENGTH = 12;
const KEY_LENGTH = 256;
const ITERATIONS = 100000;
// Encryption helper function
export async function encryptMessage(message: string, password: string) {
const encoder = new TextEncoder();
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
// Derive key from password using PBKDF2
const keyMaterial = await crypto.subtle.importKey(
"raw",
encoder.encode(password),
"PBKDF2",
false,
["deriveBits", "deriveKey"],
);
const key = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt,
iterations: ITERATIONS,
hash: "SHA-256",
},
keyMaterial,
{ name: "AES-GCM", length: KEY_LENGTH },
false,
["encrypt"],
);
// Encrypt the message
const encryptedContent = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
},
key,
encoder.encode(message),
);
// Return a JSON structure with base64-encoded components
const result = {
salt: arrayBufferToBase64(salt),
iv: arrayBufferToBase64(iv),
encrypted: arrayBufferToBase64(encryptedContent),
};
return btoa(JSON.stringify(result));
}
// Decryption helper function
export async function decryptMessage(encryptedJson: string, password: string) {
const decoder = new TextDecoder();
const { salt, iv, encrypted } = JSON.parse(atob(encryptedJson));
// Convert base64 components back to Uint8Arrays
const saltArray = base64ToArrayBuffer(salt);
const ivArray = base64ToArrayBuffer(iv);
const encryptedContent = base64ToArrayBuffer(encrypted);
// Derive the same key using PBKDF2 with the extracted salt
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveBits", "deriveKey"],
);
const key = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: saltArray,
iterations: ITERATIONS,
hash: "SHA-256",
},
keyMaterial,
{ name: "AES-GCM", length: KEY_LENGTH },
false,
["decrypt"],
);
// Decrypt the content
const decryptedContent = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: ivArray,
},
key,
encryptedContent,
);
// Convert the decrypted content back to a string
return decoder.decode(decryptedContent);
}
// Test function to verify encryption/decryption
export async function testEncryptionDecryption() {
try {
const testMessage = "Hello, this is a test message! 🚀";
const testPassword = "myTestPassword123";
console.log("Original message:", testMessage);
// Test encryption
console.log("Encrypting...");
const encrypted = await encryptMessage(testMessage, testPassword);
console.log("Encrypted result:", encrypted);
// Test decryption
console.log("Decrypting...");
const decrypted = await decryptMessage(encrypted, testPassword);
console.log("Decrypted result:", decrypted);
// Verify
const success = testMessage === decrypted;
console.log("Test " + (success ? "PASSED ✅" : "FAILED ❌"));
console.log("Messages match:", success);
// Test with wrong password
console.log("\nTesting with wrong password...");
try {
await decryptMessage(encrypted, "wrongPassword");
console.log("Should not reach here");
} catch (error) {
console.log("Correctly failed with wrong password ✅");
}
return success;
} catch (error) {
console.error("Test failed with error:", error);
return false;
}
}

87
src/libs/endorserServer.ts

@ -621,41 +621,6 @@ const planCache: LRUCache<string, PlanSummaryRecord> = new LRUCache({
max: 500,
});
/**
* Helpful for server errors, to get all the info -- including stuff skipped by toString & JSON.stringify
*
* @param error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function errorStringForLog(error: any) {
let stringifiedError = "" + error;
try {
stringifiedError = JSON.stringify(error);
} catch (e) {
// can happen with Dexie, eg:
// TypeError: Converting circular structure to JSON
// --> starting at object with constructor 'DexieError2'
// | property '_promise' -> object with constructor 'DexiePromise'
// --- property '_value' closes the circle
}
let fullError = "" + error + " - JSON: " + stringifiedError;
const errorResponseText = JSON.stringify(error.response);
// for some reason, error.response is not included in stringify result (eg. for 400 errors on invite redemptions)
if (!R.empty(errorResponseText) && !fullError.includes(errorResponseText)) {
// add error.response stuff
if (R.equals(error?.config, error?.response?.config)) {
// but exclude "config" because it's already in there
const newErrorResponseText = JSON.stringify(
R.omit(["config"] as never[], error.response),
);
fullError += " - .response w/o same config JSON: " + newErrorResponseText;
} else {
fullError += " - .response JSON: " + errorResponseText;
}
}
return fullError;
}
/**
* @param handleId nullable, in which case "undefined" will be returned
* @param requesterDid optional, in which case no private info will be returned
@ -710,6 +675,56 @@ export async function setPlanInCache(
planCache.set(handleId, planSummary);
}
/**
*
* @param error that is thrown from an Endorser server call by Axios
* @returns user-friendly message, or undefined if none found
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function serverMessageForUser(error: any) {
return (
// this is how most user messages are returned
error?.response?.data?.error?.message
// some are returned as "error" with a string, but those are more for devs and are less helpful to the user
);
}
/**
* Helpful for server errors, to get all the info -- including stuff skipped by toString & JSON.stringify
* It works with AxiosError, eg handling an error.response intelligently.
*
* @param error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function errorStringForLog(error: any) {
let stringifiedError = "" + error;
try {
stringifiedError = JSON.stringify(error);
} catch (e) {
// can happen with Dexie, eg:
// TypeError: Converting circular structure to JSON
// --> starting at object with constructor 'DexieError2'
// | property '_promise' -> object with constructor 'DexiePromise'
// --- property '_value' closes the circle
}
let fullError = "" + error + " - JSON: " + stringifiedError;
const errorResponseText = JSON.stringify(error.response);
// for some reason, error.response is not included in stringify result (eg. for 400 errors on invite redemptions)
if (!R.empty(errorResponseText) && !fullError.includes(errorResponseText)) {
// add error.response stuff
if (R.equals(error?.config, error?.response?.config)) {
// but exclude "config" because it's already in there
const newErrorResponseText = JSON.stringify(
R.omit(["config"] as never[], error.response),
);
fullError += " - .response w/o same config JSON: " + newErrorResponseText;
} else {
fullError += " - .response JSON: " + errorResponseText;
}
}
return fullError;
}
/**
*
* @returns { data: Array<OfferSummaryRecord>, hitLimit: boolean true if maximum was hit and there may be more }
@ -1113,7 +1128,7 @@ export async function createAndSubmitClaim(
} catch (error: any) {
console.error("Error submitting claim:", error);
const errorMessage: string =
error.response?.data?.error?.message ||
serverMessageForUser(error) ||
error.message ||
"Got some error submitting the claim. Check your permissions, network, and error logs.";

15
src/libs/util.ts

@ -112,6 +112,21 @@ export const isGiveAction = (
return isGiveClaimType(veriClaim.claimType);
};
export const shortDid = (did: string) => {
if (did.startsWith("did:peer:")) {
return (
did.substring(0, "did:peer:".length + 2) +
"..." +
did.substring("did:peer:".length + 18, "did:peer:".length + 25) +
"..."
);
} else if (did.startsWith("did:ethr:")) {
return did.substring(0, "did:ethr:".length + 9) + "...";
} else {
return did.substring(0, did.indexOf(":", 4) + 7) + "...";
}
}
export const nameForDid = (
activeDid: string,
contacts: Array<Contact>,

4
src/main.ts

@ -23,6 +23,7 @@ import {
faCalendar,
faCamera,
faCaretDown,
faChair,
faCheck,
faChevronDown,
faChevronLeft,
@ -73,6 +74,7 @@ import {
faPlus,
faQuestion,
faQrcode,
faRightFromBracket,
faRotate,
faShareNodes,
faSpinner,
@ -100,6 +102,7 @@ library.add(
faCalendar,
faCamera,
faCaretDown,
faChair,
faCheck,
faChevronDown,
faChevronLeft,
@ -151,6 +154,7 @@ library.add(
faQrcode,
faQuestion,
faRotate,
faRightFromBracket,
faShareNodes,
faSpinner,
faSquare,

15
src/router/index.ts

@ -183,6 +183,21 @@ const routes: Array<RouteRecordRaw> = [
name: "offer-details",
component: () => import("../views/OfferDetailsView.vue"),
},
{
path: "/onboard-meeting-list",
name: "onboard-meeting-list",
component: () => import("../views/OnboardMeetingListView.vue"),
},
{
path: "/onboard-meeting-members/:groupId",
name: "onboard-meeting-members",
component: () => import("../views/OnboardMeetingMembersView.vue"),
},
{
path: "/onboard-meeting-setup",
name: "onboard-meeting-setup",
component: () => import("../views/OnboardMeetingSetupView.vue"),
},
{
path: "/project/:id?",
name: "project",

190
src/views/ClaimReportCertificateView.vue

@ -0,0 +1,190 @@
<template>
<section id="Content">
<div v-if="claimData">
<canvas ref="claimCanvas"></canvas>
</div>
</section>
</template>
<style scoped>
canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { nextTick } from "vue";
import QRCode from "qrcode";
import { APP_SERVER, NotificationIface } from "@/constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import * as endorserServer from "@/libs/endorserServer";
@Component
export default class ClaimReportCertificateView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
allMyDids: Array<string> = [];
apiServer = "";
claimId = "";
claimData = null;
endorserServer = endorserServer;
async created() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
const pathParams = window.location.pathname.substring(
"/claim-cert/".length,
);
this.claimId = pathParams;
await this.fetchClaim();
}
async fetchClaim() {
try {
const response = await fetch(
`${this.apiServer}/api/claim/${this.claimId}`,
);
if (response.ok) {
this.claimData = await response.json();
await nextTick(); // Wait for the DOM to update
if (this.claimData) {
this.drawCanvas(this.claimData);
}
} else {
throw new Error(`Error fetching claim: ${response.statusText}`);
}
} catch (error) {
console.error("Failed to load claim:", error);
this.$notify({
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem loading the claim.",
});
}
}
async drawCanvas(
claimData: endorserServer.GenericCredWrapper<endorserServer.GenericVerifiableCredential>,
) {
await db.open();
const allContacts = await db.contacts.toArray();
const canvas = this.$refs.claimCanvas as HTMLCanvasElement;
if (canvas) {
const CANVAS_WIDTH = 1100;
const CANVAS_HEIGHT = 850;
// size to approximate portrait of 8.5"x11"
canvas.width = CANVAS_WIDTH;
canvas.height = CANVAS_HEIGHT;
const ctx = canvas.getContext("2d");
if (ctx) {
// Load the background image
const backgroundImage = new Image();
backgroundImage.src = "/img/background/cert-frame-2.jpg";
backgroundImage.onload = async () => {
// Draw the background image
ctx.drawImage(backgroundImage, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
// Set font and styles
ctx.fillStyle = "black";
// Draw claim type
ctx.font = "bold 20px Arial";
const claimTypeText =
this.endorserServer.capitalizeAndInsertSpacesBeforeCaps(
claimData.claimType || "",
);
const claimTypeWidth = ctx.measureText(claimTypeText).width;
ctx.fillText(
claimTypeText,
(CANVAS_WIDTH - claimTypeWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.33,
);
if (claimData.claim.agent) {
const presentedText = "Presented to ";
ctx.font = "14px Arial";
const presentedWidth = ctx.measureText(presentedText).width;
ctx.fillText(
presentedText,
(CANVAS_WIDTH - presentedWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.37,
);
const agentText = endorserServer.didInfoForCertificate(
claimData.claim.agent,
allContacts,
);
ctx.font = "bold 20px Arial";
const agentWidth = ctx.measureText(agentText).width;
ctx.fillText(
agentText,
(CANVAS_WIDTH - agentWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.4,
);
}
const descriptionText =
claimData.claim.name || claimData.claim.description;
if (descriptionText) {
const descriptionLine =
descriptionText.length > 50
? descriptionText.substring(0, 75) + "..."
: descriptionText;
ctx.font = "14px Arial";
const descriptionWidth = ctx.measureText(descriptionLine).width;
ctx.fillText(
descriptionLine,
(CANVAS_WIDTH - descriptionWidth) / 2,
CANVAS_HEIGHT * 0.45,
);
}
// Draw claim issuer & recipient
if (claimData.issuer) {
ctx.font = "14px Arial";
const issuerText =
"Issued by " +
endorserServer.didInfoForCertificate(
claimData.issuer,
allContacts,
);
ctx.fillText(issuerText, CANVAS_WIDTH * 0.3, CANVAS_HEIGHT * 0.6);
}
// Draw claim ID
ctx.font = "14px Arial";
ctx.fillText(this.claimId, CANVAS_WIDTH * 0.3, CANVAS_HEIGHT * 0.7);
ctx.fillText(
"via EndorserSearch.com",
CANVAS_WIDTH * 0.3,
CANVAS_HEIGHT * 0.73,
);
// Generate and draw QR code
const qrCodeCanvas = document.createElement("canvas");
await QRCode.toCanvas(
qrCodeCanvas,
APP_SERVER + "/claim/" + this.claimId,
{
width: 150,
color: { light: "#0000" /* Transparent background */ },
},
);
ctx.drawImage(qrCodeCanvas, CANVAS_WIDTH * 0.6, CANVAS_HEIGHT * 0.55);
};
}
}
}
}
</script>

146
src/views/ContactsView.vue

@ -23,27 +23,50 @@
<!-- New Contact -->
<div id="formAddNewContact" class="mt-4 mb-4 flex items-stretch">
<router-link
v-if="isRegistered"
:to="{ name: 'invite-one' }"
class="flex items-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
>
<fa icon="envelope-open-text" class="fa-fw text-2xl" />
</router-link>
<span
v-else
class="flex items-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
>
<fa
icon="envelope-open-text"
class="fa-fw text-2xl"
@click="
danger(
'You must get registered before you can invite others.',
'Not Registered',
)
"
/>
<span class="flex" v-if="isRegistered">
<router-link
:to="{ name: 'invite-one' }"
class="flex items-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
>
<fa icon="envelope-open-text" class="fa-fw text-2xl" />
</router-link>
<button
@click="showOnboardMeetingDialog()"
class="flex items-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
>
<fa icon="chair" class="fa-fw text-2xl" />
</button>
</span>
<span v-else class="flex">
<span
class="flex items-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
>
<fa
icon="envelope-open-text"
class="fa-fw text-2xl"
@click="
warning(
'You must get registered before you can create invites.',
'Not Registered',
)
"
/>
</span>
<span
class="flex items-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
>
<fa
icon="chair"
class="fa-fw text-2xl"
@click="
warning(
'You must get registered before you can initiate an onboarding meeting.',
'Not Registered',
)
"
/>
</span>
</span>
<router-link
@ -196,7 +219,7 @@
</router-link>
<span class="ml-4 text-sm overflow-hidden">{{
shortDid(contact.did)
libsUtil.shortDid(contact.did)
}}</span>
</div>
<div class="ml-4 text-sm">
@ -587,6 +610,18 @@ export default class ContactsView extends Vue {
);
}
private warning(message: string, title: string = "Error", timeout = 5000) {
this.$notify(
{
group: "alert",
type: "warning",
title: title,
text: message,
},
timeout,
);
}
private showOnboardingInfo() {
this.$notify(
{
@ -1338,21 +1373,6 @@ export default class ContactsView extends Vue {
});
}
private shortDid(did: string) {
if (did.startsWith("did:peer:")) {
return (
did.substring(0, "did:peer:".length + 2) +
"..." +
did.substring("did:peer:".length + 18, "did:peer:".length + 25) +
"..."
);
} else if (did.startsWith("did:ethr:")) {
return did.substring(0, "did:ethr:".length + 9) + "...";
} else {
return did.substring(0, did.indexOf(":", 4) + 7) + "...";
}
}
private showCopySelectionsInfo() {
this.$notify(
{
@ -1364,5 +1384,57 @@ export default class ContactsView extends Vue {
5000,
);
}
private async showOnboardMeetingDialog() {
try {
// First check if they're in a meeting
const headers = await getHeaders(this.activeDid);
const memberResponse = await this.axios.get(
this.apiServer + "/api/partner/groupOnboardMember",
{ headers }
);
if (memberResponse.data.data) {
// They're in a meeting, check if they're the host
const hostResponse = await this.axios.get(
this.apiServer + "/api/partner/groupOnboard",
{ headers }
);
if (hostResponse.data.data) {
// They're the host, take them to setup
(this.$router as Router).push({ name: "onboard-meeting-setup" });
} else {
// They're not the host, take them to list
(this.$router as Router).push({ name: "onboard-meeting-list" });
}
} else {
// They're not in a meeting, show the dialog
this.$notify(
{
group: "modal",
type: "confirm",
title: "Onboarding Meeting",
text: "Would you like to start a new meeting?",
onYes: () => {
(this.$router as Router).push({ name: "onboard-meeting-setup" });
},
yesText: "Start New Meeting",
onNo: () => {
(this.$router as Router).push({ name: "onboard-meeting-list" });
},
noText: "Join Existing Meeting"
},
-1
);
}
} catch (error) {
logConsoleAndDb("Error checking meeting status:" + errorStringForLog(error));
this.danger(
"There was an error checking your meeting status.",
"Meeting Error"
);
}
}
}
</script>

48
src/views/HomeView.vue

@ -366,6 +366,8 @@
</div>
</div>
</section>
<ChoiceButtonDialog ref="choiceButtonDialog" />
</template>
<script lang="ts">
@ -374,6 +376,7 @@ import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import App from "../App.vue";
<<<<<<< HEAD
import EntityIcon from "../components/EntityIcon.vue";
import GiftedDialog from "../components/GiftedDialog.vue";
import GiftedPrompts from "../components/GiftedPrompts.vue";
@ -383,6 +386,18 @@ import OnboardingDialog from "../components/OnboardingDialog.vue";
import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
import UserNameDialog from "../components/UserNameDialog.vue";
=======
import EntityIcon from "@/components/EntityIcon.vue";
import GiftedDialog from "@/components/GiftedDialog.vue";
import GiftedPrompts from "@/components/GiftedPrompts.vue";
import FeedFilters from "@/components/FeedFilters.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue";
import OnboardingDialog from "@/components/OnboardingDialog.vue";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import UserNameDialog from "@/components/UserNameDialog.vue";
import ChoiceButtonDialog from "@/components/ChoiceButtonDialog.vue";
>>>>>>> master
import {
AppString,
NotificationIface,
@ -448,6 +463,7 @@ interface GiveRecordWithContactInfo extends GiveSummaryRecord {
GiftedPrompts,
InfiniteScroll,
OnboardingDialog,
ChoiceButtonDialog,
QuickNav,
TopMessage,
UserNameDialog,
@ -949,24 +965,22 @@ export default class HomeView extends Vue {
}
promptForShareMethod() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Are you nearby with cameras?",
text: "If so, we'll use those with QR codes to share.",
onCancel: async () => {},
onNo: async () => {
(this.$router as Router).push({ name: "share-my-contact-info" });
},
onYes: async () => {
(this.$router as Router).push({ name: "contact-qr" });
},
noText: "we will share another way",
yesText: "we are nearby with cameras",
(this.$refs.choiceButtonDialog as ChoiceButtonDialog).open({
title: "How can you share your info?",
text: "",
option1Text: "We are in a meeting together",
option2Text: "We are nearby with cameras",
option3Text: "We will share some other way",
onOption1: () => {
(this.$router as Router).push({ name: "onboard-meeting-list" });
},
-1,
);
onOption2: () => {
(this.$router as Router).push({ name: "contact-qr" });
},
onOption3: () => {
(this.$router as Router).push({ name: "share-my-contact-info" });
},
});
}
}
</script>

6
src/views/NewEditProjectView.vue

@ -204,7 +204,11 @@ import { AxiosError, AxiosRequestHeaders } from "axios";
import { DateTime } from "luxon";
import { hexToBytes } from "@noble/hashes/utils";
// these core imports could also be included as "import type ..."
import { EventTemplate, UnsignedEvent, VerifiedEvent } from "nostr-tools/core";
import {
EventTemplate,
UnsignedEvent,
VerifiedEvent,
} from "nostr-tools/lib/types/core";
import {
accountFromExtendedKey,
extendedKeysFromSeedWords,

340
src/views/OnboardMeetingListView.vue

@ -0,0 +1,340 @@
<template>
<QuickNav selected="Contacts" />
<TopMessage />
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light mb-8">
Onboarding Meetings
</h1>
<!-- Loading State -->
<div v-if="isLoading" class="flex justify-center items-center py-8">
<fa icon="spinner" class="fa-spin-pulse" />
</div>
<div v-else-if="attendingMeeting">
<p>You are in this meeting.</p>
<div
class="p-4 bg-white rounded-lg shadow hover:shadow-md transition-shadow cursor-pointer"
@click="promptPassword(attendingMeeting)"
>
<div class="flex justify-between items-center">
<h2 class="text-xl font-medium">{{ attendingMeeting.name }}</h2>
<button
@click.stop="leaveMeeting"
class="text-red-600 hover:text-red-700 p-2"
title="Leave Meeting"
>
<fa icon="right-from-bracket" />
</button>
</div>
</div>
</div>
<!-- Meeting List -->
<div v-else class="space-y-4">
<div
v-for="meeting in meetings"
:key="meeting.groupId"
class="p-4 bg-white rounded-lg shadow hover:shadow-md transition-shadow cursor-pointer"
@click="promptPassword(meeting)"
>
<h2 class="text-xl font-medium">{{ meeting.name }}</h2>
</div>
<p v-if="meetings.length === 0" class="text-center text-gray-500 py-8">
No onboarding meetings available
</p>
</div>
<!-- Password Dialog -->
<div
v-if="showPasswordDialog"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4"
>
<div class="bg-white rounded-lg p-6 max-w-sm w-full">
<h3 class="text-lg font-medium mb-4">Enter Meeting Password</h3>
<input
ref="passwordInput"
v-model="password"
type="text"
class="w-full px-3 py-2 border rounded-md mb-4"
placeholder="Enter password"
@keyup.enter="submitPassword"
/>
<div class="flex justify-end space-x-4">
<button
@click="cancelPasswordDialog"
class="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
>
Cancel
</button>
<button
@click="submitPassword"
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Submit
</button>
</div>
</div>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { nextTick } from "vue";
import { Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "@/db/index";
import {
errorStringForLog,
getHeaders,
serverMessageForUser,
} from "@/libs/endorserServer";
import { encryptMessage } from "@/libs/crypto";
interface Meeting {
name: string;
groupId: number;
}
@Component({
components: {
QuickNav,
TopMessage,
},
})
export default class OnboardMeetingListView extends Vue {
$notify!: (
notification: {
group: string;
type: string;
title: string;
text: string;
onYes?: () => void;
yesText?: string;
},
timeout?: number,
) => void;
activeDid = "";
apiServer = "";
attendingMeeting: Meeting | null = null;
firstName = "";
isLoading = false;
meetings: Meeting[] = [];
password = "";
selectedMeeting: Meeting | null = null;
showPasswordDialog = false;
async created() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.firstName = settings.firstName || "";
await this.fetchMeetings();
}
async fetchMeetings() {
this.isLoading = true;
try {
// get the meeting that the user is attending
const headers = await getHeaders(this.activeDid);
const response = await this.axios.get(
this.apiServer + "/api/partner/groupOnboardMember",
{ headers },
);
if (response.data?.data) {
// they're in a meeting already
const attendingMeetingId = response.data.data.groupId;
// retrieve the meeting details
const headers2 = await getHeaders(this.activeDid);
const response2 = await this.axios.get(
this.apiServer + "/api/partner/groupOnboard/" + attendingMeetingId,
{ headers: headers2 },
);
if (response2.data?.data) {
this.attendingMeeting = response2.data.data;
return;
} else {
// this should never happen
logConsoleAndDb(
"Error fetching meeting for user after saying they are in one.",
true,
);
}
}
const headers2 = await getHeaders(this.activeDid);
const response2 = await this.axios.get(
this.apiServer + "/api/partner/groupsOnboarding",
{ headers: headers2 },
);
if (response2.data?.data) {
this.meetings = response2.data.data;
}
} catch (error) {
logConsoleAndDb(
"Error fetching meetings: " + errorStringForLog(error),
true,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: serverMessageForUser(error) || "Failed to fetch meetings.",
},
5000,
);
} finally {
this.isLoading = false;
}
}
promptPassword(meeting: Meeting) {
this.password = "";
this.selectedMeeting = meeting;
this.showPasswordDialog = true;
nextTick(() => {
const input = this.$refs.passwordInput as HTMLInputElement;
if (input) {
input.focus();
}
});
}
cancelPasswordDialog() {
this.password = "";
this.selectedMeeting = null;
this.showPasswordDialog = false;
}
async submitPassword() {
if (!this.selectedMeeting) {
// this should never happen
logConsoleAndDb(
"No meeting selected when prompting for password, which should never happen.",
true,
);
return;
}
try {
// Create member data object
const memberData = {
name: this.firstName,
did: this.activeDid,
};
const memberDataString = JSON.stringify(memberData);
const encryptedMemberData = await encryptMessage(
memberDataString,
this.password,
);
// Get headers for authentication
const headers = await getHeaders(this.activeDid);
// Encrypt the member data
const postResult = await this.axios.post(
this.apiServer + "/api/partner/groupOnboardMember",
{
groupId: this.selectedMeeting.groupId,
content: encryptedMemberData,
},
{ headers },
);
if (postResult.data && postResult.data.success) {
// Navigate to members view with password and groupId
(this.$router as Router).push({
name: "onboard-meeting-members",
params: {
groupId: this.selectedMeeting.groupId.toString(),
},
query: {
password: this.password,
memberId: postResult.data.memberId,
},
});
this.cancelPasswordDialog();
} else {
throw { response: postResult };
}
} catch (error) {
logConsoleAndDb(
"Error joining meeting: " + errorStringForLog(error),
true,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
serverMessageForUser(error) || "You failed to join the meeting.",
},
5000,
);
}
}
async leaveMeeting() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Leave Meeting",
text: "Are you sure you want to leave this meeting?",
onYes: async () => {
try {
const headers = await getHeaders(this.activeDid);
await this.axios.delete(
this.apiServer + "/api/partner/groupOnboardMember",
{ headers },
);
this.attendingMeeting = null;
await this.fetchMeetings();
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "You left the meeting.",
},
5000,
);
} catch (error) {
logConsoleAndDb(
"Error leaving meeting: " + errorStringForLog(error),
true,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
serverMessageForUser(error) ||
"You failed to leave the meeting.",
},
5000,
);
}
},
},
-1,
);
}
}
</script>

72
src/views/OnboardMeetingMembersView.vue

@ -0,0 +1,72 @@
<template>
<QuickNav selected="Contacts" />
<TopMessage />
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light mb-8">
Meeting Members
</h1>
<!-- Error State -->
<div v-if="errorMessage">
<div class="text-center text-red-600 py-8">
{{ errorMessage }}
</div>
<div class="text-center">
For authorization, wait for your meeting organizer to approve you.
</div>
</div>
<!-- Members List -->
<MembersList
v-else
:password="password"
:decrypt-failure-message="'That password failed. You may be in the wrong meeting. Go back and try again.'"
@error="handleError"
/>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocation } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import MembersList from "@/components/MembersList.vue";
@Component({
components: {
QuickNav,
TopMessage,
MembersList,
},
})
export default class OnboardMeetingMembersView extends Vue {
errorMessage = "";
get groupId(): string {
return (this.$route as RouteLocation).params.groupId as string;
}
get password(): string {
return (this.$route as RouteLocation).query.password as string;
}
async created() {
if (!this.groupId) {
this.errorMessage = "The group info is missing. Go back and try again.";
return;
}
if (!this.password) {
this.errorMessage = "The password is missing. Go back and try again.";
return;
}
}
handleError(message: string) {
this.errorMessage = message;
}
}
</script>

672
src/views/OnboardMeetingSetupView.vue

@ -0,0 +1,672 @@
<template>
<QuickNav selected="Contacts" />
<TopMessage />
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light">
Onboarding Meeting
</h1>
<!-- Existing Meeting Section -->
<div
v-if="!isLoading && currentMeeting != null && !isInEditOrCreateMode()"
class="mt-8 p-4 border rounded-lg bg-white shadow"
>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center">
<h2 class="text-2xl">Current Meeting</h2>
<button
@click="startEditing"
class="mb-4 text-blue-600 hover:text-blue-800 transition-colors duration-200 ml-2"
title="Edit Meeting"
>
<fa icon="pen" class="fa-fw" />
<span class="sr-only">{{
isInCreateMode() ? "Create Meeting" : "Edit Meeting"
}}</span>
</button>
</div>
<button
@click="confirmDelete"
class="text-red-600 hover:text-red-800 transition-colors duration-200"
:disabled="isDeleting"
:class="{ 'opacity-50 cursor-not-allowed': isDeleting }"
title="Delete Meeting"
>
<fa icon="trash-can" class="fa-fw" />
<span class="sr-only">{{
isDeleting ? "Deleting..." : "Delete Meeting"
}}</span>
</button>
</div>
<div class="space-y-2">
<p><strong>Name:</strong> {{ currentMeeting.name }}</p>
<p>
<strong>Expires:</strong>
{{ formatExpirationTime(currentMeeting.expiresAt) }}
</p>
<div v-if="currentMeeting.password" class="mt-4">
<p class="text-gray-600">
Share the password with the people you want to onboard.
</p>
</div>
<div v-else class="text-red-600">
Your copy of the password is not saved. Edit the meeting, or delete it
and create a new meeting.
</div>
</div>
</div>
<!-- Delete Confirmation Dialog -->
<div
v-if="showDeleteConfirm"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4"
>
<div class="bg-white rounded-lg p-6 max-w-sm w-full">
<h3 class="text-lg font-medium mb-4">Delete Meeting?</h3>
<p class="text-gray-600 mb-6">
This action cannot be undone. Are you sure you want to delete this
meeting?
</p>
<div class="flex justify-between space-x-4">
<button
@click="showDeleteConfirm = false"
class="px-4 py-2 bg-slate-500 text-white rounded hover:bg-slate-700"
>
Cancel
</button>
<button
@click="deleteMeeting"
class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
<!-- Create/Edit Meeting Form -->
<div
v-if="
!isLoading &&
isInEditOrCreateMode() &&
newOrUpdatedMeeting != null /* duplicate check is for typechecks */
"
class="mt-8"
>
<h2 class="text-2xl mb-4">
{{ isInCreateMode() ? "Create New Meeting" : "Edit Meeting" }}
</h2>
<!-- This is my first form. Not sure if I like it; will see if the browser benefits extend to the native app. -->
<form
@submit.prevent="isInCreateMode() ? createMeeting() : updateMeeting()"
class="space-y-4"
>
<div>
<label
for="meetingName"
class="block text-sm font-medium text-gray-700"
>Meeting Name</label
>
<input
id="meetingName"
v-model="newOrUpdatedMeeting.name"
type="text"
required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
placeholder="Enter meeting name"
/>
</div>
<div>
<label
for="expirationTime"
class="block text-sm font-medium text-gray-700"
>Meeting Expiration Time</label
>
<input
id="expirationTime"
v-model="newOrUpdatedMeeting.expiresAt"
type="datetime-local"
required
:min="minDateTime"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700"
>Meeting Password</label
>
<input
id="password"
v-model="newOrUpdatedMeeting.password"
type="text"
required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
placeholder="Enter meeting password"
/>
</div>
<div>
<label for="userName" class="block text-sm font-medium text-gray-700"
>Your Name</label
>
<input
id="userName"
v-model="newOrUpdatedMeeting.userFullName"
type="text"
required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
placeholder="Your name"
/>
</div>
<button
type="submit"
class="w-full bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md hover:from-green-500 hover:to-green-800"
:disabled="isLoading"
>
{{
isLoading
? isInCreateMode()
? "Creating..."
: "Updating..."
: isInCreateMode()
? "Create Meeting"
: "Update Meeting"
}}
</button>
<button
v-if="isInEditOrCreateMode()"
type="button"
@click="cancelEditing"
class="w-full bg-slate-500 text-white px-4 py-2 rounded-md hover:bg-slate-600"
>
Cancel
</button>
</form>
</div>
<!-- Members Section -->
<div
v-if="!isLoading && currentMeeting != null"
class="mt-8 p-4 border rounded-lg bg-white shadow"
>
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl">Meeting Members</h2>
</div>
<router-link
:to="onboardMeetingMembersLink()"
class="inline-block text-blue-600"
target="_blank"
>
Open shortcut page for members <fa icon="external-link" />
</router-link>
<MembersList
:password="currentMeeting.password || ''"
:decrypt-failure-message="DECRYPT_FAILURE_MESSAGE"
:show-organizer-tools="true"
@error="handleMembersError"
class="mt-4"
/>
</div>
<div v-else-if="isLoading">
<div class="flex justify-center items-center h-full">
<fa icon="spinner" class="fa-spin-pulse" />
</div>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import MembersList from "@/components/MembersList.vue";
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "@/db/index";
import {
errorStringForLog,
getHeaders,
serverMessageForUser,
} from "@/libs/endorserServer";
import { encryptMessage } from "@/libs/crypto";
interface ServerMeeting {
groupId: number; // from the server
name: string; // from the server
expiresAt: string; // from the server
userFullName?: string; // from the user's session
password?: string; // from the user's session
}
interface MeetingSetupInfo {
name: string;
expiresAt: string;
userFullName: string;
password: string;
}
@Component({
components: {
QuickNav,
TopMessage,
MembersList,
},
})
export default class OnboardMeetingView extends Vue {
$notify!: (
notification: { group: string; type: string; title: string; text: string },
timeout?: number,
) => void;
DECRYPT_FAILURE_MESSAGE =
"Unable to decrypt some member information. Check your password, or have them reset theirs if they don't show here.";
currentMeeting: ServerMeeting | null = null;
newOrUpdatedMeeting: MeetingSetupInfo | null = null;
activeDid = "";
apiServer = "";
isDeleting = false;
isLoading = true;
showDeleteConfirm = false;
fullName = "";
get minDateTime() {
const now = new Date();
now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future
return this.formatDateForInput(now);
}
async created() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.fullName = settings.firstName || "";
await this.fetchCurrentMeeting();
this.isLoading = false;
}
isInCreateMode(): boolean {
return this.newOrUpdatedMeeting != null && this.currentMeeting == null;
}
isInEditOrCreateMode(): boolean {
return this.newOrUpdatedMeeting != null;
}
getDefaultExpirationTime(): string {
const date = new Date();
// Round up to the next hour
date.setMinutes(0);
date.setSeconds(0);
date.setMilliseconds(0);
date.setHours(date.getHours() + 1); // Round up to next hour
date.setHours(date.getHours() + 2); // Add 2 more hours
return this.formatDateForInput(date);
}
// Format a date object to YYYY-MM-DDTHH:mm format for datetime-local input
private formatDateForInput(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
blankMeeting(): MeetingSetupInfo {
return {
// no groupId yet
name: "",
expiresAt: this.getDefaultExpirationTime(),
userFullName: this.fullName,
password: (this.currentMeeting?.password as string) || "",
};
}
async fetchCurrentMeeting() {
try {
const headers = await getHeaders(this.activeDid);
const response = await this.axios.get(
this.apiServer + "/api/partner/groupOnboard",
{ headers },
);
if (response?.data?.data) {
this.currentMeeting = {
...response.data.data,
userFullName: this.fullName,
password: this.currentMeeting?.password || "",
};
} else {
// no meeting found
this.newOrUpdatedMeeting = this.blankMeeting();
}
} catch (error) {
// no meeting found
this.newOrUpdatedMeeting = this.blankMeeting();
}
}
async createMeeting() {
this.isLoading = true;
try {
if (!this.newOrUpdatedMeeting) {
throw Error(
"There was no meeting data to create. We should never get here.",
);
}
// Convert local time to UTC for comparison and server submission
const localExpiresAt = new Date(this.newOrUpdatedMeeting.expiresAt);
const now = new Date();
if (localExpiresAt <= now) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Invalid Time",
text: "Select a future time for the meeting expiration.",
},
5000,
);
return;
}
if (!this.newOrUpdatedMeeting.userFullName) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Invalid Name",
text: "Please enter your name.",
},
5000,
);
return;
}
if (!this.newOrUpdatedMeeting.password) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Invalid Password",
text: "Please enter a password.",
},
5000,
);
return;
}
// create content with user's name and DID encrypted with password
const content = {
name: this.newOrUpdatedMeeting.userFullName,
did: this.activeDid,
};
const encryptedContent = await encryptMessage(
JSON.stringify(content),
this.newOrUpdatedMeeting.password,
);
const headers = await getHeaders(this.activeDid);
const response = await this.axios.post(
this.apiServer + "/api/partner/groupOnboard",
{
name: this.newOrUpdatedMeeting.name,
expiresAt: localExpiresAt.toISOString(),
content: encryptedContent,
},
{ headers },
);
if (response.data && response.data.success) {
this.currentMeeting = {
...this.newOrUpdatedMeeting,
groupId: response.data.success.groupId,
};
this.newOrUpdatedMeeting = null;
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Meeting created.",
},
3000,
);
} else {
throw { response: response };
}
} catch (error) {
logConsoleAndDb(
"Error creating meeting: " + errorStringForLog(error),
true,
);
const errorMessage = serverMessageForUser(error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
errorMessage ||
"Failed to create meeting. Try reloading or submitting again.",
},
5000,
);
} finally {
this.isLoading = false;
}
}
formatExpirationTime(expiresAt: string): string {
const expiration = new Date(expiresAt); // Server time is in UTC
const now = new Date();
const diffHours = Math.round(
(expiration.getTime() - now.getTime()) / (1000 * 60 * 60),
);
if (diffHours < 0) {
return "Expired";
} else if (diffHours < 1) {
return "Less than an hour";
} else if (diffHours === 1) {
return "1 hour";
} else {
return `${diffHours} hours`;
}
}
confirmDelete() {
this.showDeleteConfirm = true;
}
async deleteMeeting() {
this.isDeleting = true;
try {
const headers = await getHeaders(this.activeDid);
await this.axios.delete(this.apiServer + "/api/partner/groupOnboard", {
headers,
});
this.currentMeeting = null;
this.newOrUpdatedMeeting = this.blankMeeting();
this.showDeleteConfirm = false;
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Meeting deleted successfully.",
},
3000,
);
} catch (error) {
console.error("Error deleting meeting:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: serverMessageForUser(error) || "Failed to delete meeting.",
},
5000,
);
} finally {
this.isDeleting = false;
}
}
startEditing() {
// Populate form with existing meeting data
if (this.currentMeeting) {
const localExpiresAt = new Date(this.currentMeeting.expiresAt);
this.newOrUpdatedMeeting = {
name: this.currentMeeting.name,
expiresAt: this.formatDateForInput(localExpiresAt),
userFullName: this.currentMeeting.userFullName || "",
password: this.currentMeeting.password || "",
};
} else {
console.error(
"There is no current meeting to edit. We should never get here.",
);
}
}
cancelEditing() {
// Reset form data
this.newOrUpdatedMeeting = null;
}
async updateMeeting() {
this.isLoading = true;
if (!this.newOrUpdatedMeeting) {
throw Error("There was no meeting data to update.");
}
try {
// Convert local time to UTC for comparison and server submission
const localExpiresAt = new Date(this.newOrUpdatedMeeting.expiresAt);
const now = new Date();
if (localExpiresAt <= now) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Invalid Time",
text: "Select a future time for the meeting expiration.",
},
5000,
);
return;
}
if (!this.newOrUpdatedMeeting.userFullName) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Invalid Name",
text: "Please enter your name.",
},
5000,
);
return;
}
if (!this.newOrUpdatedMeeting.password) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Invalid Password",
text: "Please enter a password.",
},
5000,
);
return;
}
// create content with user's name and DID encrypted with password
const content = {
name: this.newOrUpdatedMeeting.userFullName,
did: this.activeDid,
};
const encryptedContent = await encryptMessage(
JSON.stringify(content),
this.newOrUpdatedMeeting.password,
);
const headers = await getHeaders(this.activeDid);
const response = await this.axios.put(
this.apiServer + "/api/partner/groupOnboard",
{
// the groupId is in the currentMeeting but it's not necessary while users only have one meeting
name: this.newOrUpdatedMeeting.name,
expiresAt: localExpiresAt.toISOString(),
content: encryptedContent,
},
{ headers },
);
if (response.data && response.data.success) {
// Update the current meeting with only the necessary fields
this.currentMeeting = {
...this.newOrUpdatedMeeting,
groupId: (this.currentMeeting?.groupId as number) || -1,
};
this.newOrUpdatedMeeting = null;
} else {
throw { response: response };
}
} catch (error) {
logConsoleAndDb(
"Error updating meeting: " + errorStringForLog(error),
true,
);
const errorMessage = serverMessageForUser(error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
errorMessage ||
"Failed to update meeting. Try reloading or submitting again.",
},
5000,
);
} finally {
this.isLoading = false;
}
}
onboardMeetingMembersLink(): string {
if (this.currentMeeting) {
return `/onboard-meeting-members/${this.currentMeeting?.groupId}?password=${encodeURIComponent(
this.currentMeeting?.password || "",
)}`;
}
return "";
}
handleMembersError(message: string) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: message,
},
5000,
);
}
}
</script>

2
src/views/QuickActionBvcEndView.vue

@ -22,7 +22,7 @@
<div>
<h2 class="text-2xl m-2">Confirm</h2>
<div v-if="loadingConfirms" class="flex justify-center">
<fa icon="spinner" class="animate-spin" />
<fa icon="spinner" class="fa-spin-pulse" />
</div>
<div v-else-if="claimsToConfirm.length === 0">
There are no claims yet today for you to confirm.

109
src/views/TestView.vue

@ -23,6 +23,23 @@
<div>
<h2 class="text-xl font-bold mb-4">Notiwind Alerts</h2>
<button
@click="
this.$notify(
{
group: 'alert',
type: 'toast',
text: 'I\'m a toast. Without a timeout, I\'m stuck.',
},
5000,
)
"
class="font-bold capitalize bg-slate-900 text-white px-3 py-2 rounded-md mr-2"
>
Toast
</button>
<button
@click="
this.$notify(
@ -35,7 +52,7 @@
5000,
)
"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Info
</button>
@ -52,7 +69,7 @@
5000,
)
"
class="font-bold uppercase bg-emerald-600 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-emerald-600 text-white px-3 py-2 rounded-md mr-2"
>
Success
</button>
@ -69,7 +86,7 @@
5000,
)
"
class="font-bold uppercase bg-amber-600 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-amber-600 text-white px-3 py-2 rounded-md mr-2"
>
Warning
</button>
@ -86,10 +103,55 @@
5000,
)
"
class="font-bold uppercase bg-rose-600 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-rose-600 text-white px-3 py-2 rounded-md mr-2"
>
Danger
</button>
<button
@click="
this.$notify(
{
group: 'modal',
type: 'notification-permission',
},
-1,
)
"
class="font-bold capitalize bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Notif ON
</button>
<button
@click="
this.$notify(
{
group: 'modal',
type: 'notification-mute',
},
-1,
)
"
class="font-bold capitalize bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Notif MUTE
</button>
<button
@click="
this.$notify(
{
group: 'modal',
type: 'notification-off',
},
-1,
)
"
class="font-bold capitalize bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Notif OFF
</button>
</div>
<div class="mt-8">
@ -122,7 +184,7 @@
Register Passkey
<button
@click="register()"
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
>
Simplewebauthn
</button>
@ -132,13 +194,13 @@
Create JWT
<button
@click="createJwtSimplewebauthn()"
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
>
Simplewebauthn
</button>
<button
@click="createJwtNavigator()"
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
>
Navigator
</button>
@ -148,19 +210,19 @@
Verify New JWT
<button
@click="verifySimplewebauthn()"
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
>
Simplewebauthn
</button>
<button
@click="verifyWebCrypto()"
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
>
WebCrypto
</button>
<button
@click="verifyP256()"
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
>
p256 - broken
</button>
@ -168,11 +230,25 @@
<div v-else>Verify New JWT -- requires creation first</div>
<button
@click="verifyMyJwt()"
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
>
Verify Hard-Coded JWT
</button>
</div>
<div class="mt-8">
<h2 class="text-xl font-bold mb-4">Encryption & Decryption</h2>
See console for more output.
<div>
<button
@click="testEncryptionDecryption()"
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
>
Run Test
</button>
Result: {{ encryptionTestResult }}
</div>
</div>
</section>
</template>
@ -187,6 +263,8 @@ import QuickNav from "../components/QuickNav.vue";
import { AppString, NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import * as vcLib from "../libs/crypto/vc";
import * as cryptoLib from "../libs/crypto";
import {
PeerSetup,
verifyJwtP256,
@ -217,6 +295,9 @@ const TEST_PAYLOAD = {
export default class Help extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
// for encryption/decryption
encryptionTestResult?: boolean;
// for file import
fileName?: string;
@ -227,6 +308,8 @@ export default class Help extends Vue {
peerSetup?: PeerSetup;
userName?: string;
cryptoLib = cryptoLib;
async mounted() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
@ -301,6 +384,10 @@ export default class Help extends Vue {
this.credIdHex = account.passkeyCredIdHex;
}
public async testEncryptionDecryption() {
this.encryptionTestResult = await cryptoLib.testEncryptionDecryption();
}
public async createJwtSimplewebauthn() {
const account: AccountKeyInfo | undefined = await retrieveAccountMetadata(
this.activeDid || "",

4
test-playwright/00-noid-tests.spec.ts

@ -84,8 +84,8 @@ test('Check setting name & sharing info', async ({ page }) => {
await expect(page.getByText('Set Your Name')).toBeVisible();
await page.getByRole('textbox').fill('Me Test User');
await page.locator('button:has-text("Save")').click();
await expect(page.getByText('share another way')).toBeVisible();
await page.getByRole('button', { name: /share another way/ }).click();
await expect(page.getByText('share some other way')).toBeVisible();
await page.getByRole('button', { name: /share some other way/ }).click();
await expect(page.getByRole('button', { name: 'copy to clipboard' })).toBeVisible();
await page.getByRole('button', { name: 'copy to clipboard' }).click();
await expect(page.getByText('contact info was copied')).toBeVisible();

Loading…
Cancel
Save