You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

466 lines
14 KiB

<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()"
>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</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()"
>
<font-awesome icon="plus" class="fa-fw"></font-awesome>
</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="isInviteActive(invite)"
:class="activeInviteClass"
:title="inviteLink(invite.jwt)"
@click="
copyInviteAndNotify(invite.inviteIdentifier, invite.jwt)
"
>
{{ getTruncatedInviteId(invite.inviteIdentifier) }}
</span>
<span
v-else
:class="inactiveInviteClass"
:title="invite.inviteIdentifier"
@click="
showInvite(
invite.inviteIdentifier,
!!invite.redeemedAt,
isInviteExpired(invite),
)
"
>
{{ getTruncatedInviteId(invite.inviteIdentifier) }}
</span>
</td>
<td class="text-left" :data-testId="inviteLink(invite.jwt)">
{{ invite.notes }}
</td>
<td class="text-center">
{{ formatExpirationDate(invite) }}
</td>
<td class="text-center">
{{ formatRedemptionDate(invite) }}
<br />
{{ getTruncatedRedeemedBy(invite.redeemedBy) }}
<br />
<font-awesome
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>
<font-awesome
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 { Router } from "vue-router";
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 { Contact } from "../db/tables/contacts";
import { createInviteJwt, getHeaders } from "../libs/endorserServer";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import {
NOTIFY_INVITE_LOAD_ERROR,
NOTIFY_INVITE_DELETED,
createInviteLinkCopyMessage,
createInviteIdCopyMessage,
createContactAddedMessage,
createInviteDeleteConfirmMessage,
} from "@/constants/notifications";
/**
* InviteOneView Component
*
* Manages user invitations with comprehensive invite lifecycle management.
* Handles creation, tracking, deletion, and redemption of invitations.
* Integrates with contact management for redeemed invitations.
*
* Key features:
* - Invitation lifecycle management (create, track, delete)
* - Contact integration for redeemed invitations
* - Link generation and clipboard functionality
* - Comprehensive error handling and user feedback
* - Expiration and status tracking
*
* Database Operations:
* - Account settings retrieval via PlatformServiceMixin
* - Contact queries and management via mixin methods
* - Contact insertion for redeemed invitations
*
* Migration Status: Phase 1 Complete - Database patterns modernized
*/
interface Invite {
inviteIdentifier: string;
expiresAt: string;
jwt: string;
notes: string;
redeemedAt: string | null;
redeemedBy: string | null;
}
@Component({
components: { ContactNameDialog, QuickNav, TopMessage, InviteDialog },
mixins: [PlatformServiceMixin],
})
export default class InviteOneView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
invites: Invite[] = [];
activeDid: string = "";
apiServer: string = "";
contactsRedeemed: { [key: string]: Contact } = {};
isRegistered: boolean = false;
showAppleWarning = false;
notify!: ReturnType<typeof createNotifyHelpers>;
/**
* Initializes notification helpers
*/
created() {
this.notify = createNotifyHelpers(this.$notify);
}
// =================================================
// COMPUTED PROPERTIES
// =================================================
/**
* CSS classes for active invite links
*/
get activeInviteClass(): string {
return "text-center text-blue-500 cursor-pointer";
}
/**
* CSS classes for inactive invite links
*/
get inactiveInviteClass(): string {
return "text-center text-slate-500 cursor-pointer";
}
// =================================================
// HELPER METHODS
// =================================================
/**
* Checks if an invite is currently active (not redeemed and not expired)
* @param invite - The invite to check
* @returns True if invite is active
*/
isInviteActive(invite: Invite): boolean {
return !invite.redeemedAt && invite.expiresAt > new Date().toISOString();
}
/**
* Checks if an invite has expired
* @param invite - The invite to check
* @returns True if invite is expired
*/
isInviteExpired(invite: Invite): boolean {
return invite.expiresAt < new Date().toISOString();
}
/**
* Formats expiration date for display
* @param invite - The invite to format
* @returns Formatted date string or empty if redeemed
*/
formatExpirationDate(invite: Invite): string {
return invite.redeemedAt ? "" : invite.expiresAt.substring(0, 10);
}
/**
* Formats redemption date for display
* @param invite - The invite to format
* @returns Formatted date string or empty if not redeemed
*/
formatRedemptionDate(invite: Invite): string {
return invite.redeemedAt?.substring(0, 10) || "";
}
// =================================================
// LIFECYCLE METHODS
// =================================================
/**
* Initializes component with user invitations and contact data
*
* Workflow:
* 1. Retrieves account settings via PlatformServiceMixin
* 2. Loads user invitations from server API
* 3. Loads contact data for redeemed invitations
* 4. Maps redeemed contacts for display
*/
async mounted() {
try {
// Use PlatformServiceMixin for account settings
const settings = await this.$accountSettings();
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;
// Use PlatformServiceMixin for contact retrieval
const allContacts = await this.$getAllContacts();
// Map redeemed contacts for display
for (const invite of this.invites) {
const contact = allContacts.find(
(contact) => contact.did === invite.redeemedBy,
);
if (contact && invite.redeemedBy) {
this.contactsRedeemed[invite.redeemedBy] = contact;
}
}
} catch (error) {
logger.error("Error fetching invites:", error);
this.notify.error(NOTIFY_INVITE_LOAD_ERROR.message, TIMEOUTS.LONG);
}
}
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 + "/deep-link/invite-one-accept/" + jwt;
}
copyInviteAndNotify(inviteId: string, jwt: string) {
useClipboard().copy(this.inviteLink(jwt));
this.notify.success(createInviteLinkCopyMessage(inviteId), TIMEOUTS.LONG);
}
showInvite(inviteId: string, redeemed: boolean, expired: boolean) {
useClipboard().copy(inviteId);
this.notify.success(
createInviteIdCopyMessage(inviteId, redeemed, expired),
TIMEOUTS.LONG,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
lookForErrorAndNotify(error: any, title: string, defaultMessage: string) {
logger.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.error(message, TIMEOUTS.LONG);
}
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, notes, expiresAt },
{ 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.",
);
}
},
);
}
/**
* Adds a new contact from redeemed invitation
*
* @param did - DID of the person who redeemed the invitation
* @param notes - Notes from the original invitation
*/
async 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.",
async (name) => {
// The person registered and user granted visibility, so add them
const contact = {
did: did,
name: name,
registered: true,
};
// Use PlatformServiceMixin service method for contact insertion
await this.$insertContact(contact);
this.contactsRedeemed[did] = contact;
this.notify.success(
createContactAddedMessage(name || "Contact"),
TIMEOUTS.STANDARD,
);
},
() => {},
notes,
);
}
deleteInvite(inviteId: string, notes: string) {
this.notify.confirm(createInviteDeleteConfirmMessage(notes), 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.success(NOTIFY_INVITE_DELETED.message, TIMEOUTS.STANDARD);
} catch (e) {
this.lookForErrorAndNotify(
e,
"Error Deleting Invite",
"Got an error deleting your invite.",
);
}
});
}
}
</script>