Compare commits
No commits in common. "20b02512597b7ec049bca30d7580e03e6331aa88" and "d7c860df17837274bfe61cbe42c4e9d4d84b943a" have entirely different histories.
20b0251259
...
d7c860df17
1248
package-lock.json
generated
1248
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
|
|
@ -11,27 +11,17 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
|
||||||
"@radix-ui/react-toast": "^1.2.15",
|
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"axios": "^1.13.2",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.11.0",
|
"react-router-dom": "^7.11.0",
|
||||||
"recharts": "^3.6.0",
|
|
||||||
"sonner": "^2.0.7",
|
|
||||||
"tailwind-merge": "^3.4.0"
|
"tailwind-merge": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
67
src/App.tsx
67
src/App.tsx
|
|
@ -1,63 +1,26 @@
|
||||||
import { Navigate, Route, Routes } from "react-router-dom"
|
import { Navigate, Route, Routes } from "react-router-dom"
|
||||||
import { AppShell } from "@/layouts/app-shell"
|
import { AppShell } from "@/layouts/app-shell"
|
||||||
import DashboardPage from "@/pages/admin/dashboard"
|
import DashboardPage from "@/pages/dashboard"
|
||||||
import UsersPage from "@/pages/admin/users"
|
import SubscriptionsPage from "@/pages/subscriptions"
|
||||||
import UserDetailsPage from "@/pages/admin/users/[id]"
|
import TransactionsPage from "@/pages/transactions"
|
||||||
import UserActivityPage from "@/pages/admin/users/[id]/activity"
|
import ClientsPage from "@/pages/clients"
|
||||||
import LogsPage from "@/pages/admin/logs"
|
import MembersPage from "@/pages/members"
|
||||||
import ErrorLogsPage from "@/pages/admin/logs/errors"
|
import NotificationsPage from "@/pages/notifications"
|
||||||
import AccessLogsPage from "@/pages/admin/logs/access"
|
import ActivityLogPage from "@/pages/activity-log"
|
||||||
import LogDetailsPage from "@/pages/admin/logs/[id]"
|
|
||||||
import SettingsPage from "@/pages/admin/settings"
|
|
||||||
import MaintenancePage from "@/pages/admin/maintenance"
|
|
||||||
import AnnouncementsPage from "@/pages/admin/announcements"
|
|
||||||
import AuditPage from "@/pages/admin/audit"
|
|
||||||
import SecurityPage from "@/pages/admin/security"
|
|
||||||
import FailedLoginsPage from "@/pages/admin/security/failed-logins"
|
|
||||||
import SuspiciousActivityPage from "@/pages/admin/security/suspicious"
|
|
||||||
import ApiKeysPage from "@/pages/admin/security/api-keys"
|
|
||||||
import RateLimitsPage from "@/pages/admin/security/rate-limits"
|
|
||||||
import SessionsPage from "@/pages/admin/security/sessions"
|
|
||||||
import AnalyticsPage from "@/pages/admin/analytics"
|
|
||||||
import AnalyticsOverviewPage from "@/pages/admin/analytics/overview"
|
|
||||||
import AnalyticsUsersPage from "@/pages/admin/analytics/users"
|
|
||||||
import AnalyticsRevenuePage from "@/pages/admin/analytics/revenue"
|
|
||||||
import AnalyticsStoragePage from "@/pages/admin/analytics/storage"
|
|
||||||
import AnalyticsApiPage from "@/pages/admin/analytics/api"
|
|
||||||
import HealthPage from "@/pages/admin/health"
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AppShell />}>
|
<Route element={<AppShell />}>
|
||||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
<Route index element={<DashboardPage />} />
|
||||||
<Route path="admin/dashboard" element={<DashboardPage />} />
|
<Route path="subscriptions" element={<SubscriptionsPage />} />
|
||||||
<Route path="admin/users" element={<UsersPage />} />
|
<Route path="transactions" element={<TransactionsPage />} />
|
||||||
<Route path="admin/users/:id" element={<UserDetailsPage />} />
|
<Route path="clients" element={<ClientsPage />} />
|
||||||
<Route path="admin/users/:id/activity" element={<UserActivityPage />} />
|
<Route path="members" element={<MembersPage />} />
|
||||||
<Route path="admin/logs" element={<LogsPage />} />
|
<Route path="notifications" element={<NotificationsPage />} />
|
||||||
<Route path="admin/logs/errors" element={<ErrorLogsPage />} />
|
<Route path="activity-log" element={<ActivityLogPage />} />
|
||||||
<Route path="admin/logs/access" element={<AccessLogsPage />} />
|
|
||||||
<Route path="admin/logs/:id" element={<LogDetailsPage />} />
|
|
||||||
<Route path="admin/settings" element={<SettingsPage />} />
|
|
||||||
<Route path="admin/maintenance" element={<MaintenancePage />} />
|
|
||||||
<Route path="admin/announcements" element={<AnnouncementsPage />} />
|
|
||||||
<Route path="admin/audit" element={<AuditPage />} />
|
|
||||||
<Route path="admin/security" element={<SecurityPage />} />
|
|
||||||
<Route path="admin/security/failed-logins" element={<FailedLoginsPage />} />
|
|
||||||
<Route path="admin/security/suspicious" element={<SuspiciousActivityPage />} />
|
|
||||||
<Route path="admin/security/api-keys" element={<ApiKeysPage />} />
|
|
||||||
<Route path="admin/security/rate-limits" element={<RateLimitsPage />} />
|
|
||||||
<Route path="admin/security/sessions" element={<SessionsPage />} />
|
|
||||||
<Route path="admin/analytics" element={<AnalyticsPage />} />
|
|
||||||
<Route path="admin/analytics/overview" element={<AnalyticsOverviewPage />} />
|
|
||||||
<Route path="admin/analytics/users" element={<AnalyticsUsersPage />} />
|
|
||||||
<Route path="admin/analytics/revenue" element={<AnalyticsRevenuePage />} />
|
|
||||||
<Route path="admin/analytics/storage" element={<AnalyticsStoragePage />} />
|
|
||||||
<Route path="admin/analytics/api" element={<AnalyticsApiPage />} />
|
|
||||||
<Route path="admin/health" element={<HealthPage />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
import { useState } from "react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Calendar, X } from "lucide-react"
|
|
||||||
import { format } from "date-fns"
|
|
||||||
|
|
||||||
interface DateRangePickerProps {
|
|
||||||
startDate?: string
|
|
||||||
endDate?: string
|
|
||||||
onDateRangeChange: (startDate?: string, endDate?: string) => void
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DateRangePicker({ startDate, endDate, onDateRangeChange, className }: DateRangePickerProps) {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [localStartDate, setLocalStartDate] = useState(startDate || "")
|
|
||||||
const [localEndDate, setLocalEndDate] = useState(endDate || "")
|
|
||||||
|
|
||||||
const handleApply = () => {
|
|
||||||
onDateRangeChange(localStartDate || undefined, localEndDate || undefined)
|
|
||||||
setOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClear = () => {
|
|
||||||
setLocalStartDate("")
|
|
||||||
setLocalEndDate("")
|
|
||||||
onDateRangeChange(undefined, undefined)
|
|
||||||
setOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayText = () => {
|
|
||||||
if (startDate && endDate) {
|
|
||||||
try {
|
|
||||||
const start = format(new Date(startDate), "MMM dd, yyyy")
|
|
||||||
const end = format(new Date(endDate), "MMM dd, yyyy")
|
|
||||||
return `${start} - ${end}`
|
|
||||||
} catch {
|
|
||||||
return "Invalid date range"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "Select date range"
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="outline" className={className}>
|
|
||||||
<Calendar className="w-4 h-4 mr-2" />
|
|
||||||
{displayText()}
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Select Date Range</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Choose a start and end date to filter logs
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="start-date">Start Date</Label>
|
|
||||||
<Input
|
|
||||||
id="start-date"
|
|
||||||
type="date"
|
|
||||||
value={localStartDate}
|
|
||||||
onChange={(e) => setLocalStartDate(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="end-date">End Date</Label>
|
|
||||||
<Input
|
|
||||||
id="end-date"
|
|
||||||
type="date"
|
|
||||||
value={localEndDate}
|
|
||||||
onChange={(e) => setLocalEndDate(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Button variant="outline" onClick={handleClear}>
|
|
||||||
<X className="w-4 h-4 mr-2" />
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleApply}>Apply</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const labelVariants = cva(
|
|
||||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
||||||
)
|
|
||||||
|
|
||||||
const Label = React.forwardRef<
|
|
||||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
|
||||||
VariantProps<typeof labelVariants>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<LabelPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(labelVariants(), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
Label.displayName = LabelPrimitive.Root.displayName
|
|
||||||
|
|
||||||
export { Label }
|
|
||||||
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
|
||||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Select = SelectPrimitive.Root
|
|
||||||
|
|
||||||
const SelectGroup = SelectPrimitive.Group
|
|
||||||
|
|
||||||
const SelectValue = SelectPrimitive.Value
|
|
||||||
|
|
||||||
const SelectTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Trigger
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<SelectPrimitive.Icon asChild>
|
|
||||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
|
||||||
</SelectPrimitive.Icon>
|
|
||||||
</SelectPrimitive.Trigger>
|
|
||||||
))
|
|
||||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
|
||||||
|
|
||||||
const SelectScrollUpButton = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.ScrollUpButton
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronUp className="h-4 w-4" />
|
|
||||||
</SelectPrimitive.ScrollUpButton>
|
|
||||||
))
|
|
||||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
|
||||||
|
|
||||||
const SelectScrollDownButton = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.ScrollDownButton
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</SelectPrimitive.ScrollDownButton>
|
|
||||||
))
|
|
||||||
SelectScrollDownButton.displayName =
|
|
||||||
SelectPrimitive.ScrollDownButton.displayName
|
|
||||||
|
|
||||||
const SelectContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
|
||||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Portal>
|
|
||||||
<SelectPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
||||||
position === "popper" &&
|
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
position={position}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<SelectScrollUpButton />
|
|
||||||
<SelectPrimitive.Viewport
|
|
||||||
className={cn(
|
|
||||||
"p-1",
|
|
||||||
position === "popper" &&
|
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SelectPrimitive.Viewport>
|
|
||||||
<SelectScrollDownButton />
|
|
||||||
</SelectPrimitive.Content>
|
|
||||||
</SelectPrimitive.Portal>
|
|
||||||
))
|
|
||||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
|
||||||
|
|
||||||
const SelectLabel = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Label
|
|
||||||
ref={ref}
|
|
||||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
|
||||||
|
|
||||||
const SelectItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Item
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
||||||
<SelectPrimitive.ItemIndicator>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</SelectPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
|
||||||
</SelectPrimitive.Item>
|
|
||||||
))
|
|
||||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
|
||||||
|
|
||||||
const SelectSeparator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Separator
|
|
||||||
ref={ref}
|
|
||||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
|
||||||
|
|
||||||
export {
|
|
||||||
Select,
|
|
||||||
SelectGroup,
|
|
||||||
SelectValue,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectContent,
|
|
||||||
SelectLabel,
|
|
||||||
SelectItem,
|
|
||||||
SelectSeparator,
|
|
||||||
SelectScrollUpButton,
|
|
||||||
SelectScrollDownButton,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Switch = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SwitchPrimitives.Root
|
|
||||||
className={cn(
|
|
||||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
>
|
|
||||||
<SwitchPrimitives.Thumb
|
|
||||||
className={cn(
|
|
||||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SwitchPrimitives.Root>
|
|
||||||
))
|
|
||||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
|
||||||
|
|
||||||
export { Switch }
|
|
||||||
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Tabs = TabsPrimitive.Root
|
|
||||||
|
|
||||||
const TabsList = React.forwardRef<
|
|
||||||
React.ElementRef<typeof TabsPrimitive.List>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<TabsPrimitive.List
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TabsList.displayName = TabsPrimitive.List.displayName
|
|
||||||
|
|
||||||
const TabsTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<TabsPrimitive.Trigger
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
|
||||||
|
|
||||||
const TabsContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<TabsPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
|
||||||
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import { Toaster as Sonner } from "sonner"
|
|
||||||
|
|
||||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
|
||||||
return (
|
|
||||||
<Sonner
|
|
||||||
className="toaster group"
|
|
||||||
toastOptions={{
|
|
||||||
classNames: {
|
|
||||||
toast:
|
|
||||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
|
||||||
description: "group-[.toast]:text-muted-foreground",
|
|
||||||
actionButton:
|
|
||||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
|
||||||
cancelButton:
|
|
||||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Toaster }
|
|
||||||
|
|
||||||
|
|
@ -1,19 +1,21 @@
|
||||||
import { Outlet, Link, useLocation } from "react-router-dom"
|
import { Outlet, Link, useLocation } from "react-router-dom"
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Users,
|
FolderKanban,
|
||||||
FileText,
|
Calendar,
|
||||||
|
Leaf,
|
||||||
Settings,
|
Settings,
|
||||||
Wrench,
|
|
||||||
Megaphone,
|
|
||||||
Shield,
|
|
||||||
BarChart3,
|
|
||||||
Activity,
|
|
||||||
Heart,
|
|
||||||
Search,
|
|
||||||
Mail,
|
|
||||||
Bell,
|
Bell,
|
||||||
LogOut,
|
HelpCircle,
|
||||||
|
DollarSign,
|
||||||
|
FileText,
|
||||||
|
Users,
|
||||||
|
Briefcase,
|
||||||
|
Mail,
|
||||||
|
Copy,
|
||||||
|
UserPlus,
|
||||||
|
Megaphone,
|
||||||
|
Search,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
@ -22,34 +24,35 @@ import { Separator } from "@/components/ui/separator"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const navigationItems = [
|
||||||
|
{ icon: LayoutDashboard, label: "Dashboard", path: "/" },
|
||||||
|
{ icon: FolderKanban, label: "Projects", path: "/projects" },
|
||||||
|
{ icon: Calendar, label: "Calendar", path: "/calendar" },
|
||||||
|
{ icon: Leaf, label: "Leave Management", path: "/leave" },
|
||||||
|
{ icon: Settings, label: "Settings", path: "/settings" },
|
||||||
|
{ icon: Bell, label: "Notification", path: "/notifications" },
|
||||||
|
{ icon: HelpCircle, label: "Help & Center", path: "/help" },
|
||||||
|
]
|
||||||
|
|
||||||
const adminNavigationItems = [
|
const adminNavigationItems = [
|
||||||
{ icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" },
|
{ icon: Users, label: "Subscriptions", path: "/subscriptions" },
|
||||||
{ icon: Users, label: "Users", path: "/admin/users" },
|
{ icon: DollarSign, label: "Transactions", path: "/transactions" },
|
||||||
{ icon: FileText, label: "Logs", path: "/admin/logs" },
|
{ icon: Briefcase, label: "Clients", path: "/clients" },
|
||||||
{ icon: Settings, label: "Settings", path: "/admin/settings" },
|
{ icon: Users, label: "Members", path: "/members" },
|
||||||
{ icon: Wrench, label: "Maintenance", path: "/admin/maintenance" },
|
{ icon: Bell, label: "Notifications", path: "/notifications" },
|
||||||
{ icon: Megaphone, label: "Announcements", path: "/admin/announcements" },
|
{ icon: FileText, label: "Activity Log", path: "/activity-log" },
|
||||||
{ icon: Activity, label: "Audit", path: "/admin/audit" },
|
|
||||||
{ icon: Shield, label: "Security", path: "/admin/security" },
|
|
||||||
{ icon: BarChart3, label: "Analytics", path: "/admin/analytics" },
|
|
||||||
{ icon: Heart, label: "System Health", path: "/admin/health" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
|
if (path === "/") {
|
||||||
|
return location.pathname === "/"
|
||||||
|
}
|
||||||
return location.pathname.startsWith(path)
|
return location.pathname.startsWith(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPageTitle = () => {
|
|
||||||
const currentPath = location.pathname
|
|
||||||
const item = adminNavigationItems.find((item) =>
|
|
||||||
currentPath.startsWith(item.path)
|
|
||||||
)
|
|
||||||
return item?.label || "Admin Panel"
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background">
|
<div className="flex h-screen bg-background">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
|
|
@ -57,13 +60,38 @@ export function AppShell() {
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="p-6 flex items-center gap-2">
|
<div className="p-6 flex items-center gap-2">
|
||||||
<div className="w-10 h-10 bg-primary rounded flex items-center justify-center">
|
<div className="w-10 h-10 bg-primary rounded flex items-center justify-center">
|
||||||
<span className="text-primary-foreground font-bold text-lg">A</span>
|
<span className="text-primary-foreground font-bold text-lg">H</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-foreground font-semibold text-lg">Admin Panel</span>
|
<span className="text-foreground font-semibold text-lg">TurHR</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto">
|
<nav className="flex-1 px-4 py-2 space-y-1">
|
||||||
|
{navigationItems.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
||||||
|
isActive(item.path)
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-foreground/70 hover:bg-accent hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Separator className="my-4" />
|
||||||
|
|
||||||
|
<div className="text-xs font-semibold text-foreground/50 uppercase tracking-wider px-3 py-2">
|
||||||
|
Admin
|
||||||
|
</div>
|
||||||
|
|
||||||
{adminNavigationItems.map((item) => {
|
{adminNavigationItems.map((item) => {
|
||||||
const Icon = item.icon
|
const Icon = item.icon
|
||||||
return (
|
return (
|
||||||
|
|
@ -84,29 +112,31 @@ export function AppShell() {
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User Section */}
|
{/* Announcements Card */}
|
||||||
<div className="p-4 border-t">
|
<div className="p-4">
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<Card>
|
||||||
<Avatar>
|
<CardContent className="p-4">
|
||||||
<AvatarFallback>AD</AvatarFallback>
|
<div className="flex items-center gap-2 mb-2">
|
||||||
</Avatar>
|
<Megaphone className="w-4 h-4 text-primary" />
|
||||||
<div className="flex-1 min-w-0">
|
<span className="font-semibold text-sm">Announcements</span>
|
||||||
<p className="text-sm font-medium truncate">Admin User</p>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground truncate">admin@example.com</p>
|
<p className="text-xs text-muted-foreground mb-3">
|
||||||
</div>
|
Stay updated with TurHR
|
||||||
</div>
|
</p>
|
||||||
<Button
|
<Button size="sm" className="w-full">
|
||||||
variant="outline"
|
Create Now
|
||||||
size="sm"
|
</Button>
|
||||||
className="w-full"
|
<div className="mt-3 text-xs text-muted-foreground">
|
||||||
onClick={() => {
|
Read by:
|
||||||
localStorage.removeItem('access_token')
|
</div>
|
||||||
window.location.href = '/login'
|
<div className="flex items-center gap-1 mt-1">
|
||||||
}}
|
<div className="w-6 h-6 rounded-full bg-primary/20" />
|
||||||
>
|
<div className="w-6 h-6 rounded-full bg-primary/20" />
|
||||||
<LogOut className="w-4 h-4 mr-2" />
|
<div className="w-6 h-6 rounded-full bg-primary/20" />
|
||||||
Logout
|
<span className="text-xs text-muted-foreground ml-1">+10</span>
|
||||||
</Button>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|
@ -114,7 +144,7 @@ export function AppShell() {
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{/* Top Header */}
|
{/* Top Header */}
|
||||||
<header className="h-16 border-b bg-background flex items-center justify-between px-6">
|
<header className="h-16 border-b bg-background flex items-center justify-between px-6">
|
||||||
<h1 className="text-2xl font-bold">{getPageTitle()}</h1>
|
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
|
@ -127,12 +157,21 @@ export function AppShell() {
|
||||||
<Mail className="w-5 h-5" />
|
<Mail className="w-5 h-5" />
|
||||||
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
|
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Copy className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="relative">
|
<Button variant="ghost" size="icon" className="relative">
|
||||||
<Bell className="w-5 h-5" />
|
<Users className="w-5 h-5" />
|
||||||
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
|
<span className="absolute top-0 right-0 bg-primary text-primary-foreground text-xs px-1 rounded">
|
||||||
|
+10
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Button>
|
||||||
|
<UserPlus className="w-4 h-4 mr-2" />
|
||||||
|
Invite
|
||||||
</Button>
|
</Button>
|
||||||
<Avatar>
|
<Avatar>
|
||||||
<AvatarFallback>AD</AvatarFallback>
|
<AvatarFallback>U</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -1,277 +0,0 @@
|
||||||
import axios, { type AxiosInstance, type AxiosError } from 'axios';
|
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1';
|
|
||||||
|
|
||||||
// Create axios instance
|
|
||||||
const adminApi: AxiosInstance = axios.create({
|
|
||||||
baseURL: API_BASE_URL,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add token interceptor
|
|
||||||
adminApi.interceptors.request.use(
|
|
||||||
(config) => {
|
|
||||||
const token = localStorage.getItem('access_token');
|
|
||||||
if (token) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add response interceptor for error handling
|
|
||||||
adminApi.interceptors.response.use(
|
|
||||||
(response) => response,
|
|
||||||
(error: AxiosError) => {
|
|
||||||
if (error.response?.status === 401) {
|
|
||||||
// Redirect to login
|
|
||||||
localStorage.removeItem('access_token');
|
|
||||||
window.location.href = '/login';
|
|
||||||
} else if (error.response?.status === 403) {
|
|
||||||
// Show access denied
|
|
||||||
const message = error.response?.data?.message || 'You do not have permission to access this resource';
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
import('sonner').then(({ toast }) => {
|
|
||||||
toast.error(message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (error.response?.status === 404) {
|
|
||||||
const message = error.response?.data?.message || 'Resource not found';
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
import('sonner').then(({ toast }) => {
|
|
||||||
toast.error(message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (error.response?.status === 500) {
|
|
||||||
const message = error.response?.data?.message || 'Server error occurred. Please try again later.';
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
import('sonner').then(({ toast }) => {
|
|
||||||
toast.error(message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (!error.response) {
|
|
||||||
// Network error
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
import('sonner').then(({ toast }) => {
|
|
||||||
toast.error('Network error. Please check your connection.');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// API helper functions
|
|
||||||
export const adminApiHelpers = {
|
|
||||||
// Users
|
|
||||||
getUsers: (params?: {
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
role?: string;
|
|
||||||
isActive?: boolean;
|
|
||||||
search?: string;
|
|
||||||
}) => adminApi.get('/admin/users', { params }),
|
|
||||||
|
|
||||||
getUser: (id: string) => adminApi.get(`/admin/users/${id}`),
|
|
||||||
|
|
||||||
getUserActivity: (id: string, days: number = 30) =>
|
|
||||||
adminApi.get(`/admin/users/${id}/activity`, { params: { days } }),
|
|
||||||
|
|
||||||
updateUser: (id: string, data: {
|
|
||||||
role?: string;
|
|
||||||
isActive?: boolean;
|
|
||||||
firstName?: string;
|
|
||||||
lastName?: string;
|
|
||||||
}) => adminApi.put(`/admin/users/${id}`, data),
|
|
||||||
|
|
||||||
deleteUser: (id: string, hard: boolean = false) =>
|
|
||||||
adminApi.delete(`/admin/users/${id}?hard=${hard}`),
|
|
||||||
|
|
||||||
resetPassword: (id: string) =>
|
|
||||||
adminApi.post(`/admin/users/${id}/reset-password`),
|
|
||||||
|
|
||||||
exportUsers: (format: string = 'csv') =>
|
|
||||||
adminApi.post('/admin/users/export', null, { params: { format } }),
|
|
||||||
|
|
||||||
importUsers: (file: File) => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
return adminApi.post('/admin/users/import', formData, {
|
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Logs
|
|
||||||
getLogs: (params?: {
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
level?: string;
|
|
||||||
type?: string;
|
|
||||||
userId?: string;
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
search?: string;
|
|
||||||
minDuration?: number;
|
|
||||||
}) => adminApi.get('/admin/logs', { params }),
|
|
||||||
|
|
||||||
getErrorLogs: (params?: {
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
userId?: string;
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
}) => adminApi.get('/admin/logs/errors', { params }),
|
|
||||||
|
|
||||||
getAccessLogs: (params?: {
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
userId?: string;
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
}) => adminApi.get('/admin/logs/access', { params }),
|
|
||||||
|
|
||||||
getLogById: (id: string) => adminApi.get(`/admin/logs/${id}`),
|
|
||||||
|
|
||||||
getLogStats: (startDate?: string, endDate?: string) =>
|
|
||||||
adminApi.get('/admin/logs/stats/summary', { params: { startDate, endDate } }),
|
|
||||||
|
|
||||||
exportLogs: (params: {
|
|
||||||
format?: string;
|
|
||||||
level?: string;
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
}) => adminApi.post('/admin/logs/export', null, { params }),
|
|
||||||
|
|
||||||
cleanupLogs: (days: number = 30) =>
|
|
||||||
adminApi.post('/admin/logs/cleanup', null, { params: { days } }),
|
|
||||||
|
|
||||||
// Analytics
|
|
||||||
getOverview: () => adminApi.get('/admin/analytics/overview'),
|
|
||||||
|
|
||||||
getUserGrowth: (days: number = 30) =>
|
|
||||||
adminApi.get('/admin/analytics/users/growth', { params: { days } }),
|
|
||||||
|
|
||||||
getRevenue: (period: string = '30days') =>
|
|
||||||
adminApi.get('/admin/analytics/revenue', { params: { period } }),
|
|
||||||
|
|
||||||
getStorageAnalytics: () => adminApi.get('/admin/analytics/storage'),
|
|
||||||
|
|
||||||
getApiUsage: (days: number = 7) =>
|
|
||||||
adminApi.get('/admin/analytics/api-usage', { params: { days } }),
|
|
||||||
|
|
||||||
getErrorRate: (days: number = 7) =>
|
|
||||||
adminApi.get('/admin/analytics/error-rate', { params: { days } }),
|
|
||||||
|
|
||||||
// System
|
|
||||||
getHealth: () => adminApi.get('/admin/system/health'),
|
|
||||||
|
|
||||||
getSystemInfo: () => adminApi.get('/admin/system/info'),
|
|
||||||
|
|
||||||
getSettings: (category?: string) =>
|
|
||||||
adminApi.get('/admin/system/settings', { params: { category } }),
|
|
||||||
|
|
||||||
getSetting: (key: string) => adminApi.get(`/admin/system/settings/${key}`),
|
|
||||||
|
|
||||||
createSetting: (data: {
|
|
||||||
key: string;
|
|
||||||
value: string;
|
|
||||||
category: string;
|
|
||||||
description?: string;
|
|
||||||
isPublic?: boolean;
|
|
||||||
}) => adminApi.post('/admin/system/settings', data),
|
|
||||||
|
|
||||||
updateSetting: (key: string, data: {
|
|
||||||
value: string;
|
|
||||||
description?: string;
|
|
||||||
isPublic?: boolean;
|
|
||||||
}) => adminApi.put(`/admin/system/settings/${key}`, data),
|
|
||||||
|
|
||||||
deleteSetting: (key: string) => adminApi.delete(`/admin/system/settings/${key}`),
|
|
||||||
|
|
||||||
// Maintenance
|
|
||||||
getMaintenanceStatus: () => adminApi.get('/admin/maintenance'),
|
|
||||||
|
|
||||||
enableMaintenance: (message?: string) =>
|
|
||||||
adminApi.post('/admin/maintenance/enable', { message }),
|
|
||||||
|
|
||||||
disableMaintenance: () => adminApi.post('/admin/maintenance/disable'),
|
|
||||||
|
|
||||||
// Announcements
|
|
||||||
getAnnouncements: (activeOnly: boolean = false) =>
|
|
||||||
adminApi.get('/admin/announcements', { params: { activeOnly } }),
|
|
||||||
|
|
||||||
createAnnouncement: (data: {
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
type?: string;
|
|
||||||
priority?: number;
|
|
||||||
targetAudience?: string;
|
|
||||||
startsAt?: string;
|
|
||||||
endsAt?: string;
|
|
||||||
}) => adminApi.post('/admin/announcements', data),
|
|
||||||
|
|
||||||
updateAnnouncement: (id: string, data: {
|
|
||||||
title?: string;
|
|
||||||
message?: string;
|
|
||||||
type?: string;
|
|
||||||
priority?: number;
|
|
||||||
targetAudience?: string;
|
|
||||||
startsAt?: string;
|
|
||||||
endsAt?: string;
|
|
||||||
}) => adminApi.put(`/admin/announcements/${id}`, data),
|
|
||||||
|
|
||||||
toggleAnnouncement: (id: string) =>
|
|
||||||
adminApi.patch(`/admin/announcements/${id}/toggle`),
|
|
||||||
|
|
||||||
deleteAnnouncement: (id: string) =>
|
|
||||||
adminApi.delete(`/admin/announcements/${id}`),
|
|
||||||
|
|
||||||
// Audit
|
|
||||||
getAuditLogs: (params?: {
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
userId?: string;
|
|
||||||
action?: string;
|
|
||||||
resourceType?: string;
|
|
||||||
resourceId?: string;
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
}) => adminApi.get('/admin/audit/logs', { params }),
|
|
||||||
|
|
||||||
getUserAuditActivity: (userId: string, days: number = 30) =>
|
|
||||||
adminApi.get(`/admin/audit/users/${userId}`, { params: { days } }),
|
|
||||||
|
|
||||||
getResourceHistory: (type: string, id: string) =>
|
|
||||||
adminApi.get(`/admin/audit/resource/${type}/${id}`),
|
|
||||||
|
|
||||||
getAuditStats: (startDate?: string, endDate?: string) =>
|
|
||||||
adminApi.get('/admin/audit/stats', { params: { startDate, endDate } }),
|
|
||||||
|
|
||||||
// Security
|
|
||||||
getFailedLogins: (params?: {
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
email?: string;
|
|
||||||
ipAddress?: string;
|
|
||||||
}) => adminApi.get('/admin/security/failed-logins', { params }),
|
|
||||||
|
|
||||||
getSuspiciousActivity: () => adminApi.get('/admin/security/suspicious-activity'),
|
|
||||||
|
|
||||||
getAllApiKeys: () => adminApi.get('/admin/security/api-keys'),
|
|
||||||
|
|
||||||
revokeApiKey: (id: string) =>
|
|
||||||
adminApi.patch(`/admin/security/api-keys/${id}/revoke`),
|
|
||||||
|
|
||||||
getRateLimitViolations: (days: number = 7) =>
|
|
||||||
adminApi.get('/admin/security/rate-limits', { params: { days } }),
|
|
||||||
|
|
||||||
getActiveSessions: () => adminApi.get('/admin/security/sessions'),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default adminApi;
|
|
||||||
|
|
||||||
|
|
@ -5,14 +5,12 @@ import App from './App.tsx'
|
||||||
import { BrowserRouter } from "react-router-dom"
|
import { BrowserRouter } from "react-router-dom"
|
||||||
import { QueryClientProvider } from "@tanstack/react-query"
|
import { QueryClientProvider } from "@tanstack/react-query"
|
||||||
import { queryClient } from "@/app/query-client"
|
import { queryClient } from "@/app/query-client"
|
||||||
import { Toaster } from "@/components/ui/toast"
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
<Toaster />
|
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table"
|
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
|
||||||
|
|
||||||
export default function AnalyticsApiPage() {
|
|
||||||
const { data: apiUsage, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'analytics', 'api-usage'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await adminApiHelpers.getApiUsage(7)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: errorRate, isLoading: errorRateLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'analytics', 'error-rate'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await adminApiHelpers.getErrorRate(7)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h2 className="text-3xl font-bold">API Usage Analytics</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-sm font-medium">Total API Calls</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{errorRateLoading ? (
|
|
||||||
<div className="text-2xl font-bold">...</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-2xl font-bold">{errorRate?.total || 0}</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-sm font-medium">Errors</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{errorRateLoading ? (
|
|
||||||
<div className="text-2xl font-bold">...</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-2xl font-bold">{errorRate?.errors || 0}</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-sm font-medium">Error Rate</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{errorRateLoading ? (
|
|
||||||
<div className="text-2xl font-bold">...</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{errorRate?.errorRate ? `${errorRate.errorRate.toFixed(2)}%` : '0%'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Endpoint Usage (Last 7 Days)</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-8">Loading API usage...</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Endpoint</TableHead>
|
|
||||||
<TableHead>Calls</TableHead>
|
|
||||||
<TableHead>Avg Duration (ms)</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{apiUsage?.map((endpoint: any, index: number) => (
|
|
||||||
<TableRow key={index}>
|
|
||||||
<TableCell className="font-mono text-sm">{endpoint.endpoint}</TableCell>
|
|
||||||
<TableCell>{endpoint.calls}</TableCell>
|
|
||||||
<TableCell>{endpoint.avgDuration?.toFixed(2) || 'N/A'}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
{apiUsage?.length === 0 && (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
No API usage data available
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { BarChart3, Users, DollarSign, HardDrive, Activity } from "lucide-react"
|
|
||||||
import { useNavigate } from "react-router-dom"
|
|
||||||
|
|
||||||
export default function AnalyticsPage() {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h2 className="text-3xl font-bold">Analytics</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/analytics/overview')}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<BarChart3 className="w-5 h-5" />
|
|
||||||
Overview
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Platform analytics overview
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/analytics/users')}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Users className="w-5 h-5" />
|
|
||||||
Users Analytics
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
User growth and statistics
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/analytics/revenue')}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<DollarSign className="w-5 h-5" />
|
|
||||||
Revenue Analytics
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Revenue trends and breakdown
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/analytics/storage')}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<HardDrive className="w-5 h-5" />
|
|
||||||
Storage Analytics
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Storage usage and breakdown
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/analytics/api')}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Activity className="w-5 h-5" />
|
|
||||||
API Usage
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
API endpoint usage statistics
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import DashboardPage from "../../dashboard"
|
|
||||||
|
|
||||||
export default function AnalyticsOverviewPage() {
|
|
||||||
return <DashboardPage />
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"
|
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
|
||||||
|
|
||||||
export default function AnalyticsRevenuePage() {
|
|
||||||
const { data: revenue, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'analytics', 'revenue'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await adminApiHelpers.getRevenue('90days')
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h2 className="text-3xl font-bold">Revenue Analytics</h2>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Revenue Trends (Last 90 Days)</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="h-[400px] flex items-center justify-center">Loading...</div>
|
|
||||||
) : revenue && revenue.length > 0 ? (
|
|
||||||
<ResponsiveContainer width="100%" height={400}>
|
|
||||||
<BarChart data={revenue}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey="date" />
|
|
||||||
<YAxis />
|
|
||||||
<Tooltip />
|
|
||||||
<Legend />
|
|
||||||
<Bar dataKey="revenue" fill="#8884d8" name="Revenue" />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<div className="h-[400px] flex items-center justify-center text-muted-foreground">
|
|
||||||
No data available
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts"
|
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
|
||||||
|
|
||||||
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8']
|
|
||||||
|
|
||||||
export default function AnalyticsStoragePage() {
|
|
||||||
const { data: storage, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'analytics', 'storage'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await adminApiHelpers.getStorageAnalytics()
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
|
||||||
if (bytes === 0) return '0 Bytes'
|
|
||||||
const k = 1024
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
||||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
const chartData = storage?.byCategory?.map((cat: any) => ({
|
|
||||||
name: cat.category,
|
|
||||||
value: cat.size,
|
|
||||||
})) || []
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h2 className="text-3xl font-bold">Storage Analytics</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Storage Overview</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="h-[300px] flex items-center justify-center">Loading...</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Total Storage</p>
|
|
||||||
<p className="text-2xl font-bold">
|
|
||||||
{storage?.total ? formatBytes(storage.total.size) : '0 Bytes'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Total Files</p>
|
|
||||||
<p className="text-2xl font-bold">{storage?.total?.files || 0}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Storage by Category</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="h-[300px] flex items-center justify-center">Loading...</div>
|
|
||||||
) : chartData.length > 0 ? (
|
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={chartData}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
labelLine={false}
|
|
||||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
|
||||||
outerRadius={80}
|
|
||||||
fill="#8884d8"
|
|
||||||
dataKey="value"
|
|
||||||
>
|
|
||||||
{chartData.map((entry: any, index: number) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip />
|
|
||||||
<Legend />
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
|
||||||
No data available
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{storage?.topUsers && storage.topUsers.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Top 10 Users by Storage Usage</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{storage.topUsers.map((user: any, index: number) => (
|
|
||||||
<div key={index} className="flex items-center justify-between p-2 border rounded">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{user.user}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{user.files} files</p>
|
|
||||||
</div>
|
|
||||||
<p className="font-medium">{formatBytes(user.size)}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"
|
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
|
||||||
|
|
||||||
export default function AnalyticsUsersPage() {
|
|
||||||
const { data: userGrowth, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'analytics', 'users', 'growth'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await adminApiHelpers.getUserGrowth(90)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h2 className="text-3xl font-bold">User Analytics</h2>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>User Growth (Last 90 Days)</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="h-[400px] flex items-center justify-center">Loading...</div>
|
|
||||||
) : userGrowth && userGrowth.length > 0 ? (
|
|
||||||
<ResponsiveContainer width="100%" height={400}>
|
|
||||||
<LineChart data={userGrowth}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey="date" />
|
|
||||||
<YAxis />
|
|
||||||
<Tooltip />
|
|
||||||
<Legend />
|
|
||||||
<Line type="monotone" dataKey="total" stroke="#8884d8" name="Total Users" />
|
|
||||||
<Line type="monotone" dataKey="admins" stroke="#82ca9d" name="Admins" />
|
|
||||||
<Line type="monotone" dataKey="regular" stroke="#ffc658" name="Regular Users" />
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<div className="h-[400px] flex items-center justify-center text-muted-foreground">
|
|
||||||
No data available
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
import { useState } from "react"
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Megaphone, Plus, Edit, Trash2 } from "lucide-react"
|
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import { format } from "date-fns"
|
|
||||||
|
|
||||||
export default function AnnouncementsPage() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
|
||||||
const [selectedAnnouncement, setSelectedAnnouncement] = useState<any>(null)
|
|
||||||
|
|
||||||
const { data: announcements, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'announcements'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await adminApiHelpers.getAnnouncements(false)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: async (id: string) => {
|
|
||||||
await adminApiHelpers.deleteAnnouncement(id)
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'announcements'] })
|
|
||||||
toast.success("Announcement deleted successfully")
|
|
||||||
setDeleteDialogOpen(false)
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
toast.error(error.response?.data?.message || "Failed to delete announcement")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
if (selectedAnnouncement) {
|
|
||||||
deleteMutation.mutate(selectedAnnouncement.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-3xl font-bold">Announcements</h2>
|
|
||||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Create Announcement
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>All Announcements</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-8">Loading announcements...</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Title</TableHead>
|
|
||||||
<TableHead>Type</TableHead>
|
|
||||||
<TableHead>Priority</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Start Date</TableHead>
|
|
||||||
<TableHead>End Date</TableHead>
|
|
||||||
<TableHead>Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{announcements?.map((announcement: any) => (
|
|
||||||
<TableRow key={announcement.id}>
|
|
||||||
<TableCell className="font-medium">{announcement.title}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge>{announcement.type || 'info'}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{announcement.priority || 0}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={announcement.isActive ? 'default' : 'secondary'}>
|
|
||||||
{announcement.isActive ? 'Active' : 'Inactive'}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{announcement.startsAt ? format(new Date(announcement.startsAt), 'MMM dd, yyyy') : 'N/A'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{announcement.endsAt ? format(new Date(announcement.endsAt), 'MMM dd, yyyy') : 'N/A'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<Edit className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedAnnouncement(announcement)
|
|
||||||
setDeleteDialogOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
{announcements?.length === 0 && (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
No announcements found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Delete Dialog */}
|
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete Announcement</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Are you sure you want to delete "{selectedAnnouncement?.title}"? This action cannot be undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant="destructive" onClick={handleDelete}>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
import { useState } from "react"
|
|
||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table"
|
|
||||||
import { Search, Eye } from "lucide-react"
|
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
|
||||||
import { format } from "date-fns"
|
|
||||||
|
|
||||||
export default function AuditPage() {
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [limit] = useState(50)
|
|
||||||
const [search, setSearch] = useState("")
|
|
||||||
|
|
||||||
const { data: auditData, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'audit', 'logs', page, limit, search],
|
|
||||||
queryFn: async () => {
|
|
||||||
const params: any = { page, limit }
|
|
||||||
if (search) params.search = search
|
|
||||||
const response = await adminApiHelpers.getAuditLogs(params)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h2 className="text-3xl font-bold">Audit Logs</h2>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle>All Audit Logs</CardTitle>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search audit logs..."
|
|
||||||
className="pl-10 w-64"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-8">Loading audit logs...</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Action</TableHead>
|
|
||||||
<TableHead>Resource Type</TableHead>
|
|
||||||
<TableHead>Resource ID</TableHead>
|
|
||||||
<TableHead>User</TableHead>
|
|
||||||
<TableHead>IP Address</TableHead>
|
|
||||||
<TableHead>Date</TableHead>
|
|
||||||
<TableHead>Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{auditData?.data?.map((log: any) => (
|
|
||||||
<TableRow key={log.id}>
|
|
||||||
<TableCell>
|
|
||||||
<Badge>{log.action}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{log.resourceType}</TableCell>
|
|
||||||
<TableCell className="font-mono text-sm">{log.resourceId}</TableCell>
|
|
||||||
<TableCell>{log.userId || 'N/A'}</TableCell>
|
|
||||||
<TableCell>{log.ipAddress || 'N/A'}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{format(new Date(log.createdAt), 'MMM dd, yyyy HH:mm')}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
{auditData?.data?.length === 0 && (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
No audit logs found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,306 +0,0 @@
|
||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Download, Users, FileText, DollarSign, HardDrive, TrendingUp, AlertCircle } from "lucide-react"
|
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
|
||||||
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
|
||||||
const { data: overview, isLoading: overviewLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'analytics', 'overview'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await adminApiHelpers.getOverview()
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: userGrowth, isLoading: growthLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'analytics', 'users', 'growth'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await adminApiHelpers.getUserGrowth(30)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: revenue, isLoading: revenueLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'analytics', 'revenue'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await adminApiHelpers.getRevenue('30days')
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: health, isLoading: healthLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'system', 'health'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await adminApiHelpers.getHealth()
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: errorRate, isLoading: errorRateLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'analytics', 'error-rate'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await adminApiHelpers.getErrorRate(7)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleExport = () => {
|
|
||||||
toast.success("Exporting dashboard data...")
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
|
||||||
return new Intl.NumberFormat('en-US', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'USD',
|
|
||||||
}).format(amount)
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
|
||||||
if (bytes === 0) return '0 Bytes'
|
|
||||||
const k = 1024
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
||||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-3xl font-bold">Dashboard Overview</h2>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" onClick={handleExport}>
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
Export Data
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Cards */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{overviewLoading ? (
|
|
||||||
<div className="text-2xl font-bold">...</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="text-2xl font-bold">{overview?.users?.total || 0}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{overview?.users?.active || 0} active, {overview?.users?.inactive || 0} inactive
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Total Invoices</CardTitle>
|
|
||||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{overviewLoading ? (
|
|
||||||
<div className="text-2xl font-bold">...</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="text-2xl font-bold">{overview?.invoices?.total || 0}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
All time invoices
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
|
|
||||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{overviewLoading ? (
|
|
||||||
<div className="text-2xl font-bold">...</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{overview?.revenue ? formatCurrency(overview.revenue.total) : '$0.00'}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Total revenue
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Storage Usage</CardTitle>
|
|
||||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{overviewLoading ? (
|
|
||||||
<div className="text-2xl font-bold">...</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{overview?.storage ? formatBytes(overview.storage.totalSize) : '0 Bytes'}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{overview?.storage?.documents || 0} documents
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Charts Row */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
{/* User Growth Chart */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>User Growth (Last 30 Days)</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{growthLoading ? (
|
|
||||||
<div className="h-[300px] flex items-center justify-center">Loading...</div>
|
|
||||||
) : userGrowth && userGrowth.length > 0 ? (
|
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
|
||||||
<LineChart data={userGrowth}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey="date" />
|
|
||||||
<YAxis />
|
|
||||||
<Tooltip />
|
|
||||||
<Legend />
|
|
||||||
<Line type="monotone" dataKey="total" stroke="#8884d8" name="Total Users" />
|
|
||||||
<Line type="monotone" dataKey="admins" stroke="#82ca9d" name="Admins" />
|
|
||||||
<Line type="monotone" dataKey="regular" stroke="#ffc658" name="Regular Users" />
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
|
||||||
No data available
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Revenue Chart */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Revenue Analytics (Last 30 Days)</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{revenueLoading ? (
|
|
||||||
<div className="h-[300px] flex items-center justify-center">Loading...</div>
|
|
||||||
) : revenue && revenue.length > 0 ? (
|
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
|
||||||
<BarChart data={revenue}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey="date" />
|
|
||||||
<YAxis />
|
|
||||||
<Tooltip />
|
|
||||||
<Legend />
|
|
||||||
<Bar dataKey="revenue" fill="#8884d8" name="Revenue" />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
|
||||||
No data available
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error Rate Chart */}
|
|
||||||
{errorRate && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<AlertCircle className="h-5 w-5" />
|
|
||||||
Error Rate (Last 7 Days)
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{errorRateLoading ? (
|
|
||||||
<div className="h-[200px] flex items-center justify-center">Loading...</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Total Errors</p>
|
|
||||||
<p className="text-2xl font-bold">{errorRate.errors || 0}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Total Requests</p>
|
|
||||||
<p className="text-2xl font-bold">{errorRate.total || 0}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Error Rate</p>
|
|
||||||
<p className="text-2xl font-bold">
|
|
||||||
{errorRate.errorRate ? `${errorRate.errorRate.toFixed(2)}%` : '0%'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-muted rounded-full h-4">
|
|
||||||
<div
|
|
||||||
className="bg-destructive h-4 rounded-full transition-all"
|
|
||||||
style={{
|
|
||||||
width: `${Math.min(errorRate.errorRate || 0, 100)}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* System Health */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<AlertCircle className="h-5 w-5" />
|
|
||||||
System Health
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{healthLoading ? (
|
|
||||||
<div>Loading system health...</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Status</p>
|
|
||||||
<p className="text-lg font-semibold capitalize">{health?.status || 'Unknown'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Database</p>
|
|
||||||
<p className="text-lg font-semibold capitalize">{health?.database || 'Unknown'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Recent Errors</p>
|
|
||||||
<p className="text-lg font-semibold">{health?.recentErrors || 0}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Active Users</p>
|
|
||||||
<p className="text-lg font-semibold">{health?.activeUsers || 0}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { AlertCircle, CheckCircle, XCircle, Database, Users, Activity } from "lucide-react"
|
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
|
||||||
|
|
||||||
export default function HealthPage() {
|
|
||||||
const { data: health, isLoading: healthLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'system', 'health'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await adminApiHelpers.getHealth()
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
refetchInterval: 30000, // Refetch every 30 seconds
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: systemInfo, isLoading: infoLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'system', 'info'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await adminApiHelpers.getSystemInfo()
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const getStatusIcon = (status: string) => {
|
|
||||||
switch (status?.toLowerCase()) {
|
|
||||||
case 'healthy':
|
|
||||||
case 'connected':
|
|
||||||
return <CheckCircle className="w-5 h-5 text-green-500" />
|
|
||||||
case 'degraded':
|
|
||||||
return <AlertCircle className="w-5 h-5 text-yellow-500" />
|
|
||||||
case 'disconnected':
|
|
||||||
case 'down':
|
|
||||||
return <XCircle className="w-5 h-5 text-red-500" />
|
|
||||||
default:
|
|
||||||
return <AlertCircle className="w-5 h-5 text-gray-500" />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatUptime = (seconds: number) => {
|
|
||||||
const days = Math.floor(seconds / 86400)
|
|
||||||
const hours = Math.floor((seconds % 86400) / 3600)
|
|
||||||
const minutes = Math.floor((seconds % 3600) / 60)
|
|
||||||
return `${days}d ${hours}h ${minutes}m`
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
|
||||||
if (bytes === 0) return '0 Bytes'
|
|
||||||
const k = 1024
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
||||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h2 className="text-3xl font-bold">System Health</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
{getStatusIcon(health?.status)}
|
|
||||||
System Status
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{healthLoading ? (
|
|
||||||
<div>Loading...</div>
|
|
||||||
) : (
|
|
||||||
<Badge variant={health?.status === 'healthy' ? 'default' : 'destructive'}>
|
|
||||||
{health?.status || 'Unknown'}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
{getStatusIcon(health?.database)}
|
|
||||||
Database
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{healthLoading ? (
|
|
||||||
<div>Loading...</div>
|
|
||||||
) : (
|
|
||||||
<Badge variant={health?.database === 'connected' ? 'default' : 'destructive'}>
|
|
||||||
{health?.database || 'Unknown'}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<AlertCircle className="w-5 h-5" />
|
|
||||||
Recent Errors
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{healthLoading ? (
|
|
||||||
<div>Loading...</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-2xl font-bold">{health?.recentErrors || 0}</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Users className="w-5 h-5" />
|
|
||||||
Active Users
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{healthLoading ? (
|
|
||||||
<div>Loading...</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-2xl font-bold">{health?.activeUsers || 0}</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{systemInfo && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>System Information</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{infoLoading ? (
|
|
||||||
<div className="text-center py-8">Loading system info...</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Node.js Version</p>
|
|
||||||
<p className="font-medium">{systemInfo.nodeVersion}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Platform</p>
|
|
||||||
<p className="font-medium">{systemInfo.platform}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Architecture</p>
|
|
||||||
<p className="font-medium">{systemInfo.architecture}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Uptime</p>
|
|
||||||
<p className="font-medium">{formatUptime(systemInfo.uptime)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Environment</p>
|
|
||||||
<p className="font-medium">{systemInfo.env}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Memory Usage</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
{formatBytes(systemInfo.memory?.used || 0)} / {formatBytes(systemInfo.memory?.total || 0)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">CPU Cores</p>
|
|
||||||
<p className="font-medium">{systemInfo.cpu?.cores || 'N/A'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Load Average</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
{systemInfo.cpu?.loadAverage?.map((load: number) => load.toFixed(2)).join(', ') || 'N/A'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import { useState } from "react"
|
|
||||||
|
|
||||||
export default function MaintenancePage() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const [message, setMessage] = useState("")
|
|
||||||
|
|
||||||
const { data: status, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'maintenance'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await adminApiHelpers.getMaintenanceStatus()
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const enableMutation = useMutation({
|
|
||||||
mutationFn: async (msg?: string) => {
|
|
||||||
await adminApiHelpers.enableMaintenance(msg)
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'maintenance'] })
|
|
||||||
toast.success("Maintenance mode enabled")
|
|
||||||
setMessage("")
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
toast.error(error.response?.data?.message || "Failed to enable maintenance mode")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const disableMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
await adminApiHelpers.disableMaintenance()
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'maintenance'] })
|
|
||||||
toast.success("Maintenance mode disabled")
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
toast.error(error.response?.data?.message || "Failed to disable maintenance mode")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleToggle = (enabled: boolean) => {
|
|
||||||
if (enabled) {
|
|
||||||
enableMutation.mutate(message || undefined)
|
|
||||||
} else {
|
|
||||||
disableMutation.mutate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <div className="text-center py-8">Loading maintenance status...</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h2 className="text-3xl font-bold">Maintenance Mode</h2>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle>Maintenance Status</CardTitle>
|
|
||||||
<Badge variant={status?.enabled ? 'destructive' : 'default'}>
|
|
||||||
{status?.enabled ? 'Enabled' : 'Disabled'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Label>Maintenance Mode</Label>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Enable maintenance mode to temporarily disable access to the platform
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={status?.enabled || false}
|
|
||||||
onCheckedChange={handleToggle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!status?.enabled && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="message">Maintenance Message (Optional)</Label>
|
|
||||||
<Input
|
|
||||||
id="message"
|
|
||||||
placeholder="Enter maintenance message..."
|
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
/>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
This message will be displayed to users when maintenance mode is enabled
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status?.enabled && status?.message && (
|
|
||||||
<div>
|
|
||||||
<Label>Current Message</Label>
|
|
||||||
<p className="text-sm mt-2">{status.message}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table"
|
|
||||||
import { Key, Ban } from "lucide-react"
|
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import { format } from "date-fns"
|
|
||||||
|
|
||||||
export default function ApiKeysPage() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
const { data: apiKeys, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'security', 'api-keys'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await adminApiHelpers.getAllApiKeys()
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const revokeMutation = useMutation({
|
|
||||||
mutationFn: async (id: string) => {
|
|
||||||
await adminApiHelpers.revokeApiKey(id)
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'security', 'api-keys'] })
|
|
||||||
toast.success("API key revoked successfully")
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
toast.error(error.response?.data?.message || "Failed to revoke API key")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h2 className="text-3xl font-bold">API Keys</h2>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>All API Keys</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-8">Loading API keys...</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Name</TableHead>
|
|
||||||
<TableHead>User</TableHead>
|
|
||||||
<TableHead>Last Used</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{apiKeys?.map((key: any) => (
|
|
||||||
<TableRow key={key.id}>
|
|
||||||
<TableCell className="font-medium">{key.name}</TableCell>
|
|
||||||
<TableCell>{key.userId || 'N/A'}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{key.lastUsedAt ? format(new Date(key.lastUsedAt), 'MMM dd, yyyy') : 'Never'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={key.revoked ? 'destructive' : 'default'}>
|
|
||||||
{key.revoked ? 'Revoked' : 'Active'}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{!key.revoked && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => revokeMutation.mutate(key.id)}
|
|
||||||
>
|
|
||||||
<Ban className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
{apiKeys?.length === 0 && (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
No API keys found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
import { useState } from "react"
|
|
||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table"
|
|
||||||
import { Search, Ban } from "lucide-react"
|
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
|
||||||
import { format } from "date-fns"
|
|
||||||
|
|
||||||
export default function FailedLoginsPage() {
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [limit] = useState(50)
|
|
||||||
const [search, setSearch] = useState("")
|
|
||||||
|
|
||||||
const { data: failedLogins, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'security', 'failed-logins', page, limit, search],
|
|
||||||
queryFn: async () => {
|
|
||||||
const params: any = { page, limit }
|
|
||||||
if (search) params.email = search
|
|
||||||
const response = await adminApiHelpers.getFailedLogins(params)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h2 className="text-3xl font-bold">Failed Login Attempts</h2>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle>Failed Logins</CardTitle>
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search by email..."
|
|
||||||
className="pl-10 w-64"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-8">Loading failed logins...</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Email</TableHead>
|
|
||||||
<TableHead>IP Address</TableHead>
|
|
||||||
<TableHead>User Agent</TableHead>
|
|
||||||
<TableHead>Reason</TableHead>
|
|
||||||
<TableHead>Attempted At</TableHead>
|
|
||||||
<TableHead>Blocked</TableHead>
|
|
||||||
<TableHead>Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{failedLogins?.data?.map((login: any) => (
|
|
||||||
<TableRow key={login.id}>
|
|
||||||
<TableCell className="font-medium">{login.email}</TableCell>
|
|
||||||
<TableCell className="font-mono text-sm">{login.ipAddress}</TableCell>
|
|
||||||
<TableCell className="max-w-xs truncate">{login.userAgent}</TableCell>
|
|
||||||
<TableCell>{login.reason || 'N/A'}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{format(new Date(login.attemptedAt), 'MMM dd, yyyy HH:mm')}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={login.blocked ? 'destructive' : 'secondary'}>
|
|
||||||
{login.blocked ? 'Yes' : 'No'}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<Ban className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
{failedLogins?.data?.length === 0 && (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
No failed login attempts found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Shield, AlertTriangle, Key, Gauge, Users } from "lucide-react"
|
|
||||||
import { useNavigate } from "react-router-dom"
|
|
||||||
|
|
||||||
export default function SecurityPage() {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h2 className="text-3xl font-bold">Security</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/security/failed-logins')}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<AlertTriangle className="w-5 h-5" />
|
|
||||||
Failed Logins
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
View and manage failed login attempts
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/security/suspicious')}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Shield className="w-5 h-5" />
|
|
||||||
Suspicious Activity
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Monitor suspicious IPs and emails
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/security/api-keys')}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Key className="w-5 h-5" />
|
|
||||||
API Keys
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Manage API keys and tokens
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/security/rate-limits')}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Gauge className="w-5 h-5" />
|
|
||||||
Rate Limits
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
View rate limit violations
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/security/sessions')}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Users className="w-5 h-5" />
|
|
||||||
Active Sessions
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Manage active user sessions
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table"
|
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
|
||||||
|
|
||||||
export default function RateLimitsPage() {
|
|
||||||
const { data: violations, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'security', 'rate-limits'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await adminApiHelpers.getRateLimitViolations(7)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h2 className="text-3xl font-bold">Rate Limit Violations</h2>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Recent Violations (Last 7 Days)</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-8">Loading violations...</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>User</TableHead>
|
|
||||||
<TableHead>IP Address</TableHead>
|
|
||||||
<TableHead>Requests</TableHead>
|
|
||||||
<TableHead>Period</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{violations?.map((violation: any) => (
|
|
||||||
<TableRow key={violation.id}>
|
|
||||||
<TableCell>{violation.userId || 'N/A'}</TableCell>
|
|
||||||
<TableCell className="font-mono text-sm">{violation.ipAddress}</TableCell>
|
|
||||||
<TableCell>{violation.requests}</TableCell>
|
|
||||||
<TableCell>{violation.period}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
{violations?.length === 0 && (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
No rate limit violations found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table"
|
|
||||||
import { LogOut } from "lucide-react"
|
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
|
||||||
import { format } from "date-fns"
|
|
||||||
|
|
||||||
export default function SessionsPage() {
|
|
||||||
const { data: sessions, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'security', 'sessions'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await adminApiHelpers.getActiveSessions()
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h2 className="text-3xl font-bold">Active Sessions</h2>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>All Active Sessions</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-8">Loading sessions...</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>User</TableHead>
|
|
||||||
<TableHead>IP Address</TableHead>
|
|
||||||
<TableHead>User Agent</TableHead>
|
|
||||||
<TableHead>Last Activity</TableHead>
|
|
||||||
<TableHead>Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{sessions?.map((session: any) => (
|
|
||||||
<TableRow key={session.id}>
|
|
||||||
<TableCell className="font-medium">{session.userId || 'N/A'}</TableCell>
|
|
||||||
<TableCell className="font-mono text-sm">{session.ipAddress}</TableCell>
|
|
||||||
<TableCell className="max-w-xs truncate">{session.userAgent}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{format(new Date(session.lastActivity), 'MMM dd, yyyy HH:mm')}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<LogOut className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
{sessions?.length === 0 && (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
No active sessions found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Shield, Ban } from "lucide-react"
|
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
|
||||||
|
|
||||||
export default function SuspiciousActivityPage() {
|
|
||||||
const { data: suspicious, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'security', 'suspicious'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await adminApiHelpers.getSuspiciousActivity()
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h2 className="text-3xl font-bold">Suspicious Activity</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Shield className="w-5 h-5" />
|
|
||||||
Suspicious IP Addresses
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-8">Loading...</div>
|
|
||||||
) : suspicious?.suspiciousIPs?.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{suspicious.suspiciousIPs.map((ip: any, index: number) => (
|
|
||||||
<div key={index} className="flex items-center justify-between p-2 border rounded">
|
|
||||||
<div>
|
|
||||||
<p className="font-mono font-medium">{ip.ipAddress}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{ip.attempts} attempts</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Ban className="w-4 h-4 mr-2" />
|
|
||||||
Block
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
No suspicious IPs found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Shield className="w-5 h-5" />
|
|
||||||
Suspicious Emails
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-8">Loading...</div>
|
|
||||||
) : suspicious?.suspiciousEmails?.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{suspicious.suspiciousEmails.map((email: any, index: number) => (
|
|
||||||
<div key={index} className="flex items-center justify-between p-2 border rounded">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{email.email}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{email.attempts} attempts</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Ban className="w-4 h-4 mr-2" />
|
|
||||||
Block
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
No suspicious emails found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,214 +0,0 @@
|
||||||
import { useState } from "react"
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Plus } from "lucide-react"
|
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string>("GENERAL")
|
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
|
||||||
const [newSetting, setNewSetting] = useState({
|
|
||||||
key: "",
|
|
||||||
value: "",
|
|
||||||
description: "",
|
|
||||||
isPublic: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: settings, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'settings', selectedCategory],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await adminApiHelpers.getSettings(selectedCategory)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateSettingMutation = useMutation({
|
|
||||||
mutationFn: async ({ key, value }: { key: string; value: string }) => {
|
|
||||||
await adminApiHelpers.updateSetting(key, { value })
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
|
|
||||||
toast.success("Setting updated successfully")
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
toast.error(error.response?.data?.message || "Failed to update setting")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const createSettingMutation = useMutation({
|
|
||||||
mutationFn: async (data: {
|
|
||||||
key: string
|
|
||||||
value: string
|
|
||||||
category: string
|
|
||||||
description?: string
|
|
||||||
isPublic?: boolean
|
|
||||||
}) => {
|
|
||||||
await adminApiHelpers.createSetting(data)
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
|
|
||||||
toast.success("Setting created successfully")
|
|
||||||
setCreateDialogOpen(false)
|
|
||||||
setNewSetting({ key: "", value: "", description: "", isPublic: false })
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
toast.error(error.response?.data?.message || "Failed to create setting")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleSave = (key: string, value: string) => {
|
|
||||||
updateSettingMutation.mutate({ key, value })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreate = () => {
|
|
||||||
if (!newSetting.key || !newSetting.value) {
|
|
||||||
toast.error("Key and value are required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
createSettingMutation.mutate({
|
|
||||||
key: newSetting.key,
|
|
||||||
value: newSetting.value,
|
|
||||||
category: selectedCategory,
|
|
||||||
description: newSetting.description || undefined,
|
|
||||||
isPublic: newSetting.isPublic,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-3xl font-bold">System Settings</h2>
|
|
||||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Create Setting
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs value={selectedCategory} onValueChange={setSelectedCategory}>
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="GENERAL">General</TabsTrigger>
|
|
||||||
<TabsTrigger value="EMAIL">Email</TabsTrigger>
|
|
||||||
<TabsTrigger value="STORAGE">Storage</TabsTrigger>
|
|
||||||
<TabsTrigger value="SECURITY">Security</TabsTrigger>
|
|
||||||
<TabsTrigger value="API">API</TabsTrigger>
|
|
||||||
<TabsTrigger value="FEATURES">Features</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value={selectedCategory} className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{selectedCategory} Settings</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-8">Loading settings...</div>
|
|
||||||
) : settings && settings.length > 0 ? (
|
|
||||||
settings.map((setting: any) => (
|
|
||||||
<div key={setting.id} className="space-y-2">
|
|
||||||
<Label htmlFor={setting.key}>{setting.key}</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
id={setting.key}
|
|
||||||
defaultValue={setting.value}
|
|
||||||
onBlur={(e) => {
|
|
||||||
if (e.target.value !== setting.value) {
|
|
||||||
handleSave(setting.key, e.target.value)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{setting.description && (
|
|
||||||
<p className="text-sm text-muted-foreground">{setting.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
No settings found for this category
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{/* Create Setting Dialog */}
|
|
||||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create New Setting</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Create a new system setting in the {selectedCategory} category
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="setting-key">Key *</Label>
|
|
||||||
<Input
|
|
||||||
id="setting-key"
|
|
||||||
placeholder="setting.key"
|
|
||||||
value={newSetting.key}
|
|
||||||
onChange={(e) => setNewSetting({ ...newSetting, key: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="setting-value">Value *</Label>
|
|
||||||
<Input
|
|
||||||
id="setting-value"
|
|
||||||
placeholder="Setting value"
|
|
||||||
value={newSetting.value}
|
|
||||||
onChange={(e) => setNewSetting({ ...newSetting, value: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="setting-description">Description</Label>
|
|
||||||
<Input
|
|
||||||
id="setting-description"
|
|
||||||
placeholder="Setting description"
|
|
||||||
value={newSetting.description}
|
|
||||||
onChange={(e) => setNewSetting({ ...newSetting, description: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Switch
|
|
||||||
id="setting-public"
|
|
||||||
checked={newSetting.isPublic}
|
|
||||||
onCheckedChange={(checked) => setNewSetting({ ...newSetting, isPublic: checked })}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="setting-public" className="text-sm">
|
|
||||||
Public (accessible via API)
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => {
|
|
||||||
setCreateDialogOpen(false)
|
|
||||||
setNewSetting({ key: "", value: "", description: "", isPublic: false })
|
|
||||||
}}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleCreate} disabled={createSettingMutation.isPending}>
|
|
||||||
{createSettingMutation.isPending ? "Creating..." : "Create"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
import { useParams, useNavigate } from "react-router-dom"
|
|
||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { ArrowLeft } from "lucide-react"
|
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
|
||||||
import { format } from "date-fns"
|
|
||||||
|
|
||||||
export default function UserActivityPage() {
|
|
||||||
const { id } = useParams()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
const { data: activity, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'users', id, 'activity'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await adminApiHelpers.getUserActivity(id!, 30)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
enabled: !!id,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <div className="text-center py-8">Loading activity...</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate(`/admin/users/${id}`)}>
|
|
||||||
<ArrowLeft className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<h2 className="text-3xl font-bold">User Activity</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Activity Timeline (Last 30 Days)</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{activity && activity.length > 0 ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{activity.map((item: any, index: number) => (
|
|
||||||
<div key={index} className="border-l-2 pl-4 pb-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{item.action || 'Activity'}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{item.description || item.message}</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{format(new Date(item.createdAt || item.timestamp), 'PPpp')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
No activity found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
import { useParams, useNavigate } from "react-router-dom"
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
||||||
import { ArrowLeft, Edit, Key, Trash2 } from "lucide-react"
|
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import { format } from "date-fns"
|
|
||||||
|
|
||||||
export default function UserDetailsPage() {
|
|
||||||
const { id } = useParams()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
const { data: user, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'users', id],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await adminApiHelpers.getUser(id!)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
enabled: !!id,
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateUserMutation = useMutation({
|
|
||||||
mutationFn: async (data: any) => {
|
|
||||||
await adminApiHelpers.updateUser(id!, data)
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users', id] })
|
|
||||||
toast.success("User updated successfully")
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
toast.error(error.response?.data?.message || "Failed to update user")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <div className="text-center py-8">Loading user details...</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return <div className="text-center py-8">User not found</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate('/admin/users')}>
|
|
||||||
<ArrowLeft className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<h2 className="text-3xl font-bold">User Details</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs defaultValue="info" className="space-y-4">
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="info">Information</TabsTrigger>
|
|
||||||
<TabsTrigger value="statistics">Statistics</TabsTrigger>
|
|
||||||
<TabsTrigger value="activity" onClick={() => navigate(`/admin/users/${id}/activity`)}>
|
|
||||||
Activity
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="info" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle>User Information</CardTitle>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Edit className="w-4 h-4 mr-2" />
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Key className="w-4 h-4 mr-2" />
|
|
||||||
Reset Password
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Email</p>
|
|
||||||
<p className="font-medium">{user.email}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Name</p>
|
|
||||||
<p className="font-medium">{user.firstName} {user.lastName}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Role</p>
|
|
||||||
<Badge>{user.role}</Badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Status</p>
|
|
||||||
<Badge variant={user.isActive ? 'default' : 'secondary'}>
|
|
||||||
{user.isActive ? 'Active' : 'Inactive'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Created At</p>
|
|
||||||
<p className="font-medium">{format(new Date(user.createdAt), 'PPpp')}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Updated At</p>
|
|
||||||
<p className="font-medium">{format(new Date(user.updatedAt), 'PPpp')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="statistics" className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-sm font-medium">Invoices</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{user._count?.invoices || 0}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-sm font-medium">Reports</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{user._count?.reports || 0}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-sm font-medium">Documents</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{user._count?.documents || 0}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-sm font-medium">Payments</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{user._count?.payments || 0}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,406 +0,0 @@
|
||||||
import { useState } from "react"
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|
||||||
import { useNavigate } from "react-router-dom"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Search, Download, Eye, MoreVertical, UserPlus, Edit, Trash2, Key, Upload } from "lucide-react"
|
|
||||||
import { adminApiHelpers } from "@/lib/api-client"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import { format } from "date-fns"
|
|
||||||
|
|
||||||
export default function UsersPage() {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [limit] = useState(20)
|
|
||||||
const [search, setSearch] = useState("")
|
|
||||||
const [roleFilter, setRoleFilter] = useState<string>("all")
|
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all")
|
|
||||||
const [selectedUser, setSelectedUser] = useState<any>(null)
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
|
||||||
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false)
|
|
||||||
const [importDialogOpen, setImportDialogOpen] = useState(false)
|
|
||||||
const [importFile, setImportFile] = useState<File | null>(null)
|
|
||||||
|
|
||||||
const { data: usersData, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'users', page, limit, search, roleFilter, statusFilter],
|
|
||||||
queryFn: async () => {
|
|
||||||
const params: any = { page, limit }
|
|
||||||
if (search) params.search = search
|
|
||||||
if (roleFilter !== 'all') params.role = roleFilter
|
|
||||||
if (statusFilter !== 'all') params.isActive = statusFilter === 'active'
|
|
||||||
const response = await adminApiHelpers.getUsers(params)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteUserMutation = useMutation({
|
|
||||||
mutationFn: async ({ id, hard }: { id: string; hard: boolean }) => {
|
|
||||||
await adminApiHelpers.deleteUser(id, hard)
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
|
||||||
toast.success("User deleted successfully")
|
|
||||||
setDeleteDialogOpen(false)
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
toast.error(error.response?.data?.message || "Failed to delete user")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const resetPasswordMutation = useMutation({
|
|
||||||
mutationFn: async (id: string) => {
|
|
||||||
const response = await adminApiHelpers.resetPassword(id)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
onSuccess: (data) => {
|
|
||||||
toast.success(`Password reset. Temporary password: ${data.temporaryPassword}`)
|
|
||||||
setResetPasswordDialogOpen(false)
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
toast.error(error.response?.data?.message || "Failed to reset password")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const importUsersMutation = useMutation({
|
|
||||||
mutationFn: async (file: File) => {
|
|
||||||
const response = await adminApiHelpers.importUsers(file)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
onSuccess: (data) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
|
||||||
toast.success(`Imported ${data.success} users. ${data.failed} failed.`)
|
|
||||||
setImportDialogOpen(false)
|
|
||||||
setImportFile(null)
|
|
||||||
if (data.errors && data.errors.length > 0) {
|
|
||||||
console.error('Import errors:', data.errors)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
toast.error(error.response?.data?.message || "Failed to import users")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleExport = async () => {
|
|
||||||
try {
|
|
||||||
const response = await adminApiHelpers.exportUsers('csv')
|
|
||||||
const blob = new Blob([response.data], { type: 'text/csv' })
|
|
||||||
const url = window.URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = `users-${new Date().toISOString()}.csv`
|
|
||||||
a.click()
|
|
||||||
toast.success("Users exported successfully")
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error.response?.data?.message || "Failed to export users")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
if (selectedUser) {
|
|
||||||
deleteUserMutation.mutate({ id: selectedUser.id, hard: false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleResetPassword = () => {
|
|
||||||
if (selectedUser) {
|
|
||||||
resetPasswordMutation.mutate(selectedUser.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleImport = () => {
|
|
||||||
if (importFile) {
|
|
||||||
importUsersMutation.mutate(importFile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (file) {
|
|
||||||
setImportFile(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRoleBadgeVariant = (role: string) => {
|
|
||||||
switch (role) {
|
|
||||||
case 'ADMIN':
|
|
||||||
return 'destructive'
|
|
||||||
case 'USER':
|
|
||||||
return 'default'
|
|
||||||
case 'VIEWER':
|
|
||||||
return 'secondary'
|
|
||||||
default:
|
|
||||||
return 'outline'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-3xl font-bold">Users Management</h2>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={() => setImportDialogOpen(true)}>
|
|
||||||
<Upload className="w-4 h-4 mr-2" />
|
|
||||||
Import Users
|
|
||||||
</Button>
|
|
||||||
<Button>
|
|
||||||
<UserPlus className="w-4 h-4 mr-2" />
|
|
||||||
Add User
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle>All Users</CardTitle>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search users..."
|
|
||||||
className="pl-10 w-64"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
|
||||||
<SelectTrigger className="w-40">
|
|
||||||
<SelectValue placeholder="All Roles" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Roles</SelectItem>
|
|
||||||
<SelectItem value="ADMIN">Admin</SelectItem>
|
|
||||||
<SelectItem value="USER">User</SelectItem>
|
|
||||||
<SelectItem value="VIEWER">Viewer</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
||||||
<SelectTrigger className="w-40">
|
|
||||||
<SelectValue placeholder="All Status" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Status</SelectItem>
|
|
||||||
<SelectItem value="active">Active</SelectItem>
|
|
||||||
<SelectItem value="inactive">Inactive</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button variant="outline" onClick={handleExport}>
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
Export
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-8">Loading users...</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Email</TableHead>
|
|
||||||
<TableHead>Name</TableHead>
|
|
||||||
<TableHead>Role</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Created At</TableHead>
|
|
||||||
<TableHead>Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{usersData?.data?.map((user: any) => (
|
|
||||||
<TableRow key={user.id}>
|
|
||||||
<TableCell className="font-medium">{user.email}</TableCell>
|
|
||||||
<TableCell>{user.firstName} {user.lastName}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={getRoleBadgeVariant(user.role)}>
|
|
||||||
{user.role}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={user.isActive ? 'default' : 'secondary'}>
|
|
||||||
{user.isActive ? 'Active' : 'Inactive'}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{format(new Date(user.createdAt), 'MMM dd, yyyy')}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => navigate(`/admin/users/${user.id}`)}
|
|
||||||
>
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedUser(user)
|
|
||||||
setResetPasswordDialogOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Key className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedUser(user)
|
|
||||||
setDeleteDialogOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
{usersData?.data?.length === 0 && (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
No users found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{usersData && usersData.total > limit && (
|
|
||||||
<div className="flex items-center justify-between mt-4">
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Showing {(page - 1) * limit + 1} to {Math.min(page * limit, usersData.total)} of {usersData.total} users
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
|
||||||
disabled={page === 1}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage(p => p + 1)}
|
|
||||||
disabled={page * limit >= usersData.total}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Delete Dialog */}
|
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete User</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Are you sure you want to delete {selectedUser?.email}? This action cannot be undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant="destructive" onClick={handleDelete}>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Reset Password Dialog */}
|
|
||||||
<Dialog open={resetPasswordDialogOpen} onOpenChange={setResetPasswordDialogOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Reset Password</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Reset password for {selectedUser?.email}? A temporary password will be generated.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setResetPasswordDialogOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleResetPassword}>
|
|
||||||
Reset Password
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Import Users Dialog */}
|
|
||||||
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Import Users</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Upload a CSV file with user data. The file should contain columns: email, firstName, lastName, role (optional).
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="import-file">CSV File</Label>
|
|
||||||
<Input
|
|
||||||
id="import-file"
|
|
||||||
type="file"
|
|
||||||
accept=".csv"
|
|
||||||
onChange={handleFileChange}
|
|
||||||
/>
|
|
||||||
{importFile && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Selected: {importFile.name}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => {
|
|
||||||
setImportDialogOpen(false)
|
|
||||||
setImportFile(null)
|
|
||||||
}}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleImport} disabled={!importFile || importUsersMutation.isPending}>
|
|
||||||
{importUsersMutation.isPending ? "Importing..." : "Import"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user