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