update users stat, user sheet, dan action user

This commit is contained in:
vergiLgood1 2025-02-28 22:06:43 +07:00
parent 681517e28e
commit dd24481574
11 changed files with 768 additions and 542 deletions

View File

@ -60,11 +60,11 @@ export default function RootLayout({
</nav> */} </nav> */}
<div className="flex flex-col max-w-full p-0"> <div className="flex flex-col max-w-full p-0">
{children} {children}
<Toaster position="top-right" /> <Toaster theme="system" richColors position="top-right" />
</div> </div>
{/* <footer className="w-full flex items-center justify-center border-t mx-auto text-center text-xs gap-8 py-16 mt-auto"> {/* <footer className="w-full flex items-center justify-center border-t mx-auto text-center text-xs gap-8 py-16 mt-auto">
<p> <p>
Powered by{" "} Powered by{" "}
<a <a
href="" href=""

View File

@ -112,7 +112,7 @@ export async function banUser(userId: string): Promise<User> {
banUntil.setFullYear(banUntil.getFullYear() + 100) banUntil.setFullYear(banUntil.getFullYear() + 100)
const { data, error } = await supabase.auth.admin.updateUserById(userId, { const { data, error } = await supabase.auth.admin.updateUserById(userId, {
ban_duration: "100y", ban_duration: "100h",
}) })
if (error) { if (error) {

View File

@ -1,156 +1,165 @@
"use client" "use client";
import { useState } from "react" import type React from "react";
import { useState } from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label" import { Input } from "@/components/ui/input";
import { Input } from "@/components/ui/input" import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea" import { createUser } from "@/app/protected/(admin)/dashboard/user-management/action";
import { Switch } from "@/components/ui/switch" import { toast } from "sonner";
import { createUser } from "@/app/protected/(admin)/dashboard/user-management/action" import { Mail, Lock, Loader2, X } from "lucide-react";
import { toast } from "sonner"
interface AddUserDialogProps { interface AddUserDialogProps {
open: boolean open: boolean;
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void;
onUserAdded: () => void onUserAdded: () => void;
} }
export function AddUserDialog({ open, onOpenChange, onUserAdded }: AddUserDialogProps) { export function AddUserDialog({
const [loading, setLoading] = useState(false) open,
onOpenChange,
onUserAdded,
}: AddUserDialogProps) {
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
email: "", email: "",
password: "", password: "",
phone: "",
metadata: "{}",
emailConfirm: true, emailConfirm: true,
}) });
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value })) setFormData((prev) => ({ ...prev, [name]: value }));
} };
const handleSwitchChange = (checked: boolean) => {
setFormData((prev) => ({ ...prev, emailConfirm: checked }))
}
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault();
setLoading(true) setLoading(true);
try { try {
let metadata = {}
try {
metadata = JSON.parse(formData.metadata)
} catch (error) {
toast.error("Invalid JSON. Please check your metadata format.")
setLoading(false)
return
}
await createUser({ await createUser({
email: formData.email, email: formData.email,
password: formData.password, password: formData.password,
phone: formData.phone,
user_metadata: metadata,
email_confirm: formData.emailConfirm, email_confirm: formData.emailConfirm,
}) });
toast.success("User created successfully.") toast.success("User created successfully.");
onUserAdded() onUserAdded();
onOpenChange(false) onOpenChange(false);
setFormData({ setFormData({
email: "", email: "",
password: "", password: "",
phone: "",
metadata: "{}",
emailConfirm: true, emailConfirm: true,
}) });
} catch (error) { } catch (error) {
toast.error("Failed to create user.") toast.error("Failed to create user.");
} finally { } finally {
setLoading(false) setLoading(false);
} }
} };
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md border-0 text-white">
<DialogHeader> <DialogHeader className="flex flex-row items-center justify-between space-y-0 space-x-4 pb-4">
<DialogTitle>Add User</DialogTitle> <DialogTitle className="text-xl font-semibold text-white">
<DialogDescription> Create a new user
Create a new user account with email and password. </DialogTitle>
</DialogDescription> {/* <Button
variant="ghost"
size="icon"
className="h-8 w-8 text-zinc-400 hover:text-white hover:bg-zinc-800"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4" />
</Button> */}
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<label htmlFor="email" className="text-sm text-zinc-400">
Email address
</label>
<div className="relative">
<Mail className="absolute left-3 top-2.5 h-5 w-5 text-zinc-500" />
<Input
id="email"
name="email"
type="email"
required
placeholder="user@example.com"
value={formData.email}
onChange={handleInputChange}
className="pl-10 bg-zinc-900 border-zinc-800 text-white placeholder:text-zinc-500 "
/>
</div>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm text-zinc-400">
User Password
</label>
<div className="relative">
<Lock className="absolute left-3 top-2.5 h-5 w-5 text-zinc-500" />
<Input
id="password"
name="password"
type="password"
required
placeholder="••••••••"
value={formData.password}
onChange={handleInputChange}
className="pl-10 bg-zinc-900 border-zinc-800 text-white placeholder:text-zinc-500 "
/>
</div>
</div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">Email *</Label> <div className="flex items-center space-x-2">
<Input <Checkbox
id="email" id="email-confirm"
name="email" checked={formData.emailConfirm}
type="email" onCheckedChange={(checked) =>
required setFormData((prev) => ({
value={formData.email} ...prev,
onChange={handleInputChange} emailConfirm: checked as boolean,
/> }))
}
className="border-zinc-700"
/>
<label htmlFor="email-confirm" className="text-sm text-white">
Auto Confirm User?
</label>
</div>
<p className="text-sm text-zinc-500 pl-6">
A confirmation email will not be sent when creating a user via
this form.
</p>
</div> </div>
<div className="space-y-2">
<Label htmlFor="password">Password *</Label> <Button
<Input type="submit"
id="password" disabled={loading}
name="password" className="w-full text-white"
type="password" >
required {loading ? (
value={formData.password} <>
onChange={handleInputChange} <Loader2 className="mr-2 h-4 w-4 animate-spin" />
/> Creating...
</div> </>
<div className="space-y-2"> ) : (
<Label htmlFor="phone">Phone</Label> "Create user"
<Input )}
id="phone" </Button>
name="phone"
value={formData.phone}
onChange={handleInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="metadata">Metadata (JSON)</Label>
<Textarea
id="metadata"
name="metadata"
value={formData.metadata}
onChange={handleInputChange}
className="font-mono text-sm"
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="email-confirm"
checked={formData.emailConfirm}
onCheckedChange={handleSwitchChange}
/>
<Label htmlFor="email-confirm">Auto-confirm email</Label>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Creating..." : "Create User"}
</Button>
</DialogFooter>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) );
} }

