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