Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Ability to invite team members through unique invite link #1339

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 96 additions & 37 deletions components/teams/add-team-member-modal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useRouter } from "next/router";

import { useState } from "react";
import { useEffect, useState } from "react";

import { useTeam } from "@/context/team-context";
import { toast } from "sonner";
@@ -21,6 +21,8 @@ import { Label } from "@/components/ui/label";

import { useAnalytics } from "@/lib/analytics";

import { CopyInviteLinkButton } from "./copy-invite-link-button";

export function AddTeamMembers({
open,
setOpen,
@@ -32,47 +34,89 @@ export function AddTeamMembers({
}) {
const [email, setEmail] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [joinLink, setJoinLink] = useState<string | null>(null);
const [joinLinkLoading, setJoinLinkLoading] = useState<boolean>(true);
const teamInfo = useTeam();
const analytics = useAnalytics();

useEffect(() => {
const fetchJoinLink = async () => {
setJoinLinkLoading(true);
try {
const response = await fetch(
`/api/teams/${teamInfo?.currentTeam?.id}/join-link`,
);
if (response.ok) {
const data = await response.json();
setJoinLink(data.joinLink || null);
} else {
console.error("Failed to fetch join link:", response.status);
}
} catch (error) {
console.error("Error fetching join link:", error);
} finally {
setJoinLinkLoading(false);
}
};
fetchJoinLink();
}, [teamInfo]);

const handleResetJoinLink = async () => {
setJoinLinkLoading(true);
try {
const linkResponse = await fetch(
`/api/teams/${teamInfo?.currentTeam?.id}/join-link`,
{
method: "POST",
},
);

if (!linkResponse.ok) {
throw new Error("Failed to reset join link");
}

const linkData = await linkResponse.json();
setJoinLink(linkData.joinLink || null);
toast.success("Join link has been reset!");
} catch (error) {
toast.error("Error resetting join link.");
} finally {
setJoinLinkLoading(false);
}
};

const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
event.stopPropagation();

if (!email) return;

setLoading(true);
const response = await fetch(
`/api/teams/${teamInfo?.currentTeam?.id}/invite`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: email,
}),
},
);

if (!response.ok) {
const error = await response.json();
setLoading(false);
setOpen(false);
toast.error(error);
return;
}

analytics.capture("Team Member Invitation Sent", {
email: email,
teamId: teamInfo?.currentTeam?.id,
});
try {
const response = await fetch(
`/api/teams/${teamInfo?.currentTeam?.id}/invite`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
},
);

mutate(`/api/teams/${teamInfo?.currentTeam?.id}/invitations`);
if (!response.ok) {
const error = await response.json();
throw new Error(error);
}

toast.success("An invitation email has been sent!");
setOpen(false);
setLoading(false);
toast.success("A join email has been sent!");
setOpen(false);
} catch (error: any) {
toast.error(error.message);
} finally {
setLoading(false);
}
};

return (
@@ -82,26 +126,41 @@ export function AddTeamMembers({
<DialogHeader className="text-start">
<DialogTitle>Add Member</DialogTitle>
<DialogDescription>
You can easily add team members.
Invite team members via email or share the join link.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<Label htmlFor="domain" className="opacity-80">
<Label htmlFor="email" className="opacity-80">
Email
</Label>
<Input
id="email"
placeholder="[email protected]"
className="mb-8 mt-1 w-full"
className="mb-4 mt-1 w-full"
onChange={(e) => setEmail(e.target.value)}
/>
<Button type="submit" className="mb-6 w-full" disabled={loading}>
{loading ? "Sending invitation..." : "Send Invitation"}
</Button>
</form>

<DialogFooter>
<Button type="submit" className="h-9 w-full">
{loading ? "Sending email..." : "Add member"}
<div className="mb-4">
<Label className="opacity-80">Or share join link</Label>
<Input value={joinLink || ""} readOnly className="mt-1 w-full" />
<div className="mt-2 flex space-x-2">
<CopyInviteLinkButton
inviteLink={joinLink}
className="flex-1"
/>
<Button
onClick={handleResetJoinLink}
disabled={joinLinkLoading}
className="flex-1"
>
Reset Link
</Button>
</DialogFooter>
</form>
</div>
</div>
</DialogContent>
</Dialog>
);
22 changes: 22 additions & 0 deletions components/teams/copy-invite-link-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Button } from "@/components/ui/button";
import { toast } from "sonner";

interface CopyInviteLinkButtonProps {
inviteLink: string | null;
className?: string;
}

export function CopyInviteLinkButton({ inviteLink, className }: CopyInviteLinkButtonProps) {
const handleCopyInviteLink = () => {
if (inviteLink) {
navigator.clipboard.writeText(inviteLink);
toast.success("Invite link copied to clipboard!");
}
};

return (
<Button onClick={handleCopyInviteLink} disabled={!inviteLink} className={className}>
Copy Link
</Button>
);
}
1 change: 1 addition & 0 deletions lib/constants.ts
Original file line number Diff line number Diff line change
@@ -69,6 +69,7 @@ export const EXCLUDED_PATHS = [
"/investors",
"/blog",
"/view",
"/join/[teamId]",
];

// free limits
4 changes: 2 additions & 2 deletions lib/middleware/app.ts
Original file line number Diff line number Diff line change
@@ -15,8 +15,8 @@ export default async function AppMiddleware(req: NextRequest) {
};
};

