menambahkan user management

This commit is contained in:
vergiLgood1 2025-02-28 20:23:31 +07:00
parent ca90871b22
commit 681517e28e
19 changed files with 1432 additions and 583 deletions

View File

@ -0,0 +1,18 @@
export default function DashboardPage() {
return (
<>
<header className="flex h-12 shrink-0 items-center justify-end border-b px-4 mb-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-8"></header>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
<div className="aspect-video rounded-xl bg-muted/50" />
<div className="aspect-video rounded-xl bg-muted/50" />
<div className="aspect-video rounded-xl bg-muted/50" />
</div>
<div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min" />
</div>
</>
);
}

View File

@ -0,0 +1,158 @@
"use server"
import { CreateUserParams, InviteUserParams, UpdateUserParams, User } from "@/src/models/users/users.model"
import { createClient } from "@supabase/supabase-js"
// Initialize Supabase client with admin key
const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SERVICE_ROLE_SECRET!, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
})
// Fetch all users
export async function fetchUsers(): Promise<User[]> {
const { data, error } = await supabase.auth.admin.listUsers()
if (error) {
console.error("Error fetching users:", error)
throw new Error(error.message)
}
return data.users.map(user => ({
...user,
updated_at: user.updated_at || "",
})) as User[]
}
// Create a new user
export async function createUser(params: CreateUserParams): Promise<User> {
const { data, error } = await supabase.auth.admin.createUser({
email: params.email,
password: params.password,
phone: params.phone,
user_metadata: params.user_metadata,
email_confirm: params.email_confirm,
})
if (error) {
console.error("Error creating user:", error)
throw new Error(error.message)
}
return {
...data.user,
updated_at: data.user.updated_at || "",
} as User
}
// Update an existing user
export async function updateUser(userId: string, params: UpdateUserParams): Promise<User> {
const { data, error } = await supabase.auth.admin.updateUserById(userId, {
email: params.email,
phone: params.phone,
password: params.password,
user_metadata: params.user_metadata,
})
if (error) {
console.error("Error updating user:", error)
throw new Error(error.message)
}
return {
...data.user,
updated_at: data.user.updated_at || "",
} as User
}
// Delete a user
export async function deleteUser(userId: string): Promise<void> {
const { error } = await supabase.auth.admin.deleteUser(userId)
if (error) {
console.error("Error deleting user:", error)
throw new Error(error.message)
}
}
// Send password recovery email
export async function sendPasswordRecovery(email: string): Promise<void> {
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/reset-password`,
})
if (error) {
console.error("Error sending password recovery:", error)
throw new Error(error.message)
}
}
// Send magic link
export async function sendMagicLink(email: string): Promise<void> {
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
},
})
if (error) {
console.error("Error sending magic link:", error)
throw new Error(error.message)
}
}
// Ban a user
export async function banUser(userId: string): Promise<User> {
// Ban for 100 years (effectively permanent)
const banUntil = new Date()
banUntil.setFullYear(banUntil.getFullYear() + 100)
const { data, error } = await supabase.auth.admin.updateUserById(userId, {
ban_duration: "100y",
})
if (error) {
console.error("Error banning user:", error)
throw new Error(error.message)
}
return {
...data.user,
updated_at: data.user.updated_at || "",
} as User
}
// Unban a user
export async function unbanUser(userId: string): Promise<User> {
const { data, error } = await supabase.auth.admin.updateUserById(userId, {
ban_duration: "none",
})
if (error) {
console.error("Error unbanning user:", error)
throw new Error(error.message)
}
return {
...data.user,
updated_at: data.user.updated_at || "",
} as User
}
// Invite a user
export async function inviteUser(params: InviteUserParams): Promise<void> {
const { error } = await supabase.auth.admin.inviteUserByEmail(params.email, {
data: params.user_metadata,
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
})
if (error) {
console.error("Error inviting user:", error)
throw new Error(error.message)
}
}

View File

@ -0,0 +1,26 @@
import UserManagement from "@/components/admin/users/user-management";
import { UserStats } from "@/components/admin/users/user-stats";
export default function UsersPage() {
return (
<>
<header className="flex h-16 shrink-0 items-center justify-between px-4 mb-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-8">
<div>
<h1 className="text-lg font-semibold">User Management</h1>
<p className="text-sm text-muted-foreground">
Manage user accounts and permissions
</p>
</div>
</header>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
<UserStats />
</div>
<div className="min-h-[100vh] flex-1 rounded-xl bg-background border md:min-h-min p-4">
<UserManagement />
</div>
</div>
</>
);
}

View File

@ -17,7 +17,8 @@ import {
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import * as TablerIcons from "@tabler/icons-react"; import * as TablerIcons from "@tabler/icons-react";
import { useNavigations } from "@/app/_hooks/use-navigations";
import { useNavigations } from "@/hooks/use-navigations";
interface SubSubItem { interface SubSubItem {
title: string; title: string;

View File

@ -9,7 +9,7 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { useNavigations } from "@/app/_hooks/use-navigations"; import { useNavigations } from "@/hooks/use-navigations";
import { Search, Bot, Home } from "lucide-react"; import { Search, Bot, Home } from "lucide-react";
import { IconHome, IconRobot, IconSearch } from "@tabler/icons-react"; import { IconHome, IconRobot, IconSearch } from "@tabler/icons-react";

View File

@ -0,0 +1,156 @@
"use client"
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"
interface AddUserDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onUserAdded: () => void
}
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 handleSubmit = async (e: React.FormEvent) => {
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)
setFormData({
email: "",
password: "",
phone: "",
metadata: "{}",
emailConfirm: true,
})
} catch (error) {
toast.error("Failed to create user.")
} finally {
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>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email *</Label>
<Input
id="email"
name="email"
type="email"
required
value={formData.email}
onChange={handleInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password *</Label>
<Input
id="password"
name="password"
type="password"
required
value={formData.password}
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"
/>
</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>
</DialogContent>
</Dialog>
)
}

View File

@ -2,145 +2,136 @@
import { useState } from "react" import { useState } from "react"
import { import {
type ColumnFiltersState, type ColumnDef,
type SortingState,
type VisibilityState,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable, useReactTable,
getSortedRowModel,
type SortingState,
getFilteredRowModel,
type ColumnFiltersState,
} 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 { ChevronDown, RefreshCw } from "lucide-react" import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { import { Filter } from "lucide-react"
DropdownMenu, import { Skeleton } from "@/components/ui/skeleton"
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
interface DataTableProps<TData, TValue> { interface DataTableProps<TData, TValue> {
columns: any[] columns: ColumnDef<TData, TValue>[]
data: TData[] data: TData[]
loading?: boolean
onRowClick?: (row: TData) => void onRowClick?: (row: TData) => void
} }
export function DataTable<TData, TValue>({ columns, data, onRowClick }: DataTableProps<TData, TValue>) { export function DataTable<TData, TValue>({
columns,
data,
loading = false,
onRowClick,
}: 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<VisibilityState>({}) const [columnVisibility, setColumnVisibility] = useState({})
const [rowSelection, setRowSelection] = useState({})
const table = useReactTable({ const table = useReactTable({
data, data,
columns, columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(), onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility, onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: { state: {
sorting, sorting,
columnFilters, columnFilters,
columnVisibility, columnVisibility,
rowSelection,
}, },
}) })
if (loading) {
return ( return (
<div className="w-full"> <div className="border rounded-md">
<div className="flex items-center justify-between py-4 bg-[#1c1c1c] px-4 border-b border-[#2a2a2a]"> <Table>
<div className="flex items-center gap-2"> <TableHeader>
<Input
placeholder="Search email, phone or UID"
value={(table.getColumn("email")?.getFilterValue() as string) ?? ""}
onChange={(event) => table.getColumn("email")?.setFilterValue(event.target.value)}
className="max-w-sm bg-[#121212] border-[#2a2a2a] text-white"
/>
<Select defaultValue="all">
<SelectTrigger className="w-[180px] bg-[#121212] border-[#2a2a2a] text-white">
<SelectValue placeholder="All users" />
</SelectTrigger>
<SelectContent className="bg-[#121212] border-[#2a2a2a] text-white">
<SelectItem value="all">All users</SelectItem>
<SelectItem value="admin">Admins</SelectItem>
<SelectItem value="user">Users</SelectItem>
</SelectContent>
</Select>
<Select defaultValue="all">
<SelectTrigger className="w-[180px] bg-[#121212] border-[#2a2a2a] text-white">
<SelectValue placeholder="Provider" />
</SelectTrigger>
<SelectContent className="bg-[#121212] border-[#2a2a2a] text-white">
<SelectItem value="all">All providers</SelectItem>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="google">Google</SelectItem>
</SelectContent>
</Select>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="bg-[#121212] border-[#2a2a2a] text-white">
All columns <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-[#121212] border-[#2a2a2a] text-white">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" className="bg-[#121212] border-[#2a2a2a] text-white">
Sorted by created at <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" className="bg-[#121212] border-[#2a2a2a] text-white">
<RefreshCw className="h-4 w-4" />
</Button>
<Button className="bg-emerald-600 hover:bg-emerald-700 text-white">
Add user <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
<div className="rounded-md border-0">
<Table className="bg-[#121212] text-white">
<TableHeader className="bg-[#1c1c1c]">
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="border-[#2a2a2a] hover:bg-[#1c1c1c]"> <TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => (
return ( <TableHead key={header.id}>
<TableHead key={header.id} className="text-gray-400">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead> </TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{Array.from({ length: 5 }).map((_, index) => (
<TableRow key={index}>
{columns.map((_, colIndex) => (
<TableCell key={colIndex}>
<Skeleton className="h-6 w-full" />
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
) )
})} }
return (
<div className="border rounded-md">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="relative">
{header.isPlaceholder ? null : (
<div className="flex items-center gap-2">
<div
className={
header.column.getCanSort() ? "cursor-pointer select-none flex items-center gap-1" : ""
}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{{
asc: " 🔼",
desc: " 🔽",
}[header.column.getIsSorted() as string] ?? null}
</div>
{header.column.getCanFilter() && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 ml-auto"
onClick={(e) => e.stopPropagation()}
>
<Filter className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<div className="p-2">
<Input
placeholder={`Filter ${header.column.id}...`}
value={(header.column.getFilterValue() as string) ?? ""}
onChange={(e) => header.column.setFilterValue(e.target.value)}
className="h-8"
/>
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
</TableHead>
))}
</TableRow> </TableRow>
))} ))}
</TableHeader> </TableHeader>
@ -150,7 +141,7 @@ export function DataTable<TData, TValue>({ columns, data, onRowClick }: DataTabl
<TableRow <TableRow
key={row.id} key={row.id}
data-state={row.getIsSelected() && "selected"} data-state={row.getIsSelected() && "selected"}
className="cursor-pointer border-[#2a2a2a] hover:bg-[#1c1c1c]" className="cursor-pointer hover:bg-muted/50"
onClick={() => onRowClick && onRowClick(row.original)} onClick={() => onRowClick && onRowClick(row.original)}
> >
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
@ -168,33 +159,6 @@ export function DataTable<TData, TValue>({ columns, data, onRowClick }: DataTabl
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<div className="flex items-center justify-end space-x-2 py-4 px-4 bg-[#121212] text-white">
<div className="flex-1 text-sm text-gray-400">
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
selected.
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="bg-[#1c1c1c] border-[#2a2a2a] text-white hover:bg-[#2a2a2a]"
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="bg-[#1c1c1c] border-[#2a2a2a] text-white hover:bg-[#2a2a2a]"
>
Next
</Button>
</div>
</div>
</div>
) )
} }

View File

@ -0,0 +1,115 @@
"use client"
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 { 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
}
export function InviteUserDialog({ open, onOpenChange, onUserInvited }: InviteUserDialogProps) {
const [formData, setFormData] = useState({
email: "",
metadata: "{}",
})
const inviteUserMutation = 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 inviteUser({
email: formData.email,
user_metadata: metadata,
})
},
onSuccess: () => {
toast.success("Invitation sent")
onUserInvited()
onOpenChange(false)
setFormData({
email: "",
metadata: "{}",
})
},
onError: () => {
toast.error("Failed to send invitation")
},
})
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()
}
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>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="invite-email">Email *</Label>
<Input
id="invite-email"
name="email"
type="email"
required
value={formData.email}
onChange={handleInputChange}
/>
</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)}>
Cancel
</Button>
<Button type="submit" disabled={inviteUserMutation.isPending}>
{inviteUserMutation.isPending ? "Sending..." : "Send Invitation"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@ -1,10 +1,20 @@
"use client" "use client"
import type React from "react"
import { useState } from "react" import { useState } from "react"
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from "@/components/ui/sheet"
import { UserForm } from "./user-form"
import { Button } from "@/components/ui/button" 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 { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -16,284 +26,280 @@ 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 { User } from "@/src/models/users/users.model"
import { toast } from "sonner"
interface UserSheetProps {
import { Mail, ShieldAlert, Trash2, Ban } from "lucide-react"
import { User } from "./column"
import { toast } from "@/hooks/use-toast"
interface UserDetailSheetProps {
user: User user: User
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
onUserUpdate: () => void
} }
export function UserDetailSheet({ user, open, onOpenChange }: UserDetailSheetProps) { export function UserSheet({ user, open, onOpenChange, onUserUpdate }: UserSheetProps) {
const [isDeleting, setIsDeleting] = useState(false)
const [isResetting, setIsResetting] = useState(false)
const [isSendingMagic, setIsSendingMagic] = useState(false)
const [isRemovingMfa, setIsRemovingMfa] = useState(false)
const [isBanning, setIsBanning] = useState(false)
const handleResetPassword = async () => { 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 { try {
setIsResetting(true) metadata = JSON.parse(formData.metadata)
toast({
title: "Success",
description: "Password reset email sent",
})
} catch (error) { } catch (error) {
toast({ toast.error("Invalid JSON. Please check your metadata format.")
title: "Error", throw new Error("Invalid JSON")
description: "Failed to send reset password email",
variant: "destructive",
})
console.error(error)
} finally {
setIsResetting(false)
} }
}
const handleSendMagicLink = async () => {
try {
setIsSendingMagic(true)
toast({
title: "Success",
description: "Magic link sent",
return updateUser(user.id, {
email: formData.email,
phone: formData.phone,
user_metadata: metadata,
}) })
} catch (error) { },
toast({ onSuccess: () => {
title: "Error", toast.success("User updated successfully")
description: "Failed to send magic link", onUserUpdate()
variant: "destructive", },
onError: () => {
toast.error("Failed to update user")
},
}) })
console.error(error)
} finally { const deleteUserMutation = useMutation({
setIsSendingMagic(false) mutationFn: () => deleteUser(user.id),
onSuccess: () => {
toast.success("User deleted successfully")
onUserUpdate()
},
onError: () => {
toast.error("Failed to delete user")
},
})
const sendPasswordRecoveryMutation = useMutation({
mutationFn: () => {
if (!user.email) {
throw new Error("User does not have an email address")
} }
} return sendPasswordRecovery(user.email)
},
const handleRemoveMfa = async () => { onSuccess: () => {
try { toast.success("Password recovery email sent")
setIsRemovingMfa(true) },
await removeMfaFactors(user.id) onError: () => {
toast({ toast.error("Failed to send password recovery email")
title: "Success", },
description: "MFA factors removed",
}) })
} catch (error) {
toast({ const sendMagicLinkMutation = useMutation({
title: "Error", mutationFn: () => {
description: "Failed to remove MFA factors", if (!user.email) {
variant: "destructive", throw new Error("User does not have an email address")
})
console.error(error)
} finally {
setIsRemovingMfa(false)
} }
} return sendMagicLink(user.email)
},
const handleBanUser = async () => { onSuccess: () => {
try { toast.success("Magic link sent successfully")
setIsBanning(true) },
await banUser(user.id) onError: () => {
toast({ toast.error("Failed to send magic link")
title: "Success", },
description: "User banned successfully",
}) })
} catch (error) {
toast({ const toggleBanMutation = useMutation({
title: "Error", mutationFn: () => {
description: "Failed to ban user", if (user.banned_until) {
variant: "destructive", return unbanUser(user.id)
}) } else {
console.error(error) return banUser(user.id)
} finally {
setIsBanning(false)
} }
} },
onSuccess: () => {
const handleDeleteUser = async () => { toast.success("User ban status updated")
try { onUserUpdate()
setIsDeleting(true) },
await deleteUser(user.id) onError: () => {
toast({ toast.error("Failed to update user ban status")
title: "Success", },
description: "User deleted successfully",
}) })
onOpenChange(false)
} catch (error) { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
toast({ const { name, value } = e.target
title: "Error", setFormData((prev) => ({ ...prev, [name]: value }))
description: "Failed to delete user",
variant: "destructive",
})
console.error(error)
} finally {
setIsDeleting(false)
} }
}
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-xl overflow-y-auto bg-[#121212] text-white border-l border-[#2a2a2a]"> <SheetContent className="sm:max-w-md md:max-w-lg overflow-y-auto">
<SheetHeader> <SheetHeader className="space-y-1">
<SheetTitle className="text-white">User Details</SheetTitle> <SheetTitle className="text-xl flex items-center gap-2">
User Details
{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> </SheetHeader>
<div className="py-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold">{user.email}</h2>
<p className="text-sm text-gray-400">ID: {user.id}</p>
</div>
</div>
<Tabs defaultValue="details" className="w-full"> <Tabs defaultValue="details" className="mt-6">
<TabsList className="grid w-full grid-cols-3 bg-[#1c1c1c]"> <TabsList className="grid grid-cols-3 mb-4">
<TabsTrigger value="details" className="data-[state=active]:bg-[#2a2a2a]"> <TabsTrigger value="details">Details</TabsTrigger>
Details <TabsTrigger value="security">Security</TabsTrigger>
</TabsTrigger> <TabsTrigger value="actions">Actions</TabsTrigger>
<TabsTrigger value="profile" className="data-[state=active]:bg-[#2a2a2a]">
Profile
</TabsTrigger>
<TabsTrigger value="security" className="data-[state=active]:bg-[#2a2a2a]">
Security
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="details" className="mt-4">
<UserForm user={user} /> <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>
<Button
onClick={() => updateUserMutation.mutate()}
disabled={updateUserMutation.isPending}
className="w-full"
>
{updateUserMutation.isPending ? "Saving..." : "Save Changes"}
</Button>
</TabsContent> </TabsContent>
<TabsContent value="profile" className="mt-4">
<div className="space-y-4"> <TabsContent value="security" className="space-y-4">
<h3 className="text-lg font-medium">Profile Information</h3> <div className="space-y-2">
<p className="text-sm text-gray-400">Profile data will be loaded here.</p> <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> </div>
</TabsContent> </TabsContent>
<TabsContent value="security" className="mt-4">
<div className="space-y-6"> <TabsContent value="actions" className="space-y-4">
<div className="bg-[#1c1c1c] p-4 rounded-md border border-[#2a2a2a]"> <div className="space-y-2">
<div className="flex items-center justify-between"> <Label>Ban User</Label>
<div>
<h3 className="font-medium">Reset password</h3>
<p className="text-sm text-gray-400">Send a password recovery email to the user</p>
</div>
<Button <Button
variant="outline" variant={user.banned_until ? "default" : "destructive"}
size="sm" onClick={() => toggleBanMutation.mutate()}
onClick={handleResetPassword} disabled={toggleBanMutation.isPending}
disabled={isResetting} className="w-full"
className="bg-[#121212] border-[#2a2a2a] hover:bg-[#2a2a2a]"
> >
<Mail className="mr-2 h-4 w-4" /> {user.banned_until ? "Unban User" : "Ban User"}
Send password recovery
</Button> </Button>
{user.banned_until && (
<div className="text-xs text-muted-foreground">
Banned until: {new Date(user.banned_until).toLocaleString()}
</div> </div>
)}
</div> </div>
<div className="bg-[#1c1c1c] p-4 rounded-md border border-[#2a2a2a]"> <Separator />
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">Send magic link</h3>
<p className="text-sm text-gray-400">Passwordless login via email for the user</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleSendMagicLink}
disabled={isSendingMagic}
className="bg-[#121212] border-[#2a2a2a] hover:bg-[#2a2a2a]"
>
<Mail className="mr-2 h-4 w-4" />
Send magic link
</Button>
</div>
</div>
<div> <div className="space-y-2">
<h3 className="text-lg font-medium mb-2">Danger zone</h3> <Label>Delete User</Label>
<p className="text-sm text-gray-400 mb-4">
Be wary of the following features as they cannot be undone.
</p>
<div className="space-y-4 border border-red-900/50 rounded-md overflow-hidden">
<div className="p-4 border-b border-red-900/50">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">Remove MFA factors</h4>
<p className="text-sm text-gray-400">This will log the user out of all active sessions</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleRemoveMfa}
disabled={isRemovingMfa}
className="bg-[#121212] border-[#2a2a2a] hover:bg-[#2a2a2a]"
>
<ShieldAlert className="mr-2 h-4 w-4" />
Remove MFA factors
</Button>
</div>
</div>
<div className="p-4 border-b border-red-900/50">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">Ban user</h4>
<p className="text-sm text-gray-400">Revoke access to the project for a set duration</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleBanUser}
disabled={isBanning}
className="bg-[#121212] border-[#2a2a2a] hover:bg-[#2a2a2a]"
>
<Ban className="mr-2 h-4 w-4" />
Ban user
</Button>
</div>
</div>
<div className="p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">Delete user</h4>
<p className="text-sm text-gray-400">User will no longer have access to the project</p>
</div>
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <Button variant="destructive" className="w-full">
variant="destructive" Delete User
size="sm"
className="bg-red-900 hover:bg-red-800 text-white border-0"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete user
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent className="bg-[#121212] text-white border-[#2a2a2a]"> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription className="text-gray-400"> <AlertDialogDescription>
This action cannot be undone. This will permanently delete the user account and remove This action cannot be undone. This will permanently delete the user account and remove their data
their data from our servers. from our servers.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel className="bg-[#1c1c1c] text-white border-[#2a2a2a] hover:bg-[#2a2a2a]"> <AlertDialogCancel>Cancel</AlertDialogCancel>
Cancel
</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={handleDeleteUser} onClick={() => deleteUserMutation.mutate()}
disabled={isDeleting} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
className="bg-red-900 hover:bg-red-800 text-white border-0"
> >
Delete Delete
</AlertDialogAction> </AlertDialogAction>
@ -301,13 +307,14 @@ const handleDeleteUser = async () => {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</div> </div>
</div>
</div>
</div>
</div>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div>
<SheetFooter className="mt-4">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
) )

View File

@ -1,148 +1,148 @@
"use client" // "use client"
import { zodResolver } from "@hookform/resolvers/zod" // import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form" // import { useForm } from "react-hook-form"
import { z } from "zod" // import { z } from "zod"
import { Button } from "@/components/ui/button" // import { Button } from "@/components/ui/button"
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" // import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input" // import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" // import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { useState } from "react" // import { useState } from "react"
import { User } from "./column" // import { User } from "./column"
import { updateUser } from "../../user-management/action" // import { updateUser } from "../../user-management/action"
import { toast } from "@/app/_hooks/use-toast" // import { toast } from "@/app/_hooks/use-toast"
const userFormSchema = z.object({ // const userFormSchema = z.object({
email: z.string().email({ message: "Please enter a valid email address" }), // email: z.string().email({ message: "Please enter a valid email address" }),
first_name: z.string().nullable(), // first_name: z.string().nullable(),
last_name: z.string().nullable(), // last_name: z.string().nullable(),
role: z.enum(["user", "admin", "moderator"]), // role: z.enum(["user", "admin", "moderator"]),
}) // })
type UserFormValues = z.infer<typeof userFormSchema> // type UserFormValues = z.infer<typeof userFormSchema>
interface UserFormProps { // interface UserFormProps {
user: User // user: User
} // }
export function UserForm({ user }: UserFormProps) { // export function UserForm({ user }: UserFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false) // const [isSubmitting, setIsSubmitting] = useState(false)
const form = useForm<UserFormValues>({ // const form = useForm<UserFormValues>({
resolver: zodResolver(userFormSchema), // resolver: zodResolver(userFormSchema),
defaultValues: { // defaultValues: {
email: user.email, // email: user.email,
first_name: user.first_name, // first_name: user.first_name,
last_name: user.last_name, // last_name: user.last_name,
role: user.role as "user" | "admin" | "moderator", // role: user.role as "user" | "admin" | "moderator",
}, // },
}) // })
async function onSubmit(data: UserFormValues) { // async function onSubmit(data: UserFormValues) {
try { // try {
setIsSubmitting(true) // setIsSubmitting(true)
await updateUser(user.id, data) // await updateUser(user.id, data)
toast({ // toast({
title: "User updated", // title: "User updated",
description: "The user" + user.email + " has been updated.", // description: "The user" + user.email + " has been updated.",
}) // })
} catch (error) { // } catch (error) {
toast({ // toast({
title: "Failed to update user", // title: "Failed to update user",
description: "An error occurred while updating the user.", // description: "An error occurred while updating the user.",
variant: "destructive", // variant: "destructive",
}) // })
console.error(error) // console.error(error)
} finally { // } finally {
setIsSubmitting(false) // setIsSubmitting(false)
} // }
} // }
return ( // return (
<Form {...form}> // <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> // <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField // <FormField
control={form.control} // control={form.control}
name="email" // name="email"
render={({ field }) => ( // render={({ field }) => (
<FormItem> // <FormItem>
<FormLabel className="text-white">Email</FormLabel> // <FormLabel className="text-white">Email</FormLabel>
<FormControl> // <FormControl>
<Input // <Input
{...field} // {...field}
className="bg-[#1c1c1c] border-[#2a2a2a] text-white focus-visible:ring-[#2a2a2a] focus-visible:ring-offset-0" // className="bg-[#1c1c1c] border-[#2a2a2a] text-white focus-visible:ring-[#2a2a2a] focus-visible:ring-offset-0"
/> // />
</FormControl> // </FormControl>
<FormDescription className="text-gray-400">This is the user's email address.</FormDescription> // <FormDescription className="text-gray-400">This is the user's email address.</FormDescription>
<FormMessage /> // <FormMessage />
</FormItem> // </FormItem>
)} // )}
/> // />
<div className="grid grid-cols-2 gap-4"> // <div className="grid grid-cols-2 gap-4">
<FormField // <FormField
control={form.control} // control={form.control}
name="first_name" // name="first_name"
render={({ field }) => ( // render={({ field }) => (
<FormItem> // <FormItem>
<FormLabel className="text-white">First Name</FormLabel> // <FormLabel className="text-white">First Name</FormLabel>
<FormControl> // <FormControl>
<Input // <Input
{...field} // {...field}
value={field.value || ""} // value={field.value || ""}
className="bg-[#1c1c1c] border-[#2a2a2a] text-white focus-visible:ring-[#2a2a2a] focus-visible:ring-offset-0" // className="bg-[#1c1c1c] border-[#2a2a2a] text-white focus-visible:ring-[#2a2a2a] focus-visible:ring-offset-0"
/> // />
</FormControl> // </FormControl>
<FormMessage /> // <FormMessage />
</FormItem> // </FormItem>
)} // )}
/> // />
<FormField // <FormField
control={form.control} // control={form.control}
name="last_name" // name="last_name"
render={({ field }) => ( // render={({ field }) => (
<FormItem> // <FormItem>
<FormLabel className="text-white">Last Name</FormLabel> // <FormLabel className="text-white">Last Name</FormLabel>
<FormControl> // <FormControl>
<Input // <Input
{...field} // {...field}
value={field.value || ""} // value={field.value || ""}
className="bg-[#1c1c1c] border-[#2a2a2a] text-white focus-visible:ring-[#2a2a2a] focus-visible:ring-offset-0" // className="bg-[#1c1c1c] border-[#2a2a2a] text-white focus-visible:ring-[#2a2a2a] focus-visible:ring-offset-0"
/> // />
</FormControl> // </FormControl>
<FormMessage /> // <FormMessage />
</FormItem> // </FormItem>
)} // )}
/> // />
</div> // </div>
<FormField // <FormField
control={form.control} // control={form.control}
name="role" // name="role"
render={({ field }) => ( // render={({ field }) => (
<FormItem> // <FormItem>
<FormLabel className="text-white">Role</FormLabel> // <FormLabel className="text-white">Role</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}> // <Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl> // <FormControl>
<SelectTrigger className="bg-[#1c1c1c] border-[#2a2a2a] text-white focus:ring-[#2a2a2a] focus:ring-offset-0"> // <SelectTrigger className="bg-[#1c1c1c] border-[#2a2a2a] text-white focus:ring-[#2a2a2a] focus:ring-offset-0">
<SelectValue placeholder="Select a role" /> // <SelectValue placeholder="Select a role" />
</SelectTrigger> // </SelectTrigger>
</FormControl> // </FormControl>
<SelectContent className="bg-[#1c1c1c] border-[#2a2a2a] text-white"> // <SelectContent className="bg-[#1c1c1c] border-[#2a2a2a] text-white">
<SelectItem value="user">User</SelectItem> // <SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem> // <SelectItem value="admin">Admin</SelectItem>
<SelectItem value="moderator">Moderator</SelectItem> // <SelectItem value="moderator">Moderator</SelectItem>
</SelectContent> // </SelectContent>
</Select> // </Select>
<FormDescription className="text-gray-400">The user's role determines their permissions.</FormDescription> // <FormDescription className="text-gray-400">The user's role determines their permissions.</FormDescription>
<FormMessage /> // <FormMessage />
</FormItem> // </FormItem>
)} // )}
/> // />
<Button type="submit" disabled={isSubmitting} className="bg-emerald-600 hover:bg-emerald-700 text-white"> // <Button type="submit" disabled={isSubmitting} className="bg-emerald-600 hover:bg-emerald-700 text-white">
{isSubmitting ? "Saving..." : "Save changes"} // {isSubmitting ? "Saving..." : "Save changes"}
</Button> // </Button>
</form> // </form>
</Form> // </Form>
) // )
} // }

View File

@ -0,0 +1,219 @@
"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 {
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"
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)
// Use React Query to fetch users
const { data: users = [], isLoading, refetch } = useQuery({
queryKey: ['users'],
queryFn: async () => {
try {
return await fetchUsers()
} catch (error) {
toast.error("Failed to fetch users")
return []
}
}
})
const handleUserClick = (user: User) => {
setSelectedUser(user)
setIsSheetOpen(true)
}
const handleUserUpdate = () => {
refetch()
setIsSheetOpen(false)
}
const filteredUsers = users.filter((user) => {
if (!searchQuery) return true
const query = searchQuery.toLowerCase()
return (
user.email?.toLowerCase().includes(query) ||
user.phone?.toLowerCase().includes(query) ||
user.id.toLowerCase().includes(query)
)
})
const columns = [
{
id: "email",
header: "Email",
cell: ({ row }: { row: { original: User } }) => (
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-medium">
{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>
</div>
),
filterFn: (row: any, id: string, value: string) => {
return row.original.email?.toLowerCase().includes(value.toLowerCase())
},
},
{
id: "phone",
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())
},
},
{
id: "lastSignIn",
header: "Last Sign In",
cell: ({ row }: { row: { original: User } }) => {
return row.original.last_sign_in_at
? new Date(row.original.last_sign_in_at).toLocaleString()
: "Never"
},
},
{
id: "createdAt",
header: "Created At",
cell: ({ row }: { row: { original: User } }) => {
return new Date(row.original.created_at).toLocaleString()
},
},
{
id: "status",
header: "Status",
cell: ({ row }: { row: { original: User } }) => {
if (row.original.banned_until) {
return <Badge variant="destructive">Banned</Badge>
}
if (!row.original.email_confirmed_at) {
return <Badge variant="outline">Unconfirmed</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())
},
},
{
id: "actions",
header: "",
cell: ({ row }: { row: { original: User } }) => (
<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">
<div className="flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center">
<div className="relative w-full sm:w-72">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search users..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-9 w-9"
onClick={() => setSearchQuery("")}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex gap-2 w-full sm:w-auto">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-1">
<PlusCircle className="h-4 w-4" />
Add User
<ChevronDown className="h-4 w-4 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setIsAddUserOpen(true)}>
Add User
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setIsInviteUserOpen(true)}>
Invite User
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="icon">
<Filter className="h-4 w-4" />
</Button>
</div>
</div>
<DataTable
columns={columns}
data={filteredUsers}
loading={isLoading}
onRowClick={(user) => handleUserClick(user)}
/>
{selectedUser && (
<UserSheet
user={selectedUser}
open={isSheetOpen}
onOpenChange={setIsSheetOpen}
onUserUpdate={handleUserUpdate}
/>
)}
<AddUserDialog
open={isAddUserOpen}
onOpenChange={setIsAddUserOpen}
onUserAdded={() => refetch()}
/>
<InviteUserDialog
open={isInviteUserOpen}
onOpenChange={setIsInviteUserOpen}
onUserInvited={() => refetch()}
/>
</div>
)
}

View File

@ -48,7 +48,7 @@ export function VerifyOtpForm({ className, ...props }: VerifyOtpFormProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Form {...form}> <Form {...form}>
<form className="space-y-6"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<input type="hidden" name="email" value={email} /> <input type="hidden" name="email" value={email} />
<FormField <FormField
control={form.control} control={form.control}
@ -80,7 +80,7 @@ export function VerifyOtpForm({ className, ...props }: VerifyOtpFormProps) {
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white" className="w-full bg-emerald-600 hover:bg-emerald-700 text-white"
pendingText="Verifying..." pendingText="Verifying..."
disabled={isSubmitting} disabled={isSubmitting}
onSubmit={form.handleSubmit(onSubmit)}
> >
Submit Submit
</SubmitButton> </SubmitButton>

View File

@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -23,6 +23,7 @@
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@supabase/ssr": "latest", "@supabase/ssr": "latest",
"@supabase/supabase-js": "latest", "@supabase/supabase-js": "latest",
"@tabler/icons-react": "^3.30.0",
"@tanstack/react-query": "^5.66.9", "@tanstack/react-query": "^5.66.9",
"@tanstack/react-table": "^8.21.2", "@tanstack/react-table": "^8.21.2",
"autoprefixer": "10.4.20", "autoprefixer": "10.4.20",
@ -2677,6 +2678,32 @@
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
}, },
"node_modules/@tabler/icons": {
"version": "3.30.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.30.0.tgz",
"integrity": "sha512-c8OKLM48l00u9TFbh2qhSODMONIzML8ajtCyq95rW8vzkWcBrKRPM61tdkThz2j4kd5u17srPGIjqdeRUZdfdw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
}
},
"node_modules/@tabler/icons-react": {
"version": "3.30.0",
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.30.0.tgz",
"integrity": "sha512-9KZ9D1UNAyjlLkkYp2HBPHdf6lAJ2aelDqh8YYAnnmLF3xwprWKxxW8+zw5jlI0IwdfN4XFFuzqePkaw+DpIOg==",
"license": "MIT",
"dependencies": {
"@tabler/icons": "3.30.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
},
"peerDependencies": {
"react": ">= 16"
}
},
"node_modules/@tanstack/query-core": { "node_modules/@tanstack/query-core": {
"version": "5.66.4", "version": "5.66.4",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.66.4.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.66.4.tgz",

View File

@ -28,6 +28,7 @@
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@supabase/ssr": "latest", "@supabase/ssr": "latest",
"@supabase/supabase-js": "latest", "@supabase/supabase-js": "latest",
"@tabler/icons-react": "^3.30.0",
"@tanstack/react-query": "^5.66.9", "@tanstack/react-query": "^5.66.9",
"@tanstack/react-table": "^8.21.2", "@tanstack/react-table": "^8.21.2",
"autoprefixer": "10.4.20", "autoprefixer": "10.4.20",

View File

@ -206,21 +206,11 @@ export const navData = {
}, },
{ {
title: "User Management", title: "User Management",
url: "/user-management", url: "/protected/dashboard/user-management",
slug: "user-management", slug: "user-management",
orderSeq: 5, orderSeq: 5,
icon: IconUsers, icon: IconUsers,
isActive: true, isActive: true,
subItems: [
{
title: "Users",
url: "/protected/user-management/users",
slug: "users",
icon: IconUsersGroup,
orderSeq: 1,
isActive: true,
},
],
}, },
// { // {
// title: "Communication", // title: "Communication",

View File

@ -8,6 +8,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { verifyOtp } from "@/app/(auth-pages)/action"; import { verifyOtp } from "@/app/(auth-pages)/action";
import { defaultVerifyOtpValues, VerifyOtpFormData, verifyOtpSchema } from "@/src/models/auth/verify-otp.model"; import { defaultVerifyOtpValues, VerifyOtpFormData, verifyOtpSchema } from "@/src/models/auth/verify-otp.model";
import { useNavigations } from "@/hooks/use-navigations"; import { useNavigations } from "@/hooks/use-navigations";
import { toast } from "sonner";
export function useVerifyOtpForm(initialEmail: string) { export function useVerifyOtpForm(initialEmail: string) {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@ -29,14 +30,17 @@ export function useVerifyOtpForm(initialEmail: string) {
if (result.success) { if (result.success) {
setMessage(result.message); setMessage(result.message);
// Redirect or update UI state as needed // Redirect or update UI state as needed
toast.success(result.message);
if (result.redirectTo) { if (result.redirectTo) {
router.push(result.redirectTo); router.push(result.redirectTo);
} }
} else { } else {
toast.error(result.message);
form.setError("token", { type: "manual", message: result.message }); form.setError("token", { type: "manual", message: result.message });
} }
} catch (error) { } catch (error) {
console.error("OTP verification failed", error); console.error("OTP verification failed", error);
toast.error("An unexpected error occurred. Please try again.");
form.setError("token", { form.setError("token", {
type: "manual", type: "manual",
message: "An unexpected error occurred. Please try again." message: "An unexpected error occurred. Please try again."

View File

@ -0,0 +1,41 @@
export interface User {
id: string
email?: string
phone?: string
created_at: string
updated_at: string
last_sign_in_at?: string
email_confirmed_at?: string
phone_confirmed_at?: string
banned_until?: string
factors?: {
id: string
factor_type: string
created_at: string
updated_at: string
}[]
raw_user_meta_data?: Record<string, any>
raw_app_meta_data?: Record<string, any>
}
export interface CreateUserParams {
email: string
password: string
phone?: string
user_metadata?: Record<string, any>
email_confirm?: boolean
}
export interface UpdateUserParams {
email?: string
phone?: string
password?: string
user_metadata?: Record<string, any>
}
export interface InviteUserParams {
email: string
user_metadata?: Record<string, any>
}