View File

@ -10,19 +10,22 @@ import {
type SortingState, type SortingState,
getFilteredRowModel, getFilteredRowModel,
type ColumnFiltersState, type ColumnFiltersState,
getPaginationRowModel,
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Filter } from "lucide-react" import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Filter } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
interface DataTableProps<TData, TValue> { interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[] columns: ColumnDef<TData, TValue>[]
data: TData[] data: TData[]
loading?: boolean loading?: boolean
onRowClick?: (row: TData) => void onRowClick?: (row: TData) => void
pageSize?: number
} }
export function DataTable<TData, TValue>({ export function DataTable<TData, TValue>({
@ -30,10 +33,15 @@ export function DataTable<TData, TValue>({
data, data,
loading = false, loading = false,
onRowClick, onRowClick,
pageSize = 5,
}: DataTableProps<TData, TValue>) { }: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]) const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]) const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState({}) const [columnVisibility, setColumnVisibility] = useState({})
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: pageSize,
})
const table = useReactTable({ const table = useReactTable({
data, data,
@ -44,10 +52,13 @@ export function DataTable<TData, TValue>({
onColumnFiltersChange: setColumnFilters, onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility, onColumnVisibilityChange: setColumnVisibility,
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
state: { state: {
sorting, sorting,
columnFilters, columnFilters,
columnVisibility, columnVisibility,
pagination,
}, },
}) })
@ -67,7 +78,7 @@ export function DataTable<TData, TValue>({
))} ))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{Array.from({ length: 5 }).map((_, index) => ( {Array.from({ length: pagination.pageSize }).map((_, index) => (
<TableRow key={index}> <TableRow key={index}>
{columns.map((_, colIndex) => ( {columns.map((_, colIndex) => (
<TableCell key={colIndex}> <TableCell key={colIndex}>
@ -158,6 +169,83 @@ export function DataTable<TData, TValue>({
)} )}
</TableBody> </TableBody>
</Table> </Table>
{/* Pagination Controls */}
<div className="flex items-center justify-between px-4 py-2 border-t">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div>
Showing {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1} to{" "}
{Math.min(
(table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize,
table.getFilteredRowModel().rows.length,
)}{" "}
of {table.getFilteredRowModel().rows.length} entries
</div>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[5, 10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1 text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</div>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div> </div>
) )
} }

View File

@ -1,8 +1,8 @@
"use client" "use client";
import type React from "react" import type React from "react";
import { useState } from "react" import { useState } from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -10,72 +10,80 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea";
import { useMutation } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query";
import { inviteUser } from "@/app/protected/(admin)/dashboard/user-management/action" import { inviteUser } from "@/app/protected/(admin)/dashboard/user-management/action";
import { toast } from "sonner" import { toast } from "sonner";
interface InviteUserDialogProps { interface InviteUserDialogProps {
open: boolean open: boolean;
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void;
onUserInvited: () => void onUserInvited: () => void;
} }
export function InviteUserDialog({ open, onOpenChange, onUserInvited }: InviteUserDialogProps) { export function InviteUserDialog({
open,
onOpenChange,
onUserInvited,
}: InviteUserDialogProps) {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
email: "", email: "",
metadata: "{}", metadata: "{}",
}) });
const inviteUserMutation = useMutation({ const inviteUserMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
let metadata = {} let metadata = {};
try { try {
metadata = JSON.parse(formData.metadata) metadata = JSON.parse(formData.metadata);
} catch (error) { } catch (error) {
toast.error("Invalid JSON. Please check your metadata format.") toast.error("Invalid JSON. Please check your metadata format.");
throw new Error("Invalid JSON") throw new Error("Invalid JSON");
} }
return inviteUser({ return inviteUser({
email: formData.email, email: formData.email,
user_metadata: metadata, user_metadata: metadata,
}) });
}, },
onSuccess: () => { onSuccess: () => {
toast.success("Invitation sent") toast.success("Invitation sent");
onUserInvited() onUserInvited();
onOpenChange(false) onOpenChange(false);
setFormData({ setFormData({
email: "", email: "",
metadata: "{}", metadata: "{}",
}) });
}, },
onError: () => { onError: () => {
toast.error("Failed to send invitation") toast.error("Failed to send invitation");
}, },
}) });
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { const handleInputChange = (
const { name, value } = e.target e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
setFormData((prev) => ({ ...prev, [name]: value })) ) => {
} const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault();
inviteUserMutation.mutate() inviteUserMutation.mutate();
} };
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Invite User</DialogTitle> <DialogTitle>Invite User</DialogTitle>
<DialogDescription>Send an invitation email to a new user.</DialogDescription> <DialogDescription>
Send an invitation email to a new user.
</DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@ -87,20 +95,16 @@ export function InviteUserDialog({ open, onOpenChange, onUserInvited }: InviteUs
required required
value={formData.email} value={formData.email}
onChange={handleInputChange} onChange={handleInputChange}
placeholder="example@gmail.com"
/> />
</div> </div>
<div className="space-y-2">
<Label htmlFor="invite-metadata">Metadata (JSON)</Label>
<Textarea
id="invite-metadata"
name="metadata"
value={formData.metadata}
onChange={handleInputChange}
className="font-mono text-sm"
/>
</div>
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}> <Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={inviteUserMutation.isPending}> <Button type="submit" disabled={inviteUserMutation.isPending}>
@ -110,6 +114,5 @@ export function InviteUserDialog({ open, onOpenChange, onUserInvited }: InviteUs
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) );
} }

