add team management (#131)

* add team management

* add more team management

* add join team page
This commit is contained in:
KM Koushik
2025-03-26 22:02:49 +11:00
committed by GitHub
parent f8113e64b5
commit 1ed5c8009f
26 changed files with 1348 additions and 13 deletions

13
.windsurfrules Normal file
View File

@@ -0,0 +1,13 @@
You are a Staff Engineer and an Expert in ReactJS, NextJS, JavaScript, TypeScript, HTML, CSS, NodeJS, Prisma, Postgres, and modern UI/UX frameworks (e.g., TailwindCSS, Shadcn, Radix). You are also great at scalling things. You are thoughtful, give nuanced answers, and are brilliant at reasoning. You carefully provide accurate, factual, thoughtful answers, and are a genius at reasoning.
- Follow the users requirements carefully & to the letter.
- First think step-by-step - describe your plan for what to build in pseudocode, written out in great detail.
- Always write correct, best practice, bug free, fully functional and working code also it should be aligned to listed rules down below at Code Implementation Guidelines .
- Focus on easy and readability code, over being performant.
- Fully implement all requested functionality.
- Leave NO todos, placeholders or missing pieces.
- Ensure code is complete! Verify thoroughly finalised.
- Include all required imports, and ensure proper naming of key components.
- Be concise Minimize any other prose.
- If you think there might not be a correct answer, you say so.
- If you do not know the answer, say so, instead of guessing.

View File

@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "TeamInvite" (
"id" TEXT NOT NULL,
"teamId" INTEGER NOT NULL,
"email" TEXT NOT NULL,
"role" "Role" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TeamInvite_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TeamInvite_teamId_email_key" ON "TeamInvite"("teamId", "email");

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

View File

@@ -0,0 +1,2 @@
-- AddForeignKey
ALTER TABLE "TeamInvite" ADD CONSTRAINT "TeamInvite_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -85,6 +85,7 @@ model User {
emailVerified DateTime?
image String?
isBetaUser Boolean @default(false)
createdAt DateTime @default(now())
accounts Account[]
sessions Session[]
teamUsers TeamUser[]
@@ -112,7 +113,21 @@ model Team {
campaigns Campaign[]
templates Template[]
dailyEmailUsages DailyEmailUsage[]
Subscription Subscription[]
subscription Subscription[]
invites TeamInvite[]
}
model TeamInvite {
id String @id @default(cuid())
teamId Int
email String
role Role
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@unique([teamId, email])
}
model Subscription {

View File

@@ -9,8 +9,9 @@ import { useTeam } from "~/providers/team-context";
import { api } from "~/trpc/react";
import { PlanDetails } from "~/components/payments/PlanDetails";
import { UpgradeButton } from "~/components/payments/UpgradeButton";
export default function SettingsPage() {
const { currentTeam } = useTeam();
const { currentTeam, currentIsAdmin } = useTeam();
const manageSessionUrl = api.billing.getManageSessionUrl.useMutation();
const updateBillingEmailMutation =
api.billing.updateBillingEmail.useMutation();
@@ -47,6 +48,10 @@ export default function SettingsPage() {
const paymentMethod = JSON.parse(subscription?.paymentMethod || "{}");
if (!currentIsAdmin) {
return null;
}
if (!currentTeam?.plan) {
return (
<div className="flex justify-center items-center h-full">

View File

@@ -1,5 +1,6 @@
"use client";
import { useTeam } from "~/providers/team-context";
import { SettingsNavButton } from "../dev-settings/settings-nav-button";
export const dynamic = "force-static";
@@ -9,12 +10,19 @@ export default function ApiKeysPage({
}: {
children: React.ReactNode;
}) {
const { currentIsAdmin } = useTeam();
return (
<div>
<h1 className="font-bold text-lg">Settings</h1>
<div className="flex gap-4 mt-4">
<SettingsNavButton href="/settings">Usage</SettingsNavButton>
<SettingsNavButton href="/settings/billing">Billing</SettingsNavButton>
{currentIsAdmin ? (
<SettingsNavButton href="/settings/billing">
Billing
</SettingsNavButton>
) : null}
<SettingsNavButton href="/settings/team">Team</SettingsNavButton>
</div>
<div className="mt-8">{children}</div>
</div>

View File

@@ -0,0 +1,79 @@
"use client";
import { Button } from "@unsend/ui/src/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@unsend/ui/src/dialog";
import { api } from "~/trpc/react";
import { useState } from "react";
import { toast } from "@unsend/ui/src/toaster";
import { Trash2 } from "lucide-react";
export const DeleteTeamInvite: React.FC<{
invite: { id: string; email: string };
}> = ({ invite }) => {
const [open, setOpen] = useState(false);
const deleteInviteMutation = api.team.deleteTeamInvite.useMutation();
const utils = api.useUtils();
async function onInviteDelete() {
deleteInviteMutation.mutate(
{
inviteId: invite.id,
},
{
onSuccess: async () => {
utils.team.getTeamInvites.invalidate();
setOpen(false);
toast.success("Invite cancelled successfully");
},
onError: async (error) => {
toast.error(error.message);
},
}
);
}
return (
<Dialog
open={open}
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-red-600/80" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Cancel Invite</DialogTitle>
<DialogDescription>
Are you sure you want to cancel the invite for{" "}
<span className="font-semibold text-primary">{invite.email}</span>?
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-4 mt-6">
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
isLoading={deleteInviteMutation.isPending}
onClick={onInviteDelete}
className="w-[150px]"
>
Delete Invite
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default DeleteTeamInvite;

View File

@@ -0,0 +1,88 @@
"use client";
import { Button } from "@unsend/ui/src/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@unsend/ui/src/dialog";
import { api } from "~/trpc/react";
import { useState } from "react";
import { toast } from "@unsend/ui/src/toaster";
import { Role } from "@prisma/client";
import { LogOut, Trash2 } from "lucide-react";
export const DeleteTeamMember: React.FC<{
teamUser: { userId: string; role: Role; email: string };
self: boolean;
}> = ({ teamUser, self }) => {
const [open, setOpen] = useState(false);
const deleteTeamUserMutation = api.team.deleteTeamUser.useMutation();
const utils = api.useUtils();
async function onTeamUserDelete() {
deleteTeamUserMutation.mutate(
{
userId: teamUser.userId,
},
{
onSuccess: async () => {
utils.team.getTeamUsers.invalidate();
setOpen(false);
toast.success("Team member removed successfully");
},
onError: async (error) => {
toast.error(error.message);
},
}
);
}
return (
<Dialog
open={open}
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
{self ? (
<LogOut className="h-4 w-4 text-red-600/80" />
) : (
<Trash2 className="h-4 w-4 text-red-600/80" />
)}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{self ? "Leave Team" : "Remove Team Member"}
</DialogTitle>
<DialogDescription>
{self
? "Are you sure you want to leave the team? This action cannot be undone."
: 'Are you sure you want to remove"{teamUser.email}" from the team? This action cannot be undone.'}
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-4 mt-6">
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={onTeamUserDelete}
isLoading={deleteTeamUserMutation.isPending}
className="w-[150px]"
>
{self ? "Leave" : "Remove"}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default DeleteTeamMember;

View File

@@ -0,0 +1,142 @@
"use client";
import { Button } from "@unsend/ui/src/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@unsend/ui/src/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@unsend/ui/src/form";
import { api } from "~/trpc/react";
import { useState } from "react";
import { PencilIcon } from "lucide-react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@unsend/ui/src/toaster";
import { Role } from "@prisma/client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@unsend/ui/src/select";
const teamUserSchema = z.object({
role: z.enum(["MEMBER", "ADMIN"]),
});
export const EditTeamMember: React.FC<{
teamUser: { userId: string; role: Role };
}> = ({ teamUser }) => {
const [open, setOpen] = useState(false);
const updateTeamUserMutation = api.team.updateTeamUserRole.useMutation();
const utils = api.useUtils();
const teamUserForm = useForm<z.infer<typeof teamUserSchema>>({
resolver: zodResolver(teamUserSchema),
defaultValues: {
role: teamUser.role,
},
});
async function onTeamUserUpdate(values: z.infer<typeof teamUserSchema>) {
updateTeamUserMutation.mutate(
{
userId: teamUser.userId,
role: values.role,
},
{
onSuccess: async () => {
utils.team.getTeamUsers.invalidate();
setOpen(false);
toast.success("Team member role updated successfully");
},
onError: async (error) => {
toast.error(error.message);
},
}
);
}
return (
<Dialog
open={open}
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<PencilIcon className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Team Member Role</DialogTitle>
</DialogHeader>
<div className="py-2">
<Form {...teamUserForm}>
<form
onSubmit={teamUserForm.handleSubmit(onTeamUserUpdate)}
className="space-y-8"
>
<FormField
control={teamUserForm.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="ADMIN">Admin</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
isLoading={updateTeamUserMutation.isPending}
className="w-[150px]"
>
Update Role
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
};
export default EditTeamMember;

View File

@@ -0,0 +1,178 @@
"use client";
import { useState } from "react";
import { Button } from "@unsend/ui/src/button";
import { PlusIcon } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@unsend/ui/src/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@unsend/ui/src/select";
import { Input } from "@unsend/ui/src/input";
import { useForm } from "react-hook-form";
import { api } from "~/trpc/react";
import { toast } from "@unsend/ui/src/toaster";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@unsend/ui/src/form";
import { useTeam } from "~/providers/team-context";
const inviteTeamMemberSchema = z.object({
email: z
.string({ required_error: "Email is required" })
.email("Invalid email address"),
role: z.enum(["ADMIN", "MEMBER"], {
required_error: "Please select a role",
}),
});
type FormData = z.infer<typeof inviteTeamMemberSchema>;
export default function InviteTeamMember() {
const { currentIsAdmin } = useTeam();
const [open, setOpen] = useState(false);
const form = useForm<FormData>({
resolver: zodResolver(inviteTeamMemberSchema),
defaultValues: {
email: "",
role: "MEMBER",
},
});
const utils = api.useUtils();
const createInvite = api.team.createTeamInvite.useMutation({
onSuccess: () => {
form.reset();
setOpen(false);
void utils.team.getTeamInvites.invalidate();
toast.success("Invitation sent successfully");
},
onError: (error) => {
toast.error(error.message || "Failed to send invitation");
},
});
function onSubmit(values: FormData) {
createInvite.mutate({
email: values.email,
role: values.role,
});
}
if (!currentIsAdmin) {
return null;
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm">
<PlusIcon className="mr-2 h-4 w-4" />
Invite Member
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Invite Team Member</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4 pt-4"
>
<FormField
control={form.control}
name="email"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="colleague@example.com" {...field} />
</FormControl>
{formState.errors.email ? (
<FormMessage />
) : (
<FormDescription>
Enter your colleague's email address
</FormDescription>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<div className="capitalize">
{field.value.toLowerCase()}
</div>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="ADMIN">
<div>Admin</div>
<div className="text-xs text-muted-foreground">
Manage users, update payments
</div>
</SelectItem>
<SelectItem value="MEMBER">
<div>Member</div>
<div className="text-xs text-muted-foreground">
Manage emails, domains and contacts
</div>
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={createInvite.isPending}
showSpinner
isLoading={createInvite.isPending}
className="w-[150px]"
>
{createInvite.isPending ? "Sending..." : "Send Invitation"}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,15 @@
"use client";
import InviteTeamMember from "./invite-team-member";
import TeamMembersList from "./team-members-list";
export default function TeamsPage() {
return (
<div>
<div className="flex justify-end ">
<InviteTeamMember />
</div>
<TeamMembersList />
</div>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { Button } from "@unsend/ui/src/button";
import { api } from "~/trpc/react";
import { toast } from "@unsend/ui/src/toaster";
import { RotateCw } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@unsend/ui/src/tooltip";
export const ResendTeamInvite: React.FC<{
invite: { id: string; email: string };
}> = ({ invite }) => {
const resendInviteMutation = api.team.resendTeamInvite.useMutation();
async function onResendInvite() {
resendInviteMutation.mutate(
{
inviteId: invite.id,
},
{
onSuccess: async () => {
toast.success(`Invite resent to ${invite.email}`);
},
onError: async (error) => {
toast.error(error.message);
},
}
);
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={onResendInvite}>
<RotateCw className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Resend invite</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
export default ResendTeamInvite;

View File

@@ -0,0 +1,153 @@
"use client";
import {
Table,
TableHeader,
TableRow,
TableHead,
TableBody,
TableCell,
} from "@unsend/ui/src/table";
import { api } from "~/trpc/react";
import { Button } from "@unsend/ui/src/button";
import Spinner from "@unsend/ui/src/spinner";
import { formatDistanceToNow } from "date-fns";
import { Role } from "@prisma/client";
import { EditTeamMember } from "./edit-team-member";
import { DeleteTeamMember } from "./delete-team-member";
import { ResendTeamInvite } from "./resend-team-invite";
import { DeleteTeamInvite } from "./delete-team-invite";
import { useTeam } from "~/providers/team-context";
import { useSession } from "next-auth/react";
export default function TeamMembersList() {
const { currentIsAdmin } = useTeam();
const { data: session } = useSession();
const teamUsersQuery = api.team.getTeamUsers.useQuery();
const teamInvitesQuery = api.team.getTeamInvites.useQuery();
// Combine team users and invites for display
const teamMembers = teamUsersQuery.data || [];
const pendingInvites = teamInvitesQuery.data || [];
const isLoading = teamUsersQuery.isLoading || teamInvitesQuery.isLoading;
return (
<div className="mt-10 flex flex-col gap-4">
<div className="flex flex-col rounded-xl border border-border shadow">
<Table>
<TableHeader>
<TableRow className="bg-muted/30">
<TableHead className="rounded-tl-xl">User</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>Joined</TableHead>
<TableHead className="rounded-tr-xl">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={5} className="text-center py-4">
<Spinner
className="w-6 h-6 mx-auto"
innerSvgClass="stroke-primary"
/>
</TableCell>
</TableRow>
) : teamMembers.length > 0 ? (
teamMembers.map((member) => (
<TableRow key={member.userId} className="">
<TableCell className="font-medium">
{member.user?.email || "Unknown user"}
</TableCell>
<TableCell>
<div className=" rounded capitalize py-1 text-xs">
{member.role.toLowerCase()}
</div>
</TableCell>
<TableCell>
<div className="text-center w-[100px] rounded capitalize py-1 text-xs bg-green-500/15 dark:bg-green-600/10 text-green-700 dark:text-green-600/90 border border-green-500/25 dark:border-green-700/25">
Active
</div>
</TableCell>
<TableCell>
{formatDistanceToNow(new Date(member.user.createdAt), {
addSuffix: true,
})}
</TableCell>
<TableCell>
<div className="flex gap-2">
{currentIsAdmin ? (
<EditTeamMember
teamUser={{
...member,
userId: String(member.userId),
}}
/>
) : null}
{currentIsAdmin || session?.user.id == member.userId ? (
<DeleteTeamMember
teamUser={{
userId: String(member.userId),
role: member.role,
email: member.user?.email || "Unknown user",
}}
self={session?.user.id == member.userId}
/>
) : null}
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow className="h-32">
<TableCell colSpan={5} className="text-center py-4">
No team members found
</TableCell>
</TableRow>
)}
{/* Pending invites section */}
{pendingInvites.length > 0 && (
<>
{pendingInvites.map((invite) => (
<TableRow key={invite.id} className="">
<TableCell className="font-medium">
{invite.email}
</TableCell>
<TableCell>
<div className=" w-[100px] rounded capitalize py-1 text-xs">
{invite.role.toLowerCase()}
</div>
</TableCell>
<TableCell>
<div className="text-center w-[100px] rounded capitalize py-1 text-xs bg-yellow-500/15 dark:bg-yellow-600/10 text-yellow-700 dark:text-yellow-600/90 border border-yellow-500/25 dark:border-yellow-700/25">
Pending
</div>
</TableCell>
<TableCell>
{formatDistanceToNow(new Date(invite.createdAt), {
addSuffix: true,
})}
</TableCell>
<TableCell>
<div className="flex gap-2">
{currentIsAdmin ? (
<ResendTeamInvite invite={invite} />
) : null}
{currentIsAdmin ? (
<DeleteTeamInvite invite={invite} />
) : null}
</div>
</TableCell>
</TableRow>
))}
</>
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
"use client";
import JoinTeam from "~/components/team/JoinTeam";
export default function CreateTeam() {
return (
<div className="flex items-center justify-center min-h-screen ">
<div className=" w-[300px] flex flex-col gap-8">
<JoinTeam />
</div>
</div>
);
}

View File

@@ -18,6 +18,7 @@ import { Spinner } from "@unsend/ui/src/spinner";
import { api } from "~/trpc/react";
import { useRouter } from "next/navigation";
import { toast } from "@unsend/ui/src/toaster";
import JoinTeam from "./JoinTeam";
const FormSchema = z.object({
name: z.string().min(2, {
@@ -52,9 +53,10 @@ export default function CreateTeam() {
return (
<div className="flex items-center justify-center min-h-screen ">
<div className=" w-[300px] flex flex-col gap-8">
<div className=" w-[400px] flex flex-col gap-8">
<JoinTeam showCreateTeam />
<div>
<h1 className="text-2xl font-semibold text-center">Create Team</h1>
<h1 className=" font-semibold text-center">Create Team</h1>
</div>
<Form {...form}>
<form

View File

@@ -0,0 +1,159 @@
"use client";
import { z } from "zod";
import { Button } from "@unsend/ui/src/button";
import { Spinner } from "@unsend/ui/src/spinner";
import { api } from "~/trpc/react";
import { useRouter } from "next/navigation";
import { toast } from "@unsend/ui/src/toaster";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@unsend/ui/src/dialog";
import { useState } from "react";
import type { AppRouter } from "~/server/api/root";
import type { inferRouterOutputs } from "@trpc/server";
type RouterOutputs = inferRouterOutputs<AppRouter>;
type Invite = NonNullable<
RouterOutputs["invitation"]["getUserInvites"]
>[number];
const FormSchema = z.object({
name: z.string().min(2, {
message: "Team name must be at least 2 characters.",
}),
});
export default function JoinTeam({
showCreateTeam = false,
}: {
showCreateTeam?: boolean;
}) {
const { data: invites, status: invitesStatus } =
api.invitation.getUserInvites.useQuery();
const joinTeamMutation = api.invitation.acceptTeamInvite.useMutation();
const [selectedInvite, setSelectedInvite] = useState<Invite | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const utils = api.useUtils();
const router = useRouter();
const handleAcceptInvite = (invite: Invite) => {
setSelectedInvite(invite);
setDialogOpen(true);
};
const confirmAcceptInvite = () => {
if (!selectedInvite) return;
joinTeamMutation.mutate(
{
inviteId: selectedInvite.id,
},
{
onSuccess: async () => {
toast.success(`Successfully joined ${selectedInvite.team.name}`);
await Promise.all([
utils.invitation.getUserInvites.invalidate(),
utils.team.getTeams.invalidate(),
]);
setDialogOpen(false);
router.replace("/dashboard");
},
onError: (error) => {
toast.error(`Failed to join team: ${error.message}`);
setDialogOpen(false);
},
}
);
};
if (!invites?.length)
return <div className="text-center text-xl">No invites found</div>;
return (
<div>
<div>You have been invited to join team</div>
<div className="space-y-2 mt-4">
{invites.map((invite) => (
<div
key={invite.id}
className="flex items-center gap-2 border rounded-lg p-2 px-4 shadow justify-between"
>
<div>
<div className="text-sm">{invite.team.name}</div>
<div className="flex items-center gap-2">
<div className="text-muted-foreground text-xs capitalize">
{invite.role.toLowerCase()}
</div>
<div className="text-muted-foreground text-xs">
{invite.createdAt.toLocaleDateString()}
</div>
</div>
</div>
<Button
onClick={() => handleAcceptInvite(invite)}
disabled={joinTeamMutation.isPending}
size="sm"
variant="ghost"
>
{joinTeamMutation.isPending ? (
<Spinner className="w-5 h-5" />
) : (
"Accept"
)}
</Button>
</div>
))}
</div>
{showCreateTeam ? (
<div className="mt-8 text-muted-foreground text-sm font-mono text-center">
OR
</div>
) : null}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Accept Team Invitation</DialogTitle>
<DialogDescription>
Are you sure you want to join{" "}
<span className="font-semibold text-primary">
{selectedInvite?.team.name}
</span>
? You will be added as a{" "}
<span className="font-semibold text-primary lowercase">
{selectedInvite?.role.toLowerCase()}
</span>
.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-3 mt-4">
<Button
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={joinTeamMutation.isPending}
>
Cancel
</Button>
<Button
onClick={confirmAcceptInvite}
disabled={joinTeamMutation.isPending}
>
{joinTeamMutation.isPending ? (
<Spinner className="w-5 h-5" />
) : (
"Accept Invitation"
)}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -4,6 +4,7 @@ export const PLAN_PERKS = {
"Send up to 100 emails per day",
"Can have 1 contact book",
"Can have 1 domain",
"Can have 1 team member",
],
BASIC: [
"Includes $10 of usage monthly",
@@ -11,5 +12,6 @@ export const PLAN_PERKS = {
"Send marketing emails at $0.001 per email",
"Can have unlimited contact books",
"Can have unlimited domains",
"Can have unlimited team members",
],
};

View File

@@ -18,6 +18,8 @@ interface TeamContextType {
currentTeam: Team | null;
teams: Team[];
isLoading: boolean;
currentRole: "ADMIN" | "MEMBER";
currentIsAdmin: boolean;
}
const TeamContext = createContext<TeamContextType | undefined>(undefined);
@@ -25,10 +27,14 @@ const TeamContext = createContext<TeamContextType | undefined>(undefined);
export function TeamProvider({ children }: { children: React.ReactNode }) {
const { data: teams, status } = api.team.getTeams.useQuery();
const currentTeam = teams?.[0] ?? null;
const value = {
currentTeam: teams?.[0] ?? null,
currentTeam,
teams: teams || [],
isLoading: status === "pending",
currentRole: currentTeam?.teamUsers[0]?.role ?? "MEMBER",
currentIsAdmin: currentTeam?.teamUsers[0]?.role === "ADMIN",
};
return <TeamContext.Provider value={value}>{children}</TeamContext.Provider>;

View File

@@ -8,6 +8,7 @@ import { contactsRouter } from "./routers/contacts";
import { campaignRouter } from "./routers/campaign";
import { templateRouter } from "./routers/template";
import { billingRouter } from "./routers/billing";
import { invitationRouter } from "./routers/invitiation";
/**
* This is the primary router for your server.
@@ -24,6 +25,7 @@ export const appRouter = createTRPCRouter({
campaign: campaignRouter,
template: templateRouter,
billing: billingRouter,
invitation: invitationRouter,
});
// export type definition of API

View File

@@ -6,6 +6,7 @@ import { z } from "zod";
import {
apiKeyProcedure,
createTRPCRouter,
teamAdminProcedure,
teamProcedure,
} from "~/server/api/trpc";
import {
@@ -15,11 +16,11 @@ import {
import { db } from "~/server/db";
export const billingRouter = createTRPCRouter({
createCheckoutSession: teamProcedure.mutation(async ({ ctx }) => {
createCheckoutSession: teamAdminProcedure.mutation(async ({ ctx }) => {
return (await createCheckoutSessionForTeam(ctx.team.id)).url;
}),
getManageSessionUrl: teamProcedure.mutation(async ({ ctx }) => {
getManageSessionUrl: teamAdminProcedure.mutation(async ({ ctx }) => {
return await getManageSessionUrl(ctx.team.id);
}),
@@ -65,7 +66,7 @@ export const billingRouter = createTRPCRouter({
return subscription;
}),
updateBillingEmail: teamProcedure
updateBillingEmail: teamAdminProcedure
.input(
z.object({
billingEmail: z.string().email(),

View File

@@ -0,0 +1,110 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { env } from "~/env";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
export const invitationRouter = createTRPCRouter({
createTeam: protectedProcedure
.input(z.object({ name: z.string() }))
.mutation(async ({ ctx, input }) => {
const teams = await ctx.db.team.findMany({
where: {
teamUsers: {
some: {
userId: ctx.session.user.id,
},
},
},
});
if (teams.length > 0) {
console.log("User already has a team");
return;
}
if (!env.NEXT_PUBLIC_IS_CLOUD) {
const _team = await ctx.db.team.findFirst();
if (_team) {
throw new TRPCError({
message: "Can't have multiple teams in self hosted version",
code: "UNAUTHORIZED",
});
}
}
return ctx.db.team.create({
data: {
name: input.name,
teamUsers: {
create: {
userId: ctx.session.user.id,
role: "ADMIN",
},
},
},
});
}),
getUserInvites: protectedProcedure.query(async ({ ctx }) => {
if (!ctx.session.user.email) {
return [];
}
const invites = await ctx.db.teamInvite.findMany({
where: {
email: ctx.session.user.email,
},
include: {
team: true,
},
});
return invites;
}),
getInvite: protectedProcedure
.input(z.object({ inviteId: z.string() }))
.query(async ({ ctx, input }) => {
const invite = await ctx.db.teamInvite.findUnique({
where: {
id: input.inviteId,
},
});
return invite;
}),
acceptTeamInvite: protectedProcedure
.input(z.object({ inviteId: z.string() }))
.mutation(async ({ ctx, input }) => {
const invite = await ctx.db.teamInvite.findUnique({
where: {
id: input.inviteId,
},
});
if (!invite) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invite not found",
});
}
await ctx.db.teamUser.create({
data: {
teamId: invite.teamId,
userId: ctx.session.user.id,
role: invite.role,
},
});
await ctx.db.teamInvite.delete({
where: {
id: input.inviteId,
},
});
return true;
}),
});

View File

@@ -2,7 +2,13 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { env } from "~/env";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import {
createTRPCRouter,
protectedProcedure,
teamProcedure,
teamAdminProcedure,
} from "~/server/api/trpc";
import { sendTeamInviteEmail } from "~/server/mailer";
export const teamRouter = createTRPCRouter({
createTeam: protectedProcedure
@@ -55,8 +61,227 @@ export const teamRouter = createTRPCRouter({
},
},
},
include: {
teamUsers: {
where: {
userId: ctx.session.user.id,
},
},
},
});
return teams;
}),
getTeamUsers: teamProcedure.query(async ({ ctx }) => {
const teamUsers = await ctx.db.teamUser.findMany({
where: {
teamId: ctx.team.id,
},
include: {
user: true,
},
});
return teamUsers;
}),
getTeamInvites: teamProcedure.query(async ({ ctx }) => {
const teamInvites = await ctx.db.teamInvite.findMany({
where: {
teamId: ctx.team.id,
},
});
return teamInvites;
}),
createTeamInvite: teamAdminProcedure
.input(z.object({ email: z.string(), role: z.enum(["MEMBER", "ADMIN"]) }))
.mutation(async ({ ctx, input }) => {
const user = await ctx.db.user.findUnique({
where: {
email: input.email,
},
include: {
teamUsers: true,
},
});
if (user && user.teamUsers.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "User already part of a team",
});
}
const teamInvite = await ctx.db.teamInvite.create({
data: {
teamId: ctx.team.id,
email: input.email,
role: input.role,
},
});
const teamUrl = `${env.NEXTAUTH_URL}/join-team?inviteId=${teamInvite.id}`;
await sendTeamInviteEmail(input.email, teamUrl, ctx.team.name);
return teamInvite;
}),
updateTeamUserRole: teamAdminProcedure
.input(
z.object({
userId: z.string(),
role: z.enum(["MEMBER", "ADMIN"]),
})
)
.mutation(async ({ ctx, input }) => {
const teamUser = await ctx.db.teamUser.findFirst({
where: {
teamId: ctx.team.id,
userId: Number(input.userId),
},
});
if (!teamUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Team member not found",
});
}
// Check if this is the last admin
const adminCount = await ctx.db.teamUser.count({
where: {
teamId: ctx.team.id,
role: "ADMIN",
},
});
if (adminCount === 1 && teamUser.role === "ADMIN") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Need at least one admin",
});
}
return ctx.db.teamUser.update({
where: {
teamId_userId: {
teamId: ctx.team.id,
userId: Number(input.userId),
},
},
data: {
role: input.role,
},
});
}),
deleteTeamUser: teamProcedure
.input(z.object({ userId: z.string() }))
.mutation(async ({ ctx, input }) => {
const teamUser = await ctx.db.teamUser.findFirst({
where: {
teamId: ctx.team.id,
userId: Number(input.userId),
},
});
if (!teamUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Team member not found",
});
}
if (
ctx.teamUser.role !== "ADMIN" &&
ctx.session.user.id !== Number(input.userId)
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to delete this team member",
});
}
// Check if this is the last admin
const adminCount = await ctx.db.teamUser.count({
where: {
teamId: ctx.team.id,
role: "ADMIN",
},
});
if (adminCount === 1 && teamUser.role === "ADMIN") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Need at least one admin",
});
}
return ctx.db.teamUser.delete({
where: {
teamId_userId: {
teamId: ctx.team.id,
userId: Number(input.userId),
},
},
});
}),
resendTeamInvite: teamAdminProcedure
.input(z.object({ inviteId: z.string() }))
.mutation(async ({ ctx, input }) => {
const invite = await ctx.db.teamInvite.findUnique({
where: {
id: input.inviteId,
},
});
if (!invite) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invite not found",
});
}
const teamUrl = `${env.NEXTAUTH_URL}/join-team?inviteId=${invite.id}`;
// TODO: Implement email sending logic
await sendTeamInviteEmail(invite.email, teamUrl, ctx.team.name);
return { success: true };
}),
deleteTeamInvite: teamAdminProcedure
.input(z.object({ inviteId: z.string() }))
.mutation(async ({ ctx, input }) => {
const invite = await ctx.db.teamInvite.findFirst({
where: {
teamId: ctx.team.id,
id: {
equals: input.inviteId,
},
},
});
if (!invite) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invite not found",
});
}
return ctx.db.teamInvite.delete({
where: {
teamId_email: {
teamId: ctx.team.id,
email: invite.email,
},
},
});
}),
});

View File

@@ -114,17 +114,30 @@ export const teamProcedure = protectedProcedure.use(async ({ ctx, next }) => {
where: { userId: ctx.session.user.id },
include: { team: true },
});
if (!teamUser) {
throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" });
}
return next({
ctx: {
team: teamUser.team,
teamUser,
session: { ...ctx.session, user: ctx.session.user },
},
});
});
export const teamAdminProcedure = teamProcedure.use(async ({ ctx, next }) => {
if (ctx.teamUser.role !== "ADMIN") {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to perform this action",
});
}
return next();
});
export const domainProcedure = teamProcedure
.input(z.object({ id: z.number() }))
.use(async ({ ctx, next, input }) => {
@@ -224,7 +237,6 @@ export const templateProcedure = teamProcedure
return next({ ctx: { ...ctx, template } });
});
/**
* To manage application settings, for hosted version, authenticated users will be considered as admin
*/

View File

@@ -29,6 +29,25 @@ export async function sendSignUpEmail(
await sendMail(email, subject, text, html);
}
export async function sendTeamInviteEmail(
email: string,
url: string,
teamName: string
) {
const { host } = new URL(url);
if (env.NODE_ENV === "development") {
console.log("Sending team invite email", { email, url, teamName });
return;
}
const subject = "You have been invited to join a team";
const text = `Hey,\n\nYou have been invited to join the team ${teamName} on Unsend.\n\nYou can accept the invitation by clicking the below URL:\n${url}\n\nThanks,\nUnsend Team`;
const html = `<p>Hey,</p> <p>You have been invited to join the team <b>${teamName}</b> on Unsend.</p><p>You can accept the invitation by clicking the below URL:</p><p><a href="${url}">Accept invitation</a></p><br /><br /><p>Thanks,</p><p>Unsend Team</p>`;
await sendMail(email, subject, text, html);
}
async function sendMail(
email: string,
subject: string,

View File

@@ -1,6 +1,7 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { Spinner } from "./spinner";
import { cn } from "../lib/utils";
@@ -39,17 +40,35 @@ export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
isLoading?: boolean;
showSpinner?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
(
{
className,
variant,
size,
asChild = false,
isLoading,
children,
showSpinner = false,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={isLoading || props.disabled}
{...props}
/>
>
{isLoading && showSpinner ? <Spinner className="h-4 w-4 mr-2" /> : null}
{children}
</Comp>
);
}
);