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

View File

@ -112,7 +112,7 @@ export async function banUser(userId: string): Promise<User> {
banUntil.setFullYear(banUntil.getFullYear() + 100)
const { data, error } = await supabase.auth.admin.updateUserById(userId, {
ban_duration: "100y",
ban_duration: "100h",
})
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
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 { createUser } from "@/app/protected/(admin)/dashboard/user-management/action"
import { toast } from "sonner"
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { createUser } from "@/app/protected/(admin)/dashboard/user-management/action";
import { toast } from "sonner";
import { Mail, Lock, Loader2, X } from "lucide-react";
interface AddUserDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onUserAdded: () => void
open: boolean;
onOpenChange: (open: boolean) => void;
onUserAdded: () => void;
}
export function AddUserDialog({ open, onOpenChange, onUserAdded }: AddUserDialogProps) {
const [loading, setLoading] = useState(false)
export function AddUserDialog({
open,
onOpenChange,
onUserAdded,
}: AddUserDialogProps) {
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
email: "",
password: "",
phone: "",
metadata: "{}",
emailConfirm: true,
})
});
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setFormData((prev) => ({ ...prev, [name]: value }))
}
const handleSwitchChange = (checked: boolean) => {
setFormData((prev) => ({ ...prev, emailConfirm: checked }))
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
e.preventDefault();
setLoading(true);
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({
email: formData.email,
password: formData.password,
phone: formData.phone,
user_metadata: metadata,
email_confirm: formData.emailConfirm,
})
});
toast.success("User created successfully.")
onUserAdded()
onOpenChange(false)
toast.success("User created successfully.");
onUserAdded();
onOpenChange(false);
setFormData({
email: "",
password: "",
phone: "",
metadata: "{}",
emailConfirm: true,
})
});
} catch (error) {
toast.error("Failed to create user.")
toast.error("Failed to create user.");
} finally {
setLoading(false)
}
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add User</DialogTitle>
<DialogDescription>
Create a new user account with email and password.
</DialogDescription>
<DialogContent className="sm:max-w-md border-0 text-white">
<DialogHeader className="flex flex-row items-center justify-between space-y-0 space-x-4 pb-4">
<DialogTitle className="text-xl font-semibold text-white">
Create a new user
</DialogTitle>
{/* <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>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="email">Email *</Label>
<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">Password *</Label>
<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">
<Label htmlFor="phone">Phone</Label>
<Input
id="phone"
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
<Checkbox
id="email-confirm"
checked={formData.emailConfirm}
onCheckedChange={handleSwitchChange}
onCheckedChange={(checked) =>
setFormData((prev) => ({
...prev,
emailConfirm: checked as boolean,
}))
}
className="border-zinc-700"
/>
<Label htmlFor="email-confirm">Auto-confirm email</Label>
<label htmlFor="email-confirm" className="text-sm text-white">
Auto Confirm User?
</label>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
<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>
<Button
type="submit"
disabled={loading}
className="w-full text-white"
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
"Create user"
)}
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Creating..." : "Create User"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
);
}

View File

@ -10,19 +10,22 @@ import {
type SortingState,
getFilteredRowModel,
type ColumnFiltersState,
getPaginationRowModel,
} from "@tanstack/react-table"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
loading?: boolean
onRowClick?: (row: TData) => void
pageSize?: number
}
export function DataTable<TData, TValue>({
@ -30,10 +33,15 @@ export function DataTable<TData, TValue>({
data,
loading = false,
onRowClick,
pageSize = 5,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState({})
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: pageSize,
})
const table = useReactTable({
data,
@ -44,10 +52,13 @@ export function DataTable<TData, TValue>({
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
state: {
sorting,
columnFilters,
columnVisibility,
pagination,
},
})
@ -67,7 +78,7 @@ export function DataTable<TData, TValue>({
))}
</TableHeader>
<TableBody>
{Array.from({ length: 5 }).map((_, index) => (
{Array.from({ length: pagination.pageSize }).map((_, index) => (
<TableRow key={index}>
{columns.map((_, colIndex) => (
<TableCell key={colIndex}>
@ -158,6 +169,83 @@ export function DataTable<TData, TValue>({
)}
</TableBody>
</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>
)
}

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

View File

@ -1,20 +1,12 @@
"use client"
import type React 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 { 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 {
AlertDialog,
AlertDialogAction,
@ -26,59 +18,69 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { updateUser, deleteUser, sendPasswordRecovery, sendMagicLink, banUser, unbanUser } from "@/app/protected/(admin)/dashboard/user-management/action"
import { User } from "@/src/models/users/users.model"
import { toast } from "sonner"
import { Mail, Trash2, Ban, SendHorizonal, CheckCircle, XCircle, Copy, Loader2 } from "lucide-react"
import { banUser, deleteUser, sendMagicLink, sendPasswordRecovery, unbanUser } from "@/app/protected/(admin)/dashboard/user-management/action"
interface UserSheetProps {
user: User
// // Mock functions (replace with your actual API calls)
// 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
onOpenChange: (open: boolean) => void
user: any
onUserUpdate: () => void
}
export function UserSheet({ user, open, onOpenChange, onUserUpdate }: UserSheetProps) {
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")
},
})
export function UserDetailsSheet({ open, onOpenChange, user, onUserUpdate }: UserDetailsSheetProps) {
const [isDeleting, setIsDeleting] = useState(false)
const deleteUserMutation = useMutation({
mutationFn: () => deleteUser(user.id),
onSuccess: () => {
toast.success("User deleted successfully")
onUserUpdate()
onOpenChange(false)
},
onError: () => {
toast.error("Failed to delete user")
},
onSettled: () => {
setIsDeleting(false)
},
})
const sendPasswordRecoveryMutation = useMutation({
@ -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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-md md:max-w-lg overflow-y-auto">
<SheetHeader className="space-y-1">
<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.email_confirmed_at && <Badge variant="outline">Unconfirmed</Badge>}
{!user.banned_until && user.email_confirmed_at && <Badge variant="default">Active</Badge>}
</SheetTitle>
<SheetDescription>ID: {user.id}</SheetDescription>
</SheetHeader>
<Tabs defaultValue="details" className="mt-6">
<TabsList className="grid grid-cols-3 mb-4">
<TabsTrigger value="details">Details</TabsTrigger>
<TabsTrigger value="security">Security</TabsTrigger>
<TabsTrigger value="actions">Actions</TabsTrigger>
</TabsList>
<div className="mt-6 space-y-8">
{/* User Information Section */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">User Information</h3>
<TabsContent value="details" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" value={formData.email} onChange={handleInputChange} />
</div>
<div className="space-y-2">
<Label htmlFor="phone">Phone</Label>
<Input id="phone" 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 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>
<div className="space-y-2 text-sm">
<div className="flex justify-between items-center py-1">
<span className="text-muted-foreground">User UID</span>
<div className="flex items-center">
<span className="font-mono">{user.id}</span>
<Button
onClick={() => updateUserMutation.mutate()}
disabled={updateUserMutation.isPending}
className="w-full"
variant="ghost"
size="icon"
className="h-4 w-4 ml-2"
onClick={() => {
navigator.clipboard.writeText(user.id)
// Optionally add a toast notification here
}}
>
{updateUserMutation.isPending ? "Saving..." : "Save Changes"}
<Copy className="h-4 w-4" />
</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 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>
<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 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>
))
: "No authentication factors"}
</div>
</div>
<Separator />
<div className="space-y-2">
<Label>Password Reset</Label>
{/* 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}
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>
{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="space-y-2">
<Label>Delete User</Label>
<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" className="w-full">
Delete User
<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.
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()}
onClick={() => {
setIsDeleting(true)
deleteUserMutation.mutate()
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
{isDeleting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Deleting...
</>
) : (
"Delete"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TabsContent>
</Tabs>
</div>
</div>
</div>
<SheetFooter className="mt-4">
<SheetFooter className="mt-6">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>

View File

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

View File

@ -1,45 +1,88 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Users, UserCheck, UserX } from 'lucide-react'
"use client"
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() {
const { data: users = [], isLoading } = useQuery({
queryKey: ["users"],
queryFn: fetchUsers,
})
const stats = calculateUserStats(users)
if (isLoading) {
return (
<>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">1,234</div>
<p className="text-xs text-muted-foreground">
+20.1% from last month
</p>
{[...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>
<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>
))}
</>
)
}
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 (
<>
{cards.map((card, index) => (
<Card key={index} className="bg-background border-border">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="font-medium text-sm text-muted-foreground">{card.title}</div>
<card.icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="text-3xl font-bold mb-2">{card.value}</div>
<div className="text-sm text-muted-foreground">{card.subtitle}</div>
</CardContent>
</Card>
))}
</>
)
}

View File

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

View File

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

View File

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