feat: meeting members admission dialog #210

Merged
jose merged 27 commits from meeting-members-admission-dialog into master 2025-10-30 13:58:18 +00:00
2 changed files with 620 additions and 13 deletions
Showing only changes of commit 6fbc9c2a5b - Show all commits

View File

@@ -0,0 +1,458 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<div class="text-slate-900 text-center">
<h3 class="text-lg font-semibold leading-[1.25] mb-2">
Admit Pending Members
</h3>
<p class="text-sm mb-4">
The following members are waiting to be admitted to the meeting. You
can choose to admit them and optionally add them as contacts with
visibility settings.
</p>
<!-- Custom table area - you can customize this -->
<div v-if="shouldInitializeSelection" class="mb-4">
<table
class="w-full border-collapse border border-slate-300 text-sm text-start"
>
<thead v-if="pendingMembersData && pendingMembersData.length > 0">
<tr class="bg-slate-100 font-medium">
<th class="border border-slate-300 px-3 py-2">
<label class="flex items-center gap-2">
<input
type="checkbox"
:checked="isAllSelected"
:indeterminate="isIndeterminate"
@change="toggleSelectAll"
/>
Select All
</label>
</th>
</tr>
</thead>
<tbody>
<!-- Dynamic data from MembersList -->
<tr v-if="!pendingMembersData || pendingMembersData.length === 0">
<td
class="border border-slate-300 px-3 py-2 text-center italic text-gray-500"
>
No pending members to admit
</td>
</tr>
<tr
v-for="member in pendingMembersData || []"
:key="member.member.memberId"
>
<td class="border border-slate-300 px-3 py-2">
<div class="flex items-center justify-between gap-2">
<label class="flex items-center gap-2">
<input
type="checkbox"
:checked="isMemberSelected(member.did)"
@change="toggleMemberSelection(member.did)"
/>
{{ member.name || SOMEONE_UNNAMED }}
</label>
<!-- Contact indicator - only show if they are already a contact -->
<font-awesome
v-if="member.isContact"
icon="user-circle"
class="fa-fw ms-auto text-slate-400 cursor-pointer hover:text-slate-600"
@click="showContactInfo"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="space-y-2">
<button
v-if="pendingMembersData && pendingMembersData.length > 0"
:disabled="!hasSelectedMembers"
:class="[
'block w-full text-center text-md font-bold uppercase px-2 py-2 rounded-md',
hasSelectedMembers
? 'bg-green-600 text-white cursor-pointer'
: 'bg-slate-400 text-slate-200 cursor-not-allowed',
]"
@click="admitAndSetVisibility"
>
Admit Pending + Add Contacts
</button>
<button
v-if="pendingMembersData && pendingMembersData.length > 0"
:disabled="!hasSelectedMembers"
:class="[
'block w-full text-center text-md font-bold uppercase px-2 py-2 rounded-md',
hasSelectedMembers
? 'bg-blue-600 text-white cursor-pointer'
: 'bg-slate-400 text-slate-200 cursor-not-allowed',
]"
@click="admitOnly"
>
Admit Pending Only
</button>
<button
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
@click="cancel"
>
Maybe Later
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { SOMEONE_UNNAMED } from "@/constants/entities";
import { setVisibilityUtil, getHeaders } from "@/libs/endorserServer";
import { createNotifyHelpers } from "@/utils/notify";
interface PendingMemberData {
did: string;
name: string;
isContact: boolean;
member: {
memberId: string;
};
}
@Component({
mixins: [PlatformServiceMixin],
})
export default class AdmitPendingMembersDialog extends Vue {
@Prop({ default: false }) visible!: boolean;
@Prop({ default: () => [] }) pendingMembersData!: PendingMemberData[];
@Prop({ default: "" }) activeDid!: string;
@Prop({ default: "" }) apiServer!: string;
// Vue notification system
$notify!: (
notification: { group: string; type: string; title: string; text: string },
timeout?: number,
) => void;
// Notification system
notify!: ReturnType<typeof createNotifyHelpers>;
// Component state
selectedMembers: string[] = [];
selectionInitialized = false;
// Constants
// In Vue templates, imported constants need to be explicitly made available to the template
readonly SOMEONE_UNNAMED = SOMEONE_UNNAMED;
get hasSelectedMembers() {
return this.selectedMembers.length > 0;
}
get isAllSelected() {
if (!this.pendingMembersData || this.pendingMembersData.length === 0)
return false;
return this.pendingMembersData.every((member) =>
this.selectedMembers.includes(member.did),
);
}
get isIndeterminate() {
if (!this.pendingMembersData || this.pendingMembersData.length === 0)
return false;
const selectedCount = this.pendingMembersData.filter((member) =>
this.selectedMembers.includes(member.did),
).length;
return selectedCount > 0 && selectedCount < this.pendingMembersData.length;
}
get shouldInitializeSelection() {
// This method will initialize selection when the dialog opens
if (!this.selectionInitialized) {
this.initializeSelection();
this.selectionInitialized = true;
}
return true;
}
created() {
this.notify = createNotifyHelpers(this.$notify);
}
initializeSelection() {
// Reset selection when dialog opens
this.selectedMembers = [];
// Select all by default
this.selectedMembers = this.pendingMembersData.map((member) => member.did);
}
resetSelection() {
this.selectedMembers = [];
this.selectionInitialized = false;
}
toggleSelectAll() {
if (!this.pendingMembersData || this.pendingMembersData.length === 0)
return;
if (this.isAllSelected) {
// Deselect all
this.selectedMembers = [];
} else {
// Select all
this.selectedMembers = this.pendingMembersData.map(
(member) => member.did,
);
}
}
toggleMemberSelection(memberDid: string) {
const index = this.selectedMembers.indexOf(memberDid);
if (index > -1) {
this.selectedMembers.splice(index, 1);
} else {
this.selectedMembers.push(memberDid);
}
}
isMemberSelected(memberDid: string) {
return this.selectedMembers.includes(memberDid);
}
async admitAndSetVisibility() {
try {
const selectedMembers = this.pendingMembersData.filter((member) =>
this.selectedMembers.includes(member.did),
);
let admittedCount = 0;
let contactAddedCount = 0;
let visibilitySetCount = 0;
for (const member of selectedMembers) {
try {
// First, admit the member
await this.admitMember(member);
admittedCount++;
// If they're not a contact yet, add them as a contact
if (!member.isContact) {
await this.addAsContact(member);
contactAddedCount++;
}
// Set their seesMe to true
await this.updateContactVisibility(member.did, true);
visibilitySetCount++;
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error processing member ${member.did}:`, error);
// Continue with other members even if one fails
}
}
// Show success notification
this.$notify(
{
group: "alert",
type: "success",
title: "Members Admitted Successfully",
text: `Admitted ${admittedCount} member${admittedCount === 1 ? "" : "s"}, added ${contactAddedCount} as contact${contactAddedCount === 1 ? "" : "s"}, and set visibility for ${visibilitySetCount} member${visibilitySetCount === 1 ? "" : "s"}.`,
},
5000,
);
// Emit success event
this.$emit("success", {
admittedCount,
contactAddedCount,
visibilitySetCount,
});
this.close();
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error admitting members:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to admit some members. Please try again.",
},
5000,
);
}
}
async admitOnly() {
try {
const selectedMembers = this.pendingMembersData.filter((member) =>
this.selectedMembers.includes(member.did),
);
let admittedCount = 0;
for (const member of selectedMembers) {
try {
// Just admit the member
await this.admitMember(member);
admittedCount++;
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error admitting member ${member.did}:`, error);
// Continue with other members even if one fails
}
}
// Show success notification
this.$notify(
{
group: "alert",
type: "success",
title: "Members Admitted Successfully",
text: `Admitted ${admittedCount} member${admittedCount === 1 ? "" : "s"} to the meeting.`,
},
5000,
);
// Emit success event
this.$emit("success", {
admittedCount,
contactAddedCount: 0,
visibilitySetCount: 0,
});
this.close();
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error admitting members:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to admit some members. Please try again.",
},
5000,
);
}
}
async admitMember(member: {
did: string;
name: string;
member: { memberId: string };
}) {
try {
const headers = await getHeaders(this.activeDid);
await this.axios.put(
`${this.apiServer}/api/partner/groupOnboardMember/${member.member.memberId}`,
{ admitted: true },
{ headers },
);
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error admitting member:", err);
throw err;
}
}
async addAsContact(member: { did: string; name: string }) {
try {
const newContact = {
did: member.did,
name: member.name,
};
await this.$insertContact(newContact);
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error adding contact:", err);
if (err instanceof Error && err.message?.indexOf("already exists") > -1) {
// Contact already exists, continue
} else {
throw err; // Re-throw if it's not a duplicate error
}
}
}
async updateContactVisibility(did: string, seesMe: boolean) {
try {
// Get the contact object
const contact = await this.$getContact(did);
if (!contact) {
throw new Error(`Contact not found for DID: ${did}`);
}
// Use the proper API to set visibility on the server
const result = await setVisibilityUtil(
this.activeDid,
this.apiServer,
this.axios,
contact,
seesMe,
);
if (!result.success) {
throw new Error(result.error || "Failed to set visibility");
}
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error updating contact visibility:", err);
throw err;
}
}
showContactInfo() {
this.$notify(
{
group: "alert",
type: "info",
title: "Contact Info",
text: "This user is already your contact, but they are not yet admitted to the meeting.",
},
5000,
);
}
close() {
this.resetSelection();
this.$emit("close");
}
cancel() {
this.close();
}
}
</script>
<style scoped>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog {
background: white;
border-radius: 8px;
padding: 24px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
</style>

View File

@@ -177,6 +177,16 @@
</div>
</div>
<!-- Admit Pending Members Dialog Component -->
<AdmitPendingMembersDialog
:visible="showAdmitPendingDialog"
:pending-members-data="pendingMembersData"
:active-did="activeDid"
:api-server="apiServer"
@close="closeAdmitPendingDialog"
@success="onAdmitPendingSuccess"
/>
<!-- Set Visibility Dialog Component -->
<SetBulkVisibilityDialog
:visible="showSetVisibilityDialog"
@@ -208,6 +218,7 @@ import {
} from "@/constants/notifications";
import { SOMEONE_UNNAMED } from "@/constants/entities";
import SetBulkVisibilityDialog from "./SetBulkVisibilityDialog.vue";
import AdmitPendingMembersDialog from "./AdmitPendingMembersDialog.vue";
interface Member {
admitted: boolean;
@@ -225,6 +236,7 @@ interface DecryptedMember {
@Component({
components: {
SetBulkVisibilityDialog,
AdmitPendingMembersDialog,
},
mixins: [PlatformServiceMixin],
})
@@ -253,6 +265,17 @@ export default class MembersList extends Vue {
activeDid = "";
apiServer = "";
// Admit Pending Members Dialog state
showAdmitPendingDialog = false;
pendingMembersData: Array<{
did: string;
name: string;
isContact: boolean;
member: { memberId: string };
}> = [];
admitDialogDismissed = false;
isManualRefresh = false;
// Set Visibility Dialog state
showSetVisibilityDialog = false;
visibilityDialogMembers: Array<{
@@ -296,8 +319,13 @@ export default class MembersList extends Vue {
// Start auto-refresh
this.startAutoRefresh();
// Check if we should show the visibility dialog on initial load
this.checkAndShowVisibilityDialog();
// Check if we should show the admit pending members dialog first
this.checkAndShowAdmitPendingDialog();
// If no pending members, check for visibility dialog
if (!this.showAdmitPendingDialog) {
this.checkAndShowVisibilityDialog();
}
}
async refreshData() {
@@ -305,8 +333,13 @@ export default class MembersList extends Vue {
await this.loadContacts();
await this.fetchMembers();
// Check if we should show the visibility dialog after refresh
this.checkAndShowVisibilityDialog();
// Check if we should show the admit pending members dialog first
this.checkAndShowAdmitPendingDialog();
// If no pending members, check for visibility dialog
if (!this.showAdmitPendingDialog) {
this.checkAndShowVisibilityDialog();
}
}
async fetchMembers() {
@@ -463,6 +496,26 @@ export default class MembersList extends Vue {
return this.contacts.find((contact) => contact.did === did);
}
getPendingMembers() {
return this.decryptedMembers
.filter((member) => {
// Exclude the current user
if (member.did === this.activeDid) {
return false;
}
// Only include non-admitted members
return !member.member.admitted;
})
.map((member) => ({
did: member.did,
name: member.name,
isContact: !!this.getContactFor(member.did),
member: {
memberId: member.member.memberId.toString(),
},
}));
}
getMembersForVisibility() {
return this.decryptedMembers
.filter((member) => {
@@ -492,7 +545,8 @@ export default class MembersList extends Vue {
* Check if we should show the visibility dialog
* Returns true if there are members for visibility and either:
* - This is the first time (no previous members tracked), OR
* - New members have been added since last check (not removed)
* - New members have been added since last check (not removed), OR
* - This is a manual refresh (isManualRefresh flag is set)
*/
shouldShowVisibilityDialog(): boolean {
const currentMembers = this.getMembersForVisibility();
@@ -506,6 +560,11 @@ export default class MembersList extends Vue {
return true;
}
// If this is a manual refresh, always show dialog if there are members
if (this.isManualRefresh) {
return true;
}
// Check if new members have been added (not just any change)
const currentMemberIds = currentMembers.map((m) => m.did);
const previousMemberIds = this.previousVisibilityMembers;
@@ -527,6 +586,31 @@ export default class MembersList extends Vue {
this.previousVisibilityMembers = currentMembers.map((m) => m.did);
}
/**
* Check if we should show the admit pending members dialog
*/
shouldShowAdmitPendingDialog(): boolean {
// Don't show if already dismissed
if (this.admitDialogDismissed) {
return false;
}
const pendingMembers = this.getPendingMembers();
return pendingMembers.length > 0;
}
/**
* Show the admit pending members dialog if conditions are met
*/
checkAndShowAdmitPendingDialog() {
if (this.shouldShowAdmitPendingDialog()) {
this.showAdmitPendingDialogMethod();
} else {
// Ensure dialog state is false when no pending members
this.showAdmitPendingDialog = false;
}
}
/**
* Show the visibility dialog if conditions are met
*/
@@ -675,6 +759,24 @@ export default class MembersList extends Vue {
}
}
showAdmitPendingDialogMethod() {
// Filter members to show only pending (non-admitted) members
const pendingMembers = this.getPendingMembers();
// Only show dialog if there are pending members
if (pendingMembers.length === 0) {
this.showAdmitPendingDialog = false;
return;
}
// Pause auto-refresh when dialog opens
this.stopAutoRefresh();
// Open the dialog directly
this.pendingMembersData = pendingMembers;
this.showAdmitPendingDialog = true;
}
showSetBulkVisibilityDialog() {
// Filter members to show only those who need visibility set
const membersForVisibility = this.getMembersForVisibility();
@@ -682,6 +784,9 @@ export default class MembersList extends Vue {
// Pause auto-refresh when dialog opens
this.stopAutoRefresh();
// Reset manual refresh flag when showing visibility dialog
this.isManualRefresh = false;
// Open the dialog directly
this.visibilityDialogMembers = membersForVisibility;
this.showSetVisibilityDialog = true;
@@ -717,28 +822,72 @@ export default class MembersList extends Vue {
}
}
manualRefresh() {
async manualRefresh() {
// Clear existing auto-refresh interval
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval);
this.autoRefreshInterval = null;
}
// Trigger immediate refresh and restart timer
this.refreshData();
this.startAutoRefresh();
// Set manual refresh flag
this.isManualRefresh = true;
// Reset the dismissed flag on manual refresh
this.admitDialogDismissed = false;
// Always show dialog on manual refresh if there are members for visibility
if (this.getMembersForVisibility().length > 0) {
this.showSetBulkVisibilityDialog();
// Trigger immediate refresh
await this.refreshData();
// Only start auto-refresh if no dialogs are showing
if (!this.showAdmitPendingDialog && !this.showSetVisibilityDialog) {
this.startAutoRefresh();
}
}
// Admit Pending Members Dialog methods
async closeAdmitPendingDialog() {
this.showAdmitPendingDialog = false;
this.pendingMembersData = [];
this.admitDialogDismissed = true;
// Handle manual refresh flow
if (this.isManualRefresh) {
await this.handleManualRefreshFlow();
this.isManualRefresh = false;
} else {
// Normal flow: refresh data and resume auto-refresh
this.refreshData();
this.startAutoRefresh();
}
}
async handleManualRefreshFlow() {
// Refresh data to reflect any changes made in the admit dialog
await this.refreshData();
// Use the same logic as normal flow to check for visibility dialog
this.checkAndShowVisibilityDialog();
// If no visibility dialog was shown, resume auto-refresh
if (!this.showSetVisibilityDialog) {
this.startAutoRefresh();
}
}
async onAdmitPendingSuccess(_result: {
admittedCount: number;
contactAddedCount: number;
visibilitySetCount: number;
}) {
// After admitting pending members, close the admit dialog
// The visibility dialog will be handled by the closeAdmitPendingDialog flow
await this.closeAdmitPendingDialog();
}
// Set Visibility Dialog methods
closeSetVisibilityDialog() {
this.showSetVisibilityDialog = false;
this.visibilityDialogMembers = [];
// Refresh data when dialog is closed
// Refresh data when dialog is closed to reflect any changes made
this.refreshData();
// Resume auto-refresh when dialog is closed
this.startAutoRefresh();