<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> <ul class="ml-8 mt-4 list-outside list-disc w-5/6"> <li> Note when sending <span v-if="!showAppleWarning" class="text-blue-500 cursor-pointer" @click="showAppleWarning = !showAppleWarning" > to Apple users... </span> <span v-else> to Apple users: their links often fail because their device cuts off part of the link. You might need to send it to them some other way, like in an email. </span> </li> </ul> <!-- New Project --> <button v-if="isRegistered" class="fixed right-6 top-12 text-center text-4xl leading-none bg-green-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 <br /> (click for link) </th> <th class="py-2">Notes</th> <th class="py-2">Expires At</th> <th class="py-2">Redeemed</th> </tr> </thead> <tbody> <tr v-for="invite in invites" :key="invite.inviteIdentifier" class="border-t py-2" > <td> <span v-if=" !invite.redeemedAt && invite.expiresAt > new Date().toISOString() " @click=" copyInviteAndNotify(invite.inviteIdentifier, invite.jwt) " class="text-center text-blue-500 cursor-pointer" :title="inviteLink(invite.jwt)" > {{ getTruncatedInviteId(invite.inviteIdentifier) }} </span> <span v-else @click=" showInvite( invite.inviteIdentifier, !!invite.redeemedAt, invite.expiresAt < new Date().toISOString(), ) " class="text-center text-slate-500 cursor-pointer" :title="inviteLink(invite.jwt)" > {{ getTruncatedInviteId(invite.inviteIdentifier) }} </span> </td> <td class="text-left" :data-testId="inviteLink(invite.jwt)"> {{ invite.notes }} </td> <td class="text-center"> {{ invite.redeemedAt ? "" : invite.expiresAt.substring(0, 10) }} </td> <td class="text-center"> {{ invite.redeemedAt?.substring(0, 10) }} <br /> {{ getTruncatedRedeemedBy(invite.redeemedBy) }} <br /> <fa v-if="invite.redeemedBy && !contactsRedeemed[invite.redeemedBy]" icon="plus" class="bg-green-600 text-white px-1 py-1 rounded-full cursor-pointer" @click="addNewContact(invite.redeemedBy, invite.notes)" /> </td> <td> <fa icon="trash-can" class="text-red-600 text-xl ml-2 mr-2 cursor-pointer" @click="deleteInvite(invite.inviteIdentifier, invite.notes)" /> </td> </tr> </tbody> </table> <ContactNameDialog ref="contactNameDialog" /> </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 ContactNameDialog from "@/components/ContactNameDialog.vue"; import QuickNav from "@/components/QuickNav.vue"; import TopMessage from "@/components/TopMessage.vue"; import InviteDialog from "@/components/InviteDialog.vue"; import { APP_SERVER, AppString, NotificationIface } from "@/constants/app"; import { db, retrieveSettingsForActiveAccount } from "@/db/index"; import { Contact } from "@/db/tables/contacts"; import { createInviteJwt, getHeaders } from "@/libs/endorserServer"; interface Invite { inviteIdentifier: string; expiresAt: string; jwt: string; notes: string; redeemedAt: string | null; redeemedBy: string | null; } @Component({ components: { ContactNameDialog, QuickNav, TopMessage, InviteDialog }, }) export default class InviteOneView extends Vue { $notify!: (notification: NotificationIface, timeout?: number) => void; invites: Invite[] = []; activeDid: string = ""; apiServer: string = ""; contactsRedeemed: { [key: string]: Contact } = {}; isRegistered: boolean = false; showAppleWarning = 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; const baseContacts: Contact[] = await db.contacts.toArray(); for (const invite of this.invites) { const contact = baseContacts.find( (contact) => contact.did === invite.redeemedBy, ); if (contact && invite.redeemedBy) { this.contactsRedeemed[invite.redeemedBy] = contact; } } } 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, 6)}...`; } getTruncatedRedeemedBy(redeemedBy: string | null): string { if (!redeemedBy) return ""; if (this.contactsRedeemed[redeemedBy]) { return ( this.contactsRedeemed[redeemedBy].name || AppString.NO_CONTACT_NAME ); } if (redeemedBy.length <= 19) return redeemedBy; return `${redeemedBy.slice(0, 13)}...${redeemedBy.slice(-3)}`; } inviteLink(jwt: string): string { return APP_SERVER + "/invite-one-accept/" + jwt; } copyInviteAndNotify(inviteId: string, jwt: string) { useClipboard().copy(this.inviteLink(jwt)); this.$notify( { group: "alert", type: "success", title: "Copied", text: "Your clipboard now contains the link for invite " + inviteId, }, 5000, ); } showInvite(inviteId: string, redeemed: boolean, expired: boolean) { let message = `Your clipboard now contains the invite ID ${inviteId}`; if (redeemed) { message += " (This invite has been used.)"; } else if (expired) { message += " (This invite has expired.)"; } useClipboard().copy(inviteId); this.$notify( { group: "alert", type: "success", title: "Copied", text: message, }, 5000, ); } // eslint-disable-next-line @typescript-eslint/no-explicit-any lookForErrorAndNotify(error: any, title: string, defaultMessage: string) { 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 }, ); const newInvite = { inviteIdentifier: inviteIdentifier, expiresAt: expiresAt, jwt: inviteJwt, notes: notes, redeemedAt: null, redeemedBy: null, }; this.invites = [newInvite, ...this.invites]; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { this.lookForErrorAndNotify( error, "Error Creating Invite", "Got an error creating your invite.", ); } }, ); } addNewContact(did: string, notes: string) { (this.$refs.contactNameDialog as ContactNameDialog).open( "To Whom Did You Send The Invite?", "Their name will be added to your contact list.", (name) => { // the person obviously registered themselves and this user already granted visibility, so we just add them const contact = { did: did, name: name, registered: true, }; db.contacts.add(contact); this.contactsRedeemed[did] = contact; this.$notify( { group: "alert", type: "success", title: "Contact Added", text: `${name} has been added to your contacts.`, }, 3000, ); }, () => {}, notes, ); } deleteInvite(inviteId: string, notes: string) { this.$notify( { group: "modal", type: "confirm", title: "Delete Invite?", text: `Are you sure you want to erase the invite for "${notes}"? (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>