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";
import * as TablerIcons from "@tabler/icons-react";
import { useNavigations } from "@/app/_hooks/use-navigations";
import { useNavigations } from "@/hooks/use-navigations";
interface SubSubItem {
title: string;

View File

@ -9,7 +9,7 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} 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 { 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,198 +2,162 @@
import { useState } from "react"
import {
type ColumnFiltersState,
type SortingState,
type VisibilityState,
type ColumnDef,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
getSortedRowModel,
type SortingState,
getFilteredRowModel,
type ColumnFiltersState,
} 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 { ChevronDown, RefreshCw } from "lucide-react"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Filter } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
interface DataTableProps<TData, TValue> {
columns: any[]
columns: ColumnDef<TData, TValue>[]
data: TData[]
loading?: boolean
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 [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [rowSelection, setRowSelection] = useState({})
const [columnVisibility, setColumnVisibility] = useState({})
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
})
return (
<div className="w-full">
<div className="flex items-center justify-between py-4 bg-[#1c1c1c] px-4 border-b border-[#2a2a2a]">
<div className="flex items-center gap-2">
<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]">
if (loading) {
return (
<div className="border rounded-md">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="border-[#2a2a2a] hover:bg-[#1c1c1c]">
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} className="text-gray-400">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer border-[#2a2a2a] hover:bg-[#1c1c1c]"
onClick={() => onRowClick && onRowClick(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
{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>
<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>
)
}
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>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer hover:bg-muted/50"
onClick={() => onRowClick && onRowClick(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</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"
import type React 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 { UserForm } from "./user-form"
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 {
AlertDialog,
AlertDialogAction,
@ -16,298 +26,295 @@ 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, ShieldAlert, Trash2, Ban } from "lucide-react"
import { User } from "./column"
import { toast } from "@/hooks/use-toast"
interface UserDetailSheetProps {
interface UserSheetProps {
user: User
open: boolean
onOpenChange: (open: boolean) => void
onUserUpdate: () => void
}
export function UserDetailSheet({ user, open, onOpenChange }: UserDetailSheetProps) {
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)
export function UserSheet({ user, open, onOpenChange, onUserUpdate }: UserSheetProps) {
const handleResetPassword = async () => {
try {
setIsResetting(true)
const [formData, setFormData] = useState({
email: user.email || "",
phone: user.phone || "",
metadata: JSON.stringify(user.raw_user_meta_data || {}, null, 2),
})
toast({
title: "Success",
description: "Password reset email sent",
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")
}
})
} catch (error) {
toast({
title: "Error",
description: "Failed to send reset password email",
variant: "destructive",
})
console.error(error)
} finally {
setIsResetting(false)
}
}
return updateUser(user.id, {
email: formData.email,
phone: formData.phone,
user_metadata: metadata,
})
},
onSuccess: () => {
toast.success("User updated successfully")
onUserUpdate()
},
onError: () => {
toast.error("Failed to update user")
},
})
const handleSendMagicLink = async () => {
try {
setIsSendingMagic(true)
const deleteUserMutation = useMutation({
mutationFn: () => deleteUser(user.id),
onSuccess: () => {
toast.success("User deleted successfully")
onUserUpdate()
},
onError: () => {
toast.error("Failed to delete user")
},
})
toast({
title: "Success",
description: "Magic link sent",
const sendPasswordRecoveryMutation = useMutation({
mutationFn: () => {
if (!user.email) {
throw new Error("User does not have an email address")
}
return sendPasswordRecovery(user.email)
},
onSuccess: () => {
toast.success("Password recovery email sent")
},
onError: () => {
toast.error("Failed to send password recovery email")
},
})
})
} catch (error) {
toast({
title: "Error",
description: "Failed to send magic link",
variant: "destructive",
})
console.error(error)
} finally {
setIsSendingMagic(false)
}
}
const sendMagicLinkMutation = useMutation({
mutationFn: () => {
if (!user.email) {
throw new Error("User does not have an email address")
}
return sendMagicLink(user.email)
},
onSuccess: () => {
toast.success("Magic link sent successfully")
},
onError: () => {
toast.error("Failed to send magic link")
},
})
const handleRemoveMfa = async () => {
try {
setIsRemovingMfa(true)
await removeMfaFactors(user.id)
toast({
title: "Success",
description: "MFA factors removed",
const toggleBanMutation = useMutation({
mutationFn: () => {
if (user.banned_until) {
return unbanUser(user.id)
} else {
return banUser(user.id)
}
},
onSuccess: () => {
toast.success("User ban status updated")
onUserUpdate()
},
onError: () => {
toast.error("Failed to update user ban status")
},
})
})
} catch (error) {
toast({
title: "Error",
description: "Failed to remove MFA factors",
variant: "destructive",
})
console.error(error)
} finally {
setIsRemovingMfa(false)
}
}
const handleBanUser = async () => {
try {
setIsBanning(true)
await banUser(user.id)
toast({
title: "Success",
description: "User banned successfully",
})
} catch (error) {
toast({
title: "Error",
description: "Failed to ban user",
variant: "destructive",
})
console.error(error)
} finally {
setIsBanning(false)
}
}
const handleDeleteUser = async () => {
try {
setIsDeleting(true)
await deleteUser(user.id)
toast({
title: "Success",
description: "User deleted successfully",
})
onOpenChange(false)
} catch (error) {
toast({
title: "Error",
description: "Failed to delete user",
variant: "destructive",
})
console.error(error)
} finally {
setIsDeleting(false)
}
}
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-xl overflow-y-auto bg-[#121212] text-white border-l border-[#2a2a2a]">
<SheetHeader>
<SheetTitle className="text-white">User Details</SheetTitle>
<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.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>
<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>
<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>
<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>
<Tabs defaultValue="details" className="w-full">
<TabsList className="grid w-full grid-cols-3 bg-[#1c1c1c]">
<TabsTrigger value="details" className="data-[state=active]:bg-[#2a2a2a]">
Details
</TabsTrigger>
<TabsTrigger value="profile" className="data-[state=active]:bg-[#2a2a2a]">
Profile
</TabsTrigger>
<TabsTrigger value="security" className="data-[state=active]:bg-[#2a2a2a]">
Security
</TabsTrigger>
</TabsList>
<TabsContent value="details" className="mt-4">
<UserForm user={user} />
</TabsContent>
<TabsContent value="profile" className="mt-4">
<div className="space-y-4">
<h3 className="text-lg font-medium">Profile Information</h3>
<p className="text-sm text-gray-400">Profile data will be loaded here.</p>
<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>
</TabsContent>
<TabsContent value="security" className="mt-4">
<div className="space-y-6">
<div className="bg-[#1c1c1c] p-4 rounded-md border border-[#2a2a2a]">
<div className="flex items-center justify-between">
<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
variant="outline"
size="sm"
onClick={handleResetPassword}
disabled={isResetting}
className="bg-[#121212] border-[#2a2a2a] hover:bg-[#2a2a2a]"
>
<Mail className="mr-2 h-4 w-4" />
Send password recovery
</Button>
</div>
</div>
</div>
<Button
onClick={() => updateUserMutation.mutate()}
disabled={updateUserMutation.isPending}
className="w-full"
>
{updateUserMutation.isPending ? "Saving..." : "Save Changes"}
</Button>
</TabsContent>
<div className="bg-[#1c1c1c] p-4 rounded-md border border-[#2a2a2a]">
<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>
<h3 className="text-lg font-medium mb-2">Danger zone</h3>
<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>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
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>
</AlertDialogTrigger>
<AlertDialogContent className="bg-[#121212] text-white border-[#2a2a2a]">
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription className="text-gray-400">
This action cannot be undone. This will permanently delete the user account and remove
their data from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="bg-[#1c1c1c] text-white border-[#2a2a2a] hover:bg-[#2a2a2a]">
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteUser}
disabled={isDeleting}
className="bg-red-900 hover:bg-red-800 text-white border-0"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
</div>
<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>
</TabsContent>
</Tabs>
</div>
{user.email_confirmed_at && (
<div className="text-xs text-muted-foreground">
Confirmed at: {new Date(user.email_confirmed_at).toLocaleString()}
</div>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="phone-confirmed">Phone Confirmed</Label>
<Switch id="phone-confirmed" checked={!!user.phone_confirmed_at} disabled />
</div>
{user.phone_confirmed_at && (
<div className="text-xs text-muted-foreground">
Confirmed at: {new Date(user.phone_confirmed_at).toLocaleString()}
</div>
)}
</div>
<Separator />
<div className="space-y-2">
<Label>Authentication Factors</Label>
<div className="text-sm text-muted-foreground">
{user.factors?.length
? user.factors.map((factor, i) => (
<div key={i} className="flex items-center gap-2">
<Badge variant="outline">{factor.factor_type}</Badge>
<span>{new Date(factor.created_at).toLocaleString()}</span>
</div>
))
: "No authentication factors"}
</div>
</div>
<Separator />
<div className="space-y-2">
<Label>Password Reset</Label>
<Button
variant="outline"
onClick={() => sendPasswordRecoveryMutation.mutate()}
disabled={sendPasswordRecoveryMutation.isPending || !user.email}
className="w-full"
>
Send Password Recovery Email
</Button>
</div>
<div className="space-y-2">
<Label>Magic Link</Label>
<Button
variant="outline"
onClick={() => sendMagicLinkMutation.mutate()}
disabled={sendMagicLinkMutation.isPending || !user.email}
className="w-full"
>
Send Magic Link
</Button>
</div>
</TabsContent>
<TabsContent value="actions" className="space-y-4">
<div className="space-y-2">
<Label>Ban User</Label>
<Button
variant={user.banned_until ? "default" : "destructive"}
onClick={() => toggleBanMutation.mutate()}
disabled={toggleBanMutation.isPending}
className="w-full"
>
{user.banned_until ? "Unban User" : "Ban User"}
</Button>
{user.banned_until && (
<div className="text-xs text-muted-foreground">
Banned until: {new Date(user.banned_until).toLocaleString()}
</div>
)}
</div>
<Separator />
<div className="space-y-2">
<Label>Delete User</Label>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-full">
Delete User
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the user account and remove their data
from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteUserMutation.mutate()}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TabsContent>
</Tabs>
<SheetFooter className="mt-4">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)

View File

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

View File

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

View File

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