forked from jsnbuchanan/crowd-funder-for-time-pwa
fix: image server references and test configurations - Update image server references to use test server by default for local dev - Fix registration status checks in tests - Remove verbose console logging - Update environment configurations for consistent image server usage - Fix alert handling in contact registration tests - Clean up component lifecycle logging - Add clarifying comments about shared image server usage - Update playwright test configurations for better reliability This commit ensures consistent image server behavior across environments and improves test reliability by properly handling registration status checks and alerts.
523 lines
16 KiB
Vue
523 lines
16 KiB
Vue
<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>
|