// UNAUTHENTICATED if there's no token and the path isn't /login, redirect to /login
if (!token?.email && path !== "/login") {
// UNAUTHENTICATED if there's no token and the path isn't /login or /join/[teamId], redirect to /login
if (!token?.email && path !== "/login" && !path.startsWith("/join/")) {
const loginUrl = new URL(`/login`, req.url);
// Append "next" parameter only if not navigating to the root
if (path !== "/") {
Empty file added lib/swr/use-join-team.ts
Empty file.
71 changes: 71 additions & 0 deletions lib/swr/use-joincode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useRouter } from "next/router";

import { useMemo, useState } from "react";

import { useTeam } from "@/context/team-context";
import useSWR from "swr";
import { mutate } from "swr";

import { generateCode } from "@/lib/utils";

export function useJoinCode() {
const router = useRouter();
const teamInfo = useTeam();
const [isGeneratingNewCode, setIsGeneratingNewCode] = useState(false);
const [isPending, setIsPending] = useState(false);
const teamId = teamInfo?.currentTeam?.id;

const { data: joinCode, error } = useSWR(
`/api/teams/${teamId}/joincode`,
async () => {
setIsPending(true);
const response = await fetch(`/api/teams/${teamId}/joincode`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});

if (!response.ok) {
const errorData = await response.json();
throw new Error(
errorData.message || "An error occurred while fetching join code.",
);
}

const data = await response.json();

setIsPending(false);

return data.joinCode;
},
{ dedupingInterval: 10000 },
);

const generateNewJoinCode = async () => {
setIsGeneratingNewCode(true);
const newJoinCode = generateCode();

await mutate(`/api/teams/${teamId}/joincode`, async () => {
await fetch(`/api/teams/${teamId}/joincode`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
teamId,
}),
});
setIsGeneratingNewCode(false);
return newJoinCode;
});
};

return {
joinCode,
isPending,
error,
generateNewJoinCode,
isGeneratingNewCode,
};
}
6 changes: 6 additions & 0 deletions lib/utils.ts
Original file line number Diff line number Diff line change
@@ -527,3 +527,9 @@ export function hexToRgb(hex: string) {
let b = (bigint & 255) / 255; // Convert to 0-1 range
return rgb(r, g, g);
}

export const generateCode = () => {
const code = Array.from({ length: 6 }, () => '0123456789abcdefghijklmnopqrstuvwxyz'[Math.floor(Math.random() * 36)]).join('')

return code
}
58 changes: 58 additions & 0 deletions pages/api/teams/[teamId]/join-link/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { NextApiRequest, NextApiResponse } from "next";

import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { nanoid } from "nanoid";
import { getServerSession } from "next-auth/next";

import prisma from "@/lib/prisma";
import { generateCode } from "@/lib/utils";

export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { teamId } = req.query as { teamId: string };

const session = await getServerSession(req, res, authOptions);
if (!session) {
return res.status(401).end("Unauthorized");
}

if (req.method === "POST") {
// Generate a new join code
const newJoinCode = generateCode();
await prisma.team.update({
where: { id: teamId },
data: { joinCode: newJoinCode }, // Store the unique code in joinCode
});
return res.json({
joinLink: `${process.env.NEXTAUTH_URL}/teams/join/${newJoinCode}`,
}); // Return the full join link
} else if (req.method === "GET") {
// Get the current join code
const team = await prisma.team.findUnique({
where: { id: teamId },
select: { joinCode: true }, // Change to joinCode
});

if (!team?.joinCode) {
// If no join code exists, create one
const newCode = generateCode();
await prisma.team.update({
where: { id: teamId },
data: { joinCode: newCode }, // Store the unique code in joinCode
});
return res.json({
joinLink: `${process.env.NEXTAUTH_URL}/teams/join/${newCode}`,
}); // Return the full join link
}

// Here, team.joinCode should only contain the unique code
return res.json({
joinLink: `${process.env.NEXTAUTH_URL}/teams/join/${team.joinCode}`,
}); // Return the full join link
} else {
res.setHeader("Allow", ["POST", "GET"]);
return res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
75 changes: 75 additions & 0 deletions pages/api/teams/join-link/accept.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { NextApiRequest, NextApiResponse } from "next";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth";
import { identifyUser, trackAnalytics } from "@/lib/analytics";
import { errorhandler } from "@/lib/errorHandler";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";

export default async function handle(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method === "GET") {
// GET /api/teams/join-link/accept
const session = await getServerSession(req, res, authOptions);

const { joinCode } = req.query as { joinCode: string };

if (!session) {
res.redirect(`/login?next=/api/teams/join-link/accept?joinCode=${joinCode}`);
return;
}

const userId = (session.user as CustomUser).id;

try {
const team = await prisma.team.findUnique({
where: { joinCode },
select: { id: true },
});

if (!team) {
return res.status(404).json("Invalid join link");
}

const teamId = team.id;

const userTeam = await prisma.userTeam.findFirst({
where: {
teamId,
userId,
},
});

if (userTeam) {
// User is already in the team
return res.redirect(`/documents`);
}

await prisma.team.update({
where: {
id: teamId,
},
data: {
users: {
create: {
userId,
},
},
},
});

await identifyUser(session.user?.email ?? "");
await trackAnalytics({
event: "Team Member Invitation Accepted",
teamId: teamId,
});

return res.redirect(`/documents`);
} catch (error) {
errorhandler(error, res);
}
}
}

