add team management (#131)
* add team management * add more team management * add join team page
This commit is contained in:
@@ -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");
|
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
@@ -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;
|
@@ -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 {
|
||||
|
@@ -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">
|
||||
|
@@ -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>
|
||||
|
@@ -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;
|
@@ -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;
|
142
apps/web/src/app/(dashboard)/settings/team/edit-team-member.tsx
Normal file
142
apps/web/src/app/(dashboard)/settings/team/edit-team-member.tsx
Normal 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;
|
@@ -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>
|
||||
);
|
||||
}
|
15
apps/web/src/app/(dashboard)/settings/team/page.tsx
Normal file
15
apps/web/src/app/(dashboard)/settings/team/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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;
|
153
apps/web/src/app/(dashboard)/settings/team/team-members-list.tsx
Normal file
153
apps/web/src/app/(dashboard)/settings/team/team-members-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
13
apps/web/src/app/join-team/page.tsx
Normal file
13
apps/web/src/app/join-team/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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
|
||||
|
159
apps/web/src/components/team/JoinTeam.tsx
Normal file
159
apps/web/src/components/team/JoinTeam.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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",
|
||||
],
|
||||
};
|
||||
|
@@ -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>;
|
||||
|
@@ -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
|
||||
|
@@ -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(),
|
||||
|
110
apps/web/src/server/api/routers/invitiation.ts
Normal file
110
apps/web/src/server/api/routers/invitiation.ts
Normal 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;
|
||||
}),
|
||||
});
|
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
@@ -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
|
||||
*/
|
||||
|
@@ -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,
|
||||
|
Reference in New Issue
Block a user