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: Add invite link functionality to team #1168

Open
wants to merge 8 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
4 changes: 4 additions & 0 deletions components/search-box.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ export function SearchBoxPersisted({
const [value, setValue] = useState(queryParams[urlParam] ?? "");
const [debouncedValue, setDebouncedValue] = useState(value);

// console.log("queryParams", queryParams);
// console.log("debouncedValue", debouncedValue);
// console.log("value", value);

// Set URL param when debounced value changes
useEffect(() => {
if (queryParams[urlParam] ?? "" !== debouncedValue)
Expand Down
131 changes: 89 additions & 42 deletions components/teams/add-team-member-modal.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useRouter } from "next/router";

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

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

import { Button } from "@/components/ui/button";
import {
Expand All @@ -18,8 +19,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

import { useAnalytics } from "@/lib/analytics";
import { InviteLinkModal } from "./invite-link-modal";

export function AddTeamMembers({
open,
Expand All @@ -32,8 +32,54 @@ export function AddTeamMembers({
}) {
const [email, setEmail] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [inviteLinkLoading, setInviteLinkLoading] = useState<boolean>(true);
const teamInfo = useTeam();
const analytics = useAnalytics();

const [inviteLinkModalOpen, setInviteLinkModalOpen] = useState<boolean>(false);
const [inviteLink, setInviteLink] = useState<string | null>(null);

const fetcher = (url: string) => fetch(url).then(res => {
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
});

const { data, error } = useSWR(
teamInfo?.currentTeam?.id ? `/api/teams/${teamInfo.currentTeam.id}/invite-link` : null,
fetcher
);

useEffect(() => {
if (data) {
setInviteLink(data.inviteLink || null);
}
}, [data]);

useEffect(() => {
setInviteLinkLoading(!inviteLink && !error);
}, [inviteLink, error]);

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

if (!linkResponse.ok) {
throw new Error("Failed to reset invite link");
}
const linkData = await linkResponse.json();
setInviteLink(linkData.inviteLink || null);
toast.success("Invite link has been reset!");
} catch (error) {
toast.error("Error resetting invite link.");
} finally {
setInviteLinkLoading(false);
}
};

const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
Expand All @@ -42,37 +88,31 @@ export function AddTeamMembers({
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("An invitation email has been sent!");
setOpen(false);
} catch (error: any) {
toast.error(error.message);
} finally {
setLoading(false);
}
};

return (
Expand All @@ -82,27 +122,34 @@ export function AddTeamMembers({
<DialogHeader className="text-start">
<DialogTitle>Add Member</DialogTitle>
<DialogDescription>
You can easily add team members.
Invite team members via email.
</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)}
/>

<DialogFooter>
<Button type="submit" className="h-9 w-full">
{loading ? "Sending email..." : "Add member"}
</Button>
</DialogFooter>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Sending invitation..." : "Send Invitation"}
</Button>
</form>

<Button onClick={() => setInviteLinkModalOpen(true)} className="w-full">
Invite Link
</Button>
</DialogContent>
<InviteLinkModal
open={inviteLinkModalOpen}
setOpen={setInviteLinkModalOpen}
inviteLink={inviteLink}
handleResetInviteLink={handleResetInviteLink}
/>
</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>
);
}
55 changes: 55 additions & 0 deletions components/teams/invite-link-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CopyInviteLinkButton } from "./copy-invite-link-button";
import { useEffect } from "react";

export function InviteLinkModal({
open,
setOpen,
inviteLink,
handleResetInviteLink,
}: {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
inviteLink: string | null;
handleResetInviteLink: () => Promise<void>;
}) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader className="text-start">
<DialogTitle>Invite Link</DialogTitle>
<DialogDescription>
Share the invite link with your team members.
</DialogDescription>
</DialogHeader>

{/* Invite Link Section */}
<div className="mb-4">
<Label className="opacity-80">Invite Link</Label>
<Input
value={inviteLink || ""}
readOnly
className="mt-1 w-full"
onFocus={(e) => e.target.blur()}
/>
<div className="mt-2 flex space-x-2">
<CopyInviteLinkButton
inviteLink={inviteLink}
className="flex-1"
/>
<Button
onClick={handleResetInviteLink}
disabled={!inviteLink}
className="flex-1"
>
Reset Link
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
2 changes: 1 addition & 1 deletion pages/api/auth/[...nextauth].ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,4 @@ export const authOptions: NextAuthOptions = {
},
};

export default NextAuth(authOptions);
export default NextAuth(authOptions);
54 changes: 54 additions & 0 deletions pages/api/teams/[teamId]/invite-link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next";
import prisma from "@/lib/prisma";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { nanoid } from 'nanoid';

// Customize nanoid to generate a unique code of length 15
const generateUniqueInviteCode = (): string => {
return nanoid(15); // Generate a unique code of length 15
};

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");
}

switch (req.method) {
case "POST":
// Generate a new invite code
const newInviteCode = generateUniqueInviteCode();
await prisma.team.update({
where: { id: teamId },
data: { inviteCode: newInviteCode }, // Store the unique code in inviteCode
});
return res.json({ inviteLink: `${process.env.NEXTAUTH_URL}/teams/invite/${newInviteCode}` }); // Return the full invite link

case "GET":
// Get the current invite code
const team = await prisma.team.findUnique({
where: { id: teamId },
select: { inviteCode: true }, // Change to inviteCode
});

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

// Here, team.inviteCode should only contain the unique code
return res.json({ inviteLink: `${process.env.NEXTAUTH_URL}/teams/invite/${team.inviteCode}` }); // Return the full invite link

default:
res.setHeader("Allow", ["POST", "GET"]);
return res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
2 changes: 1 addition & 1 deletion pages/api/teams/[teamId]/invite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,4 +152,4 @@ export default async function handle(
errorhandler(error, res);
}
}
}
}
Loading
Loading