diff --git a/README.md b/README.md index d5a0682..5836447 100644 --- a/README.md +++ b/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 diff --git a/src/components/ChoiceButtonDialog.vue b/src/components/ChoiceButtonDialog.vue new file mode 100644 index 0000000..2e8118a --- /dev/null +++ b/src/components/ChoiceButtonDialog.vue @@ -0,0 +1,152 @@ + + + diff --git a/src/components/GiftedDialog.vue b/src/components/GiftedDialog.vue index 2c28d76..d08b3fd 100644 --- a/src/components/GiftedDialog.vue +++ b/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( { diff --git a/src/components/MembersList.vue b/src/components/MembersList.vue new file mode 100644 index 0000000..32850aa --- /dev/null +++ b/src/components/MembersList.vue @@ -0,0 +1,481 @@ + + + diff --git a/src/components/OfferDialog.vue b/src/components/OfferDialog.vue index 2b038c2..8888de0 100644 --- a/src/components/OfferDialog.vue +++ b/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 ); } } diff --git a/src/libs/crypto/index.ts b/src/libs/crypto/index.ts index e9deff8..5f323d6 100644 --- a/src/libs/crypto/index.ts +++ b/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; + } +} diff --git a/src/libs/endorserServer.ts b/src/libs/endorserServer.ts index e78f5f0..45d0ab1 100644 --- a/src/libs/endorserServer.ts +++ b/src/libs/endorserServer.ts @@ -621,41 +621,6 @@ const planCache: LRUCache = 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, 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."; diff --git a/src/libs/util.ts b/src/libs/util.ts index 49206c9..4a6581f 100644 --- a/src/libs/util.ts +++ b/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, diff --git a/src/main.ts b/src/main.ts index afa709c..b0785d8 100644 --- a/src/main.ts +++ b/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, diff --git a/src/router/index.ts b/src/router/index.ts index 6c477d2..8b51445 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -183,6 +183,21 @@ const routes: Array = [ 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", diff --git a/src/views/ClaimReportCertificateView.vue b/src/views/ClaimReportCertificateView.vue new file mode 100644 index 0000000..0cfd08c --- /dev/null +++ b/src/views/ClaimReportCertificateView.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/src/views/ContactsView.vue b/src/views/ContactsView.vue index 2ec4cac..da35265 100644 --- a/src/views/ContactsView.vue +++ b/src/views/ContactsView.vue @@ -23,27 +23,50 @@
- - - - - + + + + + + + + + + + + + + {{ - shortDid(contact.did) + libsUtil.shortDid(contact.did) }}
@@ -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" + ); + } + } } diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 09a8cb2..dc212ce 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -366,6 +366,8 @@
+ + diff --git a/src/views/NewEditProjectView.vue b/src/views/NewEditProjectView.vue index 9a738fb..71bcce5 100644 --- a/src/views/NewEditProjectView.vue +++ b/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, diff --git a/src/views/OnboardMeetingListView.vue b/src/views/OnboardMeetingListView.vue new file mode 100644 index 0000000..35e2805 --- /dev/null +++ b/src/views/OnboardMeetingListView.vue @@ -0,0 +1,340 @@ + + + diff --git a/src/views/OnboardMeetingMembersView.vue b/src/views/OnboardMeetingMembersView.vue new file mode 100644 index 0000000..6618fc3 --- /dev/null +++ b/src/views/OnboardMeetingMembersView.vue @@ -0,0 +1,72 @@ + + + diff --git a/src/views/OnboardMeetingSetupView.vue b/src/views/OnboardMeetingSetupView.vue new file mode 100644 index 0000000..8b1c004 --- /dev/null +++ b/src/views/OnboardMeetingSetupView.vue @@ -0,0 +1,672 @@ + + + diff --git a/src/views/QuickActionBvcEndView.vue b/src/views/QuickActionBvcEndView.vue index 7973d7d..ca4acdd 100644 --- a/src/views/QuickActionBvcEndView.vue +++ b/src/views/QuickActionBvcEndView.vue @@ -22,7 +22,7 @@

Confirm

- +
There are no claims yet today for you to confirm. diff --git a/src/views/TestView.vue b/src/views/TestView.vue index 6de5b03..b62f500 100644 --- a/src/views/TestView.vue +++ b/src/views/TestView.vue @@ -23,6 +23,23 @@

Notiwind Alerts

+ + + @@ -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 @@ -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 @@ -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 + + + + + +
@@ -122,7 +184,7 @@ Register Passkey @@ -132,13 +194,13 @@ Create JWT @@ -148,19 +210,19 @@ Verify New JWT @@ -168,11 +230,25 @@
Verify New JWT -- requires creation first
+ +
+

Encryption & Decryption

+ See console for more output. +
+ + Result: {{ encryptionTestResult }} +
+
@@ -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 || "", diff --git a/test-playwright/00-noid-tests.spec.ts b/test-playwright/00-noid-tests.spec.ts index df87db2..3631e87 100644 --- a/test-playwright/00-noid-tests.spec.ts +++ b/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();