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
Show file tree
Hide file tree
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";
Expand All @@ -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,
Expand All @@ -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 (
Expand All @@ -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>
);
Expand Down
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
Expand Up @@ -69,6 +69,7 @@ export const EXCLUDED_PATHS = [
"/investors",
"/blog",
"/view",
"/join/[teamId]",
];

// free limits
Expand Down
4 changes: 2 additions & 2 deletions lib/middleware/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 !== "/") {
Expand Down
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
Expand Up @@ -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`);
}
}
Loading