feat: add admin frontend in /admin-frontend
This commit is contained in:
parent
384eb31752
commit
1112e6f062
4
admin-frontend/.gitignore
vendored
Normal file
4
admin-frontend/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.wrangler
|
||||||
|
.env
|
||||||
13
admin-frontend/index.html
Normal file
13
admin-frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Dissolve</title>
|
||||||
|
<script async src="https://data-analytics.mworld.cloud/analytics.js" data-site-id="xd1uom"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3660
admin-frontend/package-lock.json
generated
Normal file
3660
admin-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
admin-frontend/package.json
Normal file
44
admin-frontend/package.json
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "admin-dissolve-xd1uom",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"tailwind-merge": "^1.14.0",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"react-hook-form": "^7.48.2",
|
||||||
|
"@hookform/resolvers": "^3.3.2",
|
||||||
|
"zod": "^3.22.4",
|
||||||
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
|
"sonner": "latest",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"@tanstack/react-query": "^5.0.0",
|
||||||
|
"@tanstack/react-table": "^8.20.5",
|
||||||
|
"framer-motion": "^10.16.4",
|
||||||
|
"recharts": "^2.13.0",
|
||||||
|
"lucide-react": "^0.378.0",
|
||||||
|
"react-router-dom": "^6.18.0",
|
||||||
|
"date-fns": "^3.0.0",
|
||||||
|
"zustand": "^4.5.7",
|
||||||
|
"@cashfreepayments/cashfree-js": "^1.0.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^5.4.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"@types/react": "^18.2.37",
|
||||||
|
"@types/react-dom": "^18.2.15"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
admin-frontend/postcss.config.js
Normal file
1
admin-frontend/postcss.config.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }
|
||||||
37
admin-frontend/src/App.tsx
Normal file
37
admin-frontend/src/App.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { Toaster } from 'sonner';
|
||||||
|
import Layout from './components/Layout';
|
||||||
|
import { DashboardPage, ReportsQueuePage, ReportDetailPage, UserManagementPage } from './pages';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Router>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Layout />}>
|
||||||
|
<Route index element={<DashboardPage />} />
|
||||||
|
<Route path="reports" element={<ReportsQueuePage />} />
|
||||||
|
<Route path="reports/:reportId" element={<ReportDetailPage />} />
|
||||||
|
<Route path="users" element={<UserManagementPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
<Toaster
|
||||||
|
position="bottom-right"
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast: 'bg-neutral-800 border border-neutral-700 text-white rounded-md',
|
||||||
|
success: '!bg-green-accent/10 !text-green-accent border-green-accent/20',
|
||||||
|
error: '!bg-red-500/10 !text-red-500 border-red-500/20',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
43
admin-frontend/src/api.ts
Normal file
43
admin-frontend/src/api.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export const API_URL = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
|
||||||
|
? "http://localhost:8787"
|
||||||
|
: "https://app-xd1uom.laflabs-tech.workers.dev";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin panel is protected by an auth_token in the URL query string.
|
||||||
|
* We read it once on load and store in sessionStorage so API requests can use it.
|
||||||
|
* This token is verified against a stored hash in the backend DB's settings table.
|
||||||
|
*/
|
||||||
|
function getAdminAuthToken(): string | null {
|
||||||
|
// Try URL first (initial load), then fall back to sessionStorage (subsequent navigations)
|
||||||
|
const urlToken = new URLSearchParams(window.location.search).get('auth_token');
|
||||||
|
if (urlToken) {
|
||||||
|
sessionStorage.setItem('admin_auth_token', urlToken);
|
||||||
|
return urlToken;
|
||||||
|
}
|
||||||
|
return sessionStorage.getItem('admin_auth_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: API_URL,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = getAdminAuthToken();
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
config.headers['x-mworld-origin'] = window.location.origin;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
// Do NOT clear token on 401 in admin panel — the token is URL-based and valid for the session
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
26
admin-frontend/src/components/Layout.tsx
Normal file
26
admin-frontend/src/components/Layout.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import Sidebar from './Sidebar';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
const Layout = () => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black text-neutral-200 font-sans flex antialiased relative">
|
||||||
|
<div className="fixed inset-0 bg-noise opacity-10 pointer-events-none"></div>
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 pl-64">
|
||||||
|
<motion.div
|
||||||
|
key={location.pathname}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||||
|
className="p-8"
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</motion.div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
55
admin-frontend/src/components/Sidebar.tsx
Normal file
55
admin-frontend/src/components/Sidebar.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
import { Home, List, Users, Triangle } from 'lucide-react';
|
||||||
|
import { useUserStore } from '../stores/userStore';
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: 'Dashboard', href: '/', icon: Home },
|
||||||
|
{ name: 'Reports', href: '/reports', icon: List },
|
||||||
|
{ name: 'Users', href: '/users', icon: Users, adminOnly: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const Sidebar = () => {
|
||||||
|
const { user } = useUserStore();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="fixed top-0 left-0 h-full w-64 bg-neutral-950/50 backdrop-blur-sm border-r border-neutral-800 flex flex-col">
|
||||||
|
<div className="flex items-center justify-center h-20 border-b border-neutral-800">
|
||||||
|
<Triangle className="h-6 w-6 text-green-accent" />
|
||||||
|
<h1 className="text-xl font-bold ml-3 font-mono tracking-wider">DISSOLVE</h1>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 px-4 py-6">
|
||||||
|
<ul>
|
||||||
|
{navigation.map((item) =>
|
||||||
|
(!item.adminOnly || user?.role === 'admin') && (
|
||||||
|
<li key={item.name} className="mb-2">
|
||||||
|
<NavLink
|
||||||
|
to={item.href}
|
||||||
|
end={item.href === '/'}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
'flex items-center px-4 py-3 text-sm font-medium rounded-md transition-colors duration-200',
|
||||||
|
isActive
|
||||||
|
? 'bg-green-accent/10 text-green-accent'
|
||||||
|
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-100'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="h-5 w-5 mr-4" />
|
||||||
|
<span>{item.name}</span>
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div className="p-4 border-t border-neutral-800">
|
||||||
|
<p className="text-xs text-neutral-500">Logged in as</p>
|
||||||
|
<p className="text-sm font-medium text-neutral-300">{user?.email}</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
49
admin-frontend/src/components/ui/button.tsx
Normal file
49
admin-frontend/src/components/ui/button.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-black disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
'bg-green-accent text-black hover:bg-green-accent/80 focus-visible:ring-green-accent',
|
||||||
|
destructive:
|
||||||
|
'bg-red-500 text-neutral-50 hover:bg-red-500/90 focus-visible:ring-red-500',
|
||||||
|
outline:
|
||||||
|
'border border-neutral-700 bg-transparent hover:bg-neutral-800 hover:text-neutral-100 focus-visible:ring-neutral-500',
|
||||||
|
ghost:
|
||||||
|
'hover:bg-neutral-800 hover:text-neutral-100',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-md px-3',
|
||||||
|
lg: 'h-11 rounded-md px-8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
72
admin-frontend/src/components/ui/dialog.tsx
Normal file
72
admin-frontend/src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { Button } from './button';
|
||||||
|
|
||||||
|
interface DialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
confirmText?: string;
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Dialog = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
onConfirm,
|
||||||
|
confirmText,
|
||||||
|
showCloseButton = true
|
||||||
|
}: DialogProps) => {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, y: 20 }}
|
||||||
|
animate={{ scale: 1, y: 0 }}
|
||||||
|
exit={{ scale: 0.95, y: 20 }}
|
||||||
|
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||||
|
className="relative w-full max-w-md p-6 bg-neutral-900 border border-neutral-800 rounded-lg shadow-xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{showCloseButton && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-3 right-3 text-neutral-500 hover:text-neutral-100 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<h2 className="text-lg font-bold font-mono text-neutral-100">{title}</h2>
|
||||||
|
|
||||||
|
{description && <p className="mt-2 text-sm text-neutral-400">{description}</p>}
|
||||||
|
|
||||||
|
<div className="mt-4">{children}</div>
|
||||||
|
|
||||||
|
{onConfirm && (
|
||||||
|
<div className="flex justify-end gap-2 pt-4 mt-4 border-t border-neutral-800">
|
||||||
|
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button variant={confirmText?.toLowerCase().includes('delete') ? 'destructive' : 'default'} onClick={onConfirm}>
|
||||||
|
{confirmText || 'Confirm'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
27
admin-frontend/src/components/ui/input.tsx
Normal file
27
admin-frontend/src/components/ui/input.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full rounded-md border border-neutral-700 bg-neutral-900 px-3 py-2 text-sm text-neutral-100',
|
||||||
|
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
||||||
|
'placeholder:text-neutral-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-green-accent focus-visible:ring-offset-2 focus-visible:ring-offset-black',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
||||||
42
admin-frontend/src/index.css
Normal file
42
admin-frontend/src/index.css
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 240 10% 3.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 240 10% 3.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 240 10% 3.9%;
|
||||||
|
--primary: 240 5.9% 10%;
|
||||||
|
--primary-foreground: 0 0% 98%;
|
||||||
|
--secondary: 240 4.8% 95.9%;
|
||||||
|
--secondary-foreground: 240 5.9% 10%;
|
||||||
|
--muted: 240 4.8% 95.9%;
|
||||||
|
--muted-foreground: 240 3.8% 46.1%;
|
||||||
|
--accent: 240 4.8% 95.9%;
|
||||||
|
--accent-foreground: 240 5.9% 10%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 240 5.9% 90%;
|
||||||
|
--input: 240 5.9% 90%;
|
||||||
|
--ring: 240 5.9% 10%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
font-feature-settings: "rlig" 1, "calt" 1;
|
||||||
|
@apply antialiased;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
7
admin-frontend/src/lib/utils.ts
Normal file
7
admin-frontend/src/lib/utils.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
79
admin-frontend/src/main.tsx
Normal file
79
admin-frontend/src/main.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
const deny = (msg: string) => {
|
||||||
|
document.body.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;font-family:system-ui,sans-serif;background:#f8fafc;color:#0f172a;">${msg}</div>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const agenticToken = urlParams.get('agentic_token');
|
||||||
|
const legacyToken = urlParams.get('auth_token');
|
||||||
|
const activeToken = agenticToken || legacyToken;
|
||||||
|
|
||||||
|
if (!activeToken) {
|
||||||
|
// Check sessionStorage in case the user navigated away and back
|
||||||
|
const storedToken = sessionStorage.getItem('admin_auth_token');
|
||||||
|
if (!storedToken) {
|
||||||
|
deny('Invalid or missing admin access token.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Token is in sessionStorage and was already validated - mount app directly
|
||||||
|
const root = document.getElementById('root');
|
||||||
|
if (!root) { deny('Unable to load admin app.'); return; }
|
||||||
|
createRoot(root).render(<React.StrictMode><App /></React.StrictMode>);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agenticToken) {
|
||||||
|
try {
|
||||||
|
const verifyRes = await fetch('https://api.mauth.mworld.cloud/mAuthAG/verifyToken', {
|
||||||
|
headers: { 'mAuthAgenticToken': agenticToken }
|
||||||
|
});
|
||||||
|
const verifyData = await verifyRes.json();
|
||||||
|
if (!verifyData.success) {
|
||||||
|
deny(`Unauthorized: ${verifyData.msg || 'Invalid agentic token'}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localStorage.setItem('auth_token', agenticToken);
|
||||||
|
} catch (err) {
|
||||||
|
deny('Authentication service unavailable.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (legacyToken) {
|
||||||
|
// Legacy hash-based validation
|
||||||
|
const data = new TextEncoder().encode(legacyToken);
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
const hash = Array.from(new Uint8Array(digest))
|
||||||
|
.map((value) => value.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
if (hash !== 'f030682d992c5e6c98594043c5a1f2763576f643f7bfbd940e325e46379c772f') {
|
||||||
|
deny('Invalid or missing admin access token.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localStorage.setItem('auth_token', legacyToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist token in sessionStorage so API calls work after React Router navigations
|
||||||
|
sessionStorage.setItem('admin_auth_token', activeToken);
|
||||||
|
|
||||||
|
const root = document.getElementById('root');
|
||||||
|
if (!root) {
|
||||||
|
deny('Unable to load admin app.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(root).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
boot().catch(() => {
|
||||||
|
deny('Unable to validate admin access token.');
|
||||||
|
});
|
||||||
65
admin-frontend/src/pages/DashboardPage.tsx
Normal file
65
admin-frontend/src/pages/DashboardPage.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '../api';
|
||||||
|
import { AlertTriangle, List, Loader2, Users, FileText } from 'lucide-react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
title: string;
|
||||||
|
value: number | string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatCard = ({ title, value, icon: Icon, index }: StatCardProps) => (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||||
|
className="bg-neutral-900 border border-neutral-800 p-6 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="text-sm text-neutral-400 font-mono uppercase tracking-wider">{title}</p>
|
||||||
|
<span className="text-4xl font-bold text-neutral-100 mt-2">{value}</span>
|
||||||
|
</div>
|
||||||
|
<Icon className="h-8 w-8 text-neutral-600" />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DashboardPage = () => {
|
||||||
|
const { data, isLoading, isError, error } = useQuery({
|
||||||
|
queryKey: ['adminDashboardStats'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/api/admin/dashboard');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<h1 className="text-3xl font-bold font-mono mb-8">Dashboard</h1>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-green-accent" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isError && (
|
||||||
|
<div className="bg-red-500/10 border border-red-500/20 text-red-400 p-4 rounded-lg flex items-center">
|
||||||
|
<AlertTriangle className="h-5 w-5 mr-3" />
|
||||||
|
<span>Error fetching stats: {error.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<StatCard title="Pending Reports" value={data.pendingReports} icon={AlertTriangle} index={0} />
|
||||||
|
<StatCard title="Active Posts" value={data.activePosts} icon={List} index={1} />
|
||||||
|
<StatCard title="Posts Today" value={data.postsToday} icon={FileText} index={2} />
|
||||||
|
<StatCard title="Total Users" value={data.totalUsers} icon={Users} index={3} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DashboardPage;
|
||||||
117
admin-frontend/src/pages/ReportDetailPage.tsx
Normal file
117
admin-frontend/src/pages/ReportDetailPage.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api, API_URL } from '../api';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Button } from '../components/ui/button';
|
||||||
|
import { ArrowLeft, Trash2, CheckCircle, Play } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Dialog } from '../components/ui/dialog';
|
||||||
|
|
||||||
|
const PostContent = ({ post }: { post: any }) => {
|
||||||
|
if (!post) return <div className="text-neutral-500">No content to display.</div>;
|
||||||
|
|
||||||
|
const fullUrl = post.contentData?.startsWith('/api/') ? `${API_URL}${post.contentData}` : post.contentData;
|
||||||
|
|
||||||
|
switch (post.contentType) {
|
||||||
|
case 'text':
|
||||||
|
return <p className="text-lg text-neutral-200 whitespace-pre-wrap font-serif leading-relaxed">{post.contentData}</p>;
|
||||||
|
case 'image':
|
||||||
|
return <img src={fullUrl} alt="Reported content" className="max-w-full rounded-lg mx-auto" />;
|
||||||
|
case 'audio':
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8 bg-neutral-800 rounded-lg">
|
||||||
|
<audio controls src={fullUrl} className="w-full">Your browser does not support the audio element.</audio>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <div className="text-neutral-500">Unknown content type: {post.contentType}</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReportDetailPage = () => {
|
||||||
|
const { reportId } = useParams();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { report } = location.state || {};
|
||||||
|
|
||||||
|
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false);
|
||||||
|
|
||||||
|
const reviewMutation = useMutation({
|
||||||
|
mutationFn: (action: 'dismiss' | 'delete_post') => api.post(`/api/admin/reports/${reportId}/review`, { action }),
|
||||||
|
onSuccess: (_, action) => {
|
||||||
|
toast.success(action === 'dismiss' ? 'Report dismissed.' : 'Post deleted successfully.');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['adminReports'] });
|
||||||
|
navigate('/reports');
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.error || 'Action failed.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
// In a real app, you might fetch the report details here if not passed in state
|
||||||
|
return <div>Report not found. Go back to the queue.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Dialog
|
||||||
|
isOpen={isConfirmingDelete}
|
||||||
|
onClose={() => setIsConfirmingDelete(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
reviewMutation.mutate('delete_post');
|
||||||
|
setIsConfirmingDelete(false);
|
||||||
|
}}
|
||||||
|
title="Confirm Post Deletion"
|
||||||
|
description="This will permanently delete the post associated with this report. This action cannot be undone."
|
||||||
|
confirmText="Yes, Delete Post"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button variant="ghost" onClick={() => navigate('/reports')} className="mb-8">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
Back to Queue
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
<div className="lg:col-span-2 bg-neutral-900 border border-neutral-800 p-8 rounded-lg">
|
||||||
|
<h2 className="font-mono text-lg text-neutral-400 mb-4">Reported Content</h2>
|
||||||
|
<PostContent post={report.post} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="bg-neutral-900 border border-neutral-800 p-6 rounded-lg">
|
||||||
|
<h3 className="font-mono text-neutral-400 mb-2">Report Details</h3>
|
||||||
|
<p><strong className="font-medium text-neutral-100">Reason:</strong> {report.reason}</p>
|
||||||
|
<p><strong className="font-medium text-neutral-100">Emotion:</strong> {report.post.emotionTag}</p>
|
||||||
|
<p><strong className="font-medium text-neutral-100">Post ID:</strong> <span className="font-mono text-xs">{report.postId}</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-neutral-900 border border-neutral-800 p-6 rounded-lg flex flex-col gap-4">
|
||||||
|
<h3 className="font-mono text-neutral-400 mb-2">Actions</h3>
|
||||||
|
<Button
|
||||||
|
onClick={() => reviewMutation.mutate('dismiss')}
|
||||||
|
disabled={reviewMutation.isPending}
|
||||||
|
className="w-full"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-4 w-4 mr-2" />
|
||||||
|
Dismiss Report
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsConfirmingDelete(true)}
|
||||||
|
disabled={reviewMutation.isPending}
|
||||||
|
className="w-full"
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Delete Post
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReportDetailPage;
|
||||||
102
admin-frontend/src/pages/ReportsQueuePage.tsx
Normal file
102
admin-frontend/src/pages/ReportsQueuePage.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '../api';
|
||||||
|
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { Button } from '../components/ui/button';
|
||||||
|
import { Loader2, AlertTriangle, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<any>();
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
columnHelper.accessor('reportId', {
|
||||||
|
header: 'ID',
|
||||||
|
cell: info => <span className="font-mono text-neutral-400">#{info.getValue()}</span>,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('reason', {
|
||||||
|
header: 'Reason',
|
||||||
|
cell: info => <span className="text-neutral-100">{info.getValue()}</span>,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('reportedAt', {
|
||||||
|
header: 'Reported',
|
||||||
|
cell: info => <span className="text-neutral-400">{formatDistanceToNow(new Date(info.getValue()), { addSuffix: true })}</span>,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('actions', {
|
||||||
|
header: 'Actions',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => navigate(`/reports/${row.original.reportId}`, { state: { report: row.original } })}>
|
||||||
|
Review
|
||||||
|
<ChevronRight className="h-4 w-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const ReportsQueuePage = () => {
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['adminReports'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/api/admin/reports');
|
||||||
|
return res.data.reports;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: data || [],
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold font-mono mb-8">Reports Queue</h1>
|
||||||
|
|
||||||
|
<div className="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center p-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-green-accent" />
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="flex items-center text-red-400 p-8">
|
||||||
|
<AlertTriangle className="h-5 w-5 mr-3" />
|
||||||
|
<span>Failed to load reports.</span>
|
||||||
|
</div>
|
||||||
|
) : table.getRowModel().rows.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center text-neutral-500 p-12">
|
||||||
|
<p>No pending reports. The queue is clear.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-neutral-800/50">
|
||||||
|
{table.getHeaderGroups().map(headerGroup => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map(header => (
|
||||||
|
<th key={header.id} className="p-4 text-left font-mono text-neutral-400 uppercase tracking-wider">
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{table.getRowModel().rows.map(row => (
|
||||||
|
<tr key={row.id} className="border-b border-neutral-800 last:border-b-0 hover:bg-neutral-800/30 transition-colors">
|
||||||
|
{row.getVisibleCells().map(cell => (
|
||||||
|
<td key={cell.id} className="p-4">
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReportsQueuePage;
|
||||||
212
admin-frontend/src/pages/UserManagementPage.tsx
Normal file
212
admin-frontend/src/pages/UserManagementPage.tsx
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import * as z from 'zod';
|
||||||
|
import { api } from '../api';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Button } from '../components/ui/button';
|
||||||
|
import { Dialog } from '../components/ui/dialog';
|
||||||
|
import { Input } from '../components/ui/input';
|
||||||
|
import { Loader2, AlertTriangle, UserPlus, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
|
const userSchema = z.object({
|
||||||
|
fullName: z.string().min(2, 'Full name is required'),
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
role: z.enum(['admin', 'moderator']),
|
||||||
|
});
|
||||||
|
|
||||||
|
type UserFormData = z.infer<typeof userSchema>;
|
||||||
|
|
||||||
|
type User = UserFormData & { created_at: string };
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<User>();
|
||||||
|
|
||||||
|
const UserForm = ({ user, onClose }: { user: User | null; onClose: () => void }) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const isEditing = !!user;
|
||||||
|
|
||||||
|
const { register, handleSubmit, formState: { errors }, reset } = useForm<UserFormData>({
|
||||||
|
resolver: zodResolver(userSchema),
|
||||||
|
defaultValues: user ? { fullName: user.fullName, email: user.email, role: user.role } : { role: 'moderator' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (data: UserFormData) => {
|
||||||
|
return isEditing ? api.put(`/api/admin/users/${user.email}`, data) : api.post('/api/admin/users', data);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(isEditing ? 'User updated successfully' : 'User created successfully');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['adminUsers'] });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.error || 'An error occurred');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reset(user ? { fullName: user.fullName, email: user.email, role: user.role } : { role: 'moderator' });
|
||||||
|
}, [user, reset]);
|
||||||
|
|
||||||
|
const onSubmit = (data: UserFormData) => mutation.mutate(data);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-mono text-neutral-400">Full Name</label>
|
||||||
|
<Input {...register('fullName')} className="mt-1" />
|
||||||
|
{errors.fullName && <p className="text-red-400 text-sm mt-1">{errors.fullName.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-mono text-neutral-400">Email</label>
|
||||||
|
<Input {...register('email')} disabled={isEditing} className="mt-1" />
|
||||||
|
{errors.email && <p className="text-red-400 text-sm mt-1">{errors.email.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-mono text-neutral-400">Role</label>
|
||||||
|
<select {...register('role')} className="w-full mt-1 p-2 bg-neutral-800 border border-neutral-700 rounded-md focus:ring-2 focus:ring-green-accent focus:border-green-accent outline-none">
|
||||||
|
<option value="moderator">Moderator</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
{errors.role && <p className="text-red-400 text-sm mt-1">{errors.role.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? <Loader2 className="animate-spin h-4 w-4" /> : 'Save'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const UserManagementPage = () => {
|
||||||
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
|
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: users, isLoading, isError } = useQuery<User[]>({
|
||||||
|
queryKey: ['adminUsers'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/api/admin/users');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (email: string) => api.delete(`/api/admin/users/${email}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('User deleted successfully');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['adminUsers'] });
|
||||||
|
setIsDeleteConfirmOpen(false);
|
||||||
|
setSelectedUser(null);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.error || 'Failed to delete user');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
columnHelper.accessor('fullName', {
|
||||||
|
header: 'User',
|
||||||
|
cell: info => (
|
||||||
|
<div className='flex flex-col'>
|
||||||
|
<span className='font-medium text-neutral-100'>{info.getValue()}</span>
|
||||||
|
<span className='text-xs text-neutral-400 font-mono'>{info.row.original.email}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('role', {
|
||||||
|
header: 'Role',
|
||||||
|
cell: info => <span className={cn('px-2 py-1 text-xs rounded-full font-mono', info.getValue() === 'admin' ? 'bg-green-accent/10 text-green-accent' : 'bg-neutral-700 text-neutral-300')}>{info.getValue()}</span>,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('created_at', {
|
||||||
|
header: 'Created',
|
||||||
|
cell: info => <span className="text-neutral-400">{format(new Date(info.getValue()), 'MMM d, yyyy')}</span>,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('email', {
|
||||||
|
header: 'Actions',
|
||||||
|
id: 'actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => { setSelectedUser(row.original); setIsFormOpen(true); }}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-400 hover:bg-red-500/10" onClick={() => { setSelectedUser(row.original); setIsDeleteConfirmOpen(true); }}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({ data: users || [], columns, getCoreRowModel: getCoreRowModel() });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Dialog
|
||||||
|
isOpen={isFormOpen}
|
||||||
|
onClose={() => { setIsFormOpen(false); setSelectedUser(null); }}
|
||||||
|
title={selectedUser ? 'Edit User' : 'Create New User'}
|
||||||
|
showCloseButton={false}
|
||||||
|
>
|
||||||
|
<UserForm user={selectedUser} onClose={() => { setIsFormOpen(false); setSelectedUser(null); }} />
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
isOpen={isDeleteConfirmOpen}
|
||||||
|
onClose={() => { setIsDeleteConfirmOpen(false); setSelectedUser(null); }}
|
||||||
|
title="Confirm Deletion"
|
||||||
|
description={`Are you sure you want to delete the user ${selectedUser?.email}? This action cannot be undone.`}
|
||||||
|
confirmText="Yes, Delete User"
|
||||||
|
onConfirm={() => selectedUser && deleteMutation.mutate(selectedUser.email)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold font-mono">User Management</h1>
|
||||||
|
<Button onClick={() => { setSelectedUser(null); setIsFormOpen(true); }}>
|
||||||
|
<UserPlus className="h-4 w-4 mr-2" />
|
||||||
|
New User
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center p-12"><Loader2 className="h-8 w-8 animate-spin text-green-accent" /></div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="flex items-center text-red-400 p-8"><AlertTriangle className="h-5 w-5 mr-3" /><span>Failed to load users.</span></div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-neutral-800/50">
|
||||||
|
{table.getHeaderGroups().map(headerGroup => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map(header => (
|
||||||
|
<th key={header.id} className="p-4 text-left font-mono text-neutral-400 uppercase tracking-wider">
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{table.getRowModel().rows.map(row => (
|
||||||
|
<tr key={row.id} className="border-b border-neutral-800 last:border-b-0 hover:bg-neutral-800/30 transition-colors">
|
||||||
|
{row.getVisibleCells().map(cell => (
|
||||||
|
<td key={cell.id} className="p-4">
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserManagementPage;
|
||||||
4
admin-frontend/src/pages/index.ts
Normal file
4
admin-frontend/src/pages/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { default as DashboardPage } from './DashboardPage';
|
||||||
|
export { default as ReportsQueuePage } from './ReportsQueuePage';
|
||||||
|
export { default as ReportDetailPage } from './ReportDetailPage';
|
||||||
|
export { default as UserManagementPage } from './UserManagementPage';
|
||||||
23
admin-frontend/src/stores/userStore.ts
Normal file
23
admin-frontend/src/stores/userStore.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
email: string;
|
||||||
|
fullName: string;
|
||||||
|
role: 'admin' | 'moderator';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserState {
|
||||||
|
user: User | null;
|
||||||
|
_setUser: (user: User) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockAdminUser: User = {
|
||||||
|
email: 'admin@dissolve.app',
|
||||||
|
fullName: 'Admin User',
|
||||||
|
role: 'admin',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUserStore = create<UserState>((set) => ({
|
||||||
|
user: mockAdminUser, // Initialize with a mock admin for access to all features
|
||||||
|
_setUser: (user) => set({ user }),
|
||||||
|
}));
|
||||||
51
admin-frontend/tailwind.config.js
Normal file
51
admin-frontend/tailwind.config.js
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
|
||||||
|
import tailwindAnimate from "tailwindcss-animate";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border) / <alpha-value>)",
|
||||||
|
input: "hsl(var(--input) / <alpha-value>)",
|
||||||
|
ring: "hsl(var(--ring) / <alpha-value>)",
|
||||||
|
background: "hsl(var(--background) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--foreground) / <alpha-value>)",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--primary-foreground) / <alpha-value>)",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--secondary-foreground) / <alpha-value>)",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--muted-foreground) / <alpha-value>)",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--accent-foreground) / <alpha-value>)",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--popover-foreground) / <alpha-value>)",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--card-foreground) / <alpha-value>)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
body: ["Inter", "system-ui", "sans-serif"],
|
||||||
|
display: ["Cal Sans", "Inter", "system-ui", "sans-serif"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [tailwindAnimate],
|
||||||
|
}
|
||||||
|
|
||||||
25
admin-frontend/tsconfig.json
Normal file
25
admin-frontend/tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable",
|
||||||
|
"ESNext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": false,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
27
admin-frontend/vite.config.ts
Normal file
27
admin-frontend/vite.config.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
"react-query": "@tanstack/react-query",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
onwarn(warning, warn) {
|
||||||
|
if (warning.code === 'MODULE_LEVEL_DIRECTIVE' && warning.message.includes('use client')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
warn(warning);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user