Admin
This commit is contained in:
parent
d7c860df17
commit
f80ff9317e
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,17 +11,27 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@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-label": "^2.1.8",
|
||||
"@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-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",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.561.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.11.0",
|
||||
"recharts": "^3.6.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
67
src/App.tsx
67
src/App.tsx
|
|
@ -1,26 +1,63 @@
|
|||
import { Navigate, Route, Routes } from "react-router-dom"
|
||||
import { AppShell } from "@/layouts/app-shell"
|
||||
import DashboardPage from "@/pages/dashboard"
|
||||
import SubscriptionsPage from "@/pages/subscriptions"
|
||||
import TransactionsPage from "@/pages/transactions"
|
||||
import ClientsPage from "@/pages/clients"
|
||||
import MembersPage from "@/pages/members"
|
||||
import NotificationsPage from "@/pages/notifications"
|
||||
import ActivityLogPage from "@/pages/activity-log"
|
||||
import DashboardPage from "@/pages/admin/dashboard"
|
||||
import UsersPage from "@/pages/admin/users"
|
||||
import UserDetailsPage from "@/pages/admin/users/[id]"
|
||||
import UserActivityPage from "@/pages/admin/users/[id]/activity"
|
||||
import LogsPage from "@/pages/admin/logs"
|
||||
import ErrorLogsPage from "@/pages/admin/logs/errors"
|
||||
import AccessLogsPage from "@/pages/admin/logs/access"
|
||||
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() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="subscriptions" element={<SubscriptionsPage />} />
|
||||
<Route path="transactions" element={<TransactionsPage />} />
|
||||
<Route path="clients" element={<ClientsPage />} />
|
||||
<Route path="members" element={<MembersPage />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route path="activity-log" element={<ActivityLogPage />} />
|
||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||
<Route path="admin/dashboard" element={<DashboardPage />} />
|
||||
<Route path="admin/users" element={<UsersPage />} />
|
||||
<Route path="admin/users/:id" element={<UserDetailsPage />} />
|
||||
<Route path="admin/users/:id/activity" element={<UserActivityPage />} />
|
||||
<Route path="admin/logs" element={<LogsPage />} />
|
||||
<Route path="admin/logs/errors" element={<ErrorLogsPage />} />
|
||||
<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 path="*" element={<Navigate to="/" replace />} />
|
||||
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
121
src/components/ui/dialog.tsx
Normal file
121
src/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
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,
|
||||
}
|
||||
|
||||
25
src/components/ui/label.tsx
Normal file
25
src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
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 }
|
||||
|
||||
158
src/components/ui/select.tsx
Normal file
158
src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
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,
|
||||
}
|
||||
|
||||
28
src/components/ui/switch.tsx
Normal file
28
src/components/ui/switch.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
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 }
|
||||
|
||||
54
src/components/ui/tabs.tsx
Normal file
54
src/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
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 }
|
||||
|
||||
27
src/components/ui/toast.tsx
Normal file
27
src/components/ui/toast.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
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,21 +1,19 @@
|
|||
import { Outlet, Link, useLocation } from "react-router-dom"
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FolderKanban,
|
||||
Calendar,
|
||||
Leaf,
|
||||
Settings,
|
||||
Bell,
|
||||
HelpCircle,
|
||||
DollarSign,
|
||||
FileText,
|
||||
Users,
|
||||
Briefcase,
|
||||
Mail,
|
||||
Copy,
|
||||
UserPlus,
|
||||
FileText,
|
||||
Settings,
|
||||
Wrench,
|
||||
Megaphone,
|
||||
Shield,
|
||||
BarChart3,
|
||||
Activity,
|
||||
Heart,
|
||||
Search,
|
||||
Mail,
|
||||
Bell,
|
||||
LogOut,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
|
@ -24,35 +22,34 @@ import { Separator } from "@/components/ui/separator"
|
|||
import { Card, CardContent } from "@/components/ui/card"
|
||||
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 = [
|
||||
{ icon: Users, label: "Subscriptions", path: "/subscriptions" },
|
||||
{ icon: DollarSign, label: "Transactions", path: "/transactions" },
|
||||
{ icon: Briefcase, label: "Clients", path: "/clients" },
|
||||
{ icon: Users, label: "Members", path: "/members" },
|
||||
{ icon: Bell, label: "Notifications", path: "/notifications" },
|
||||
{ icon: FileText, label: "Activity Log", path: "/activity-log" },
|
||||
{ icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" },
|
||||
{ icon: Users, label: "Users", path: "/admin/users" },
|
||||
{ icon: FileText, label: "Logs", path: "/admin/logs" },
|
||||
{ icon: Settings, label: "Settings", path: "/admin/settings" },
|
||||
{ icon: Wrench, label: "Maintenance", path: "/admin/maintenance" },
|
||||
{ icon: Megaphone, label: "Announcements", path: "/admin/announcements" },
|
||||
{ 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() {
|
||||
const location = useLocation()
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === "/") {
|
||||
return location.pathname === "/"
|
||||
}
|
||||
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 (
|
||||
<div className="flex h-screen bg-background">
|
||||
{/* Sidebar */}
|
||||
|
|
@ -60,38 +57,13 @@ export function AppShell() {
|
|||
{/* Logo */}
|
||||
<div className="p-6 flex items-center gap-2">
|
||||
<div className="w-10 h-10 bg-primary rounded flex items-center justify-center">
|
||||
<span className="text-primary-foreground font-bold text-lg">H</span>
|
||||
<span className="text-primary-foreground font-bold text-lg">A</span>
|
||||
</div>
|
||||
<span className="text-foreground font-semibold text-lg">TurHR</span>
|
||||
<span className="text-foreground font-semibold text-lg">Admin Panel</span>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<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>
|
||||
|
||||
<nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto">
|
||||
{adminNavigationItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
|
|
@ -112,31 +84,29 @@ export function AppShell() {
|
|||
})}
|
||||
</nav>
|
||||
|
||||
{/* Announcements Card */}
|
||||
<div className="p-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Megaphone className="w-4 h-4 text-primary" />
|
||||
<span className="font-semibold text-sm">Announcements</span>
|
||||
{/* User Section */}
|
||||
<div className="p-4 border-t">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Avatar>
|
||||
<AvatarFallback>AD</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">Admin User</p>
|
||||
<p className="text-xs text-muted-foreground truncate">admin@example.com</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Stay updated with TurHR
|
||||
</p>
|
||||
<Button size="sm" className="w-full">
|
||||
Create Now
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
localStorage.removeItem('access_token')
|
||||
window.location.href = '/login'
|
||||
}}
|
||||
>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
Logout
|
||||
</Button>
|
||||
<div className="mt-3 text-xs text-muted-foreground">
|
||||
Read by:
|
||||
</div>
|
||||
<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" />
|
||||
<div className="w-6 h-6 rounded-full bg-primary/20" />
|
||||
<span className="text-xs text-muted-foreground ml-1">+10</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
|
@ -144,7 +114,7 @@ export function AppShell() {
|
|||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Top Header */}
|
||||
<header className="h-16 border-b bg-background flex items-center justify-between px-6">
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
<h1 className="text-2xl font-bold">{getPageTitle()}</h1>
|
||||
<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" />
|
||||
|
|
@ -157,21 +127,12 @@ export function AppShell() {
|
|||
<Mail className="w-5 h-5" />
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Copy className="w-5 h-5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="relative">
|
||||
<Users className="w-5 h-5" />
|
||||
<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
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
|
||||
</Button>
|
||||
<Avatar>
|
||||
<AvatarFallback>U</AvatarFallback>
|
||||
<AvatarFallback>AD</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
248
src/lib/api-client.ts
Normal file
248
src/lib/api-client.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
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';
|
||||
}
|
||||
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,12 +5,14 @@ import App from './App.tsx'
|
|||
import { BrowserRouter } from "react-router-dom"
|
||||
import { QueryClientProvider } from "@tanstack/react-query"
|
||||
import { queryClient } from "@/app/query-client"
|
||||
import { Toaster } from "@/components/ui/toast"
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<Toaster />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
|
|
|
|||
114
src/pages/admin/analytics/api.tsx
Normal file
114
src/pages/admin/analytics/api.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
||||
87
src/pages/admin/analytics/index.tsx
Normal file
87
src/pages/admin/analytics/index.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
||||
6
src/pages/admin/analytics/overview.tsx
Normal file
6
src/pages/admin/analytics/overview.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import DashboardPage from "../../dashboard"
|
||||
|
||||
export default function AnalyticsOverviewPage() {
|
||||
return <DashboardPage />
|
||||
}
|
||||
|
||||
47
src/pages/admin/analytics/revenue.tsx
Normal file
47
src/pages/admin/analytics/revenue.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
||||
119
src/pages/admin/analytics/storage.tsx
Normal file
119
src/pages/admin/analytics/storage.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
||||
49
src/pages/admin/analytics/users.tsx
Normal file
49
src/pages/admin/analytics/users.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
||||
166
src/pages/admin/announcements/index.tsx
Normal file
166
src/pages/admin/announcements/index.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
||||
106
src/pages/admin/audit/index.tsx
Normal file
106
src/pages/admin/audit/index.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
||||
254
src/pages/admin/dashboard/index.tsx
Normal file
254
src/pages/admin/dashboard/index.tsx
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
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 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>
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
||||
183
src/pages/admin/health/index.tsx
Normal file
183
src/pages/admin/health/index.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
||||
116
src/pages/admin/maintenance/index.tsx
Normal file
116
src/pages/admin/maintenance/index.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
||||
105
src/pages/admin/security/api-keys.tsx
Normal file
105
src/pages/admin/security/api-keys.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
||||
105
src/pages/admin/security/failed-logins.tsx
Normal file
105
src/pages/admin/security/failed-logins.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
||||
87
src/pages/admin/security/index.tsx
Normal file
87
src/pages/admin/security/index.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
||||
67
src/pages/admin/security/rate-limits.tsx
Normal file
67
src/pages/admin/security/rate-limits.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
||||
78
src/pages/admin/security/sessions.tsx
Normal file
78
src/pages/admin/security/sessions.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
||||
91
src/pages/admin/security/suspicious.tsx
Normal file
91
src/pages/admin/security/suspicious.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
||||
94
src/pages/admin/settings/index.tsx
Normal file
94
src/pages/admin/settings/index.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
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 { adminApiHelpers } from "@/lib/api-client"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export default function SettingsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("GENERAL")
|
||||
|
||||
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 handleSave = (key: string, value: string) => {
|
||||
updateSettingMutation.mutate({ key, value })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-3xl font-bold">System Settings</h2>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
66
src/pages/admin/users/[id]/activity.tsx
Normal file
66
src/pages/admin/users/[id]/activity.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
||||
155
src/pages/admin/users/[id]/index.tsx
Normal file
155
src/pages/admin/users/[id]/index.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
||||
326
src/pages/admin/users/index.tsx
Normal file
326
src/pages/admin/users/index.tsx
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
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 {
|
||||
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 } 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 { 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 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 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>
|
||||
<Button>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
Add User
|
||||
</Button>
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user