View File

@ -1,20 +1,12 @@
"use client" "use client"
import type React from "react"
import { useState } from "react" import { useState } from "react"
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from "@/components/ui/sheet"
import { Button } from "@/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Switch } from "@/components/ui/switch"
import { Separator } from "@/components/ui/separator"
import { Badge } from "@/components/ui/badge"
import { useMutation } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
import { toast } from "sonner"
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -26,59 +18,69 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog" } from "@/components/ui/alert-dialog"
import { updateUser, deleteUser, sendPasswordRecovery, sendMagicLink, banUser, unbanUser } from "@/app/protected/(admin)/dashboard/user-management/action" import { Mail, Trash2, Ban, SendHorizonal, CheckCircle, XCircle, Copy, Loader2 } from "lucide-react"
import { User } from "@/src/models/users/users.model" import { banUser, deleteUser, sendMagicLink, sendPasswordRecovery, unbanUser } from "@/app/protected/(admin)/dashboard/user-management/action"
import { toast } from "sonner"
interface UserSheetProps { // // Mock functions (replace with your actual API calls)
user: User // const updateUser = async (id: string, data: any) => {
// console.log(`Updating user ${id} with data:`, data)
// await new Promise((resolve) => setTimeout(resolve, 500)) // Simulate API call
// return { id, ...data }
// }
// const deleteUser = async (id: string) => {
// console.log(`Deleting user ${id}`)
// await new Promise((resolve) => setTimeout(resolve, 500)) // Simulate API call
// return { success: true }
// }
// const sendPasswordRecovery = async (email: string) => {
// console.log(`Sending password recovery email to ${email}`)
// await new Promise((resolve) => setTimeout(resolve, 500)) // Simulate API call
// return { success: true }
// }
// const sendMagicLink = async (email: string) => {
// console.log(`Sending magic link to ${email}`)
// await new Promise((resolve) => setTimeout(resolve, 500)) // Simulate API call
// return { success: true }
// }
// const banUser = async (id: string) => {
// console.log(`Banning user ${id}`)
// await new Promise((resolve) => setTimeout(resolve, 500)) // Simulate API call
// return { success: true }
// }
// const unbanUser = async (id: string) => {
// console.log(`Unbanning user ${id}`)
// await new Promise((resolve) => setTimeout(resolve, 500)) // Simulate API call
// return { success: true }
// }
interface UserDetailsSheetProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
user: any
onUserUpdate: () => void onUserUpdate: () => void
} }
export function UserSheet({ user, open, onOpenChange, onUserUpdate }: UserSheetProps) { export function UserDetailsSheet({ open, onOpenChange, user, onUserUpdate }: UserDetailsSheetProps) {
const [isDeleting, setIsDeleting] = useState(false)
const [formData, setFormData] = useState({
email: user.email || "",
phone: user.phone || "",
metadata: JSON.stringify(user.raw_user_meta_data || {}, null, 2),
})
const updateUserMutation = useMutation({
mutationFn: async () => {
let metadata = {}
try {
metadata = JSON.parse(formData.metadata)
} catch (error) {
toast.error("Invalid JSON. Please check your metadata format.")
throw new Error("Invalid JSON")
}
return updateUser(user.id, {
email: formData.email,
phone: formData.phone,
user_metadata: metadata,
})
},
onSuccess: () => {
toast.success("User updated successfully")
onUserUpdate()
},
onError: () => {
toast.error("Failed to update user")
},
})
const deleteUserMutation = useMutation({ const deleteUserMutation = useMutation({
mutationFn: () => deleteUser(user.id), mutationFn: () => deleteUser(user.id),
onSuccess: () => { onSuccess: () => {
toast.success("User deleted successfully") toast.success("User deleted successfully")
onUserUpdate() onUserUpdate()
onOpenChange(false)
}, },
onError: () => { onError: () => {
toast.error("Failed to delete user") toast.error("Failed to delete user")
}, },
onSettled: () => {
setIsDeleting(false)
},
}) })
const sendPasswordRecoveryMutation = useMutation({ const sendPasswordRecoveryMutation = useMutation({
@ -104,7 +106,7 @@ export function UserSheet({ user, open, onOpenChange, onUserUpdate }: UserSheetP
return sendMagicLink(user.email) return sendMagicLink(user.email)
}, },
onSuccess: () => { onSuccess: () => {
toast.success("Magic link sent successfully") toast.success("Magic link sent successfully")
}, },
onError: () => { onError: () => {
toast.error("Failed to send magic link") toast.error("Failed to send magic link")
@ -128,189 +130,247 @@ export function UserSheet({ user, open, onOpenChange, onUserUpdate }: UserSheetP
}, },
}) })
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setFormData((prev) => ({ ...prev, [name]: value }))
}
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-md md:max-w-lg overflow-y-auto"> <SheetContent className="sm:max-w-md md:max-w-lg overflow-y-auto">
<SheetHeader className="space-y-1"> <SheetHeader className="space-y-1">
<SheetTitle className="text-xl flex items-center gap-2"> <SheetTitle className="text-xl flex items-center gap-2">
User Details {user.email}
<Button
variant="ghost"
size="icon"
className="h-4 w-4"
onClick={() => {
navigator.clipboard.writeText(user.email)
// Optionally add a toast notification here
}}
>
<Copy className="h-4 w-4" />
</Button>
{user.banned_until && <Badge variant="destructive">Banned</Badge>} {user.banned_until && <Badge variant="destructive">Banned</Badge>}
{!user.email_confirmed_at && <Badge variant="outline">Unconfirmed</Badge>} {!user.email_confirmed_at && <Badge variant="outline">Unconfirmed</Badge>}
{!user.banned_until && user.email_confirmed_at && <Badge variant="default">Active</Badge>} {!user.banned_until && user.email_confirmed_at && <Badge variant="default">Active</Badge>}
</SheetTitle> </SheetTitle>
<SheetDescription>ID: {user.id}</SheetDescription>
</SheetHeader> </SheetHeader>
<Tabs defaultValue="details" className="mt-6"> <div className="mt-6 space-y-8">
<TabsList className="grid grid-cols-3 mb-4"> {/* User Information Section */}
<TabsTrigger value="details">Details</TabsTrigger> <div className="space-y-4">
<TabsTrigger value="security">Security</TabsTrigger> <h3 className="text-lg font-semibold">User Information</h3>
<TabsTrigger value="actions">Actions</TabsTrigger>
</TabsList>
<TabsContent value="details" className="space-y-4"> <div className="space-y-2 text-sm">
<div className="space-y-2"> <div className="flex justify-between items-center py-1">
<Label htmlFor="email">Email</Label> <span className="text-muted-foreground">User UID</span>
<Input id="email" name="email" value={formData.email} onChange={handleInputChange} /> <div className="flex items-center">
</div> <span className="font-mono">{user.id}</span>
<div className="space-y-2"> <Button
<Label htmlFor="phone">Phone</Label> variant="ghost"
<Input id="phone" name="phone" value={formData.phone} onChange={handleInputChange} /> size="icon"
</div> className="h-4 w-4 ml-2"
<div className="space-y-2"> onClick={() => {
<Label htmlFor="metadata">Metadata (JSON)</Label> navigator.clipboard.writeText(user.id)
<Textarea // Optionally add a toast notification here
id="metadata" }}
name="metadata" >
value={formData.metadata} <Copy className="h-4 w-4" />
onChange={handleInputChange}
className="font-mono text-sm h-40"
/>
</div>
<div className="space-y-2">
<Label>Created At</Label>
<div className="text-sm text-muted-foreground">{new Date(user.created_at).toLocaleString()}</div>
</div>
<div className="space-y-2">
<Label>Last Sign In</Label>
<div className="text-sm text-muted-foreground">
{user.last_sign_in_at ? new Date(user.last_sign_in_at).toLocaleString() : "Never"}
</div>
</div>
<Button
onClick={() => updateUserMutation.mutate()}
disabled={updateUserMutation.isPending}
className="w-full"
>
{updateUserMutation.isPending ? "Saving..." : "Save Changes"}
</Button>
</TabsContent>
<TabsContent value="security" className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="email-confirmed">Email Confirmed</Label>
<Switch id="email-confirmed" checked={!!user.email_confirmed_at} disabled />
</div>
{user.email_confirmed_at && (
<div className="text-xs text-muted-foreground">
Confirmed at: {new Date(user.email_confirmed_at).toLocaleString()}
</div>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="phone-confirmed">Phone Confirmed</Label>
<Switch id="phone-confirmed" checked={!!user.phone_confirmed_at} disabled />
</div>
{user.phone_confirmed_at && (
<div className="text-xs text-muted-foreground">
Confirmed at: {new Date(user.phone_confirmed_at).toLocaleString()}
</div>
)}
</div>
<Separator />
<div className="space-y-2">
<Label>Authentication Factors</Label>
<div className="text-sm text-muted-foreground">
{user.factors?.length
? user.factors.map((factor, i) => (
<div key={i} className="flex items-center gap-2">
<Badge variant="outline">{factor.factor_type}</Badge>
<span>{new Date(factor.created_at).toLocaleString()}</span>
</div>
))
: "No authentication factors"}
</div>
</div>
<Separator />
<div className="space-y-2">
<Label>Password Reset</Label>
<Button
variant="outline"
onClick={() => sendPasswordRecoveryMutation.mutate()}
disabled={sendPasswordRecoveryMutation.isPending || !user.email}
className="w-full"
>
Send Password Recovery Email
</Button>
</div>
<div className="space-y-2">
<Label>Magic Link</Label>
<Button
variant="outline"
onClick={() => sendMagicLinkMutation.mutate()}
disabled={sendMagicLinkMutation.isPending || !user.email}
className="w-full"
>
Send Magic Link
</Button>
</div>
</TabsContent>
<TabsContent value="actions" className="space-y-4">
<div className="space-y-2">
<Label>Ban User</Label>
<Button
variant={user.banned_until ? "default" : "destructive"}
onClick={() => toggleBanMutation.mutate()}
disabled={toggleBanMutation.isPending}
className="w-full"
>
{user.banned_until ? "Unban User" : "Ban User"}
</Button>
{user.banned_until && (
<div className="text-xs text-muted-foreground">
Banned until: {new Date(user.banned_until).toLocaleString()}
</div>
)}
</div>
<Separator />
<div className="space-y-2">
<Label>Delete User</Label>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-full">
Delete User
</Button> </Button>
</AlertDialogTrigger> </div>
<AlertDialogContent> </div>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the user account and remove their data
from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteUserMutation.mutate()}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TabsContent>
</Tabs>
<SheetFooter className="mt-4"> <div className="flex justify-between items-center py-1">
<span className="text-muted-foreground">Created at</span>
<span>{new Date(user.created_at).toLocaleString()}</span>
</div>
<div className="flex justify-between items-center py-1">
<span className="text-muted-foreground">Updated at</span>
<span>{new Date(user.updated_at || user.created_at).toLocaleString()}</span>
</div>
<div className="flex justify-between items-center py-1">
<span className="text-muted-foreground">Invited at</span>
<span>{user.invited_at ? new Date(user.invited_at).toLocaleString() : "-"}</span>
</div>
<div className="flex justify-between items-center py-1">
<span className="text-muted-foreground">Confirmation sent at</span>
<span>{user.confirmation_sent_at ? new Date(user.confirmation_sent_at).toLocaleString() : "-"}</span>
</div>
<div className="flex justify-between items-center py-1">
<span className="text-muted-foreground">Confirmed at</span>
<span>{user.email_confirmed_at ? new Date(user.email_confirmed_at).toLocaleString() : "-"}</span>
</div>
<div className="flex justify-between items-center py-1">
<span className="text-muted-foreground">Last signed in</span>
<span>{user.last_sign_in_at ? new Date(user.last_sign_in_at).toLocaleString() : "Never"}</span>
</div>
<div className="flex justify-between items-center py-1">
<span className="text-muted-foreground">SSO</span>
<XCircle className="h-4 w-4 text-muted-foreground" />
</div>
</div>
</div>
<Separator />
{/* Provider Information Section */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Provider Information</h3>
<p className="text-sm text-muted-foreground">The user has the following providers</p>
<div className="border rounded-md p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Mail className="h-5 w-5" />
<div>
<div className="font-medium">Email</div>
<div className="text-xs text-muted-foreground">Signed in with a email account via OAuth</div>
</div>
</div>
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
<CheckCircle className="h-3 w-3 mr-1" /> Enabled
</Badge>
</div>
</div>
<div className="border rounded-md p-4 space-y-4">
<div className="flex justify-between items-center">
<div>
<h4 className="font-medium">Reset password</h4>
<p className="text-xs text-muted-foreground">Send a password recovery email to the user</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => sendPasswordRecoveryMutation.mutate()}
disabled={sendPasswordRecoveryMutation.isPending || !user.email}
>
{sendPasswordRecoveryMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending...
</>
) : (
<>
<Mail className="h-4 w-4 mr-2" />
Send password recovery
</>
)}
</Button>
</div>
<Separator />
<div className="flex justify-between items-center">
<div>
<h4 className="font-medium">Send magic link</h4>
<p className="text-xs text-muted-foreground">Passwordless login via email for the user</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => sendMagicLinkMutation.mutate()}
disabled={sendMagicLinkMutation.isPending || !user.email}
>
{sendMagicLinkMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending...
</>
) : (
<>
<SendHorizonal className="h-4 w-4 mr-2" />
Send magic link
</>
)}
</Button>
</div>
</div>
</div>
<Separator />
{/* Danger Zone Section */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-destructive">Danger zone</h3>
<p className="text-sm text-muted-foreground">Be wary of the following features as they cannot be undone.</p>
<div className="space-y-4">
<div className="border border-destructive/20 rounded-md p-4 flex justify-between items-center">
<div>
<h4 className="font-medium">Ban user</h4>
<p className="text-xs text-muted-foreground">Revoke access to the project for a set duration</p>
</div>
<Button
variant={user.banned_until ? "outline" : "outline"}
size="sm"
onClick={() => toggleBanMutation.mutate()}
disabled={toggleBanMutation.isPending}
>
{toggleBanMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{user.banned_until ? "Unbanning..." : "Banning..."}
</>
) : (
<>
<Ban className="h-4 w-4 mr-2" />
{user.banned_until ? "Unban user" : "Ban user"}
</>
)}
</Button>
</div>
<div className="border border-destructive/20 rounded-md p-4 flex justify-between items-center">
<div>
<h4 className="font-medium">Delete user</h4>
<p className="text-xs text-muted-foreground">User will no longer have access to the project</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm">
<Trash2 className="h-4 w-4 mr-2" />
Delete user
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the user account and remove their
data from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setIsDeleting(true)
deleteUserMutation.mutate()
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Deleting...
</>
) : (
"Delete"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
</div>
<SheetFooter className="mt-6">
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
Close Close
</Button> </Button>

View File

@ -1,67 +1,78 @@
"use client" "use client";
import { useState } from "react" import { useState } from "react";
import { PlusCircle, Search, Filter, MoreHorizontal, X, ChevronDown } from 'lucide-react' import {
import { Button } from "@/components/ui/button" PlusCircle,
import { Input } from "@/components/ui/input" Search,
import { Badge } from "@/components/ui/badge" Filter,
MoreHorizontal,
X,
ChevronDown,
UserPlus,
Mail,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu";
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query";
import { fetchUsers } from "@/app/protected/(admin)/dashboard/user-management/action" import { fetchUsers } from "@/app/protected/(admin)/dashboard/user-management/action";
import { User } from "@/src/models/users/users.model" import { User } from "@/src/models/users/users.model";
import { toast } from "sonner" import { toast } from "sonner";
import { DataTable } from "./data-table" import { DataTable } from "./data-table";
import { UserSheet } from "./sheet" import { InviteUserDialog } from "./invite-user";
import { InviteUserDialog } from "./invite-user" import { AddUserDialog } from "./add-user-dialog";
import { AddUserDialog } from "./add-user-dialog" import { UserDetailsSheet } from "./sheet";
export default function UserManagement() { export default function UserManagement() {
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("");
const [selectedUser, setSelectedUser] = useState<User | null>(null) const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [isSheetOpen, setIsSheetOpen] = useState(false) const [isSheetOpen, setIsSheetOpen] = useState(false);
const [isAddUserOpen, setIsAddUserOpen] = useState(false) const [isAddUserOpen, setIsAddUserOpen] = useState(false);
const [isInviteUserOpen, setIsInviteUserOpen] = useState(false) const [isInviteUserOpen, setIsInviteUserOpen] = useState(false);
// Use React Query to fetch users // Use React Query to fetch users
const { data: users = [], isLoading, refetch } = useQuery({ const {
queryKey: ['users'], data: users = [],
isLoading,
refetch,
} = useQuery({
queryKey: ["users"],
queryFn: async () => { queryFn: async () => {
try { try {
return await fetchUsers() return await fetchUsers();
} catch (error) { } catch (error) {
toast.error("Failed to fetch users") toast.error("Failed to fetch users");
return [] return [];
} }
} },
}) });
const handleUserClick = (user: User) => { const handleUserClick = (user: User) => {
setSelectedUser(user) setSelectedUser(user);
setIsSheetOpen(true) setIsSheetOpen(true);
} };
const handleUserUpdate = () => { const handleUserUpdate = () => {
refetch() refetch();
setIsSheetOpen(false) setIsSheetOpen(false);
} };
const filteredUsers = users.filter((user) => { const filteredUsers = users.filter((user) => {
if (!searchQuery) return true if (!searchQuery) return true;
const query = searchQuery.toLowerCase() const query = searchQuery.toLowerCase();
return ( return (
user.email?.toLowerCase().includes(query) || user.email?.toLowerCase().includes(query) ||
user.phone?.toLowerCase().includes(query) || user.phone?.toLowerCase().includes(query) ||
user.id.toLowerCase().includes(query) user.id.toLowerCase().includes(query)
) );
}) });
const columns = [ const columns = [
{ {
@ -73,13 +84,17 @@ export default function UserManagement() {
{row.original.email?.[0]?.toUpperCase() || "?"} {row.original.email?.[0]?.toUpperCase() || "?"}
</div> </div>
<div> <div>
<div className="font-medium">{row.original.email || "No email"}</div> <div className="font-medium">
<div className="text-xs text-muted-foreground">{row.original.id}</div> {row.original.email || "No email"}
</div>
<div className="text-xs text-muted-foreground">
{row.original.id}
</div>
</div> </div>
</div> </div>
), ),
filterFn: (row: any, id: string, value: string) => { filterFn: (row: any, id: string, value: string) => {
return row.original.email?.toLowerCase().includes(value.toLowerCase()) return row.original.email?.toLowerCase().includes(value.toLowerCase());
}, },
}, },
{ {
@ -87,23 +102,23 @@ export default function UserManagement() {
header: "Phone", header: "Phone",
cell: ({ row }: { row: { original: User } }) => row.original.phone || "-", cell: ({ row }: { row: { original: User } }) => row.original.phone || "-",
filterFn: (row: any, id: string, value: string) => { filterFn: (row: any, id: string, value: string) => {
return row.original.phone?.toLowerCase().includes(value.toLowerCase()) return row.original.phone?.toLowerCase().includes(value.toLowerCase());
}, },
}, },
{ {
id: "lastSignIn", id: "lastSignIn",
header: "Last Sign In", header: "Last Sign In",
cell: ({ row }: { row: { original: User } }) => { cell: ({ row }: { row: { original: User } }) => {
return row.original.last_sign_in_at return row.original.last_sign_in_at
? new Date(row.original.last_sign_in_at).toLocaleString() ? new Date(row.original.last_sign_in_at).toLocaleString()
: "Never" : "Never";
}, },
}, },
{ {
id: "createdAt", id: "createdAt",
header: "Created At", header: "Created At",
cell: ({ row }: { row: { original: User } }) => { cell: ({ row }: { row: { original: User } }) => {
return new Date(row.original.created_at).toLocaleString() return new Date(row.original.created_at).toLocaleString();
}, },
}, },
{ {
@ -111,35 +126,39 @@ export default function UserManagement() {
header: "Status", header: "Status",
cell: ({ row }: { row: { original: User } }) => { cell: ({ row }: { row: { original: User } }) => {
if (row.original.banned_until) { if (row.original.banned_until) {
return <Badge variant="destructive">Banned</Badge> return <Badge variant="destructive">Banned</Badge>;
} }
if (!row.original.email_confirmed_at) { if (!row.original.email_confirmed_at) {
return <Badge variant="outline">Unconfirmed</Badge> return <Badge variant="outline">Unconfirmed</Badge>;
} }
return <Badge variant="default">Active</Badge> return <Badge variant="default">Active</Badge>;
}, },
filterFn: (row: any, id: string, value: string) => { filterFn: (row: any, id: string, value: string) => {
const status = row.original.banned_until const status = row.original.banned_until
? "banned" ? "banned"
: !row.original.email_confirmed_at : !row.original.email_confirmed_at
? "unconfirmed" ? "unconfirmed"
: "active" : "active";
return status.includes(value.toLowerCase()) return status.includes(value.toLowerCase());
}, },
}, },
{ {
id: "actions", id: "actions",
header: "", header: "",
cell: ({ row }: { row: { original: User } }) => ( cell: ({ row }: { row: { original: User } }) => (
<Button variant="ghost" size="icon" onClick={(e) => { <Button
e.stopPropagation() variant="ghost"
handleUserClick(row.original) size="icon"
}}> onClick={(e) => {
e.stopPropagation();
handleUserClick(row.original);
}}
>
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
), ),
}, },
] ];
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -174,10 +193,12 @@ export default function UserManagement() {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setIsAddUserOpen(true)}> <DropdownMenuItem onClick={() => setIsAddUserOpen(true)}>
Add User <UserPlus className="h-4 w-4 mr-2" />
Create new user
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setIsInviteUserOpen(true)}> <DropdownMenuItem onClick={() => setIsInviteUserOpen(true)}>
Invite User <Mail className="h-4 w-4 mr-2" />
Send invitation
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -195,7 +216,7 @@ export default function UserManagement() {
/> />
{selectedUser && ( {selectedUser && (
<UserSheet <UserDetailsSheet
user={selectedUser} user={selectedUser}
open={isSheetOpen} open={isSheetOpen}
onOpenChange={setIsSheetOpen} onOpenChange={setIsSheetOpen}
@ -215,5 +236,5 @@ export default function UserManagement() {
onUserInvited={() => refetch()} onUserInvited={() => refetch()}
/> />
</div> </div>
) );
} }

View File

@ -1,45 +1,88 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" "use client"
import { Users, UserCheck, UserX } from 'lucide-react'
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent } from "@/components/ui/card"
import { Users, UserCheck, UserX } from "lucide-react"
import { fetchUsers } from "@/app/protected/(admin)/dashboard/user-management/action"
import { User } from "@/src/models/users/users.model"
function calculateUserStats(users: User[]) {
const totalUsers = users.length
const activeUsers = users.filter((user) => !user.banned_until && user.email_confirmed_at).length
const inactiveUsers = totalUsers - activeUsers
return {
totalUsers,
activeUsers,
inactiveUsers,
activePercentage: totalUsers > 0 ? ((activeUsers / totalUsers) * 100).toFixed(1) : "0.0",
inactivePercentage: totalUsers > 0 ? ((inactiveUsers / totalUsers) * 100).toFixed(1) : "0.0",
}
}
export function UserStats() { export function UserStats() {
const { data: users = [], isLoading } = useQuery({
queryKey: ["users"],
queryFn: fetchUsers,
})
const stats = calculateUserStats(users)
if (isLoading) {
return (
<>
{[...Array(3)].map((_, i) => (
<Card key={i} className="bg-background border-border">
<CardContent className="p-6">
<div className="space-y-4 animate-pulse">
<div className="h-5 w-24 bg-muted rounded" />
<div className="h-8 w-16 bg-muted rounded" />
<div className="h-4 w-32 bg-muted rounded" />
</div>
</CardContent>
</Card>
))}
</>
)
}
const cards = [
{
title: "Total Users",
value: stats.totalUsers,
subtitle: "Updated just now",
icon: Users,
},
{
title: "Active Users",
value: stats.activeUsers,
subtitle: `${stats.activePercentage}% of total users`,
icon: UserCheck,
},
{
title: "Inactive Users",
value: stats.inactiveUsers,
subtitle: `${stats.inactivePercentage}% of total users`,
icon: UserX,
},
]
return ( return (
<> <>
<Card> {cards.map((card, index) => (
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <Card key={index} className="bg-background border-border">
<CardTitle className="text-sm font-medium">Total Users</CardTitle> <CardContent className="p-6">
<Users className="h-4 w-4 text-muted-foreground" /> <div className="flex items-center justify-between mb-4">
</CardHeader> <div className="font-medium text-sm text-muted-foreground">{card.title}</div>
<CardContent> <card.icon className="h-4 w-4 text-muted-foreground" />
<div className="text-2xl font-bold">1,234</div> </div>
<p className="text-xs text-muted-foreground"> <div className="text-3xl font-bold mb-2">{card.value}</div>
+20.1% from last month <div className="text-sm text-muted-foreground">{card.subtitle}</div>
</p> </CardContent>
</CardContent> </Card>
</Card> ))}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Users</CardTitle>
<UserCheck className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">1,105</div>
<p className="text-xs text-muted-foreground">
89.5% of total users
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Inactive Users</CardTitle>
<UserX className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">129</div>
<p className="text-xs text-muted-foreground">
10.5% of total users
</p>
</CardContent>
</Card>
</> </>
) )
} }

View File

@ -1,36 +1,36 @@
"use client" // "use client"
import { useState } from "react" // import { useState } from "react"
import { DataTable } from "./data-table" // import { DataTable } from "./data-table"
import { columns, User } from "./column" // import { columns, User } from "./column"
import { useQuery } from "react-query" // import { useQuery } from "react-query"
import { getUsers } from "../../user-management/action" // import { getUsers } from "../../user-management/action"
import { UserDetailSheet } from "./sheet" // import { UserDetailSheet } from "./sheet"
export function UsersTable() { // export function UsersTable() {
const [selectedUser, setSelectedUser] = useState<User | null>(null) // const [selectedUser, setSelectedUser] = useState<User | null>(null)
const [sheetOpen, setSheetOpen] = useState(false) // const [sheetOpen, setSheetOpen] = useState(false)
const { data: users = [], isLoading } = useQuery({ // const { data: users = [], isLoading } = useQuery({
queryKey: ["users"], // queryKey: ["users"],
queryFn: getUsers, // queryFn: getUsers,
}) // })
const handleRowClick = (user: User) => { // const handleRowClick = (user: User) => {
setSelectedUser(user) // setSelectedUser(user)
setSheetOpen(true) // setSheetOpen(true)
} // }
if (isLoading) { // if (isLoading) {
return <div>Loading...</div> // return <div>Loading...</div>
} // }
return ( // return (
<div className="w-full"> // <div className="w-full">
<DataTable columns={columns} data={users} onRowClick={handleRowClick} /> // <DataTable columns={columns} data={users} onRowClick={handleRowClick} />
{selectedUser && <UserDetailSheet user={selectedUser} open={sheetOpen} onOpenChange={setSheetOpen} />} // {selectedUser && <UserDetailSheet user={selectedUser} open={sheetOpen} onOpenChange={setSheetOpen} />}
</div> // </div>
) // )
} // }

View File

@ -1,18 +1,18 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react" import { X } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef< const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>, React.ElementRef<typeof DialogPrimitive.Overlay>,
@ -26,8 +26,8 @@ const DialogOverlay = React.forwardRef<
)} )}
{...props} {...props}
/> />
)) ));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
@ -50,8 +50,8 @@ const DialogContent = React.forwardRef<
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
)) ));
DialogContent.displayName = DialogPrimitive.Content.displayName DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ const DialogHeader = ({
className, className,
@ -64,8 +64,8 @@ const DialogHeader = ({
)} )}
{...props} {...props}
/> />
) );
DialogHeader.displayName = "DialogHeader" DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ const DialogFooter = ({
className, className,
@ -78,8 +78,8 @@ const DialogFooter = ({
)} )}
{...props} {...props}
/> />
) );
DialogFooter.displayName = "DialogFooter" DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef< const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>, React.ElementRef<typeof DialogPrimitive.Title>,
@ -93,8 +93,8 @@ const DialogTitle = React.forwardRef<
)} )}
{...props} {...props}
/> />
)) ));
DialogTitle.displayName = DialogPrimitive.Title.displayName DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef< const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>, React.ElementRef<typeof DialogPrimitive.Description>,
@ -105,8 +105,8 @@ const DialogDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ));
DialogDescription.displayName = DialogPrimitive.Description.displayName DialogDescription.displayName = DialogPrimitive.Description.displayName;
export { export {
Dialog, Dialog,
@ -119,4 +119,4 @@ export {
DialogFooter, DialogFooter,
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
} };

View File

@ -7,6 +7,8 @@ export interface User {
last_sign_in_at?: string last_sign_in_at?: string
email_confirmed_at?: string email_confirmed_at?: string
phone_confirmed_at?: string phone_confirmed_at?: string
invited_at?: string
confirmation_sent_at?: string
banned_until?: string banned_until?: string
factors?: { factors?: {
id: string id: string