<template> <QuickNav selected="Invite" /> <TopMessage /> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <!-- Back --> <div class="text-lg text-center font-light relative px-7"> <h1 class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" @click="$router.back()" > <fa icon="chevron-left" class="fa-fw"></fa> </h1> </div> <!-- Heading --> <h1 class="text-4xl text-center font-light">Invitations</h1> <!-- New Project --> <button v-if="isRegistered" class="fixed right-6 top-12 text-center text-4xl leading-none bg-blue-600 text-white w-14 py-2.5 rounded-full" @click="createInvite()" > <fa icon="plus" class="fa-fw"></fa> </button> <InviteDialog ref="inviteDialog" /> <!-- Invites Table --> <div v-if="invites.length" class="mt-6"> <table class="min-w-full bg-white"> <thead> <tr> <th class="py-2">ID</th> <th class="py-2">Notes</th> <th class="py-2">Expires At</th> <th class="py-2">Redeemed By</th> </tr> </thead> <tbody> <tr v-for="invite in invites" :key="invite.inviteIdentifier" class="border-t" > <td class="py-2 text-center text-blue-500" @click="copyInviteAndNotify(invite.inviteIdentifier, invite.jwt)" title="{{ inviteLink(invite.jwt) }}" > {{ getTruncatedInviteId(invite.inviteIdentifier) }} </td> <td class="py-2 text-left" :data-testId="inviteLink(invite.jwt)"> {{ invite.notes }} </td> <td class="py-2 text-center"> {{ invite.expiresAt.substring(0, 10) }} </td> <td class="py-2 text-center"> {{ getTruncatedRedeemedBy(invite.redeemedBy) }} </td> <td> <fa icon="trash-can" class="text-red-600 text-xl ml-2 mr-2 cursor-pointer" @click="deleteInvite(invite.inviteIdentifier)" /> </td> </tr> </tbody> </table> </div> <p v-else class="mt-6 text-center">No invites found.</p> </section> </template> <script lang="ts"> import axios from "axios"; import { Component, Vue } from "vue-facing-decorator"; import { useClipboard } from "@vueuse/core"; import { db, retrieveSettingsForActiveAccount } from "../db"; import QuickNav from "@/components/QuickNav.vue"; import TopMessage from "@/components/TopMessage.vue"; import InviteDialog from "@/components/InviteDialog.vue"; import { APP_SERVER, NotificationIface } from "@/constants/app"; import { createInviteJwt, getHeaders } from "@/libs/endorserServer"; interface Invite { inviteIdentifier: string; expiresAt: string; jwt: string; notes: string; redeemedBy: string | null; } @Component({ components: { QuickNav, TopMessage, InviteDialog }, }) export default class InviteOneView extends Vue { $notify!: (notification: NotificationIface, timeout?: number) => void; invites: Invite[] = []; activeDid: string = ""; apiServer: string = ""; isRegistered: boolean = false; async mounted() { try { await db.open(); const settings = await retrieveSettingsForActiveAccount(); this.activeDid = settings.activeDid || ""; this.apiServer = settings.apiServer || ""; this.isRegistered = !!settings.isRegistered; const headers = await getHeaders(this.activeDid); const response = await axios.get( this.apiServer + "/api/userUtil/invite", { headers }, ); this.invites = response.data.data; } catch (error) { console.error("Error fetching invites:", error); this.$notify( { group: "alert", type: "danger", title: "Load Error", text: "Got an error loading your invites.", }, 5000, ); } } getTruncatedInviteId(inviteId: string): string { if (inviteId.length <= 9) return inviteId; return `${inviteId.slice(0, 3)}...${inviteId.slice(-3)}`; } getTruncatedRedeemedBy(redeemedBy: string | null): string { if (!redeemedBy) return ""; if (redeemedBy.length <= 19) return redeemedBy; return `${redeemedBy.slice(0, 13)}...${redeemedBy.slice(-3)}`; } inviteLink(jwt: string): string { return APP_SERVER + "/contacts?inviteJwt=" + jwt; } copyInviteAndNotify(inviteId: string, jwt: string) { useClipboard().copy(this.inviteLink(jwt)); this.$notify( { group: "alert", type: "success", title: "Copied", text: "Link for invite " + inviteId + " is copied to clipboard.", }, 3000, ); } lookForErrorAndNotify(error, title, defaultMessage) { console.error(title, "-", error); let message = defaultMessage; if (error.response && error.response.data && error.response.data.error) { if (error.response.data.error.message) { message = error.response.data.error.message; } else { message = error.response.data.error; } } this.$notify( { group: "alert", type: "danger", title: title, text: message, }, 5000, ); } async createInvite() { const inviteIdentifier = Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2); (this.$refs.inviteDialog as InviteDialog).open( inviteIdentifier, async (notes, expiresAt) => { try { const headers = await getHeaders(this.activeDid); if (!expiresAt) { throw { response: { data: { error: "You must select an expiration date." }, }, }; } const expiresIn = (new Date(expiresAt).getTime() - Date.now()) / 1000; const inviteJwt = await createInviteJwt( this.activeDid, undefined, inviteIdentifier, expiresIn, ); await axios.post( this.apiServer + "/api/userUtil/invite", { inviteJwt: inviteJwt, notes: notes }, { headers }, ); this.invites.push({ inviteIdentifier: inviteIdentifier, expiresAt: expiresAt, jwt: inviteJwt, notes: notes, redeemedBy: null, }); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { this.lookForErrorAndNotify( error, "Error Creating Invite", "Got an error creating your invite.", ); } }, ); } deleteInvite(inviteId: string) { this.$notify( { group: "modal", type: "confirm", title: "Delete Invite?", text: "Are you sure you want to erase this invite? (There is no undo.)", onYes: async () => { const headers = await getHeaders(this.activeDid); try { const result = await axios.delete( this.apiServer + "/api/userUtil/invite/" + inviteId, { headers }, ); if (result.status !== 204) { throw result.data; } this.invites = this.invites.filter( (invite) => invite.inviteIdentifier !== inviteId, ); this.$notify( { group: "alert", type: "success", title: "Deleted", text: "Invite deleted.", }, 3000, ); } catch (e) { this.lookForErrorAndNotify( e, "Error Deleting Invite", "Got an error deleting your invite.", ); } }, }, -1, ); } } </script>