44 changes: 44 additions & 0 deletions pages/api/teams/join-link/info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { NextApiRequest, NextApiResponse } from "next";

import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth";

import prisma from "@/lib/prisma";

export default async function handle(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method === "GET") {
// GET /api/teams/join-link/info
const session = await getServerSession(req, res, authOptions);

const { joinCode } = req.query as { joinCode: string };

if (!session) {
res.redirect(
`/login?next=/api/teams/join-link/info?joinCode=${joinCode}`,
);
return;
}

try {
const team = await prisma.team.findUnique({
where: { joinCode },
select: { name: true },
});

if (!team) {
return res.status(404).json({ error: "Invalid join link" });
}

return res.json({ teamName: team.name });
} catch (error) {
console.error(error);
return res.status(500).json("Internal Server Error");
}
} else {
res.setHeader("Allow", ["GET"]);
return res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
1 change: 0 additions & 1 deletion pages/settings/people.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import Link from "next/link";
import { useRouter } from "next/router";

import { useState } from "react";
100 changes: 100 additions & 0 deletions pages/teams/join/[code]/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useRouter } from "next/router";

import { useEffect, useState } from "react";

import { useSession } from "next-auth/react";

import prisma from "@/lib/prisma";

const AcceptJoin = () => {
const router = useRouter();
const { code } = router.query;
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [joinLink, setJoinLink] = useState<string | null>(null); // State for join link
const [teamName, setTeamName] = useState<string | null>(null); // State for team name

const { data: session, status } = useSession();

useEffect(() => {
const fetchJoinLink = async () => {
if (code) {
const response = await fetch(
`/api/teams/join-link/info?joinCode=${code}`,
{
method: "GET",
},
);
// getting teamName
const data = await response.json();
if (data.error) {
setError(data.error); // Set error if data.error exists
setLoading(false); // Set loading to false after error is fetched
return; // Exit early if error is found
}
console.log("data :", data);
setJoinLink(data.joinLink);
setTeamName(data.teamName);
setLoading(false); // Set loading to false after data is fetched
}
};

fetchJoinLink();
}, [code]);

const handleJoin = async () => {
if (code) {
try {
const response = await fetch(
`/api/teams/join-link/accept?joinCode=${code}`,
{
method: "GET",
},
);
if (!response.ok) {
throw new Error("Failed to join team");
}
router.push(`/documents`);
} catch (error) {
setError("Failed to join team");
}
}
};

if (loading) {
return (
<div className="flex h-screen flex-col items-center justify-center">
<p>Loading...</p>
</div>
);
}

if (error) {
return (
<div className="flex h-screen flex-col items-center justify-center">
<p className="text-center text-lg text-red-500">{error}</p>
</div>
);
}

return (
<div className="flex h-screen flex-col items-center justify-center">
<h1 className="mb-4 text-2xl font-bold">
{teamName ? `Join "${teamName}"` : "Join Team"}
</h1>
<button
onClick={handleJoin}
className="rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700"
>
Join Team
</button>
{joinLink && (
<p className="mt-4">
Your join link: <a href={joinLink}>{joinLink}</a>
</p>
)}
</div>
);
};

export default AcceptJoin;
10 changes: 5 additions & 5 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -72,9 +72,9 @@ model Team {
viewerGroups ViewerGroup[]
viewers Viewer[]
links Link[]
views View[]
joinCode String? @unique
links Link[]
views View[]
plan String @default("free")
stripeId String? @unique // Stripe customer ID
subscriptionId String? @unique // Stripe subscription ID
@@ -187,7 +187,7 @@ model DocumentPage {
id String @id @default(cuid())
version DocumentVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
versionId String
pageNumber Int // e.g., 1, 2, 3 for
pageNumber Int // e.g., 1, 2, 3 for
embeddedLinks String[]
pageLinks Json? // This will store the page links data: [{href: "https://example.com", coords: "0,0,100,100"}]
metadata Json? // This will store the page metadata: {originalWidth: 100, origianlHeight: 100, scaledWidth: 50, scaledHeight: 50, scaleFactor: 2}
@@ -281,7 +281,7 @@ model LinkPreset {
enableCustomMetaTag Boolean? @default(false) // Optional give user a option to enable the custom metatag
metaTitle String? // This will be the meta title of the link
metaDescription String? // This will be the meta description of the link
metaImage String? // This will be the meta image of the link
metaImage String? // This will be the meta image of the link
metaFavicon String? // This will be the meta favicon of the link
createdAt DateTime @default(now())