menambahkan user management
This commit is contained in:
parent
ca90871b22
commit
681517e28e
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -2,198 +2,162 @@
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
if (loading) {
|
||||||
<div className="w-full">
|
return (
|
||||||
<div className="flex items-center justify-between py-4 bg-[#1c1c1c] px-4 border-b border-[#2a2a2a]">
|
<div className="border rounded-md">
|
||||||
<div className="flex items-center gap-2">
|
<Table>
|
||||||
<Input
|
<TableHeader>
|
||||||
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>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{table.getRowModel().rows?.length ? (
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
table.getRowModel().rows.map((row) => (
|
<TableRow key={index}>
|
||||||
<TableRow
|
{columns.map((_, colIndex) => (
|
||||||
key={row.id}
|
<TableCell key={colIndex}>
|
||||||
data-state={row.getIsSelected() && "selected"}
|
<Skeleton className="h-6 w-full" />
|
||||||
className="cursor-pointer border-[#2a2a2a] hover:bg-[#1c1c1c]"
|
</TableCell>
|
||||||
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>
|
</TableRow>
|
||||||
)}
|
))}
|
||||||
</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.
|
return (
|
||||||
</div>
|
<div className="border rounded-md">
|
||||||
<div className="space-x-2">
|
<Table>
|
||||||
<Button
|
<TableHeader>
|
||||||
variant="outline"
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
size="sm"
|
<TableRow key={headerGroup.id}>
|
||||||
onClick={() => table.previousPage()}
|
{headerGroup.headers.map((header) => (
|
||||||
disabled={!table.getCanPreviousPage()}
|
<TableHead key={header.id} className="relative">
|
||||||
className="bg-[#1c1c1c] border-[#2a2a2a] text-white hover:bg-[#2a2a2a]"
|
{header.isPlaceholder ? null : (
|
||||||
>
|
<div className="flex items-center gap-2">
|
||||||
Previous
|
<div
|
||||||
</Button>
|
className={
|
||||||
<Button
|
header.column.getCanSort() ? "cursor-pointer select-none flex items-center gap-1" : ""
|
||||||
variant="outline"
|
}
|
||||||
size="sm"
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
onClick={() => table.nextPage()}
|
>
|
||||||
disabled={!table.getCanNextPage()}
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
className="bg-[#1c1c1c] border-[#2a2a2a] text-white hover:bg-[#2a2a2a]"
|
{{
|
||||||
>
|
asc: " 🔼",
|
||||||
Next
|
desc: " 🔽",
|
||||||
</Button>
|
}[header.column.getIsSorted() as string] ?? null}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -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,298 +26,295 @@ 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({
|
||||||
try {
|
email: user.email || "",
|
||||||
setIsResetting(true)
|
phone: user.phone || "",
|
||||||
|
metadata: JSON.stringify(user.raw_user_meta_data || {}, null, 2),
|
||||||
|
})
|
||||||
|
|
||||||
toast({
|
const updateUserMutation = useMutation({
|
||||||
title: "Success",
|
mutationFn: async () => {
|
||||||
description: "Password reset email sent",
|
let metadata = {}
|
||||||
|
try {
|
||||||
|
metadata = JSON.parse(formData.metadata)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Invalid JSON. Please check your metadata format.")
|
||||||
|
throw new Error("Invalid JSON")
|
||||||
|
}
|
||||||
|
|
||||||
})
|
return updateUser(user.id, {
|
||||||
} catch (error) {
|
email: formData.email,
|
||||||
toast({
|
phone: formData.phone,
|
||||||
title: "Error",
|
user_metadata: metadata,
|
||||||
description: "Failed to send reset password email",
|
})
|
||||||
variant: "destructive",
|
},
|
||||||
})
|
onSuccess: () => {
|
||||||
console.error(error)
|
toast.success("User updated successfully")
|
||||||
} finally {
|
onUserUpdate()
|
||||||
setIsResetting(false)
|
},
|
||||||
}
|
onError: () => {
|
||||||
}
|
toast.error("Failed to update user")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const handleSendMagicLink = async () => {
|
const deleteUserMutation = useMutation({
|
||||||
try {
|
mutationFn: () => deleteUser(user.id),
|
||||||
setIsSendingMagic(true)
|
onSuccess: () => {
|
||||||
|
toast.success("User deleted successfully")
|
||||||
|
onUserUpdate()
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to delete user")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
toast({
|
const sendPasswordRecoveryMutation = useMutation({
|
||||||
title: "Success",
|
mutationFn: () => {
|
||||||
description: "Magic link sent",
|
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")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
})
|
const sendMagicLinkMutation = useMutation({
|
||||||
} catch (error) {
|
mutationFn: () => {
|
||||||
toast({
|
if (!user.email) {
|
||||||
title: "Error",
|
throw new Error("User does not have an email address")
|
||||||
description: "Failed to send magic link",
|
}
|
||||||
variant: "destructive",
|
return sendMagicLink(user.email)
|
||||||
})
|
},
|
||||||
console.error(error)
|
onSuccess: () => {
|
||||||
} finally {
|
toast.success("Magic link sent successfully")
|
||||||
setIsSendingMagic(false)
|
},
|
||||||
}
|
onError: () => {
|
||||||
}
|
toast.error("Failed to send magic link")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const handleRemoveMfa = async () => {
|
const toggleBanMutation = useMutation({
|
||||||
try {
|
mutationFn: () => {
|
||||||
setIsRemovingMfa(true)
|
if (user.banned_until) {
|
||||||
await removeMfaFactors(user.id)
|
return unbanUser(user.id)
|
||||||
toast({
|
} else {
|
||||||
title: "Success",
|
return banUser(user.id)
|
||||||
description: "MFA factors removed",
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("User ban status updated")
|
||||||
|
onUserUpdate()
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to update user ban status")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
})
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
} catch (error) {
|
const { name, value } = e.target
|
||||||
toast({
|
setFormData((prev) => ({ ...prev, [name]: value }))
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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">
|
<Tabs defaultValue="details" className="mt-6">
|
||||||
<div>
|
<TabsList className="grid grid-cols-3 mb-4">
|
||||||
<h2 className="text-xl font-semibold">{user.email}</h2>
|
<TabsTrigger value="details">Details</TabsTrigger>
|
||||||
<p className="text-sm text-gray-400">ID: {user.id}</p>
|
<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>
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="phone">Phone</Label>
|
||||||
<Tabs defaultValue="details" className="w-full">
|
<Input id="phone" name="phone" value={formData.phone} onChange={handleInputChange} />
|
||||||
<TabsList className="grid w-full grid-cols-3 bg-[#1c1c1c]">
|
</div>
|
||||||
<TabsTrigger value="details" className="data-[state=active]:bg-[#2a2a2a]">
|
<div className="space-y-2">
|
||||||
Details
|
<Label htmlFor="metadata">Metadata (JSON)</Label>
|
||||||
</TabsTrigger>
|
<Textarea
|
||||||
<TabsTrigger value="profile" className="data-[state=active]:bg-[#2a2a2a]">
|
id="metadata"
|
||||||
Profile
|
name="metadata"
|
||||||
</TabsTrigger>
|
value={formData.metadata}
|
||||||
<TabsTrigger value="security" className="data-[state=active]:bg-[#2a2a2a]">
|
onChange={handleInputChange}
|
||||||
Security
|
className="font-mono text-sm h-40"
|
||||||
</TabsTrigger>
|
/>
|
||||||
</TabsList>
|
</div>
|
||||||
<TabsContent value="details" className="mt-4">
|
<div className="space-y-2">
|
||||||
<UserForm user={user} />
|
<Label>Created At</Label>
|
||||||
</TabsContent>
|
<div className="text-sm text-muted-foreground">{new Date(user.created_at).toLocaleString()}</div>
|
||||||
<TabsContent value="profile" className="mt-4">
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-2">
|
||||||
<h3 className="text-lg font-medium">Profile Information</h3>
|
<Label>Last Sign In</Label>
|
||||||
<p className="text-sm text-gray-400">Profile data will be loaded here.</p>
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{user.last_sign_in_at ? new Date(user.last_sign_in_at).toLocaleString() : "Never"}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</div>
|
||||||
<TabsContent value="security" className="mt-4">
|
<Button
|
||||||
<div className="space-y-6">
|
onClick={() => updateUserMutation.mutate()}
|
||||||
<div className="bg-[#1c1c1c] p-4 rounded-md border border-[#2a2a2a]">
|
disabled={updateUserMutation.isPending}
|
||||||
<div className="flex items-center justify-between">
|
className="w-full"
|
||||||
<div>
|
>
|
||||||
<h3 className="font-medium">Reset password</h3>
|
{updateUserMutation.isPending ? "Saving..." : "Save Changes"}
|
||||||
<p className="text-sm text-gray-400">Send a password recovery email to the user</p>
|
</Button>
|
||||||
</div>
|
</TabsContent>
|
||||||
<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 className="bg-[#1c1c1c] p-4 rounded-md border border-[#2a2a2a]">
|
<TabsContent value="security" className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-2">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="font-medium">Send magic link</h3>
|
<Label htmlFor="email-confirmed">Email Confirmed</Label>
|
||||||
<p className="text-sm text-gray-400">Passwordless login via email for the user</p>
|
<Switch id="email-confirmed" checked={!!user.email_confirmed_at} disabled />
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
{user.email_confirmed_at && (
|
||||||
</Tabs>
|
<div className="text-xs text-muted-foreground">
|
||||||
</div>
|
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>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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>
|
||||||
)
|
// )
|
||||||
}
|
// }
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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."
|
||||||
|
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue