<template> <div class="space-y-4"> <!-- Loading State --> <div v-if="isLoading" class="mt-16 text-center text-4xl bg-slate-400 text-white w-14 py-2.5 rounded-full mx-auto" > <fa icon="spinner" class="fa-spin-pulse" /> </div> <!-- Members List --> <div v-else> <div class="text-center text-red-600 py-4"> {{ decryptionErrorMessage() }} </div> <div v-if="missingMyself" class="py-4 text-red-600"> You are not currently admitted by the organizer. </div> <div v-if="!firstName" class="py-4 text-red-600"> Your name is not set, so others may not recognize you. Reload this page to set it. </div> <div> <span v-if="membersToShow().length > 0 && showOrganizerTools && isOrganizer" class="inline-flex items-center flex-wrap" > <span class="inline-flex items-center"> • Click <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> / <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 v-if="membersToShow().length > 0" class="inline-flex items-center" > • Click <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 class="flex justify-center"> <!-- always have at least one refresh button even without members in case the organizer changes the password --> <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="mt-2 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="membersToShow().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; } interface DecryptedMember { member: Member; name: string; did: string; isRegistered: boolean; } @Component export default class MembersList extends Vue { $notify!: (notification: NotificationIface, timeout?: number) => void; libsUtil = libsUtil; @Prop({ required: true }) password!: string; @Prop({ default: false }) showOrganizerTools!: boolean; decryptedMembers: DecryptedMember[] = []; firstName = ""; isLoading = true; isOrganizer = false; members: Member[] = []; missingPassword = false; missingMyself = false; activeDid = ""; apiServer = ""; contacts: Array<Contact> = []; async created() { const settings = await retrieveSettingsForActiveAccount(); this.activeDid = settings.activeDid || ""; this.apiServer = settings.apiServer || ""; this.firstName = settings.firstName || ""; await this.fetchMembers(); await this.loadContacts(); } async fetchMembers() { try { this.isLoading = true; 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, isRegistered: !!content.isRegistered, }); 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; } decryptionErrorMessage(): string { if (this.isOrganizer) { if (this.decryptedMembers.length < this.members.length) { return "Some members have data that cannot be decrypted with that password."; } else { // the lists must be equal return ""; } } else { // non-organizers should only see problems if the first (organizer) member is not decrypted if ( this.decryptedMembers.length === 0 || this.decryptedMembers[0].member.memberId !== this.members[0].memberId ) { return "Your password is not the same as the organizer. Reload or have them check their password."; } else { // the first (organizer) member was decrypted OK return ""; } } } 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 in Time Safari and to 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(decrMember: DecryptedMember) { const contact = this.getContactFor(decrMember.did); if (!decrMember.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(decrMember); // After adding as contact, proceed with admission await this.toggleAdmission(decrMember); }, 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? If they are not a contact, you will not know their name after this meeting.", yesText: "Continue", onYes: async () => { await this.toggleAdmission(decrMember); }, onCancel: async () => { // Do nothing, effectively canceling the operation }, }, -1, ); }, }, -1, ); } else { // If already a contact, proceed directly with admission this.toggleAdmission(decrMember); } } async toggleAdmission(decrMember: DecryptedMember) { try { const headers = await getHeaders(this.activeDid); await this.axios.put( `${this.apiServer}/api/partner/groupOnboardMember/${decrMember.member.memberId}`, { admitted: !decrMember.member.admitted }, { headers }, ); // Update local state decrMember.member.admitted = !decrMember.member.admitted; const oldContact = this.getContactFor(decrMember.did); // if admitted, now register that user if they are not registered if ( decrMember.member.admitted && !decrMember.isRegistered && !oldContact?.registered ) { const contactOldOrNew: Contact = oldContact || { did: decrMember.did, name: decrMember.name, }; try { const result = await register( this.activeDid, this.apiServer, this.axios, contactOldOrNew, ); if (result.success) { decrMember.isRegistered = true; if (oldContact) { await db.contacts.update(decrMember.did, { registered: true }); oldContact.registered = true; } this.$notify( { group: "alert", type: "success", title: "Registered", text: "Besides being admitted, they were also registered.", }, 3000, ); } else { throw result; } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { // registration failure is likely explained by a message from the server const additionalInfo = serverMessageForUser(error) || error?.error || ""; this.$notify( { group: "alert", type: "warning", title: "Registration failed", text: "They were admitted to the meeting. However, registration failed. You can register them from the contacts screen. " + additionalInfo, }, 12000, ); } } } 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>