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

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,