From 1ed5c8009f0e9d44e543df3697f9ba35e59362c4 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Wed, 26 Mar 2025 22:02:49 +1100 Subject: [PATCH] add team management (#131) * add team management * add more team management * add join team page --- .windsurfrules | 13 + .../migration.sql | 14 ++ .../migration.sql | 2 + .../migration.sql | 2 + apps/web/prisma/schema.prisma | 17 +- .../app/(dashboard)/settings/billing/page.tsx | 7 +- .../src/app/(dashboard)/settings/layout.tsx | 10 +- .../settings/team/delete-team-invite.tsx | 79 ++++++ .../settings/team/delete-team-member.tsx | 88 +++++++ .../settings/team/edit-team-member.tsx | 142 +++++++++++ .../settings/team/invite-team-member.tsx | 178 ++++++++++++++ .../app/(dashboard)/settings/team/page.tsx | 15 ++ .../settings/team/resend-team-invite.tsx | 51 ++++ .../settings/team/team-members-list.tsx | 153 ++++++++++++ apps/web/src/app/join-team/page.tsx | 13 + apps/web/src/components/team/CreateTeam.tsx | 6 +- apps/web/src/components/team/JoinTeam.tsx | 159 ++++++++++++ apps/web/src/lib/constants/payments.ts | 2 + apps/web/src/providers/team-context.tsx | 8 +- apps/web/src/server/api/root.ts | 2 + apps/web/src/server/api/routers/billing.ts | 7 +- .../web/src/server/api/routers/invitiation.ts | 110 +++++++++ apps/web/src/server/api/routers/team.ts | 227 +++++++++++++++++- apps/web/src/server/api/trpc.ts | 14 +- apps/web/src/server/mailer.ts | 19 ++ packages/ui/src/button.tsx | 23 +- 26 files changed, 1348 insertions(+), 13 deletions(-) create mode 100644 .windsurfrules create mode 100644 apps/web/prisma/migrations/20250323114242_add_team_invites/migration.sql create mode 100644 apps/web/prisma/migrations/20250323115457_add_created_at_for_users/migration.sql create mode 100644 apps/web/prisma/migrations/20250325113154_add_team_invites_foreign_key_to_team/migration.sql create mode 100644 apps/web/src/app/(dashboard)/settings/team/delete-team-invite.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/team/delete-team-member.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/team/edit-team-member.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/team/invite-team-member.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/team/page.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/team/resend-team-invite.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/team/team-members-list.tsx create mode 100644 apps/web/src/app/join-team/page.tsx create mode 100644 apps/web/src/components/team/JoinTeam.tsx create mode 100644 apps/web/src/server/api/routers/invitiation.ts diff --git a/.windsurfrules b/.windsurfrules new file mode 100644 index 0000000..2117545 --- /dev/null +++ b/.windsurfrules @@ -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 user’s 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 todo’s, 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. diff --git a/apps/web/prisma/migrations/20250323114242_add_team_invites/migration.sql b/apps/web/prisma/migrations/20250323114242_add_team_invites/migration.sql new file mode 100644 index 0000000..e6a1997 --- /dev/null +++ b/apps/web/prisma/migrations/20250323114242_add_team_invites/migration.sql @@ -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"); diff --git a/apps/web/prisma/migrations/20250323115457_add_created_at_for_users/migration.sql b/apps/web/prisma/migrations/20250323115457_add_created_at_for_users/migration.sql new file mode 100644 index 0000000..eb3480e --- /dev/null +++ b/apps/web/prisma/migrations/20250323115457_add_created_at_for_users/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/apps/web/prisma/migrations/20250325113154_add_team_invites_foreign_key_to_team/migration.sql b/apps/web/prisma/migrations/20250325113154_add_team_invites_foreign_key_to_team/migration.sql new file mode 100644 index 0000000..37d56a0 --- /dev/null +++ b/apps/web/prisma/migrations/20250325113154_add_team_invites_foreign_key_to_team/migration.sql @@ -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; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index a68a10f..e26228f 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -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 { diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx index c72ac9c..77b30d4 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -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 (
diff --git a/apps/web/src/app/(dashboard)/settings/layout.tsx b/apps/web/src/app/(dashboard)/settings/layout.tsx index 221c972..49509cd 100644 --- a/apps/web/src/app/(dashboard)/settings/layout.tsx +++ b/apps/web/src/app/(dashboard)/settings/layout.tsx @@ -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 (

Settings

Usage - Billing + {currentIsAdmin ? ( + + Billing + + ) : null} + Team
{children}
diff --git a/apps/web/src/app/(dashboard)/settings/team/delete-team-invite.tsx b/apps/web/src/app/(dashboard)/settings/team/delete-team-invite.tsx new file mode 100644 index 0000000..b0ff28a --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/team/delete-team-invite.tsx @@ -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 ( + (_open !== open ? setOpen(_open) : null)} + > + + + + + + Cancel Invite + + Are you sure you want to cancel the invite for{" "} + {invite.email}? + + +
+ + +
+
+
+ ); +}; + +export default DeleteTeamInvite; diff --git a/apps/web/src/app/(dashboard)/settings/team/delete-team-member.tsx b/apps/web/src/app/(dashboard)/settings/team/delete-team-member.tsx new file mode 100644 index 0000000..cf424b9 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/team/delete-team-member.tsx @@ -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 ( + (_open !== open ? setOpen(_open) : null)} + > + + + + + + + {self ? "Leave Team" : "Remove Team Member"} + + + {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.'} + + +
+ + +
+
+
+ ); +}; + +export default DeleteTeamMember; diff --git a/apps/web/src/app/(dashboard)/settings/team/edit-team-member.tsx b/apps/web/src/app/(dashboard)/settings/team/edit-team-member.tsx new file mode 100644 index 0000000..c8a436f --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/team/edit-team-member.tsx @@ -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>({ + resolver: zodResolver(teamUserSchema), + defaultValues: { + role: teamUser.role, + }, + }); + + async function onTeamUserUpdate(values: z.infer) { + 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 ( + (_open !== open ? setOpen(_open) : null)} + > + + + + + + Edit Team Member Role + +
+
+ + ( + + Role + + + + )} + /> +
+ + +
+ + +
+
+
+ ); +}; + +export default EditTeamMember; diff --git a/apps/web/src/app/(dashboard)/settings/team/invite-team-member.tsx b/apps/web/src/app/(dashboard)/settings/team/invite-team-member.tsx new file mode 100644 index 0000000..42bb6be --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/team/invite-team-member.tsx @@ -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; + +export default function InviteTeamMember() { + const { currentIsAdmin } = useTeam(); + + const [open, setOpen] = useState(false); + + const form = useForm({ + 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 ( + + + + + + + Invite Team Member + +
+ + ( + + Email + + + + {formState.errors.email ? ( + + ) : ( + + Enter your colleague's email address + + )} + + )} + /> + ( + + Role + + + + )} + /> +
+ + +
+ + +
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/settings/team/page.tsx b/apps/web/src/app/(dashboard)/settings/team/page.tsx new file mode 100644 index 0000000..bee3670 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/team/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import InviteTeamMember from "./invite-team-member"; +import TeamMembersList from "./team-members-list"; + +export default function TeamsPage() { + return ( +
+
+ +
+ +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/settings/team/resend-team-invite.tsx b/apps/web/src/app/(dashboard)/settings/team/resend-team-invite.tsx new file mode 100644 index 0000000..3a87f1e --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/team/resend-team-invite.tsx @@ -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 ( + + + + + + +

Resend invite

+
+
+
+ ); +}; + +export default ResendTeamInvite; diff --git a/apps/web/src/app/(dashboard)/settings/team/team-members-list.tsx b/apps/web/src/app/(dashboard)/settings/team/team-members-list.tsx new file mode 100644 index 0000000..e548c93 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/team/team-members-list.tsx @@ -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 ( +
+
+ + + + User + Role + Status + Joined + Actions + + + + {isLoading ? ( + + + + + + ) : teamMembers.length > 0 ? ( + teamMembers.map((member) => ( + + + {member.user?.email || "Unknown user"} + + +
+ {member.role.toLowerCase()} +
+
+ +
+ Active +
+
+ + {formatDistanceToNow(new Date(member.user.createdAt), { + addSuffix: true, + })} + + +
+ {currentIsAdmin ? ( + + ) : null} + {currentIsAdmin || session?.user.id == member.userId ? ( + + ) : null} +
+
+
+ )) + ) : ( + + + No team members found + + + )} + + {/* Pending invites section */} + {pendingInvites.length > 0 && ( + <> + {pendingInvites.map((invite) => ( + + + {invite.email} + + +
+ {invite.role.toLowerCase()} +
+
+ +
+ Pending +
+
+ + {formatDistanceToNow(new Date(invite.createdAt), { + addSuffix: true, + })} + + +
+ {currentIsAdmin ? ( + + ) : null} + {currentIsAdmin ? ( + + ) : null} +
+
+
+ ))} + + )} +
+
+
+
+ ); +} diff --git a/apps/web/src/app/join-team/page.tsx b/apps/web/src/app/join-team/page.tsx new file mode 100644 index 0000000..0005ab1 --- /dev/null +++ b/apps/web/src/app/join-team/page.tsx @@ -0,0 +1,13 @@ +"use client"; + +import JoinTeam from "~/components/team/JoinTeam"; + +export default function CreateTeam() { + return ( +
+
+ +
+
+ ); +} diff --git a/apps/web/src/components/team/CreateTeam.tsx b/apps/web/src/components/team/CreateTeam.tsx index f28942c..99c0915 100644 --- a/apps/web/src/components/team/CreateTeam.tsx +++ b/apps/web/src/components/team/CreateTeam.tsx @@ -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 (
-
+
+
-

Create Team

+

Create Team

; +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(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
No invites found
; + + return ( +
+
You have been invited to join team
+
+ {invites.map((invite) => ( +
+
+
{invite.team.name}
+
+
+ {invite.role.toLowerCase()} +
+
+ {invite.createdAt.toLocaleDateString()} +
+
+
+ +
+ ))} +
+ {showCreateTeam ? ( +
+ OR +
+ ) : null} + + + + + Accept Team Invitation + + Are you sure you want to join{" "} + + {selectedInvite?.team.name} + + ? You will be added as a{" "} + + {selectedInvite?.role.toLowerCase()} + + . + + +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/lib/constants/payments.ts b/apps/web/src/lib/constants/payments.ts index 30203d2..b050b31 100644 --- a/apps/web/src/lib/constants/payments.ts +++ b/apps/web/src/lib/constants/payments.ts @@ -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", ], }; diff --git a/apps/web/src/providers/team-context.tsx b/apps/web/src/providers/team-context.tsx index 5513181..c96bc7a 100644 --- a/apps/web/src/providers/team-context.tsx +++ b/apps/web/src/providers/team-context.tsx @@ -18,6 +18,8 @@ interface TeamContextType { currentTeam: Team | null; teams: Team[]; isLoading: boolean; + currentRole: "ADMIN" | "MEMBER"; + currentIsAdmin: boolean; } const TeamContext = createContext(undefined); @@ -25,10 +27,14 @@ const TeamContext = createContext(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 {children}; diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts index 399cbc0..07c80e8 100644 --- a/apps/web/src/server/api/root.ts +++ b/apps/web/src/server/api/root.ts @@ -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 diff --git a/apps/web/src/server/api/routers/billing.ts b/apps/web/src/server/api/routers/billing.ts index c389ec4..a12280f 100644 --- a/apps/web/src/server/api/routers/billing.ts +++ b/apps/web/src/server/api/routers/billing.ts @@ -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(), diff --git a/apps/web/src/server/api/routers/invitiation.ts b/apps/web/src/server/api/routers/invitiation.ts new file mode 100644 index 0000000..c010cc9 --- /dev/null +++ b/apps/web/src/server/api/routers/invitiation.ts @@ -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; + }), +}); diff --git a/apps/web/src/server/api/routers/team.ts b/apps/web/src/server/api/routers/team.ts index afcf4c6..87855c9 100644 --- a/apps/web/src/server/api/routers/team.ts +++ b/apps/web/src/server/api/routers/team.ts @@ -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, + }, + }, + }); + }), }); diff --git a/apps/web/src/server/api/trpc.ts b/apps/web/src/server/api/trpc.ts index 06ec679..e7bd521 100644 --- a/apps/web/src/server/api/trpc.ts +++ b/apps/web/src/server/api/trpc.ts @@ -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 */ diff --git a/apps/web/src/server/mailer.ts b/apps/web/src/server/mailer.ts index bc639e7..732cd40 100644 --- a/apps/web/src/server/mailer.ts +++ b/apps/web/src/server/mailer.ts @@ -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 = `

Hey,

You have been invited to join the team ${teamName} on Unsend.

You can accept the invitation by clicking the below URL:

Accept invitation



Thanks,

Unsend Team

`; + + await sendMail(email, subject, text, html); +} + async function sendMail( email: string, subject: string, diff --git a/packages/ui/src/button.tsx b/packages/ui/src/button.tsx index 6f965d8..4d0a4d0 100644 --- a/packages/ui/src/button.tsx +++ b/packages/ui/src/button.tsx @@ -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, VariantProps { asChild?: boolean; + isLoading?: boolean; + showSpinner?: boolean; } const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { + ( + { + className, + variant, + size, + asChild = false, + isLoading, + children, + showSpinner = false, + ...props + }, + ref + ) => { const Comp = asChild ? Slot : "button"; return ( + > + {isLoading && showSpinner ? : null} + {children} + ); } );