mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 10:19:51 -06:00
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:
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -41,6 +41,9 @@ module.exports = {
|
||||
scale: {
|
||||
97: "0.97",
|
||||
},
|
||||
gridTemplateColumns: {
|
||||
20: "repeat(20, minmax(0, 1fr))",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("@tailwindcss/forms"), require("@tailwindcss/typography")],
|
||||
|
||||
Reference in New Issue
Block a user