Matthew Raymer
2 days ago
20 changed files with 2399 additions and 112 deletions
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
Loading…
Reference in new issue