Browse Source

fix linting (and change a little wording in onboarding page)

master
Trent Larson 4 days ago
parent
commit
64830eeb05
  1. 2
      src/components/ChoiceButtonDialog.vue
  2. 6
      src/components/GiftedDialog.vue
  3. 58
      src/components/MembersList.vue
  4. 5
      src/components/OfferDialog.vue
  5. 44
      src/libs/crypto/index.ts
  6. 1
      src/libs/endorserServer.ts
  7. 18
      src/router/index.ts
  8. 9
      src/views/ContactsView.vue
  9. 158
      src/views/OnboardMeetingListView.vue
  10. 20
      src/views/OnboardMeetingMembersView.vue
  11. 333
      src/views/OnboardMeetingSetupView.vue

2
src/components/ChoiceButtonDialog.vue

@ -117,7 +117,7 @@ export default class PromptDialog extends Vue {
onOption3: this.onOption3, onOption3: this.onOption3,
onCancel: this.onCancel, onCancel: this.onCancel,
} as NotificationIface, } as NotificationIface,
-1 -1,
); );
} }

6
src/components/GiftedDialog.vue

@ -90,7 +90,11 @@
import { Vue, Component, Prop } from "vue-facing-decorator"; import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app"; import { NotificationIface } from "@/constants/app";
import { createAndSubmitGive, didInfo, serverMessageForUser } from "@/libs/endorserServer"; import {
createAndSubmitGive,
didInfo,
serverMessageForUser,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util"; import * as libsUtil from "@/libs/util";
import { db, retrieveSettingsForActiveAccount } from "@/db/index"; import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import { Contact } from "@/db/tables/contacts"; import { Contact } from "@/db/tables/contacts";

58
src/components/MembersList.vue

@ -7,8 +7,11 @@
<!-- Members List --> <!-- Members List -->
<div v-else class="space-y-4"> <div v-else class="space-y-4">
<div v-for="member in decryptedMembers" :key="member.memberId" <div
class="p-4 bg-gray-50 rounded-lg"> v-for="member in decryptedMembers"
:key="member.memberId"
class="p-4 bg-gray-50 rounded-lg"
>
<h3 class="text-lg font-medium">{{ member.name }}</h3> <h3 class="text-lg font-medium">{{ member.name }}</h3>
<p class="text-sm text-gray-600">{{ member.did }}</p> <p class="text-sm text-gray-600">{{ member.did }}</p>
</div> </div>
@ -16,18 +19,29 @@
<p v-if="members.length === 0" class="text-center text-gray-500 py-4"> <p v-if="members.length === 0" class="text-center text-gray-500 py-4">
No members have joined this meeting yet No members have joined this meeting yet
</p> </p>
<p v-if="decryptedMembers.length < members.length" class="text-center text-red-600 py-4"> <p
{{ decryptFailureMessage || "Your password failed. Please go back and try again." }} 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> </p>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop } from 'vue-facing-decorator'; import { Component, Vue, Prop } from "vue-facing-decorator";
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from '@/db/index';
import { errorStringForLog, getHeaders, serverMessageForUser } from '@/libs/endorserServer'; import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "@/db/index";
import { decryptMessage } from '@/libs/crypto'; import {
errorStringForLog,
getHeaders,
serverMessageForUser,
} from "@/libs/endorserServer";
import { decryptMessage } from "@/libs/crypto";
interface Member { interface Member {
memberId: number; memberId: number;
@ -43,19 +57,20 @@ interface DecryptedMember {
@Component @Component
export default class MembersList extends Vue { export default class MembersList extends Vue {
@Prop({ required: true }) password!: string; @Prop({ required: true }) password!: string;
@Prop({ default: 'Your password failed. Please go back and try again.' }) decryptFailureMessage!: string; @Prop({ default: "Your password failed. Please go back and try again." })
decryptFailureMessage!: string;
decryptedMembers: DecryptedMember[] = []; decryptedMembers: DecryptedMember[] = [];
missingPassword = false; missingPassword = false;
isLoading = false; isLoading = false;
members: Member[] = []; members: Member[] = [];
activeDid = ''; activeDid = "";
apiServer = ''; apiServer = "";
async created() { async created() {
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || ''; this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || ''; this.apiServer = settings.apiServer || "";
await this.fetchMembers(); await this.fetchMembers();
} }
@ -65,7 +80,7 @@ export default class MembersList extends Vue {
const headers = await getHeaders(this.activeDid); const headers = await getHeaders(this.activeDid);
const response = await this.axios.get( const response = await this.axios.get(
`${this.apiServer}/api/partner/groupOnboardMembers/`, `${this.apiServer}/api/partner/groupOnboardMembers/`,
{ headers } { headers },
); );
if (response.data && response.data.data) { if (response.data && response.data.data) {
@ -73,8 +88,14 @@ export default class MembersList extends Vue {
await this.decryptMemberContents(); await this.decryptMemberContents();
} }
} catch (error) { } catch (error) {
logConsoleAndDb('Error fetching members: ' + errorStringForLog(error), true); logConsoleAndDb(
this.$emit('error', serverMessageForUser(error) || 'Failed to fetch members.'); "Error fetching members: " + errorStringForLog(error),
true,
);
this.$emit(
"error",
serverMessageForUser(error) || "Failed to fetch members.",
);
} finally { } finally {
this.isLoading = false; this.isLoading = false;
} }
@ -90,7 +111,10 @@ export default class MembersList extends Vue {
for (const member of this.members) { for (const member of this.members) {
try { try {
const decryptedContent = await decryptMessage(member.content, this.password); const decryptedContent = await decryptMessage(
member.content,
this.password,
);
const content = JSON.parse(decryptedContent); const content = JSON.parse(decryptedContent);
this.decryptedMembers.push({ this.decryptedMembers.push({

5
src/components/OfferDialog.vue

@ -83,7 +83,10 @@
import { Vue, Component, Prop } from "vue-facing-decorator"; import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app"; import { NotificationIface } from "@/constants/app";
import { createAndSubmitOffer, serverMessageForUser } from "@/libs/endorserServer"; import {
createAndSubmitOffer,
serverMessageForUser,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util"; import * as libsUtil from "@/libs/util";
import { retrieveSettingsForActiveAccount } from "@/db/index"; import { retrieveSettingsForActiveAccount } from "@/db/index";

44
src/libs/crypto/index.ts

@ -177,41 +177,41 @@ export async function encryptMessage(message: string, password: string) {
// Derive key from password using PBKDF2 // Derive key from password using PBKDF2
const keyMaterial = await crypto.subtle.importKey( const keyMaterial = await crypto.subtle.importKey(
'raw', "raw",
encoder.encode(password), encoder.encode(password),
'PBKDF2', "PBKDF2",
false, false,
['deriveBits', 'deriveKey'] ["deriveBits", "deriveKey"],
); );
const key = await crypto.subtle.deriveKey( const key = await crypto.subtle.deriveKey(
{ {
name: 'PBKDF2', name: "PBKDF2",
salt, salt,
iterations: ITERATIONS, iterations: ITERATIONS,
hash: 'SHA-256' hash: "SHA-256",
}, },
keyMaterial, keyMaterial,
{ name: 'AES-GCM', length: KEY_LENGTH }, { name: "AES-GCM", length: KEY_LENGTH },
false, false,
['encrypt'] ["encrypt"],
); );
// Encrypt the message // Encrypt the message
const encryptedContent = await crypto.subtle.encrypt( const encryptedContent = await crypto.subtle.encrypt(
{ {
name: 'AES-GCM', name: "AES-GCM",
iv iv,
}, },
key, key,
encoder.encode(message) encoder.encode(message),
); );
// Return a JSON structure with base64-encoded components // Return a JSON structure with base64-encoded components
const result = { const result = {
salt: arrayBufferToBase64(salt), salt: arrayBufferToBase64(salt),
iv: arrayBufferToBase64(iv), iv: arrayBufferToBase64(iv),
encrypted: arrayBufferToBase64(encryptedContent) encrypted: arrayBufferToBase64(encryptedContent),
}; };
return btoa(JSON.stringify(result)); return btoa(JSON.stringify(result));
@ -229,34 +229,34 @@ export async function decryptMessage(encryptedJson: string, password: string) {
// Derive the same key using PBKDF2 with the extracted salt // Derive the same key using PBKDF2 with the extracted salt
const keyMaterial = await crypto.subtle.importKey( const keyMaterial = await crypto.subtle.importKey(
'raw', "raw",
new TextEncoder().encode(password), new TextEncoder().encode(password),
'PBKDF2', "PBKDF2",
false, false,
['deriveBits', 'deriveKey'] ["deriveBits", "deriveKey"],
); );
const key = await crypto.subtle.deriveKey( const key = await crypto.subtle.deriveKey(
{ {
name: 'PBKDF2', name: "PBKDF2",
salt: saltArray, salt: saltArray,
iterations: ITERATIONS, iterations: ITERATIONS,
hash: 'SHA-256' hash: "SHA-256",
}, },
keyMaterial, keyMaterial,
{ name: 'AES-GCM', length: KEY_LENGTH }, { name: "AES-GCM", length: KEY_LENGTH },
false, false,
['decrypt'] ["decrypt"],
); );
// Decrypt the content // Decrypt the content
const decryptedContent = await crypto.subtle.decrypt( const decryptedContent = await crypto.subtle.decrypt(
{ {
name: 'AES-GCM', name: "AES-GCM",
iv: ivArray iv: ivArray,
}, },
key, key,
encryptedContent encryptedContent,
); );
// Convert the decrypted content back to a string // Convert the decrypted content back to a string
@ -289,7 +289,7 @@ export async function testEncryptionDecryption() {
// Test with wrong password // Test with wrong password
console.log("\nTesting with wrong password..."); console.log("\nTesting with wrong password...");
try { try {
const wrongDecrypted = await decryptMessage(encrypted, "wrongPassword"); await decryptMessage(encrypted, "wrongPassword");
console.log("Should not reach here"); console.log("Should not reach here");
} catch (error) { } catch (error) {
console.log("Correctly failed with wrong password ✅"); console.log("Correctly failed with wrong password ✅");

1
src/libs/endorserServer.ts

@ -680,6 +680,7 @@ export async function setPlanInCache(
* @param error that is thrown from an Endorser server call by Axios * @param error that is thrown from an Endorser server call by Axios
* @returns user-friendly message, or undefined if none found * @returns user-friendly message, or undefined if none found
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function serverMessageForUser(error: any) { export function serverMessageForUser(error: any) {
return ( return (
// this is how most user messages are returned // this is how most user messages are returned

18
src/router/index.ts

@ -180,19 +180,19 @@ const routes: Array<RouteRecordRaw> = [
component: () => import("../views/OfferDetailsView.vue"), component: () => import("../views/OfferDetailsView.vue"),
}, },
{ {
path: '/onboard-meeting-list', path: "/onboard-meeting-list",
name: 'onboard-meeting-list', name: "onboard-meeting-list",
component: () => import('../views/OnboardMeetingListView.vue'), component: () => import("../views/OnboardMeetingListView.vue"),
}, },
{ {
path: '/onboard-meeting-members/:groupId', path: "/onboard-meeting-members/:groupId",
name: 'onboard-meeting-members', name: "onboard-meeting-members",
component: () => import('../views/OnboardMeetingMembersView.vue'), component: () => import("../views/OnboardMeetingMembersView.vue"),
}, },
{ {
path: '/onboard-meeting-setup', path: "/onboard-meeting-setup",
name: 'onboard-meeting-setup', name: "onboard-meeting-setup",
component: () => import('../views/OnboardMeetingSetupView.vue'), component: () => import("../views/OnboardMeetingSetupView.vue"),
}, },
{ {
path: "/project/:id?", path: "/project/:id?",

9
src/views/ContactsView.vue

@ -37,11 +37,10 @@
<fa icon="chair" class="fa-fw text-2xl" /> <fa icon="chair" class="fa-fw text-2xl" />
</router-link> </router-link>
</span> </span>
<span v-else class="flex">
<span <span
v-else class="flex items-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
class="flex"
> >
<span class="flex items-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md">
<fa <fa
icon="envelope-open-text" icon="envelope-open-text"
class="fa-fw text-2xl" class="fa-fw text-2xl"
@ -53,7 +52,9 @@
" "
/> />
</span> </span>
<span class="flex items-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"> <span
class="flex items-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
>
<fa <fa
icon="chair" icon="chair"
class="fa-fw text-2xl" class="fa-fw text-2xl"

158
src/views/OnboardMeetingListView.vue

@ -34,7 +34,9 @@
<!-- Meeting List --> <!-- Meeting List -->
<div v-else class="space-y-4"> <div v-else class="space-y-4">
<div v-for="meeting in meetings" :key="meeting.groupId" <div
v-for="meeting in meetings"
:key="meeting.groupId"
class="p-4 bg-white rounded-lg shadow hover:shadow-md transition-shadow cursor-pointer" class="p-4 bg-white rounded-lg shadow hover:shadow-md transition-shadow cursor-pointer"
@click="promptPassword(meeting)" @click="promptPassword(meeting)"
> >
@ -47,7 +49,10 @@
</div> </div>
<!-- Password Dialog --> <!-- Password Dialog -->
<div v-if="showPasswordDialog" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4"> <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"> <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> <h3 class="text-lg font-medium mb-4">Enter Meeting Password</h3>
<input <input
@ -78,13 +83,19 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from 'vue-facing-decorator'; import { Component, Vue } from "vue-facing-decorator";
import QuickNav from '@/components/QuickNav.vue'; import { nextTick } from "vue";
import TopMessage from '@/components/TopMessage.vue'; import { Router } from "vue-router";
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from '@/db/index';
import { errorStringForLog, getHeaders, serverMessageForUser } from '@/libs/endorserServer'; import QuickNav from "@/components/QuickNav.vue";
import { encryptMessage } from '@/libs/crypto'; import TopMessage from "@/components/TopMessage.vue";
import { nextTick } from 'vue'; import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "@/db/index";
import {
errorStringForLog,
getHeaders,
serverMessageForUser,
} from "@/libs/endorserServer";
import { encryptMessage } from "@/libs/crypto";
interface Meeting { interface Meeting {
name: string; name: string;
@ -98,23 +109,26 @@ interface Meeting {
}, },
}) })
export default class OnboardMeetingListView extends Vue { export default class OnboardMeetingListView extends Vue {
$notify!: (notification: { group: string; type: string; title: string; text: string }, timeout?: number) => void; $notify!: (
notification: { group: string; type: string; title: string; text: string },
timeout?: number,
) => void;
activeDid = ''; activeDid = "";
apiServer = ''; apiServer = "";
attendingMeeting: Meeting | null = null; attendingMeeting: Meeting | null = null;
firstName = ''; firstName = "";
isLoading = false; isLoading = false;
meetings: Meeting[] = []; meetings: Meeting[] = [];
password = ''; password = "";
selectedMeeting: Meeting | null = null; selectedMeeting: Meeting | null = null;
showPasswordDialog = false; showPasswordDialog = false;
async created() { async created() {
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || ''; this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || ''; this.apiServer = settings.apiServer || "";
this.firstName = settings.firstName || ''; this.firstName = settings.firstName || "";
await this.fetchMeetings(); await this.fetchMeetings();
} }
@ -124,8 +138,8 @@ export default class OnboardMeetingListView extends Vue {
// get the meeting that the user is attending // get the meeting that the user is attending
const headers = await getHeaders(this.activeDid); const headers = await getHeaders(this.activeDid);
const response = await this.axios.get( const response = await this.axios.get(
this.apiServer + '/api/partner/groupOnboardMember', this.apiServer + "/api/partner/groupOnboardMember",
{ headers } { headers },
); );
if (response.data?.data) { if (response.data?.data) {
@ -134,8 +148,8 @@ export default class OnboardMeetingListView extends Vue {
// retrieve the meeting details // retrieve the meeting details
const headers2 = await getHeaders(this.activeDid); const headers2 = await getHeaders(this.activeDid);
const response2 = await this.axios.get( const response2 = await this.axios.get(
this.apiServer + '/api/partner/groupOnboard/' + attendingMeetingId, this.apiServer + "/api/partner/groupOnboard/" + attendingMeetingId,
{ headers: headers2 } { headers: headers2 },
); );
if (response2.data?.data) { if (response2.data?.data) {
@ -143,29 +157,35 @@ export default class OnboardMeetingListView extends Vue {
return; return;
} else { } else {
// this should never happen // this should never happen
logConsoleAndDb('Error fetching meeting for user after saying they are in one.', true); logConsoleAndDb(
"Error fetching meeting for user after saying they are in one.",
true,
);
} }
} }
const headers2 = await getHeaders(this.activeDid); const headers2 = await getHeaders(this.activeDid);
const response2 = await this.axios.get( const response2 = await this.axios.get(
this.apiServer + '/api/partner/groupsOnboarding', this.apiServer + "/api/partner/groupsOnboarding",
{ headers: headers2 } { headers: headers2 },
); );
if (response2.data?.data) { if (response2.data?.data) {
this.meetings = response2.data.data; this.meetings = response2.data.data;
} }
} catch (error) { } catch (error) {
logConsoleAndDb('Error fetching meetings: ' + errorStringForLog(error), true); logConsoleAndDb(
"Error fetching meetings: " + errorStringForLog(error),
true,
);
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error', title: "Error",
text: serverMessageForUser(error) || 'Failed to fetch meetings.', text: serverMessageForUser(error) || "Failed to fetch meetings.",
}, },
5000 5000,
); );
} finally { } finally {
this.isLoading = false; this.isLoading = false;
@ -173,7 +193,7 @@ export default class OnboardMeetingListView extends Vue {
} }
promptPassword(meeting: Meeting) { promptPassword(meeting: Meeting) {
this.password = ''; this.password = "";
this.selectedMeeting = meeting; this.selectedMeeting = meeting;
this.showPasswordDialog = true; this.showPasswordDialog = true;
nextTick(() => { nextTick(() => {
@ -185,7 +205,7 @@ export default class OnboardMeetingListView extends Vue {
} }
cancelPasswordDialog() { cancelPasswordDialog() {
this.password = ''; this.password = "";
this.selectedMeeting = null; this.selectedMeeting = null;
this.showPasswordDialog = false; this.showPasswordDialog = false;
} }
@ -193,7 +213,10 @@ export default class OnboardMeetingListView extends Vue {
async submitPassword() { async submitPassword() {
if (!this.selectedMeeting) { if (!this.selectedMeeting) {
// this should never happen // this should never happen
logConsoleAndDb('No meeting selected when prompting for password, which should never happen.', true); logConsoleAndDb(
"No meeting selected when prompting for password, which should never happen.",
true,
);
return; return;
} }
@ -201,35 +224,38 @@ export default class OnboardMeetingListView extends Vue {
// Create member data object // Create member data object
const memberData = { const memberData = {
name: this.firstName, name: this.firstName,
did: this.activeDid did: this.activeDid,
}; };
const memberDataString = JSON.stringify(memberData); const memberDataString = JSON.stringify(memberData);
const encryptedMemberData = await encryptMessage(memberDataString, this.password); const encryptedMemberData = await encryptMessage(
memberDataString,
this.password,
);
// Get headers for authentication // Get headers for authentication
const headers = await getHeaders(this.activeDid); const headers = await getHeaders(this.activeDid);
// Encrypt the member data // Encrypt the member data
const postResult = await this.axios.post( const postResult = await this.axios.post(
this.apiServer + '/api/partner/groupOnboardMember', this.apiServer + "/api/partner/groupOnboardMember",
{ {
groupId: this.selectedMeeting.groupId, groupId: this.selectedMeeting.groupId,
content: encryptedMemberData content: encryptedMemberData,
}, },
{ headers } { headers },
); );
if (postResult.data && postResult.data.success) { if (postResult.data && postResult.data.success) {
// Navigate to members view with password and groupId // Navigate to members view with password and groupId
this.$router.push({ (this.$router as Router).push({
name: 'onboard-meeting-members', name: "onboard-meeting-members",
params: { params: {
groupId: this.selectedMeeting.groupId.toString() groupId: this.selectedMeeting.groupId.toString(),
}, },
query: { query: {
password: this.password, password: this.password,
memberId: postResult.data.memberId memberId: postResult.data.memberId,
} },
}); });
this.cancelPasswordDialog(); this.cancelPasswordDialog();
@ -237,15 +263,19 @@ export default class OnboardMeetingListView extends Vue {
throw { response: postResult }; throw { response: postResult };
} }
} catch (error) { } catch (error) {
logConsoleAndDb('Error joining meeting: ' + errorStringForLog(error), true); logConsoleAndDb(
"Error joining meeting: " + errorStringForLog(error),
true,
);
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error', title: "Error",
text: serverMessageForUser(error) || 'Failed to join meeting.', text:
serverMessageForUser(error) || "You failed to join the meeting.",
}, },
5000 5000,
); );
} }
} }
@ -254,8 +284,8 @@ export default class OnboardMeetingListView extends Vue {
try { try {
const headers = await getHeaders(this.activeDid); const headers = await getHeaders(this.activeDid);
await this.axios.delete( await this.axios.delete(
this.apiServer + '/api/partner/groupOnboardMember', this.apiServer + "/api/partner/groupOnboardMember",
{ headers } { headers },
); );
this.attendingMeeting = null; this.attendingMeeting = null;
@ -263,23 +293,27 @@ export default class OnboardMeetingListView extends Vue {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'success', type: "success",
title: 'Success', title: "Success",
text: 'Successfully left the meeting.', text: "You left the meeting.",
}, },
5000 5000,
); );
} catch (error) { } catch (error) {
logConsoleAndDb('Error leaving meeting: ' + errorStringForLog(error), true); logConsoleAndDb(
"Error leaving meeting: " + errorStringForLog(error),
true,
);
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error', title: "Error",
text: serverMessageForUser(error) || 'Failed to leave meeting.', text:
serverMessageForUser(error) || "You failed to leave the meeting.",
}, },
5000 5000,
); );
} }
} }

20
src/views/OnboardMeetingMembersView.vue

@ -38,10 +38,12 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from 'vue-facing-decorator'; import { Component, Vue } from "vue-facing-decorator";
import QuickNav from '@/components/QuickNav.vue'; import { RouteLocation } from "vue-router";
import TopMessage from '@/components/TopMessage.vue';
import MembersList from '@/components/MembersList.vue'; import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import MembersList from "@/components/MembersList.vue";
@Component({ @Component({
components: { components: {
@ -51,23 +53,23 @@ import MembersList from '@/components/MembersList.vue';
}, },
}) })
export default class OnboardMeetingMembersView extends Vue { export default class OnboardMeetingMembersView extends Vue {
errorMessage = ''; errorMessage = "";
get groupId(): string { get groupId(): string {
return this.$route.params.groupId as string; return (this.$route as RouteLocation).params.groupId as string;
} }
get password(): string { get password(): string {
return this.$route.query.password as string; return (this.$route as RouteLocation).query.password as string;
} }
async created() { async created() {
if (!this.groupId) { if (!this.groupId) {
this.errorMessage = 'The group info is missing. Go back and try again.'; this.errorMessage = "The group info is missing. Go back and try again.";
return; return;
} }
if (!this.password) { if (!this.password) {
this.errorMessage = 'The password is missing. Go back and try again.'; this.errorMessage = "The password is missing. Go back and try again.";
return; return;
} }
} }

333
src/views/OnboardMeetingSetupView.vue

@ -22,7 +22,9 @@
title="Edit Meeting" title="Edit Meeting"
> >
<fa icon="pen" class="fa-fw" /> <fa icon="pen" class="fa-fw" />
<span class="sr-only">{{ isInCreateMode() ? 'Create Meeting' : 'Edit Meeting' }}</span> <span class="sr-only">{{
isInCreateMode() ? "Create Meeting" : "Edit Meeting"
}}</span>
</button> </button>
</div> </div>
<button <button
@ -33,28 +35,41 @@
title="Delete Meeting" title="Delete Meeting"
> >
<fa icon="trash-can" class="fa-fw" /> <fa icon="trash-can" class="fa-fw" />
<span class="sr-only">{{ isDeleting ? 'Deleting...' : 'Delete Meeting' }}</span> <span class="sr-only">{{
isDeleting ? "Deleting..." : "Delete Meeting"
}}</span>
</button> </button>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<p><strong>Name:</strong> {{ currentMeeting.name }}</p> <p><strong>Name:</strong> {{ currentMeeting.name }}</p>
<p><strong>Expires:</strong> {{ formatExpirationTime(currentMeeting.expiresAt) }}</p> <p>
<strong>Expires:</strong>
{{ formatExpirationTime(currentMeeting.expiresAt) }}
</p>
<div v-if="currentMeeting.password" class="mt-4"> <div v-if="currentMeeting.password" class="mt-4">
<p class="text-gray-600">Share the password with the people you want to onboard.</p> <p class="text-gray-600">
Share the password with the people you want to onboard.
</p>
</div> </div>
<div v-else class="text-red-600"> <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. Your copy of the password is not saved. Edit the meeting, or delete it
and create a new meeting.
</div> </div>
</div> </div>
</div> </div>
<!-- Delete Confirmation Dialog --> <!-- Delete Confirmation Dialog -->
<div v-if="showDeleteConfirm" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4"> <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"> <div class="bg-white rounded-lg p-6 max-w-sm w-full">
<h3 class="text-lg font-medium mb-4">Delete Meeting?</h3> <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> <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"> <div class="flex justify-between space-x-4">
<button <button
@click="showDeleteConfirm = false" @click="showDeleteConfirm = false"
@ -74,14 +89,27 @@
<!-- Create/Edit Meeting Form --> <!-- Create/Edit Meeting Form -->
<div <div
v-if="!isLoading && isInEditOrCreateMode() && newOrUpdatedMeeting != null /* duplicate check is for typechecks */" v-if="
!isLoading &&
isInEditOrCreateMode() &&
newOrUpdatedMeeting != null /* duplicate check is for typechecks */
"
class="mt-8" class="mt-8"
> >
<h2 class="text-2xl mb-4">{{ isInCreateMode() ? 'Create New Meeting' : 'Edit Meeting' }}</h2> <h2 class="text-2xl mb-4">
<!-- This is my first form. Not sure whether I like it or not; gotta see if the browser benefits extend to the native app. --> {{ isInCreateMode() ? "Create New Meeting" : "Edit Meeting" }}
<form @submit.prevent="isInCreateMode() ? createMeeting() : updateMeeting()" class="space-y-4"> </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> <div>
<label for="meetingName" class="block text-sm font-medium text-gray-700">Meeting Name</label> <label
for="meetingName"
class="block text-sm font-medium text-gray-700"
>Meeting Name</label
>
<input <input
id="meetingName" id="meetingName"
v-model="newOrUpdatedMeeting.name" v-model="newOrUpdatedMeeting.name"
@ -93,7 +121,11 @@
</div> </div>
<div> <div>
<label for="expirationTime" class="block text-sm font-medium text-gray-700">Meeting Expiration Time</label> <label
for="expirationTime"
class="block text-sm font-medium text-gray-700"
>Meeting Expiration Time</label
>
<input <input
id="expirationTime" id="expirationTime"
v-model="newOrUpdatedMeeting.expiresAt" v-model="newOrUpdatedMeeting.expiresAt"
@ -105,7 +137,9 @@
</div> </div>
<div> <div>
<label for="password" class="block text-sm font-medium text-gray-700">Meeting Password</label> <label for="password" class="block text-sm font-medium text-gray-700"
>Meeting Password</label
>
<input <input
id="password" id="password"
v-model="newOrUpdatedMeeting.password" v-model="newOrUpdatedMeeting.password"
@ -117,7 +151,9 @@
</div> </div>
<div> <div>
<label for="userName" class="block text-sm font-medium text-gray-700">Your Name</label> <label for="userName" class="block text-sm font-medium text-gray-700"
>Your Name</label
>
<input <input
id="userName" id="userName"
v-model="newOrUpdatedMeeting.userFullName" v-model="newOrUpdatedMeeting.userFullName"
@ -133,7 +169,15 @@
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" 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" :disabled="isLoading"
> >
{{ isLoading ? (isInCreateMode() ? 'Creating...' : 'Updating...' ) : (isInCreateMode() ? 'Create Meeting' : 'Update Meeting') }} {{
isLoading
? isInCreateMode()
? "Creating..."
: "Updating..."
: isInCreateMode()
? "Create Meeting"
: "Update Meeting"
}}
</button> </button>
<button <button
v-if="isInEditOrCreateMode()" v-if="isInEditOrCreateMode()"
@ -147,12 +191,15 @@
</div> </div>
<!-- Members Section --> <!-- Members Section -->
<div v-if="!isLoading && currentMeeting != null" class="mt-8 p-4 border rounded-lg bg-white shadow"> <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"> <div class="flex items-center justify-between mb-4">
<h2 class="text-2xl">Meeting Members</h2> <h2 class="text-2xl">Meeting Members</h2>
</div> </div>
<router-link <router-link
:to="`/onboard-meeting-members/${currentMeeting.groupId}?password=${encodeURIComponent(currentMeeting.password || '')}`" :to="onboardMeetingMembersLink()"
class="inline-block px-4 text-blue-600" class="inline-block px-4 text-blue-600"
target="_blank" target="_blank"
> >
@ -176,16 +223,20 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from 'vue-facing-decorator'; import { Component, Vue } from "vue-facing-decorator";
import QuickNav from '@/components/QuickNav.vue'; import QuickNav from "@/components/QuickNav.vue";
import TopMessage from '@/components/TopMessage.vue'; import TopMessage from "@/components/TopMessage.vue";
import MembersList from '@/components/MembersList.vue'; import MembersList from "@/components/MembersList.vue";
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from '@/db/index'; import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "@/db/index";
import { errorStringForLog, getHeaders, serverMessageForUser } from '@/libs/endorserServer'; import {
import { encryptMessage } from '@/libs/crypto'; errorStringForLog,
getHeaders,
serverMessageForUser,
} from "@/libs/endorserServer";
import { encryptMessage } from "@/libs/crypto";
interface ServerMeeting { interface ServerMeeting {
groupId: string; // from the server groupId: number; // from the server
name: string; // from the server name: string; // from the server
expiresAt: string; // from the server expiresAt: string; // from the server
userFullName?: string; // from the user's session userFullName?: string; // from the user's session
@ -207,16 +258,19 @@ interface MeetingSetupInfo {
}, },
}) })
export default class OnboardMeetingView extends Vue { export default class OnboardMeetingView extends Vue {
$notify!: (notification: { group: string; type: string; title: string; text: string }, timeout?: number) => void; $notify!: (
notification: { group: string; type: string; title: string; text: string },
timeout?: number,
) => void;
currentMeeting: ServerMeeting | null = null; currentMeeting: ServerMeeting | null = null;
newOrUpdatedMeeting: MeetingSetupInfo | null = null; newOrUpdatedMeeting: MeetingSetupInfo | null = null;
activeDid = ''; activeDid = "";
apiServer = ''; apiServer = "";
isDeleting = false; isDeleting = false;
isLoading = true; isLoading = true;
showDeleteConfirm = false; showDeleteConfirm = false;
fullName = ''; fullName = "";
get minDateTime() { get minDateTime() {
const now = new Date(); const now = new Date();
now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future
@ -225,9 +279,9 @@ export default class OnboardMeetingView extends Vue {
async created() { async created() {
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || ''; this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || ''; this.apiServer = settings.apiServer || "";
this.fullName = settings.firstName || ''; this.fullName = settings.firstName || "";
await this.fetchCurrentMeeting(); await this.fetchCurrentMeeting();
this.isLoading = false; this.isLoading = false;
@ -255,10 +309,10 @@ export default class OnboardMeetingView extends Vue {
// Format a date object to YYYY-MM-DDTHH:mm format for datetime-local input // Format a date object to YYYY-MM-DDTHH:mm format for datetime-local input
private formatDateForInput(date: Date): string { private formatDateForInput(date: Date): string {
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, '0'); const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`; return `${year}-${month}-${day}T${hours}:${minutes}`;
} }
@ -266,10 +320,10 @@ export default class OnboardMeetingView extends Vue {
blankMeeting(): MeetingSetupInfo { blankMeeting(): MeetingSetupInfo {
return { return {
// no groupId yet // no groupId yet
name: '', name: "",
expiresAt: this.getDefaultExpirationTime(), expiresAt: this.getDefaultExpirationTime(),
userFullName: this.fullName, userFullName: this.fullName,
password: this.currentMeeting?.password || "", password: (this.currentMeeting?.password as string) || "",
}; };
} }
@ -277,8 +331,8 @@ export default class OnboardMeetingView extends Vue {
try { try {
const headers = await getHeaders(this.activeDid); const headers = await getHeaders(this.activeDid);
const response = await this.axios.get( const response = await this.axios.get(
this.apiServer + '/api/partner/groupOnboard', this.apiServer + "/api/partner/groupOnboard",
{ headers } { headers },
); );
if (response?.data?.data) { if (response?.data?.data) {
@ -302,7 +356,9 @@ export default class OnboardMeetingView extends Vue {
try { try {
if (!this.newOrUpdatedMeeting) { if (!this.newOrUpdatedMeeting) {
throw Error('There was no meeting data to create. We should never get here.'); throw Error(
"There was no meeting data to create. We should never get here.",
);
} }
// Convert local time to UTC for comparison and server submission // Convert local time to UTC for comparison and server submission
@ -311,57 +367,59 @@ export default class OnboardMeetingView extends Vue {
if (localExpiresAt <= now) { if (localExpiresAt <= now) {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'warning', type: "warning",
title: 'Invalid Time', title: "Invalid Time",
text: 'Select a future time for the meeting expiration.', text: "Select a future time for the meeting expiration.",
}, },
5000 5000,
); );
return; return;
} }
if (!this.newOrUpdatedMeeting.userFullName) { if (!this.newOrUpdatedMeeting.userFullName) {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'warning', type: "warning",
title: 'Invalid Name', title: "Invalid Name",
text: 'Please enter your name.', text: "Please enter your name.",
}, },
5000 5000,
); );
return; return;
} }
if (!this.newOrUpdatedMeeting.password) { if (!this.newOrUpdatedMeeting.password) {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'warning', type: "warning",
title: 'Invalid Password', title: "Invalid Password",
text: 'Please enter a password.', text: "Please enter a password.",
}, },
5000 5000,
); );
return; return;
} }
// create content with user's name and DID encrypted with password // create content with user's name and DID encrypted with password
const content = { const content = {
name: this.newOrUpdatedMeeting.userFullName, name: this.newOrUpdatedMeeting.userFullName,
did: this.activeDid, did: this.activeDid,
}; };
const encryptedContent = await encryptMessage(JSON.stringify(content), this.newOrUpdatedMeeting.password); const encryptedContent = await encryptMessage(
JSON.stringify(content),
this.newOrUpdatedMeeting.password,
);
const headers = await getHeaders(this.activeDid); const headers = await getHeaders(this.activeDid);
const response = await this.axios.post( const response = await this.axios.post(
this.apiServer + '/api/partner/groupOnboard', this.apiServer + "/api/partner/groupOnboard",
{ {
name: this.newOrUpdatedMeeting.name, name: this.newOrUpdatedMeeting.name,
expiresAt: localExpiresAt.toISOString(), expiresAt: localExpiresAt.toISOString(),
content: encryptedContent, content: encryptedContent,
}, },
{ headers } { headers },
); );
if (response.data && response.data.success) { if (response.data && response.data.success) {
@ -373,27 +431,32 @@ export default class OnboardMeetingView extends Vue {
this.newOrUpdatedMeeting = null; this.newOrUpdatedMeeting = null;
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'success', type: "success",
title: 'Success', title: "Success",
text: 'Meeting created.', text: "Meeting created.",
}, },
3000 3000,
); );
} else { } else {
throw { response: response }; throw { response: response };
} }
} catch (error) { } catch (error) {
logConsoleAndDb('Error creating meeting: ' + errorStringForLog(error), true); logConsoleAndDb(
"Error creating meeting: " + errorStringForLog(error),
true,
);
const errorMessage = serverMessageForUser(error); const errorMessage = serverMessageForUser(error);
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error', title: "Error",
text: errorMessage || 'Failed to create meeting. Try reloading or submitting again.', text:
errorMessage ||
"Failed to create meeting. Try reloading or submitting again.",
}, },
5000 5000,
); );
} finally { } finally {
this.isLoading = false; this.isLoading = false;
@ -403,14 +466,16 @@ export default class OnboardMeetingView extends Vue {
formatExpirationTime(expiresAt: string): string { formatExpirationTime(expiresAt: string): string {
const expiration = new Date(expiresAt); // Server time is in UTC const expiration = new Date(expiresAt); // Server time is in UTC
const now = new Date(); const now = new Date();
const diffHours = Math.round((expiration.getTime() - now.getTime()) / (1000 * 60 * 60)); const diffHours = Math.round(
(expiration.getTime() - now.getTime()) / (1000 * 60 * 60),
);
if (diffHours < 0) { if (diffHours < 0) {
return 'Expired'; return "Expired";
} else if (diffHours < 1) { } else if (diffHours < 1) {
return 'Less than an hour'; return "Less than an hour";
} else if (diffHours === 1) { } else if (diffHours === 1) {
return '1 hour'; return "1 hour";
} else { } else {
return `${diffHours} hours`; return `${diffHours} hours`;
} }
@ -424,10 +489,9 @@ export default class OnboardMeetingView extends Vue {
this.isDeleting = true; this.isDeleting = true;
try { try {
const headers = await getHeaders(this.activeDid); const headers = await getHeaders(this.activeDid);
await this.axios.delete( await this.axios.delete(this.apiServer + "/api/partner/groupOnboard", {
this.apiServer + '/api/partner/groupOnboard', headers,
{ headers } });
);
this.currentMeeting = null; this.currentMeeting = null;
this.newOrUpdatedMeeting = this.blankMeeting(); this.newOrUpdatedMeeting = this.blankMeeting();
@ -435,23 +499,23 @@ export default class OnboardMeetingView extends Vue {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'success', type: "success",
title: 'Success', title: "Success",
text: 'Meeting deleted successfully.', text: "Meeting deleted successfully.",
}, },
3000 3000,
); );
} catch (error) { } catch (error) {
console.error('Error deleting meeting:', error); console.error("Error deleting meeting:", error);
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error', title: "Error",
text: serverMessageForUser(error) || 'Failed to delete meeting.', text: serverMessageForUser(error) || "Failed to delete meeting.",
}, },
5000 5000,
); );
} finally { } finally {
this.isDeleting = false; this.isDeleting = false;
@ -465,11 +529,13 @@ export default class OnboardMeetingView extends Vue {
this.newOrUpdatedMeeting = { this.newOrUpdatedMeeting = {
name: this.currentMeeting.name, name: this.currentMeeting.name,
expiresAt: this.formatDateForInput(localExpiresAt), expiresAt: this.formatDateForInput(localExpiresAt),
userFullName: this.currentMeeting.userFullName || '', userFullName: this.currentMeeting.userFullName || "",
password: this.currentMeeting.password || '', password: this.currentMeeting.password || "",
}; };
} else { } else {
console.error('There is no current meeting to edit. We should never get here.'); console.error(
"There is no current meeting to edit. We should never get here.",
);
} }
} }
@ -481,7 +547,7 @@ export default class OnboardMeetingView extends Vue {
async updateMeeting() { async updateMeeting() {
this.isLoading = true; this.isLoading = true;
if (!this.newOrUpdatedMeeting) { if (!this.newOrUpdatedMeeting) {
throw Error('There was no meeting data to update.'); throw Error("There was no meeting data to update.");
} }
try { try {
@ -491,36 +557,36 @@ export default class OnboardMeetingView extends Vue {
if (localExpiresAt <= now) { if (localExpiresAt <= now) {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'warning', type: "warning",
title: 'Invalid Time', title: "Invalid Time",
text: 'Select a future time for the meeting expiration.', text: "Select a future time for the meeting expiration.",
}, },
5000 5000,
); );
return; return;
} }
if (!this.newOrUpdatedMeeting.userFullName) { if (!this.newOrUpdatedMeeting.userFullName) {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'warning', type: "warning",
title: 'Invalid Name', title: "Invalid Name",
text: 'Please enter your name.', text: "Please enter your name.",
}, },
5000 5000,
); );
return; return;
} }
if (!this.newOrUpdatedMeeting.password) { if (!this.newOrUpdatedMeeting.password) {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'warning', type: "warning",
title: 'Invalid Password', title: "Invalid Password",
text: 'Please enter a password.', text: "Please enter a password.",
}, },
5000 5000,
); );
return; return;
} }
@ -529,56 +595,73 @@ export default class OnboardMeetingView extends Vue {
name: this.newOrUpdatedMeeting.userFullName, name: this.newOrUpdatedMeeting.userFullName,
did: this.activeDid, did: this.activeDid,
}; };
const encryptedContent = await encryptMessage(JSON.stringify(content), this.newOrUpdatedMeeting.password); const encryptedContent = await encryptMessage(
JSON.stringify(content),
this.newOrUpdatedMeeting.password,
);
const headers = await getHeaders(this.activeDid); const headers = await getHeaders(this.activeDid);
const response = await this.axios.put( const response = await this.axios.put(
this.apiServer + '/api/partner/groupOnboard', this.apiServer + "/api/partner/groupOnboard",
{ {
// the groupId is in the currentMeeting but it's not necessary while users only have one meeting // the groupId is in the currentMeeting but it's not necessary while users only have one meeting
name: this.newOrUpdatedMeeting.name, name: this.newOrUpdatedMeeting.name,
expiresAt: localExpiresAt.toISOString(), expiresAt: localExpiresAt.toISOString(),
content: encryptedContent, content: encryptedContent,
}, },
{ headers } { headers },
); );
if (response.data && response.data.success) { if (response.data && response.data.success) {
// Update the current meeting with only the necessary fields // Update the current meeting with only the necessary fields
this.currentMeeting = { this.currentMeeting = {
...this.newOrUpdatedMeeting, ...this.newOrUpdatedMeeting,
groupId: this.currentMeeting?.groupId || "", groupId: (this.currentMeeting?.groupId as number) || -1,
}; };
this.newOrUpdatedMeeting = null; this.newOrUpdatedMeeting = null;
} else { } else {
throw { response: response }; throw { response: response };
} }
} catch (error) { } catch (error) {
logConsoleAndDb('Error updating meeting: ' + errorStringForLog(error), true); logConsoleAndDb(
"Error updating meeting: " + errorStringForLog(error),
true,
);
const errorMessage = serverMessageForUser(error); const errorMessage = serverMessageForUser(error);
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error', title: "Error",
text: errorMessage || 'Failed to update meeting. Try reloading or submitting again.', text:
errorMessage ||
"Failed to update meeting. Try reloading or submitting again.",
}, },
5000 5000,
); );
} finally { } finally {
this.isLoading = false; this.isLoading = false;
} }
} }
onboardMeetingMembersLink(): string {
if (this.currentMeeting) {
return `/onboard-meeting-members/${this.currentMeeting?.groupId}?password=${encodeURIComponent(
this.currentMeeting?.password || "",
)}`;
}
return "";
}
handleMembersError(message: string) { handleMembersError(message: string) {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error', title: "Error",
text: message, text: message,
}, },
5000 5000,
); );
} }
} }

Loading…
Cancel
Save