Files
better-auth/demo/nextjs/app/dashboard/organization-card.tsx
Bereket Engida c945d1ba95 feat: demo
2024-09-16 13:53:41 +03:00

481 lines
23 KiB
TypeScript

"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { organization, useActiveOrganization, useListOrganizations, useSession } from "@/lib/auth-client";
import { ActiveOrganization, Session } from "@/lib/auth-types";
import { ChevronDownIcon, PlusIcon } from "@radix-ui/react-icons";
import { Loader2, MailPlus } from "lucide-react";
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { AnimatePresence, motion } from "framer-motion";
import CopyButton from "@/components/ui/copy-button";
import Image from "next/image";
export function OrganizationCard(props: {
session: Session | null
}) {
const organizations = useListOrganizations()
const activeOrg = useActiveOrganization()
const [optimisticOrg, setOptimisticOrg] = useState<ActiveOrganization | null>(null)
const [isRevoking, setIsRevoking] = useState<string[]>([]);
useEffect(() => {
setOptimisticOrg(activeOrg.data)
}, [activeOrg.data])
const inviteVariants = {
hidden: { opacity: 0, height: 0 },
visible: { opacity: 1, height: 'auto' },
exit: { opacity: 0, height: 0 }
};
const { data } = useSession()
const session = data || props.session
const currentMember = optimisticOrg?.members.find((member) => member.userId === session?.user.id)
return (
<Card>
<CardHeader>
<CardTitle>
Organization
</CardTitle>
<div className="flex justify-between">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex items-center gap-1 cursor-pointer">
<p className="text-sm">
<span className="font-bold">
</span> {optimisticOrg?.name || "Personal"}
</p>
<ChevronDownIcon />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem className=" py-1" onClick={() => {
organization.setActive(null)
setOptimisticOrg(null)
}}>
<p className="text-sm sm">
Personal
</p>
</DropdownMenuItem>
{
organizations.data?.map((org) => (
<DropdownMenuItem className=" py-1" key={org.id} onClick={() => {
if (org.id === optimisticOrg?.id) {
return
}
organization.setActive(org.id)
setOptimisticOrg({
members: [],
invitations: [],
...org
})
}}>
<p className="text-sm sm">
{org.name}
</p>
</DropdownMenuItem>
))
}
</DropdownMenuContent>
</DropdownMenu>
<div>
<CreateOrganizationDialog />
</div>
</div>
<div className="flex items-center gap-2">
<Avatar className="rounded-none">
<AvatarImage className="object-cover w-full h-full rounded-none" src={optimisticOrg?.logo || ""} />
<AvatarFallback className="rounded-none">
{optimisticOrg?.name?.charAt(0) || "P"}
</AvatarFallback>
</Avatar>
<div>
<p>
{optimisticOrg?.name || "Personal"}
</p>
<p className="text-xs text-muted-foreground">
{optimisticOrg?.members.length || 1} members
</p>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex gap-8">
<div className="flex flex-col gap-2 flex-grow">
<p className="font-medium border-b-2 border-b-foreground/10">
Members
</p>
<div className="flex flex-col gap-2">
{
optimisticOrg?.members.map((member) => (
<div key={member.id} className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Avatar className="w-8 h-8">
<AvatarImage src={member.user.image} className="object-cover w-full h-full" />
<AvatarFallback>
{member.user.name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm">
{member.user.name}
</p>
<p className="text-xs text-muted-foreground">
{member.role}
</p>
</div>
</div>
{
(member.role !== "owner" && (currentMember?.role === "owner" || currentMember?.role === "admin")) && (
<Button size="sm" variant="destructive" onClick={() => {
organization.removeMember({
memberIdOrEmail: member.id
})
}}>
Remove
</Button>
)}
</div>
))
}
{
!optimisticOrg?.id && (
<div>
<div className="flex items-center gap-2">
<Avatar>
<AvatarImage src={session?.user.image} />
<AvatarFallback>
{session?.user.name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm">
{session?.user.name}
</p>
<p className="text-xs text-muted-foreground">
Owner
</p>
</div>
</div>
</div>
)
}
</div>
</div>
<div className="flex flex-col gap-2 flex-grow">
<p className="font-medium border-b-2 border-b-foreground/10">
Invites
</p>
<div className="flex flex-col gap-2">
<AnimatePresence>
{
optimisticOrg?.invitations
.filter((invitation) => invitation.status === "pending")
.map((invitation) => (
<motion.div
key={invitation.id}
className="flex items-center justify-between"
variants={inviteVariants}
initial="hidden"
animate="visible"
exit="exit"
layout
>
<div>
<p className="text-sm">
{invitation.email}
</p>
<p className="text-xs text-muted-foreground">
{invitation.role}
</p>
</div>
<div className="flex items-center gap-2">
<Button
disabled={isRevoking.includes(invitation.id)}
size="sm"
variant="destructive"
onClick={() => {
organization.cancelInvitation({
invitationId: invitation.id,
options: {
onRequest: () => {
setIsRevoking([...isRevoking, invitation.id])
},
onSuccess: () => {
toast.message("Invitation revoked successfully");
setIsRevoking(isRevoking.filter((id) => id !== invitation.id))
setOptimisticOrg({
...optimisticOrg,
invitations: optimisticOrg?.invitations.filter((inv) => inv.id !== invitation.id)
})
},
onError: (ctx) => {
toast.error(ctx.error.message);
setIsRevoking(isRevoking.filter((id) => id !== invitation.id))
}
}
})
}}
>
{isRevoking.includes(invitation.id) ? <Loader2 className="animate-spin" size={16} /> : "Revoke"}
</Button>
<div>
<CopyButton textToCopy={`${window.location.origin}/accept-invitation/${invitation.id}`} />
</div>
</div>
</motion.div>
))
}
</AnimatePresence>
{
optimisticOrg?.invitations.length === 0 && (
<motion.p
className="text-sm text-muted-foreground"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
No Active Invitations
</motion.p>
)
}
{
!optimisticOrg?.id && (
<Label className="text-xs text-muted-foreground">
You can&apos;t invite members to your personal workspace.
</Label>
)
}
</div>
</div>
</div>
<div className="flex justify-end w-full mt-4">
<div>
<div>
{
optimisticOrg?.id && (
<InviteMemberDialog setOptimisticOrg={setOptimisticOrg} optimisticOrg={optimisticOrg} />
)
}
</div>
</div>
</div>
</CardContent>
{/* <CardFooter className="flex justify-between">
</CardFooter> */}
</Card>
)
}
function CreateOrganizationDialog() {
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [isSlugEdited, setIsSlugEdited] = useState(false);
const [logo, setLogo] = useState<string | null>(null);
useEffect(() => {
if (!isSlugEdited) {
const generatedSlug = name.trim().toLowerCase().replace(/\s+/g, "-");
setSlug(generatedSlug);
}
}, [name, isSlugEdited]);
useEffect(() => {
if (open) {
setName("");
setSlug("");
setIsSlugEdited(false);
setLogo(null);
}
}, [open]);
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
const reader = new FileReader();
reader.onloadend = () => {
setLogo(reader.result as string);
};
reader.readAsDataURL(file);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" className="w-full gap-2" variant="default">
<PlusIcon />
<p>
New Organization
</p>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
New Organization
</DialogTitle>
<DialogDescription>
Create a new organization to collaborate with your team.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label>
Organization Name
</Label>
<Input
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>
Organization Slug
</Label>
<Input
value={slug}
onChange={(e) => {
setSlug(e.target.value);
setIsSlugEdited(true);
}}
placeholder="Slug"
/>
</div>
<div className="flex flex-col gap-2">
<Label>
Logo
</Label>
<Input type="file" accept="image/*" onChange={handleLogoChange} />
{logo && (
<div className="mt-2">
<Image src={logo} alt="Logo preview" className="w-16 h-16 object-cover" width={16} height={16} />
</div>
)}
</div>
</div>
<DialogFooter>
<Button onClick={async () => {
setLoading(true);
await organization.create({
name: name,
slug: slug,
logo: logo || undefined,
options: {
onResponse: () => {
setLoading(false);
},
onSuccess: () => {
toast.success("Organization created successfully");
setOpen(false);
},
onError: (error) => {
toast.error(error.error.message);
setLoading(false);
}
}
})
}}>
{loading ? <Loader2 className="animate-spin" size={16} /> : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function InviteMemberDialog({ setOptimisticOrg, optimisticOrg }: { setOptimisticOrg: (org: ActiveOrganization | null) => void, optimisticOrg: ActiveOrganization | null }) {
const [open, setOpen] = useState(false);
const [email, setEmail] = useState("");
const [role, setRole] = useState("member");
const [loading, setLoading] = useState(false);
return (
<Dialog>
<DialogTrigger asChild>
<Button size="sm" className="w-full gap-2" variant="secondary">
<MailPlus size={16} />
<p>
Invite Member
</p>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
Invite Member
</DialogTitle>
<DialogDescription>
Invite a member to your organization.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<Label>
Email
</Label>
<Input placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} />
<Label>
Role
</Label>
<Select value={role} onValueChange={setRole}>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">
Admin
</SelectItem>
<SelectItem value="member">
Member
</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter>
<DialogClose>
<Button disabled={loading} onClick={async () => {
const invite = organization.inviteMember({
email: email,
role: role as "member",
options: {
throw: true,
onSuccess: (ctx) => {
if (optimisticOrg) {
setOptimisticOrg({
...optimisticOrg,
invitations: [...(optimisticOrg?.invitations || []), ctx.data]
})
}
}
}
})
toast.promise(invite, {
loading: "Inviting member...",
success: "Member invited successfully",
error: (error) => error.error.message,
})
}}>
Invite
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}