Browse Source

make screen where user can create a group onboarding meeting

master
Trent Larson 5 days ago
parent
commit
2a23587c3b
  1. 4
      src/components/GiftedDialog.vue
  2. 6
      src/components/OfferDialog.vue
  3. 158
      src/libs/crypto/index.ts
  4. 85
      src/libs/endorserServer.ts
  5. 2
      src/main.ts
  6. 5
      src/router/index.ts
  7. 190
      src/views/ClaimReportCertificateView.vue
  8. 55
      src/views/ContactsView.vue
  9. 6
      src/views/NewEditProjectView.vue
  10. 355
      src/views/OnboardMeetingView.vue
  11. 2
      src/views/QuickActionBvcEndView.vue
  12. 54
      src/views/TestView.vue

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(
{

6
src/components/OfferDialog.vue

@ -83,7 +83,7 @@
import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app";
import { createAndSubmitOffer } from "@/libs/endorserServer";
import { createAndSubmitOffer, serverMessageForUser } from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { retrieveSettingsForActiveAccount } from "@/db/index";
@ -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 {
const wrongDecrypted = 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;
}
}

85
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,54 @@ 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
*/
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
*
* @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 +1126,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.";

2
src/main.ts

@ -23,6 +23,7 @@ import {
faCalendar,
faCamera,
faCaretDown,
faChair,
faCheck,
faChevronDown,
faChevronLeft,
@ -100,6 +101,7 @@ library.add(
faCalendar,
faCamera,
faCaretDown,
faChair,
faCheck,
faChevronDown,
faChevronLeft,

5
src/router/index.ts

@ -179,6 +179,11 @@ const routes: Array<RouteRecordRaw> = [
name: "offer-details",
component: () => import("../views/OfferDetailsView.vue"),
},
{
path: '/onboard-meeting',
name: 'onboard-meeting',
component: () => import('../views/OnboardMeetingView.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>

55
src/views/ContactsView.vue

@ -23,27 +23,48 @@
<!-- 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 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>
<router-link
:to="{ name: 'onboard-meeting' }"
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" />
</router-link>
</span>
<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"
class="flex"
>
<fa
<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="
danger(
'You must get registered before you can invite others.',
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
@ -587,6 +608,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(
{

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,

355
src/views/OnboardMeetingView.vue

@ -0,0 +1,355 @@
<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="existingMeeting" class="mt-8 p-4 border rounded-lg bg-white shadow">
<h2 class="text-2xl mb-4">Current Meeting</h2>
<div class="space-y-2">
<p><strong>Name:</strong> {{ existingMeeting.name }}</p>
<p><strong>Expires:</strong> {{ formatExpirationTime(existingMeeting.expiresAt) }}</p>
<p class="mt-4 text-sm text-gray-600">Share the the password with the people you want to onboard.</p>
</div>
<div class="flex justify-end mt-4">
<button
@click="confirmDelete"
class="flex items-center justify-center w-10 h-10 rounded-full bg-red-100 hover:bg-red-200 transition-colors duration-200"
:disabled="isDeleting"
:class="{ 'opacity-50 cursor-not-allowed': isDeleting }"
title="Delete Meeting"
>
<fa icon="trash-can" class="fa-fw text-red-600" />
<span class="sr-only">{{ isDeleting ? 'Deleting...' : 'Delete Meeting' }}</span>
</button>
</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 New Meeting Form -->
<div v-if="!existingMeeting && !isLoading" class="mt-8">
<h2 class="text-2xl mb-4">Create New Meeting</h2>
<form @submit.prevent="createMeeting" 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="currentMeeting.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="currentMeeting.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="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="currentMeeting.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 ? 'Creating...' : 'Create Meeting' }}
</button>
</form>
</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 { retrieveSettingsForActiveAccount } from '@/db/index';
import { getHeaders, serverMessageForUser } from '@/libs/endorserServer';
import { encryptMessage } from '@/libs/crypto';
interface Meeting {
groupId?: string;
name: string;
expiresAt: string;
}
interface NewMeeting extends Meeting {
userFullName: string;
}
@Component({
components: {
QuickNav,
TopMessage,
},
})
export default class OnboardMeetingView extends Vue {
$notify!: (notification: { group: string; type: string; title: string; text: string }, timeout?: number) => void;
existingMeeting: Meeting | null = null;
currentMeeting: NewMeeting = {
name: '',
expiresAt: this.getDefaultExpirationTime(),
userFullName: '',
};
password = '';
isLoading = false;
activeDid = '';
apiServer = '';
isDeleting = false;
showDeleteConfirm = false;
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.currentMeeting.userFullName = settings.firstName || '';
await this.fetchExistingMeeting();
}
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}`;
}
async fetchExistingMeeting() {
try {
const headers = await getHeaders(this.activeDid);
const response = await this.axios.get(
this.apiServer + '/api/partner/groupOnboard',
{ headers }
);
if (response.data && response.data.data) {
this.existingMeeting = response.data.data;
}
} catch (error) {
console.log('Error fetching existing meeting:', error);
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Error',
text: serverMessageForUser(error) || 'Failed to fetch existing meeting.',
},
);
}
}
async createMeeting() {
this.isLoading = true;
try {
// Convert local time to UTC for comparison and server submission
const localExpiresAt = new Date(this.currentMeeting.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;
}
// create content with user's name and DID encrypted with password
const content = {
name: this.currentMeeting.userFullName,
did: this.activeDid,
};
const encryptedContent = await encryptMessage(JSON.stringify(content), this.password);
const headers = await getHeaders(this.activeDid);
const response = await this.axios.post(
this.apiServer + '/api/partner/groupOnboard',
{
name: this.currentMeeting.name,
expiresAt: localExpiresAt.toISOString(),
content: encryptedContent,
},
{ headers }
);
if (response.data && response.data.success) {
this.existingMeeting = {
groupId: response.data.groupId,
name: this.currentMeeting.name,
expiresAt: localExpiresAt.toISOString(),
};
this.$notify(
{
group: 'alert',
type: 'success',
title: 'Success',
text: 'Meeting created.',
},
3000
);
} else {
throw new Error('Failed to create meeting due to unexpected response data: ' + JSON.stringify(response.data));
}
} catch (error) {
console.error('Error creating meeting:', error);
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.existingMeeting = null;
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;
}
}
}
</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.

54
src/views/TestView.vue

@ -35,7 +35,7 @@
5000,
)
"
class="font-bold uppercase bg-slate-900 text-white px-3 py-2 rounded-md mr-2"
class="font-bold capitalize bg-slate-900 text-white px-3 py-2 rounded-md mr-2"
>
Toast
</button>
@ -52,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>
@ -69,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>
@ -86,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>
@ -103,7 +103,7 @@
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>
@ -118,7 +118,7 @@
-1,
)
"
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"
>
Notif ON
</button>
@ -133,7 +133,7 @@
-1,
)
"
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"
>
Notif MUTE
</button>
@ -148,7 +148,7 @@
-1,
)
"
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"
>
Notif OFF
</button>
@ -184,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>
@ -194,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>
@ -210,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>
@ -230,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>
@ -248,6 +262,7 @@ import { Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import { AppString, NotificationIface } from "@/constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import * as cryptoLib from "@/libs/crypto";
import * as vcLib from "@/libs/crypto/vc";
import {
PeerSetup,
@ -279,6 +294,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;
@ -289,6 +307,8 @@ export default class Help extends Vue {
peerSetup?: PeerSetup;
userName?: string;
cryptoLib = cryptoLib;
async mounted() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
@ -363,6 +383,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 || "",

Loading…
Cancel
Save