Add Option to Display Invite Link in Team Settings (#452)

* add share link feature

* add grid-cols-20 in tailwind.config

* update edit members section styles

* Check for valid inviteId when sharing invite
This commit is contained in:
tyjkerr
2023-06-30 22:05:05 +07:00
committed by GitHub
parent aa80eb5d96
commit 1d76365f04
5 changed files with 159 additions and 15 deletions

View File

@@ -7,6 +7,7 @@ import {
deleteInvite,
removeMember,
resendInvite,
shareInvite,
updateInviteeRole,
updateMemberRole,
useMembers,
@@ -27,7 +28,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@formbricks/ui";
import { PaperAirplaneIcon, TrashIcon } from "@heroicons/react/24/outline";
import { PaperAirplaneIcon, ShareIcon, TrashIcon } from "@heroicons/react/24/outline";
import { useState } from "react";
import toast from "react-hot-toast";
import AddMemberModal from "./AddMemberModal";
@@ -35,6 +36,7 @@ import CreateTeamModal from "@/components/team/CreateTeamModal";
import { capitalizeFirstLetter } from "@/lib/utils";
import { useProfile } from "@/lib/profile";
import { ChevronDownIcon } from "@heroicons/react/20/solid";
import ShareInviteModal from "@/app/environments/[environmentId]/settings/members/ShareInviteModal";
type EditMembershipsProps = {
environmentId: string;
@@ -128,6 +130,8 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
const [isAddMemberModalOpen, setAddMemberModalOpen] = useState(false);
const [isDeleteMemberModalOpen, setDeleteMemberModalOpen] = useState(false);
const [isCreateTeamModalOpen, setCreateTeamModalOpen] = useState(false);
const [showShareInviteModal, setShowShareInviteModal] = useState(false);
const [shareInviteToken, setShareInviteToken] = useState<string>("");
const [activeMember, setActiveMember] = useState({} as any);
const { profile } = useProfile();
@@ -160,6 +164,12 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
mutateTeam();
};
const handleShareInvite = async (member) => {
const { inviteToken } = await shareInvite(team.teamId, member.inviteId);
setShareInviteToken(inviteToken);
setShowShareInviteModal(true);
};
const handleResendInvite = async (inviteId) => {
await resendInvite(team.teamId, inviteId);
};
@@ -192,28 +202,28 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
)}
</div>
<div className="rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-8 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="px-6"></div>
<div className="col-span-2">Fullname</div>
<div className="col-span-2">Email</div>
<div className="">Role</div>
<div className=""></div>
<div className="grid h-12 grid-cols-20 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2"></div>
<div className="col-span-5">Fullname</div>
<div className="col-span-5">Email</div>
<div className="col-span-3">Role</div>
<div className="col-span-5"></div>
</div>
<div className="grid-cols-8">
<div className="grid-cols-20">
{[...team.members, ...team.invitees].map((member) => (
<div
className="grid h-auto w-full grid-cols-8 content-center rounded-lg p-0.5 py-2 text-left text-sm text-slate-900"
className="grid h-auto w-full grid-cols-20 content-center rounded-lg p-0.5 py-2 text-left text-sm text-slate-900"
key={member.email}>
<div className="h-58 px-6 ">
<div className="h-58 pl-4 col-span-2">
<ProfileAvatar userId={member.userId || member.email} />
</div>
<div className="ph-no-capture col-span-2 flex flex-col justify-center break-all">
<div className="ph-no-capture col-span-5 flex flex-col justify-center break-all">
<p>{member.name}</p>
</div>
<div className="ph-no-capture col-span-2 flex flex-col justify-center break-all">
<div className="ph-no-capture col-span-5 flex flex-col justify-center break-all">
{member.email}
</div>
<div className="ph-no-capture col-span-1 flex flex-col items-start justify-center break-all">
<div className="ph-no-capture col-span-3 flex flex-col items-start justify-center break-all">
<RoleElement
isAdminOrOwner={isAdminOrOwner}
memberRole={member.role}
@@ -225,8 +235,8 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
inviteId={member?.inviteId}
/>
</div>
<div className="col-span-2 flex items-center justify-end gap-x-6 pr-6">
{!member.accepted && <Badge type="warning" text="Pending" size="tiny" />}
<div className="col-span-5 flex items-center justify-end gap-x-4 pr-4">
{!member.accepted && <Badge className="mr-2" type="warning" text="Pending" size="tiny" />}
{member.role !== "owner" && (
<button onClick={(e) => handleOpenDeleteMemberModal(e, member)}>
<TrashIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
@@ -234,6 +244,19 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
)}
{!member.accepted && (
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => {
handleShareInvite(member);
}}>
<ShareIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
</button>
</TooltipTrigger>
<TooltipContent className="TooltipContent" sideOffset={5}>
Share Invite Link
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -267,6 +290,13 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
deleteWhat={activeMember.name + " from your team"}
onDelete={handleDeleteMember}
/>
{showShareInviteModal && (
<ShareInviteModal
inviteToken={shareInviteToken}
open={showShareInviteModal}
setOpen={setShowShareInviteModal}
/>
)}
</>
);
}

View File

@@ -0,0 +1,69 @@
"use client";
import Modal from "@/components/shared/Modal";
import { Button } from "@formbricks/ui";
import { CheckIcon } from "@heroicons/react/24/outline";
import { DocumentDuplicateIcon } from "@heroicons/react/24/solid";
import { useRef } from "react";
import toast from "react-hot-toast";
interface ShareInviteModalProps {
inviteToken: string;
open: boolean;
setOpen: (open: boolean) => void;
}
export default function ShareInviteModal({ inviteToken, open, setOpen }: ShareInviteModalProps) {
const linkTextRef = useRef(null);
const handleTextSelection = () => {
if (linkTextRef.current) {
const range = document.createRange();
range.selectNodeContents(linkTextRef.current);
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
}
};
return (
<Modal open={open} setOpen={setOpen} blur={false}>
<div>
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-teal-100">
<CheckIcon className="h-6 w-6 text-teal-600" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg font-semibold leading-6 text-gray-900">Your team invite link is ready!</h3>
<div className="mt-4">
<p className="text-sm text-gray-500">Share this link to let your team member join your team:</p>
<p
ref={linkTextRef}
className="relative mt-3 w-full truncate rounded-lg border border-slate-300 bg-slate-50 p-3 text-center text-slate-800"
onClick={() => handleTextSelection()}>
{`${window.location.protocol}//${window.location.host}/invite?token=${inviteToken}`}
</p>
</div>
<div className="mt-4 flex justify-end space-x-2">
<Button
variant="secondary"
onClick={() => {
navigator.clipboard.writeText(
`${window.location.protocol}//${window.location.host}/invite?token=${inviteToken}`
);
toast.success("URL copied to clipboard!");
}}
title="Copy invite link to clipboard"
aria-label="Copy invite link to clipboard"
EndIcon={DocumentDuplicateIcon}>
Copy URL
</Button>
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -97,3 +97,21 @@ export const resendInvite = async (teamId: string, inviteId: string) => {
return false;
}
};
export const shareInvite = async (teamId: string, inviteId: string) => {
try {
const res = await fetch(`/api/v1/teams/${teamId}/invite/${inviteId}`, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
if (res.status !== 200) {
const json = await res.json();
throw Error(json.message);
}
return res.json();
} catch (error) {
console.error(error);
throw Error(`shareInvite: unable to get invite link: ${error.message}`);
}
};

View File

@@ -1,5 +1,6 @@
import { getSessionUser, isAdminOrOwner } from "@/lib/api/apiHelper";
import { sendInviteMemberEmail } from "@/lib/email";
import { createInviteToken } from "@/lib/jwt";
import { prisma } from "@formbricks/database";
import type { NextApiRequest, NextApiResponse } from "next";
@@ -112,6 +113,29 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
return res.status(200).json(updatedInvite);
}
// GET /api/v1/teams/[teamId]/invite/[inviteId]
// Retrieve an invite token
else if (req.method === "GET") {
const invite = await prisma.invite.findUnique({
where: {
id: inviteId,
},
select: {
email: true,
},
});
if (!invite) {
return res.status(403).json({ message: "You are not allowed to share this invite link" });
}
const inviteToken = createInviteToken(inviteId, invite?.email, {
expiresIn: "7d",
});
return res.status(200).json({ inviteToken: encodeURIComponent(inviteToken) });
}
// Unknown HTTP Method
else {

View File

@@ -41,6 +41,9 @@ module.exports = {
scale: {
97: "0.97",
},
gridTemplateColumns: {
20: "repeat(20, minmax(0, 1fr))",
},
},
},
plugins: [require("@tailwindcss/forms"), require("@tailwindcss/typography")],