Migrate frontend from Next.js to Vite + React Router
Full technology change: Next.js App Router replaced with Vite + react-router-dom v6. All 21 pages and 5 landing components converted (next/link -> Link, next/navigation -> react-router-dom hooks, next/font -> @fontsource/inter, process.env -> import.meta.env). Dropped the unused Stripe API routes, which can't run in a client-only Vite build anyway and were never wired to any page. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
61d04a662f
commit
5b4834e6a5
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_URL=https://odoo-mcp.thedomainnest.com
|
||||||
|
VITE_APP_URL=https://your-frontend-domain.com
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -5,3 +5,4 @@ dist/
|
|||||||
*.local
|
*.local
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
|
|||||||
13
index.html
Normal file
13
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>OdooMCP Cloud — Secure MCP Server for Odoo</title>
|
||||||
|
<meta name="description" content="Connect your Odoo ERP to Claude, Codex, and AI agents with a secure hosted MCP server." />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3229
package-lock.json
generated
Normal file
3229
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "odoomcp-cloud-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --port 3000",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview --port 3000",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/inter": "^5.0.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.400.0",
|
||||||
|
"react": "^18",
|
||||||
|
"react-dom": "^18",
|
||||||
|
"react-router-dom": "^6.26.0",
|
||||||
|
"recharts": "^3.9.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"autoprefixer": "^10.0.1",
|
||||||
|
"postcss": "^8",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5",
|
||||||
|
"vite": "^5.4.0",
|
||||||
|
"vite-tsconfig-paths": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.cjs
Normal file
6
postcss.config.cjs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
52
src/App.tsx
Normal file
52
src/App.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { Routes, Route } from 'react-router-dom';
|
||||||
|
import Landing from './pages/Landing';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import Signup from './pages/Signup';
|
||||||
|
import ForgotPassword from './pages/ForgotPassword';
|
||||||
|
import ResetPassword from './pages/ResetPassword';
|
||||||
|
|
||||||
|
import DashboardLayout from './pages/dashboard/DashboardLayout';
|
||||||
|
import DashboardHome from './pages/dashboard/DashboardHome';
|
||||||
|
import Connections from './pages/dashboard/Connections';
|
||||||
|
import Billing from './pages/dashboard/Billing';
|
||||||
|
import Endpoints from './pages/dashboard/Endpoints';
|
||||||
|
import Tokens from './pages/dashboard/Tokens';
|
||||||
|
import Usage from './pages/dashboard/Usage';
|
||||||
|
import Logs from './pages/dashboard/Logs';
|
||||||
|
|
||||||
|
import AdminLogin from './pages/admin/AdminLogin';
|
||||||
|
import AdminLayout from './pages/admin/AdminLayout';
|
||||||
|
import AdminOverview from './pages/admin/AdminOverview';
|
||||||
|
import AdminAnalytics from './pages/admin/AdminAnalytics';
|
||||||
|
import AdminUsers from './pages/admin/AdminUsers';
|
||||||
|
import AdminUserDetail from './pages/admin/AdminUserDetail';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Landing />} />
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/signup" element={<Signup />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
|
|
||||||
|
<Route path="/dashboard" element={<DashboardLayout />}>
|
||||||
|
<Route index element={<DashboardHome />} />
|
||||||
|
<Route path="connections" element={<Connections />} />
|
||||||
|
<Route path="billing" element={<Billing />} />
|
||||||
|
<Route path="endpoints" element={<Endpoints />} />
|
||||||
|
<Route path="tokens" element={<Tokens />} />
|
||||||
|
<Route path="usage" element={<Usage />} />
|
||||||
|
<Route path="logs" element={<Logs />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="/admin/login" element={<AdminLogin />} />
|
||||||
|
<Route path="/admin" element={<AdminLayout />}>
|
||||||
|
<Route index element={<AdminOverview />} />
|
||||||
|
<Route path="analytics" element={<AdminAnalytics />} />
|
||||||
|
<Route path="users" element={<AdminUsers />} />
|
||||||
|
<Route path="users/:id" element={<AdminUserDetail />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/components/landing/Features.tsx
Normal file
56
src/components/landing/Features.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { Shield, Zap, Code2, Key, Database, Settings2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const FEATURES = [
|
||||||
|
{
|
||||||
|
icon: Shield,
|
||||||
|
title: 'Secure & Hosted',
|
||||||
|
desc: 'We host and maintain the MCP server infrastructure.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Zap,
|
||||||
|
title: 'Claude Ready',
|
||||||
|
desc: 'Codex, Cursor and other clients supported.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Code2,
|
||||||
|
title: 'Developer Friendly',
|
||||||
|
desc: 'Isolated tokens, rotation, usage limits and logs.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Key,
|
||||||
|
title: 'Secure Tokens',
|
||||||
|
desc: 'Isolated tokens, rotation, usage limits and logs.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Database,
|
||||||
|
title: 'Odoo Modules',
|
||||||
|
desc: 'CRM, Sales, Inventory, Accounting and more.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Settings2,
|
||||||
|
title: 'Admin Controls',
|
||||||
|
desc: 'Granular permissions and full activity logs.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Features() {
|
||||||
|
return (
|
||||||
|
<section id="features" className="py-20 bg-gray-50">
|
||||||
|
<div className="max-w-7xl mx-auto px-4">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-6">
|
||||||
|
{FEATURES.map(({ icon: Icon, title, desc }) => (
|
||||||
|
<div key={title} className="bg-white rounded-xl p-5 shadow-sm border border-gray-100 flex flex-col gap-3">
|
||||||
|
<div className="w-10 h-10 bg-brand-50 rounded-lg flex items-center justify-center">
|
||||||
|
<Icon size={20} className="text-brand-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-gray-900">{title}</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1 leading-relaxed">{desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
src/components/landing/Hero.tsx
Normal file
95
src/components/landing/Hero.tsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { ArrowRight, Shield } from 'lucide-react';
|
||||||
|
|
||||||
|
const AI_TOOLS = [
|
||||||
|
{ name: 'Claude', color: 'bg-orange-100 text-orange-700' },
|
||||||
|
{ name: 'Codex', color: 'bg-green-100 text-green-700' },
|
||||||
|
{ name: 'Cursor', color: 'bg-blue-100 text-blue-700' },
|
||||||
|
{ name: 'AI Agents', color: 'bg-purple-100 text-purple-700' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Hero() {
|
||||||
|
return (
|
||||||
|
<section className="pt-32 pb-20 px-4">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="flex flex-col lg:flex-row items-center gap-16">
|
||||||
|
{/* Left: copy */}
|
||||||
|
<div className="flex-1 max-w-xl">
|
||||||
|
<div className="inline-flex items-center gap-2 bg-brand-50 text-brand-700 text-xs font-medium px-3 py-1.5 rounded-full mb-6">
|
||||||
|
<Shield size={12} />
|
||||||
|
Secure MCP Server for Odoo
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl sm:text-5xl font-bold text-gray-900 leading-tight mb-4">
|
||||||
|
Connect your Odoo ERP to{' '}
|
||||||
|
<span className="text-gradient">Claude, Codex, and AI agents</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-gray-600 mb-8">
|
||||||
|
A secure hosted MCP server for Odoo. Subscribe, connect your Odoo
|
||||||
|
instance, and instantly use your ERP data inside your favorite AI tools.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-3 mb-8">
|
||||||
|
<Link to="/signup"
|
||||||
|
className="inline-flex items-center gap-2 gradient-brand text-white px-6 py-3 rounded-xl font-semibold hover:opacity-90 transition-opacity">
|
||||||
|
Start Free Trial <ArrowRight size={16} />
|
||||||
|
</Link>
|
||||||
|
<Link to="#how-it-works"
|
||||||
|
className="inline-flex items-center gap-2 border border-gray-200 text-gray-700 px-6 py-3 rounded-xl font-semibold hover:bg-gray-50 transition-colors">
|
||||||
|
View Setup Guide
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">No credit card required · 7-day free trial · Cancel anytime</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: diagram */}
|
||||||
|
<div className="flex-1 flex justify-center lg:justify-end">
|
||||||
|
<div className="bg-white border border-gray-100 rounded-2xl shadow-xl p-8 w-full max-w-md">
|
||||||
|
{/* Odoo ERP → OdooMCP → AI Tools */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="w-16 h-16 bg-purple-50 rounded-xl flex items-center justify-center">
|
||||||
|
<span className="text-2xl">🗄️</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-gray-600">Odoo ERP</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 border-t-2 border-dashed border-brand-200 relative">
|
||||||
|
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-brand-400 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="w-16 h-16 gradient-brand rounded-xl flex items-center justify-center shadow-lg">
|
||||||
|
<span className="text-white text-xs font-bold">MCP</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-brand-700">OdooMCP Cloud</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 border-t-2 border-dashed border-brand-200 relative">
|
||||||
|
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-brand-400 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{AI_TOOLS.map((t) => (
|
||||||
|
<div key={t.name}
|
||||||
|
className={`text-xs font-medium px-2.5 py-1 rounded-lg ${t.color}`}>
|
||||||
|
{t.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* MCP endpoint badge */}
|
||||||
|
<div className="mt-8 bg-gray-50 rounded-xl p-4">
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Your MCP Endpoint</p>
|
||||||
|
<p className="text-xs font-mono text-brand-600 break-all">
|
||||||
|
https://odoo-mcp.thedomainnest.com/mcp/sse
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4 mt-3 text-xs text-gray-600">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="w-2 h-2 bg-green-400 rounded-full" /> Active
|
||||||
|
</span>
|
||||||
|
<span>Tools: 24 enabled</span>
|
||||||
|
<span>Claude: Connected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/landing/HowItWorks.tsx
Normal file
33
src/components/landing/HowItWorks.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
const STEPS = [
|
||||||
|
{ n: 1, title: 'Create Account', desc: 'Sign up and choose the best plan for your needs.' },
|
||||||
|
{ n: 2, title: 'Connect Odoo', desc: 'Add your Odoo instance and select modules.' },
|
||||||
|
{ n: 3, title: 'Get MCP Endpoint', desc: 'We generate your secure MCP URL and token.' },
|
||||||
|
{ n: 4, title: 'Connect AI Tools', desc: 'Use our guides for Claude, Codex, Cursor and more.' },
|
||||||
|
{ n: 5, title: 'Ask Your ERP', desc: 'Query your Odoo data using natural language.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function HowItWorks() {
|
||||||
|
return (
|
||||||
|
<section id="how-it-works" className="py-20">
|
||||||
|
<div className="max-w-7xl mx-auto px-4">
|
||||||
|
<h2 className="text-3xl font-bold text-center text-gray-900 mb-16">How it works</h2>
|
||||||
|
<div className="relative flex flex-col sm:flex-row gap-8">
|
||||||
|
{/* connecting line */}
|
||||||
|
<div className="hidden sm:block absolute top-8 left-[10%] right-[10%] h-0.5 bg-brand-100" />
|
||||||
|
{STEPS.map((s, i) => (
|
||||||
|
<div key={s.n} className="flex-1 flex flex-col items-center text-center relative">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-brand-600 text-white flex items-center justify-center font-bold text-lg mb-4 shadow-lg z-10">
|
||||||
|
{s.n}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">{s.title}</h3>
|
||||||
|
<p className="text-xs text-gray-500 leading-relaxed">{s.desc}</p>
|
||||||
|
{i < STEPS.length - 1 && (
|
||||||
|
<div className="hidden sm:block absolute top-8 right-0 translate-x-1/2 text-brand-300 text-xl z-20">→</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/components/landing/Navbar.tsx
Normal file
58
src/components/landing/Navbar.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Menu, X } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<nav className="fixed top-0 left-0 right-0 z-50 bg-white border-b border-gray-100">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between h-16">
|
||||||
|
{/* Logo */}
|
||||||
|
<Link to="/" className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-lg gradient-brand flex items-center justify-center">
|
||||||
|
<span className="text-white font-bold text-sm">O</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-gray-900 text-sm">OdooMCP Cloud</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Desktop nav */}
|
||||||
|
<div className="hidden md:flex items-center gap-6 text-sm text-gray-600">
|
||||||
|
{['Features', 'Use Cases', 'Pricing', 'Docs', 'Security', 'Contact'].map((item) => (
|
||||||
|
<Link key={item} to={`#${item.toLowerCase().replace(' ', '-')}`}
|
||||||
|
className="hover:text-gray-900 transition-colors">{item}</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden md:flex items-center gap-3">
|
||||||
|
<Link to="/login" className="text-sm text-gray-600 hover:text-gray-900 px-3 py-1.5">
|
||||||
|
Log in
|
||||||
|
</Link>
|
||||||
|
<Link to="/signup"
|
||||||
|
className="text-sm bg-brand-600 text-white px-4 py-1.5 rounded-lg hover:bg-brand-700 transition-colors font-medium">
|
||||||
|
Start Free Trial
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile toggle */}
|
||||||
|
<button className="md:hidden p-2" onClick={() => setOpen(!open)}>
|
||||||
|
{open ? <X size={20} /> : <Menu size={20} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div className="md:hidden border-t border-gray-100 bg-white px-4 py-4 space-y-3">
|
||||||
|
{['Features', 'Use Cases', 'Pricing', 'Docs', 'Security', 'Contact'].map((item) => (
|
||||||
|
<Link key={item} to={`#${item.toLowerCase()}`}
|
||||||
|
className="block text-sm text-gray-700 py-1" onClick={() => setOpen(false)}>{item}</Link>
|
||||||
|
))}
|
||||||
|
<hr className="border-gray-100" />
|
||||||
|
<Link to="/login" className="block text-sm text-gray-700 py-1">Log in</Link>
|
||||||
|
<Link to="/signup" className="block w-full text-center bg-brand-600 text-white px-4 py-2 rounded-lg text-sm font-medium">
|
||||||
|
Start Free Trial
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
src/components/landing/Pricing.tsx
Normal file
148
src/components/landing/Pricing.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
const PLANS = [
|
||||||
|
{
|
||||||
|
name: 'Starter',
|
||||||
|
price: 19,
|
||||||
|
desc: 'For small Odoo users',
|
||||||
|
popular: false,
|
||||||
|
features: [
|
||||||
|
'1 Odoo connection',
|
||||||
|
'1 MCP endpoint',
|
||||||
|
'Read-only tools',
|
||||||
|
'Token rotation',
|
||||||
|
'Claude/Codex setup guide',
|
||||||
|
'1,000 MCP requests/month',
|
||||||
|
'Email support',
|
||||||
|
],
|
||||||
|
cta: 'Start Free Trial',
|
||||||
|
href: '/signup?plan=starter',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pro',
|
||||||
|
price: 49,
|
||||||
|
desc: 'For growing businesses',
|
||||||
|
popular: true,
|
||||||
|
features: [
|
||||||
|
'3 Odoo connections',
|
||||||
|
'Read / Write tools',
|
||||||
|
'Token rotation',
|
||||||
|
'Access logs',
|
||||||
|
'10,000 MCP requests/month',
|
||||||
|
'Priority support',
|
||||||
|
],
|
||||||
|
cta: 'Start Free Trial',
|
||||||
|
href: '/signup?plan=pro',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Agency',
|
||||||
|
price: 149,
|
||||||
|
desc: 'For agencies & consultants',
|
||||||
|
popular: false,
|
||||||
|
features: [
|
||||||
|
'25 client workspaces',
|
||||||
|
'White-label endpoint',
|
||||||
|
'Team members',
|
||||||
|
'Usage dashboard',
|
||||||
|
'Custom tool permissions',
|
||||||
|
'50,000 MCP requests/month',
|
||||||
|
],
|
||||||
|
cta: 'Start Free Trial',
|
||||||
|
href: '/signup?plan=agency',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Enterprise',
|
||||||
|
price: null,
|
||||||
|
desc: 'For large organisations',
|
||||||
|
popular: false,
|
||||||
|
features: [
|
||||||
|
'Dedicated MCP server',
|
||||||
|
'Private deployment',
|
||||||
|
'SSO / OAuth',
|
||||||
|
'Advanced audit logs',
|
||||||
|
'Custom Odoo modules',
|
||||||
|
'SLA & dedicated support',
|
||||||
|
],
|
||||||
|
cta: 'Contact Sales',
|
||||||
|
href: '/contact',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Pricing() {
|
||||||
|
return (
|
||||||
|
<section id="pricing" className="py-20 bg-gray-50">
|
||||||
|
<div className="max-w-7xl mx-auto px-4">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 mb-3">Simple, transparent pricing</h2>
|
||||||
|
<p className="text-gray-600">Choose the plan that fits your business. All plans include 7-day free trial.</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{PLANS.map((plan) => (
|
||||||
|
<div
|
||||||
|
key={plan.name}
|
||||||
|
className={clsx(
|
||||||
|
'relative rounded-2xl p-6 flex flex-col',
|
||||||
|
plan.popular
|
||||||
|
? 'gradient-brand text-white shadow-xl scale-105'
|
||||||
|
: 'bg-white border border-gray-200 shadow-sm',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{plan.popular && (
|
||||||
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-yellow-400 text-yellow-900 text-xs font-bold px-3 py-1 rounded-full">
|
||||||
|
Most Popular
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className={clsx('font-bold text-lg mb-1', plan.popular ? 'text-white' : 'text-gray-900')}>
|
||||||
|
{plan.name}
|
||||||
|
</h3>
|
||||||
|
<p className={clsx('text-xs mb-4', plan.popular ? 'text-purple-200' : 'text-gray-500')}>
|
||||||
|
{plan.desc}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-end gap-1">
|
||||||
|
{plan.price ? (
|
||||||
|
<>
|
||||||
|
<span className={clsx('text-4xl font-bold', plan.popular ? 'text-white' : 'text-gray-900')}>
|
||||||
|
${plan.price}
|
||||||
|
</span>
|
||||||
|
<span className={clsx('text-sm mb-1', plan.popular ? 'text-purple-200' : 'text-gray-500')}>
|
||||||
|
/month
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className={clsx('text-4xl font-bold', plan.popular ? 'text-white' : 'text-gray-900')}>
|
||||||
|
Custom
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="flex-1 space-y-2.5 mb-6">
|
||||||
|
{plan.features.map((f) => (
|
||||||
|
<li key={f} className="flex items-start gap-2 text-xs">
|
||||||
|
<Check size={13} className={clsx('mt-0.5 shrink-0', plan.popular ? 'text-purple-200' : 'text-brand-500')} />
|
||||||
|
<span className={plan.popular ? 'text-purple-100' : 'text-gray-700'}>{f}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to={plan.href}
|
||||||
|
className={clsx(
|
||||||
|
'block text-center text-sm font-semibold px-4 py-2.5 rounded-xl transition-all',
|
||||||
|
plan.popular
|
||||||
|
? 'bg-white text-brand-700 hover:bg-purple-50'
|
||||||
|
: 'gradient-brand text-white hover:opacity-90',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{plan.cta}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/index.css
Normal file
21
src/index.css
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply bg-white text-gray-900 antialiased;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.gradient-brand {
|
||||||
|
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
|
||||||
|
}
|
||||||
|
.text-gradient {
|
||||||
|
background: linear-gradient(135deg, #7c3aed, #a78bfa);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
}
|
||||||
363
src/lib/api.ts
Normal file
363
src/lib/api.ts
Normal file
@ -0,0 +1,363 @@
|
|||||||
|
const BASE = import.meta.env.VITE_API_URL ?? 'https://odoo-mcp.thedomainnest.com';
|
||||||
|
|
||||||
|
async function req<T>(path: string, opts?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
headers: { 'Content-Type': 'application/json', ...opts?.headers },
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.detail ?? 'Request failed');
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignupPayload {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
odoo_url?: string;
|
||||||
|
odoo_database?: string;
|
||||||
|
odoo_username?: string;
|
||||||
|
odoo_credential?: string;
|
||||||
|
odoo_is_api_key?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignupResult {
|
||||||
|
user_id: number;
|
||||||
|
email: string;
|
||||||
|
api_key: string;
|
||||||
|
mcp_connection: { url: string; header: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeResult {
|
||||||
|
user_id: number;
|
||||||
|
email: string;
|
||||||
|
api_key_prefix: string;
|
||||||
|
created_at: string;
|
||||||
|
odoo_instances: Array<{
|
||||||
|
instance_name: string;
|
||||||
|
url: string;
|
||||||
|
database: string;
|
||||||
|
username: string;
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CredentialPayload {
|
||||||
|
instance_name: string;
|
||||||
|
url: string;
|
||||||
|
database: string;
|
||||||
|
username: string;
|
||||||
|
credential: string;
|
||||||
|
is_api_key: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiKeyInfo {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
key_prefix: string;
|
||||||
|
created_at: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
revoked_at: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsSummary {
|
||||||
|
total_calls: number;
|
||||||
|
success_count: number;
|
||||||
|
error_count: number;
|
||||||
|
avg_duration_ms: number;
|
||||||
|
total_duration_ms: number;
|
||||||
|
unique_tools: number;
|
||||||
|
unique_instances: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolStat {
|
||||||
|
tool_name: string;
|
||||||
|
calls: number;
|
||||||
|
success_count: number;
|
||||||
|
error_count: number;
|
||||||
|
avg_duration_ms: number;
|
||||||
|
total_duration_ms: number;
|
||||||
|
last_used_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstanceStat {
|
||||||
|
instance_name: string;
|
||||||
|
calls: number;
|
||||||
|
success_count: number;
|
||||||
|
error_count: number;
|
||||||
|
avg_duration_ms: number;
|
||||||
|
total_duration_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeseriesBucket {
|
||||||
|
period: string;
|
||||||
|
calls: number;
|
||||||
|
avg_duration_ms: number;
|
||||||
|
error_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolDetail {
|
||||||
|
tool_name: string;
|
||||||
|
summary: {
|
||||||
|
calls: number;
|
||||||
|
success_count: number;
|
||||||
|
error_count: number;
|
||||||
|
avg_duration_ms: number;
|
||||||
|
total_duration_ms: number;
|
||||||
|
};
|
||||||
|
timeseries: TimeseriesBucket[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
id: number;
|
||||||
|
tool_name: string;
|
||||||
|
instance_name: string;
|
||||||
|
started_at: string;
|
||||||
|
duration_ms: number;
|
||||||
|
success: boolean;
|
||||||
|
error_message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogFilters {
|
||||||
|
tool_name?: string;
|
||||||
|
instance_name?: string;
|
||||||
|
success?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeader(apiKey: string) {
|
||||||
|
return { Authorization: `Bearer ${apiKey}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function qs(params: Record<string, string | number | boolean | undefined>): string {
|
||||||
|
const parts = Object.entries(params)
|
||||||
|
.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
||||||
|
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
||||||
|
return parts.length ? `?${parts.join('&')}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
signup: (body: SignupPayload) =>
|
||||||
|
req<SignupResult>('/api/signup', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
|
||||||
|
login: (email: string, password: string) =>
|
||||||
|
req<{ user_id: number; email: string; api_key_prefix: string }>('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
me: (apiKey: string) =>
|
||||||
|
req<MeResult>('/api/me', { headers: authHeader(apiKey) }),
|
||||||
|
|
||||||
|
regenKey: (apiKey: string) =>
|
||||||
|
req<{ api_key: string; mcp_connection: { url: string; header: string } }>('/api/api-key/regenerate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeader(apiKey),
|
||||||
|
}),
|
||||||
|
|
||||||
|
addCredential: (apiKey: string, body: CredentialPayload) =>
|
||||||
|
req('/api/credentials', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeader(apiKey),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
|
||||||
|
listCredentials: (apiKey: string) =>
|
||||||
|
req<{ instances: MeResult['odoo_instances'] }>('/api/credentials', {
|
||||||
|
headers: authHeader(apiKey),
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteCredential: (apiKey: string, name: string) =>
|
||||||
|
req(`/api/credentials/${name}`, { method: 'DELETE', headers: authHeader(apiKey) }),
|
||||||
|
|
||||||
|
forgotPassword: (email: string) =>
|
||||||
|
req<{ message: string }>('/api/password/forgot', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
resetPassword: (token: string, new_password: string) =>
|
||||||
|
req<{ message: string }>('/api/password/reset', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ token, new_password }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
listApiKeys: (apiKey: string) =>
|
||||||
|
req<{ keys: ApiKeyInfo[]; total: number }>('/api/keys', { headers: authHeader(apiKey) }),
|
||||||
|
|
||||||
|
createApiKey: (apiKey: string, name: string) =>
|
||||||
|
req<{ id: number; name: string; api_key: string; mcp_connection: { url: string; header: string } }>(
|
||||||
|
'/api/keys',
|
||||||
|
{ method: 'POST', headers: authHeader(apiKey), body: JSON.stringify({ name }) },
|
||||||
|
),
|
||||||
|
|
||||||
|
revokeApiKey: (apiKey: string, id: number) =>
|
||||||
|
req<{ success: boolean }>(`/api/keys/${id}`, { method: 'DELETE', headers: authHeader(apiKey) }),
|
||||||
|
|
||||||
|
analyticsSummary: (apiKey: string, since?: string, until?: string) =>
|
||||||
|
req<AnalyticsSummary>(`/api/analytics/summary${qs({ since, until })}`, {
|
||||||
|
headers: authHeader(apiKey),
|
||||||
|
}),
|
||||||
|
|
||||||
|
analyticsByTool: (apiKey: string, since?: string, until?: string) =>
|
||||||
|
req<{ tools: ToolStat[] }>(`/api/analytics/by-tool${qs({ since, until })}`, {
|
||||||
|
headers: authHeader(apiKey),
|
||||||
|
}),
|
||||||
|
|
||||||
|
analyticsByInstance: (apiKey: string, since?: string, until?: string) =>
|
||||||
|
req<{ instances: InstanceStat[] }>(`/api/analytics/by-instance${qs({ since, until })}`, {
|
||||||
|
headers: authHeader(apiKey),
|
||||||
|
}),
|
||||||
|
|
||||||
|
analyticsTimeseries: (apiKey: string, since?: string, until?: string, bucket: 'hour' | 'day' | 'week' = 'day') =>
|
||||||
|
req<{ buckets: TimeseriesBucket[] }>(`/api/analytics/timeseries${qs({ since, until, bucket })}`, {
|
||||||
|
headers: authHeader(apiKey),
|
||||||
|
}),
|
||||||
|
|
||||||
|
analyticsToolDetail: (apiKey: string, toolName: string, since?: string, until?: string) =>
|
||||||
|
req<ToolDetail>(`/api/analytics/tools/${encodeURIComponent(toolName)}${qs({ since, until })}`, {
|
||||||
|
headers: authHeader(apiKey),
|
||||||
|
}),
|
||||||
|
|
||||||
|
listLogs: (apiKey: string, page = 1, pageSize = 25, filters?: LogFilters) =>
|
||||||
|
req<{ items: LogEntry[]; page: number; page_size: number; total: number }>(
|
||||||
|
`/api/logs${qs({ page, page_size: pageSize, ...filters })}`,
|
||||||
|
{ headers: authHeader(apiKey) },
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Session helpers (localStorage)
|
||||||
|
export const session = {
|
||||||
|
save: (apiKey: string, email: string) => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
localStorage.setItem('odoomcp_key', apiKey);
|
||||||
|
localStorage.setItem('odoomcp_email', email);
|
||||||
|
},
|
||||||
|
getKey: (): string | null => {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
return localStorage.getItem('odoomcp_key');
|
||||||
|
},
|
||||||
|
getEmail: (): string | null => {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
return localStorage.getItem('odoomcp_email');
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
localStorage.removeItem('odoomcp_key');
|
||||||
|
localStorage.removeItem('odoomcp_email');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Admin Panel ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AdminUserSummary {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
created_at: string;
|
||||||
|
is_active: boolean;
|
||||||
|
credential_count: number;
|
||||||
|
active_key_count: number;
|
||||||
|
total_calls: number;
|
||||||
|
last_active_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUserDetail {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
odoo_instances: MeResult['odoo_instances'];
|
||||||
|
api_keys: ApiKeyInfo[];
|
||||||
|
usage_summary: AnalyticsSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminSystemSummary {
|
||||||
|
total_users: number;
|
||||||
|
active_users: number;
|
||||||
|
disabled_users: number;
|
||||||
|
total_calls: number;
|
||||||
|
success_count: number;
|
||||||
|
error_count: number;
|
||||||
|
avg_duration_ms: number;
|
||||||
|
active_callers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminAuthHeader(token: string) {
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
login: (email: string, password: string) =>
|
||||||
|
req<{ admin_token: string; message: string }>('/api/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
logout: (token: string) =>
|
||||||
|
req<{ success: boolean }>('/api/admin/logout', { method: 'POST', headers: adminAuthHeader(token) }),
|
||||||
|
|
||||||
|
listUsers: (token: string, page = 1, pageSize = 25, since?: string, until?: string) =>
|
||||||
|
req<{ items: AdminUserSummary[]; page: number; page_size: number; total: number }>(
|
||||||
|
`/api/admin/users${qs({ page, page_size: pageSize, since, until })}`,
|
||||||
|
{ headers: adminAuthHeader(token) },
|
||||||
|
),
|
||||||
|
|
||||||
|
getUser: (token: string, userId: number) =>
|
||||||
|
req<AdminUserDetail>(`/api/admin/users/${userId}`, { headers: adminAuthHeader(token) }),
|
||||||
|
|
||||||
|
setUserStatus: (token: string, userId: number, isActive: boolean) =>
|
||||||
|
req<{ success: boolean }>(`/api/admin/users/${userId}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: adminAuthHeader(token),
|
||||||
|
body: JSON.stringify({ is_active: isActive }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteUser: (token: string, userId: number) =>
|
||||||
|
req<{ success: boolean }>(`/api/admin/users/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: adminAuthHeader(token),
|
||||||
|
}),
|
||||||
|
|
||||||
|
revokeUserKey: (token: string, userId: number, keyId: number) =>
|
||||||
|
req<{ success: boolean }>(`/api/admin/users/${userId}/keys/${keyId}/revoke`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: adminAuthHeader(token),
|
||||||
|
}),
|
||||||
|
|
||||||
|
analyticsSummary: (token: string, since?: string, until?: string) =>
|
||||||
|
req<AdminSystemSummary>(`/api/admin/analytics/summary${qs({ since, until })}`, {
|
||||||
|
headers: adminAuthHeader(token),
|
||||||
|
}),
|
||||||
|
|
||||||
|
analyticsByTool: (token: string, since?: string, until?: string) =>
|
||||||
|
req<{ tools: ToolStat[] }>(`/api/admin/analytics/by-tool${qs({ since, until })}`, {
|
||||||
|
headers: adminAuthHeader(token),
|
||||||
|
}),
|
||||||
|
|
||||||
|
analyticsTimeseries: (token: string, since?: string, until?: string, bucket: 'hour' | 'day' | 'week' = 'day') =>
|
||||||
|
req<{ buckets: TimeseriesBucket[] }>(`/api/admin/analytics/timeseries${qs({ since, until, bucket })}`, {
|
||||||
|
headers: adminAuthHeader(token),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Admin session helpers — deliberately separate localStorage keys from the
|
||||||
|
// regular user `session` above so the two auth states never collide.
|
||||||
|
export const adminSession = {
|
||||||
|
save: (token: string, email: string) => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
localStorage.setItem('odoomcp_admin_token', token);
|
||||||
|
localStorage.setItem('odoomcp_admin_email', email);
|
||||||
|
},
|
||||||
|
getToken: (): string | null => {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
return localStorage.getItem('odoomcp_admin_token');
|
||||||
|
},
|
||||||
|
getEmail: (): string | null => {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
return localStorage.getItem('odoomcp_admin_email');
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
localStorage.removeItem('odoomcp_admin_token');
|
||||||
|
localStorage.removeItem('odoomcp_admin_email');
|
||||||
|
},
|
||||||
|
};
|
||||||
14
src/main.tsx
Normal file
14
src/main.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import '@fontsource/inter';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
91
src/pages/ForgotPassword.tsx
Normal file
91
src/pages/ForgotPassword.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
export default function ForgotPassword() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.forgotPassword(email);
|
||||||
|
setSent(true);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Something went wrong');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<Link to="/" className="inline-flex items-center gap-2 mb-6">
|
||||||
|
<div className="w-8 h-8 rounded-lg gradient-brand flex items-center justify-center">
|
||||||
|
<span className="text-white font-bold text-sm">O</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-gray-900">OdooMCP Cloud</span>
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Reset your password</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Enter your email and we'll send you a reset link
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-8">
|
||||||
|
{sent ? (
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="bg-green-50 border border-green-100 text-green-700 text-sm rounded-lg px-4 py-3">
|
||||||
|
If that email is registered, a reset link has been sent. Check your inbox.
|
||||||
|
</div>
|
||||||
|
<Link to="/login" className="text-sm text-brand-600 hover:underline font-medium">
|
||||||
|
Back to login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1.5">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||||
|
placeholder="you@company.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-100 text-red-700 text-xs rounded-lg px-3 py-2.5">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full gradient-brand text-white py-2.5 rounded-xl text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{loading ? 'Sending…' : 'Send reset link'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-gray-500 mt-6">
|
||||||
|
Remembered your password?{' '}
|
||||||
|
<Link to="/login" className="text-brand-600 hover:underline font-medium">
|
||||||
|
Log in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/pages/Landing.tsx
Normal file
67
src/pages/Landing.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import Navbar from '@/components/landing/Navbar';
|
||||||
|
import Hero from '@/components/landing/Hero';
|
||||||
|
import Features from '@/components/landing/Features';
|
||||||
|
import HowItWorks from '@/components/landing/HowItWorks';
|
||||||
|
import Pricing from '@/components/landing/Pricing';
|
||||||
|
|
||||||
|
const TRUST_LOGOS = ['odoo', 'ACSONE', 'VENTURE', 'CYBROSYS', 'Camptocamp', 'Shuva', 'PRAGMATIC'];
|
||||||
|
|
||||||
|
export default function Landing() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Navbar />
|
||||||
|
<main>
|
||||||
|
<Hero />
|
||||||
|
<Features />
|
||||||
|
<HowItWorks />
|
||||||
|
<Pricing />
|
||||||
|
|
||||||
|
{/* Trust logos */}
|
||||||
|
<section className="py-12 border-t border-gray-100">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 text-center">
|
||||||
|
<p className="text-xs text-gray-400 mb-6 uppercase tracking-widest">
|
||||||
|
Trusted by Odoo professionals teams worldwide
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-8">
|
||||||
|
{TRUST_LOGOS.map((name) => (
|
||||||
|
<span key={name} className="text-sm font-semibold text-gray-400 hover:text-gray-600 transition-colors">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="bg-gray-900 text-gray-400 py-12">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 flex flex-col sm:flex-row justify-between gap-6 text-xs">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="w-6 h-6 rounded gradient-brand" />
|
||||||
|
<span className="text-white font-semibold text-sm">OdooMCP Cloud</span>
|
||||||
|
</div>
|
||||||
|
<p>Secure MCP Server for Odoo ERP</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-12">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-white font-medium">Product</p>
|
||||||
|
{['Features', 'Pricing', 'Security', 'Docs'].map((l) => (
|
||||||
|
<p key={l}><a href="#" className="hover:text-white transition-colors">{l}</a></p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-white font-medium">Company</p>
|
||||||
|
{['About', 'Blog', 'Contact', 'Privacy'].map((l) => (
|
||||||
|
<p key={l}><a href="#" className="hover:text-white transition-colors">{l}</a></p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-7xl mx-auto px-4 mt-8 pt-6 border-t border-gray-800 text-xs text-center">
|
||||||
|
© {new Date().getFullYear()} OdooMCP Cloud. All rights reserved.
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
src/pages/Login.tsx
Normal file
102
src/pages/Login.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { api, session } from '@/lib/api';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [form, setForm] = useState({ email: '', password: '' });
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const savedKey = session.getKey();
|
||||||
|
if (!savedKey) {
|
||||||
|
setError('No API key found. Please use your API key directly or re-generate it from a previous signup.');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Verify credentials via /api/login then confirm key works
|
||||||
|
await api.login(form.email, form.password);
|
||||||
|
session.save(savedKey, form.email);
|
||||||
|
navigate('/dashboard');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Login failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<Link to="/" className="inline-flex items-center gap-2 mb-6">
|
||||||
|
<div className="w-8 h-8 rounded-lg gradient-brand flex items-center justify-center">
|
||||||
|
<span className="text-white font-bold text-sm">O</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-gray-900">OdooMCP Cloud</span>
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Welcome back</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Log in to your dashboard</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-8">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1.5">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||||
|
placeholder="you@company.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<label className="block text-xs font-medium text-gray-700">Password</label>
|
||||||
|
<Link to="/forgot-password" className="text-xs text-brand-600 hover:underline">
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-100 text-red-700 text-xs rounded-lg px-3 py-2.5">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full gradient-brand text-white py-2.5 rounded-xl text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{loading ? 'Logging in…' : 'Log in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-gray-500 mt-6">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link to="/signup" className="text-brand-600 hover:underline font-medium">
|
||||||
|
Start free trial
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
src/pages/ResetPassword.tsx
Normal file
126
src/pages/ResetPassword.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
export default function ResetPassword() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const token = searchParams.get('token') ?? '';
|
||||||
|
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirm, setConfirm] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
setError('Missing or invalid reset link. Request a new one.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('Password must be at least 8 characters.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password !== confirm) {
|
||||||
|
setError('Passwords do not match.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.resetPassword(token, password);
|
||||||
|
setDone(true);
|
||||||
|
setTimeout(() => navigate('/login'), 2500);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Reset failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<Link to="/" className="inline-flex items-center gap-2 mb-6">
|
||||||
|
<div className="w-8 h-8 rounded-lg gradient-brand flex items-center justify-center">
|
||||||
|
<span className="text-white font-bold text-sm">O</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-gray-900">OdooMCP Cloud</span>
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Set a new password</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Choose a new password for your account
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-8">
|
||||||
|
{done ? (
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="bg-green-50 border border-green-100 text-green-700 text-sm rounded-lg px-4 py-3">
|
||||||
|
Password reset. All existing API keys were revoked. Redirecting to login…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{!token && (
|
||||||
|
<div className="bg-yellow-50 border border-yellow-100 text-yellow-800 text-xs rounded-lg px-3 py-2.5">
|
||||||
|
No reset token found in the link. Please use the link from your email, or{' '}
|
||||||
|
<Link to="/forgot-password" className="underline font-medium">request a new one</Link>.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1.5">New password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1.5">Confirm password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-100 text-red-700 text-xs rounded-lg px-3 py-2.5">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full gradient-brand text-white py-2.5 rounded-xl text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{loading ? 'Resetting…' : 'Reset password'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-gray-500 mt-6">
|
||||||
|
<Link to="/login" className="text-brand-600 hover:underline font-medium">
|
||||||
|
Back to login
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
src/pages/Signup.tsx
Normal file
166
src/pages/Signup.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { api, session } from '@/lib/api';
|
||||||
|
import { Copy, Check, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Signup() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const planFromUrl = params.get('plan') ?? 'starter';
|
||||||
|
|
||||||
|
const [step, setStep] = useState<'account' | 'key'>('account');
|
||||||
|
const [form, setForm] = useState({ email: '', password: '', confirmPassword: '' });
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSignup(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (form.password !== form.confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await api.signup({ email: form.email, password: form.password });
|
||||||
|
setApiKey(result.api_key);
|
||||||
|
session.save(result.api_key, result.email);
|
||||||
|
setStep('key');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Signup failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyKey() {
|
||||||
|
navigator.clipboard.writeText(apiKey);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||||
|
{step === 'key' ? (
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-8">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
|
<Check size={20} className="text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-bold text-gray-900">Account created!</h2>
|
||||||
|
<p className="text-xs text-gray-500">Save your API key — shown once only</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4 mb-6">
|
||||||
|
<div className="flex items-start gap-2 mb-3">
|
||||||
|
<AlertCircle size={14} className="text-yellow-700 mt-0.5 shrink-0" />
|
||||||
|
<p className="text-xs text-yellow-700 font-medium">
|
||||||
|
This is your API key. It will NOT be shown again. Copy and store it securely.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white rounded-lg border border-yellow-200 px-3 py-2.5">
|
||||||
|
<code className="flex-1 text-xs text-gray-800 break-all font-mono">{apiKey}</code>
|
||||||
|
<button onClick={copyKey} className="shrink-0 text-brand-600 hover:text-brand-700">
|
||||||
|
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 rounded-xl p-4 mb-6 text-xs text-gray-700">
|
||||||
|
<p className="font-medium mb-2">Your MCP Server URL:</p>
|
||||||
|
<code className="text-brand-600 break-all">
|
||||||
|
{import.meta.env.VITE_API_URL}/mcp/sse
|
||||||
|
</code>
|
||||||
|
<p className="font-medium mt-3 mb-1">Bearer Token:</p>
|
||||||
|
<code className="text-gray-600 break-all">{apiKey}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/dashboard')}
|
||||||
|
className="w-full gradient-brand text-white py-2.5 rounded-xl text-sm font-semibold hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
Go to Dashboard →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<Link to="/" className="inline-flex items-center gap-2 mb-6">
|
||||||
|
<div className="w-8 h-8 rounded-lg gradient-brand flex items-center justify-center">
|
||||||
|
<span className="text-white font-bold text-sm">O</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-gray-900">OdooMCP Cloud</span>
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Start your free trial</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Plan: <span className="capitalize font-medium text-brand-600">{planFromUrl}</span> · 7 days free
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-8">
|
||||||
|
<form onSubmit={handleSignup} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1.5">Email</label>
|
||||||
|
<input
|
||||||
|
type="email" required
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||||
|
placeholder="you@company.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1.5">Password</label>
|
||||||
|
<input
|
||||||
|
type="password" required minLength={8}
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||||
|
placeholder="Min 8 characters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1.5">Confirm Password</label>
|
||||||
|
<input
|
||||||
|
type="password" required
|
||||||
|
value={form.confirmPassword}
|
||||||
|
onChange={(e) => setForm({ ...form, confirmPassword: e.target.value })}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-100 text-red-700 text-xs rounded-lg px-3 py-2.5">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit" disabled={loading}
|
||||||
|
className="w-full gradient-brand text-white py-2.5 rounded-xl text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{loading ? 'Creating account…' : 'Create Account'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-gray-400">
|
||||||
|
No credit card required · Cancel anytime
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-gray-500 mt-6">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link to="/login" className="text-brand-600 hover:underline font-medium">Log in</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/pages/admin/AdminAnalytics.tsx
Normal file
86
src/pages/admin/AdminAnalytics.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { adminApi, adminSession, ToolStat } from '@/lib/api';
|
||||||
|
|
||||||
|
const RANGES = [
|
||||||
|
{ label: '7 days', days: 7 },
|
||||||
|
{ label: '30 days', days: 30 },
|
||||||
|
{ label: '90 days', days: 90 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function isoDaysAgo(days: number): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setUTCDate(d.getUTCDate() - days);
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminAnalytics() {
|
||||||
|
const [rangeDays, setRangeDays] = useState(30);
|
||||||
|
const [byTool, setByTool] = useState<ToolStat[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
const token = adminSession.getToken();
|
||||||
|
if (!token) return;
|
||||||
|
setLoading(true);
|
||||||
|
adminApi
|
||||||
|
.analyticsByTool(token, isoDaysAgo(rangeDays))
|
||||||
|
.then((r) => setByTool(r.tools))
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [rangeDays]);
|
||||||
|
|
||||||
|
useEffect(load, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">System Analytics</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">Every tool called, aggregated across all users.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||||
|
{RANGES.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.days}
|
||||||
|
onClick={() => setRangeDays(r.days)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
rangeDays === r.days ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
|
||||||
|
{byTool.length === 0 && !loading ? (
|
||||||
|
<div className="p-12 text-center text-sm text-gray-400">No tool calls in this range yet.</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-100">
|
||||||
|
<tr className="text-left text-xs text-gray-500">
|
||||||
|
<th className="px-6 py-3 font-medium">Tool</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Total Calls</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Errors</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Avg Duration</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Last Used</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-50">
|
||||||
|
{byTool.map((t) => (
|
||||||
|
<tr key={t.tool_name}>
|
||||||
|
<td className="px-6 py-3 font-mono text-xs text-gray-900">{t.tool_name}</td>
|
||||||
|
<td className="px-6 py-3">{t.calls}</td>
|
||||||
|
<td className="px-6 py-3 text-red-600">{t.error_count}</td>
|
||||||
|
<td className="px-6 py-3 text-gray-600">{t.avg_duration_ms.toFixed(1)} ms</td>
|
||||||
|
<td className="px-6 py-3 text-xs text-gray-400">{t.last_used_at}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
src/pages/admin/AdminLayout.tsx
Normal file
101
src/pages/admin/AdminLayout.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useLocation, Link, Outlet } from 'react-router-dom';
|
||||||
|
import { adminApi, adminSession } from '@/lib/api';
|
||||||
|
import {
|
||||||
|
ShieldCheck, LayoutDashboard, Users, BarChart2, LogOut, Menu,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
const NAV = [
|
||||||
|
{ label: 'Overview', href: '/admin', icon: LayoutDashboard },
|
||||||
|
{ label: 'Users', href: '/admin/users', icon: Users },
|
||||||
|
{ label: 'System Analytics', href: '/admin/analytics', icon: BarChart2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminLayout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const pathname = location.pathname;
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = adminSession.getToken();
|
||||||
|
const em = adminSession.getEmail();
|
||||||
|
if (!token) { navigate('/admin/login', { replace: true }); return; }
|
||||||
|
if (em) setEmail(em);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
const token = adminSession.getToken();
|
||||||
|
if (token) {
|
||||||
|
try { await adminApi.logout(token); } catch { /* token may already be expired server-side */ }
|
||||||
|
}
|
||||||
|
adminSession.clear();
|
||||||
|
navigate('/admin/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const Sidebar = (
|
||||||
|
<aside className="flex flex-col h-full bg-gray-950 text-gray-300 w-64 shrink-0">
|
||||||
|
<div className="px-5 py-4 border-b border-gray-800 flex items-center gap-2">
|
||||||
|
<div className="w-7 h-7 rounded bg-gray-800 flex items-center justify-center">
|
||||||
|
<ShieldCheck size={14} className="text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<span className="text-white font-semibold text-sm">Admin Panel</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-0.5">
|
||||||
|
{NAV.map(({ label, href, icon: Icon }) => {
|
||||||
|
const active = pathname === href || (href !== '/admin' && pathname.startsWith(href));
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
to={href}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors',
|
||||||
|
active ? 'bg-brand-600 text-white' : 'hover:bg-gray-800 hover:text-white',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={16} />
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="px-3 pb-4 border-t border-gray-800 pt-3 space-y-0.5">
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm hover:bg-gray-800 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut size={16} /> Logout
|
||||||
|
</button>
|
||||||
|
<div className="px-3 pt-2">
|
||||||
|
<p className="text-xs text-gray-500 truncate">{email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden bg-gray-50">
|
||||||
|
<div className="hidden lg:flex flex-col h-full">{Sidebar}</div>
|
||||||
|
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div className="lg:hidden fixed inset-0 z-50 flex">
|
||||||
|
<div className="flex flex-col h-full">{Sidebar}</div>
|
||||||
|
<div className="flex-1 bg-black/40" onClick={() => setSidebarOpen(false)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<div className="lg:hidden flex items-center gap-3 px-4 py-3 bg-white border-b border-gray-100">
|
||||||
|
<button onClick={() => setSidebarOpen(true)}><Menu size={20} /></button>
|
||||||
|
<span className="font-semibold text-sm text-gray-900">Admin Panel</span>
|
||||||
|
</div>
|
||||||
|
<main className="flex-1 overflow-y-auto p-6"><Outlet /></main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/pages/admin/AdminLogin.tsx
Normal file
81
src/pages/admin/AdminLogin.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { adminApi, adminSession } from '@/lib/api';
|
||||||
|
import { ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function AdminLogin() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [form, setForm] = useState({ email: '', password: '' });
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await adminApi.login(form.email, form.password);
|
||||||
|
adminSession.save(result.admin_token, form.email);
|
||||||
|
navigate('/admin');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Login failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-950 px-4">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-gray-800 mb-4">
|
||||||
|
<ShieldCheck size={22} className="text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Admin Panel</h1>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">Operator access only</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-2xl border border-gray-800 p-8">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-400 mb-1.5">Admin Email</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||||
|
placeholder="admin@yourcompany.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-400 mb-1.5">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-950 border border-red-900 text-red-400 text-xs rounded-lg px-3 py-2.5">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full gradient-brand text-white py-2.5 rounded-xl text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{loading ? 'Signing in…' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
src/pages/admin/AdminOverview.tsx
Normal file
146
src/pages/admin/AdminOverview.tsx
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { adminApi, adminSession, AdminSystemSummary, ToolStat, TimeseriesBucket } from '@/lib/api';
|
||||||
|
import {
|
||||||
|
LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import { Users, Activity, XCircle, Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
const RANGES = [
|
||||||
|
{ label: '7 days', days: 7 },
|
||||||
|
{ label: '30 days', days: 30 },
|
||||||
|
{ label: '90 days', days: 90 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function isoDaysAgo(days: number): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setUTCDate(d.getUTCDate() - days);
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminOverview() {
|
||||||
|
const [rangeDays, setRangeDays] = useState(30);
|
||||||
|
const [summary, setSummary] = useState<AdminSystemSummary | null>(null);
|
||||||
|
const [byTool, setByTool] = useState<ToolStat[]>([]);
|
||||||
|
const [timeseries, setTimeseries] = useState<TimeseriesBucket[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
const token = adminSession.getToken();
|
||||||
|
if (!token) return;
|
||||||
|
setLoading(true);
|
||||||
|
const since = isoDaysAgo(rangeDays);
|
||||||
|
Promise.all([
|
||||||
|
adminApi.analyticsSummary(token, since),
|
||||||
|
adminApi.analyticsByTool(token, since),
|
||||||
|
adminApi.analyticsTimeseries(token, since, undefined, rangeDays > 30 ? 'week' : 'day'),
|
||||||
|
])
|
||||||
|
.then(([s, t, ts]) => {
|
||||||
|
setSummary(s);
|
||||||
|
setByTool(t.tools);
|
||||||
|
setTimeseries(ts.buckets);
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [rangeDays]);
|
||||||
|
|
||||||
|
useEffect(load, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">System Overview</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">Activity across every registered user.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||||
|
{RANGES.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.days}
|
||||||
|
onClick={() => setRangeDays(r.days)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
rangeDays === r.days ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<StatCard icon={Users} label="Total Users" value={summary?.total_users ?? '—'} color="blue" />
|
||||||
|
<StatCard icon={Users} label="Active / Disabled" value={summary ? `${summary.active_users} / ${summary.disabled_users}` : '—'} color="purple" />
|
||||||
|
<StatCard icon={Activity} label="Total Calls" value={summary?.total_calls ?? '—'} color="green" />
|
||||||
|
<StatCard icon={XCircle} label="Errors" value={summary?.error_count ?? '—'} color="red" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<StatCard icon={Clock} label="Avg Duration" value={summary ? `${summary.avg_duration_ms.toFixed(1)} ms` : '—'} color="purple" />
|
||||||
|
<StatCard icon={Activity} label="Active Callers" value={summary?.active_callers ?? '—'} color="blue" />
|
||||||
|
<StatCard icon={Activity} label="Success Rate" value={summary && summary.total_calls > 0 ? `${((summary.success_count / summary.total_calls) * 100).toFixed(1)}%` : '—'} color="green" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 mb-4">Calls Over Time (All Users)</h2>
|
||||||
|
{timeseries.length === 0 && !loading ? (
|
||||||
|
<EmptyChart />
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<LineChart data={timeseries}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||||
|
<XAxis dataKey="period" tick={{ fontSize: 11 }} stroke="#9ca3af" />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} stroke="#9ca3af" allowDecimals={false} />
|
||||||
|
<Tooltip />
|
||||||
|
<Line type="monotone" dataKey="calls" stroke="#7c3aed" strokeWidth={2} dot={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 mb-4">Most Used Tools (System-Wide)</h2>
|
||||||
|
{byTool.length === 0 && !loading ? (
|
||||||
|
<EmptyChart />
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<BarChart data={byTool.slice(0, 10)}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||||
|
<XAxis dataKey="tool_name" tick={{ fontSize: 10 }} stroke="#9ca3af" interval={0} angle={-30} textAnchor="end" height={70} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} stroke="#9ca3af" allowDecimals={false} />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="calls" fill="#7c3aed" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ icon: Icon, label, value, color }: {
|
||||||
|
icon: typeof Activity; label: string; value: string | number; color: 'blue' | 'green' | 'red' | 'purple';
|
||||||
|
}) {
|
||||||
|
const colors = {
|
||||||
|
blue: 'bg-blue-50 text-blue-600',
|
||||||
|
green: 'bg-green-50 text-green-600',
|
||||||
|
red: 'bg-red-50 text-red-600',
|
||||||
|
purple: 'bg-purple-50 text-purple-600',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
|
||||||
|
<div className={`w-9 h-9 rounded-lg flex items-center justify-center mb-3 ${colors[color]}`}>
|
||||||
|
<Icon size={16} />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{value}</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">{label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyChart() {
|
||||||
|
return (
|
||||||
|
<div className="h-[240px] flex items-center justify-center text-sm text-gray-400">
|
||||||
|
No data in this range yet.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
src/pages/admin/AdminUserDetail.tsx
Normal file
187
src/pages/admin/AdminUserDetail.tsx
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { adminApi, adminSession, AdminUserDetail as AdminUserDetailType } from '@/lib/api';
|
||||||
|
import { ArrowLeft, Trash2, Activity, CheckCircle2, XCircle, Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function AdminUserDetailPage() {
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const userId = Number(params.id);
|
||||||
|
|
||||||
|
const [user, setUser] = useState<AdminUserDetailType | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
const token = adminSession.getToken();
|
||||||
|
if (!token) return;
|
||||||
|
setLoading(true);
|
||||||
|
adminApi
|
||||||
|
.getUser(token, userId)
|
||||||
|
.then(setUser)
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
useEffect(load, [load]);
|
||||||
|
|
||||||
|
async function toggleActive() {
|
||||||
|
if (!user) return;
|
||||||
|
const token = adminSession.getToken();
|
||||||
|
if (!token) return;
|
||||||
|
await adminApi.setUserStatus(token, user.id, !user.is_active);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!user) return;
|
||||||
|
if (!confirm(`Permanently delete "${user.email}"? This cannot be undone.`)) return;
|
||||||
|
const token = adminSession.getToken();
|
||||||
|
if (!token) return;
|
||||||
|
await adminApi.deleteUser(token, user.id);
|
||||||
|
navigate('/admin/users');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeKey(keyId: number, name: string) {
|
||||||
|
if (!user) return;
|
||||||
|
if (!confirm(`Force-revoke "${name}"? Any client using this key will immediately lose access.`)) return;
|
||||||
|
const token = adminSession.getToken();
|
||||||
|
if (!token) return;
|
||||||
|
await adminApi.revokeUserKey(token, user.id, keyId);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading && !user) {
|
||||||
|
return <div className="max-w-4xl text-sm text-gray-400">Loading…</div>;
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
return <div className="max-w-4xl text-sm text-gray-400">User not found.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl space-y-6">
|
||||||
|
<Link to="/admin/users" className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700">
|
||||||
|
<ArrowLeft size={14} /> Back to users
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">{user.email}</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
|
Joined {user.created_at} ·{' '}
|
||||||
|
<span className={user.is_active ? 'text-green-700' : 'text-gray-500'}>
|
||||||
|
{user.is_active ? 'Active' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={toggleActive}
|
||||||
|
className="border border-gray-200 text-gray-700 px-4 py-2 rounded-xl text-sm font-medium hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
{user.is_active ? 'Disable Account' : 'Enable Account'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="flex items-center gap-2 bg-red-50 text-red-600 px-4 py-2 rounded-xl text-sm font-medium hover:bg-red-100"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} /> Delete Account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usage summary */}
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<StatCard icon={Activity} label="Total Calls" value={user.usage_summary.total_calls} color="blue" />
|
||||||
|
<StatCard icon={CheckCircle2} label="Successful" value={user.usage_summary.success_count} color="green" />
|
||||||
|
<StatCard icon={XCircle} label="Errors" value={user.usage_summary.error_count} color="red" />
|
||||||
|
<StatCard icon={Clock} label="Avg Duration" value={`${user.usage_summary.avg_duration_ms.toFixed(1)} ms`} color="purple" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Odoo instances */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 mb-4">Odoo Instances</h2>
|
||||||
|
{user.odoo_instances.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">No Odoo instances connected.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{user.odoo_instances.map((inst) => (
|
||||||
|
<div key={inst.instance_name} className="flex items-center justify-between text-sm border-b border-gray-50 pb-2">
|
||||||
|
<span className="font-medium text-gray-900">{inst.instance_name}</span>
|
||||||
|
<span className="text-xs text-gray-500">{inst.url} · {inst.database} · {inst.type}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API keys */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900">API Keys</h2>
|
||||||
|
</div>
|
||||||
|
{user.api_keys.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-sm text-gray-400">No API keys.</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-100">
|
||||||
|
<tr className="text-left text-xs text-gray-500">
|
||||||
|
<th className="px-6 py-3 font-medium">Name</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Key</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Last Used</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Status</th>
|
||||||
|
<th className="px-6 py-3 font-medium"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-50">
|
||||||
|
{user.api_keys.map((k) => (
|
||||||
|
<tr key={k.id}>
|
||||||
|
<td className="px-6 py-3 font-medium text-gray-900">{k.name}</td>
|
||||||
|
<td className="px-6 py-3 font-mono text-xs text-gray-500">{k.key_prefix}...</td>
|
||||||
|
<td className="px-6 py-3 text-xs text-gray-500">{k.last_used_at ?? 'Never'}</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
{k.is_active ? (
|
||||||
|
<span className="text-xs text-green-700 bg-green-50 px-2 py-0.5 rounded-full">Active</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full">Revoked</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-right">
|
||||||
|
{k.is_active && (
|
||||||
|
<button
|
||||||
|
onClick={() => revokeKey(k.id, k.name)}
|
||||||
|
className="text-gray-400 hover:text-red-500 p-1 transition-colors"
|
||||||
|
title="Force revoke"
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ icon: Icon, label, value, color }: {
|
||||||
|
icon: typeof Activity; label: string; value: string | number; color: 'blue' | 'green' | 'red' | 'purple';
|
||||||
|
}) {
|
||||||
|
const colors = {
|
||||||
|
blue: 'bg-blue-50 text-blue-600',
|
||||||
|
green: 'bg-green-50 text-green-600',
|
||||||
|
red: 'bg-red-50 text-red-600',
|
||||||
|
purple: 'bg-purple-50 text-purple-600',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
|
||||||
|
<div className={`w-9 h-9 rounded-lg flex items-center justify-center mb-3 ${colors[color]}`}>
|
||||||
|
<Icon size={16} />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{value}</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">{label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
src/pages/admin/AdminUsers.tsx
Normal file
146
src/pages/admin/AdminUsers.tsx
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { adminApi, adminSession, AdminUserSummary } from '@/lib/api';
|
||||||
|
import { ChevronLeft, ChevronRight, Trash2, Users as UsersIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
export default function AdminUsers() {
|
||||||
|
const [items, setItems] = useState<AdminUserSummary[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
const token = adminSession.getToken();
|
||||||
|
if (!token) return;
|
||||||
|
setLoading(true);
|
||||||
|
adminApi
|
||||||
|
.listUsers(token, page, PAGE_SIZE)
|
||||||
|
.then((r) => {
|
||||||
|
setItems(r.items);
|
||||||
|
setTotal(r.total);
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
useEffect(load, [load]);
|
||||||
|
|
||||||
|
async function toggleActive(user: AdminUserSummary) {
|
||||||
|
const token = adminSession.getToken();
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
await adminApi.setUserStatus(token, user.id, !user.is_active);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : 'Failed to update status');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(user: AdminUserSummary) {
|
||||||
|
if (!confirm(`Permanently delete "${user.email}"? This removes their Odoo connections, API keys, and usage history. This cannot be undone.`)) return;
|
||||||
|
const token = adminSession.getToken();
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
await adminApi.deleteUser(token, user.id);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : 'Failed to delete user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">Users</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">Every registered account on this server.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
|
||||||
|
{items.length === 0 && !loading ? (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<UsersIcon size={32} className="mx-auto text-gray-300 mb-3" />
|
||||||
|
<p className="text-sm font-medium text-gray-600">No users yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-100">
|
||||||
|
<tr className="text-left text-xs text-gray-500">
|
||||||
|
<th className="px-6 py-3 font-medium">Email</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Joined</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Instances</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Active Keys</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Total Calls</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Last Active</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Status</th>
|
||||||
|
<th className="px-6 py-3 font-medium"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-50">
|
||||||
|
{items.map((u) => (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<Link to={`/admin/users/${u.id}`} className="font-medium text-gray-900 hover:text-brand-600 hover:underline">
|
||||||
|
{u.email}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-xs text-gray-500">{u.created_at}</td>
|
||||||
|
<td className="px-6 py-3">{u.credential_count}</td>
|
||||||
|
<td className="px-6 py-3">{u.active_key_count}</td>
|
||||||
|
<td className="px-6 py-3">{u.total_calls}</td>
|
||||||
|
<td className="px-6 py-3 text-xs text-gray-400">{u.last_active_at ?? 'Never'}</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleActive(u)}
|
||||||
|
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
|
||||||
|
u.is_active
|
||||||
|
? 'text-green-700 bg-green-50 hover:bg-green-100'
|
||||||
|
: 'text-gray-500 bg-gray-100 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{u.is_active ? 'Active' : 'Disabled'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(u)}
|
||||||
|
className="text-gray-400 hover:text-red-500 p-1 transition-colors"
|
||||||
|
title="Delete user"
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{total > PAGE_SIZE && (
|
||||||
|
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||||
|
<span>Page {page} of {totalPages} ({total} total)</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="p-2 border border-gray-200 rounded-lg disabled:opacity-40 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="p-2 border border-gray-200 rounded-lg disabled:opacity-40 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
src/pages/dashboard/Billing.tsx
Normal file
97
src/pages/dashboard/Billing.tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { Check } from 'lucide-react';
|
||||||
|
|
||||||
|
const PLANS = [
|
||||||
|
{ name: 'Starter', price: 19, current: false, features: ['1 connection', '1 MCP endpoint', 'Read-only', '1,000 req/mo'] },
|
||||||
|
{ name: 'Pro', price: 49, current: true, features: ['3 connections', 'Read/Write', 'Access logs', '10,000 req/mo'] },
|
||||||
|
{ name: 'Agency', price: 149, current: false, features: ['25 workspaces', 'White-label', 'Team members', '50,000 req/mo'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Billing() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">Billing</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">Manage your subscription and payment details.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current plan */}
|
||||||
|
<div className="bg-white rounded-xl border border-brand-200 shadow-sm p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Current Plan</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900">Pro Plan</p>
|
||||||
|
<p className="text-sm text-gray-500">$49/month · Renews July 29, 2026</p>
|
||||||
|
</div>
|
||||||
|
<span className="bg-green-100 text-green-700 text-xs font-medium px-3 py-1.5 rounded-full">Active</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex gap-3">
|
||||||
|
<button className="text-sm border border-gray-200 text-gray-600 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors">
|
||||||
|
Manage Payment Method
|
||||||
|
</button>
|
||||||
|
<button className="text-sm text-red-600 border border-red-200 px-4 py-2 rounded-lg hover:bg-red-50 transition-colors">
|
||||||
|
Cancel Subscription
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plan comparison */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 mb-4">Change Plan</h2>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{PLANS.map((plan) => (
|
||||||
|
<div key={plan.name}
|
||||||
|
className={`rounded-xl border p-5 ${plan.current ? 'border-brand-400 bg-brand-50' : 'border-gray-200 bg-white'}`}>
|
||||||
|
<p className="font-semibold text-sm text-gray-900">{plan.name}</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 mt-1 mb-3">${plan.price}<span className="text-sm font-normal text-gray-400">/mo</span></p>
|
||||||
|
<ul className="space-y-1.5 mb-4">
|
||||||
|
{plan.features.map((f) => (
|
||||||
|
<li key={f} className="flex items-center gap-1.5 text-xs text-gray-600">
|
||||||
|
<Check size={11} className="text-brand-500" /> {f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{plan.current ? (
|
||||||
|
<div className="text-center text-xs text-brand-600 font-medium py-2">Current Plan</div>
|
||||||
|
) : (
|
||||||
|
<button className="w-full gradient-brand text-white py-2 rounded-lg text-xs font-medium hover:opacity-90 transition-opacity">
|
||||||
|
Switch to {plan.name}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invoice history */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 mb-4">Invoice History</h2>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-gray-400 border-b border-gray-100">
|
||||||
|
<th className="text-left pb-2 font-medium">Date</th>
|
||||||
|
<th className="text-left pb-2 font-medium">Description</th>
|
||||||
|
<th className="text-left pb-2 font-medium">Amount</th>
|
||||||
|
<th className="text-left pb-2 font-medium">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-50">
|
||||||
|
{[
|
||||||
|
{ date: 'Jun 29, 2026', desc: 'Pro Plan — Monthly', amount: '$49.00', status: 'Paid' },
|
||||||
|
{ date: 'May 29, 2026', desc: 'Pro Plan — Monthly', amount: '$49.00', status: 'Paid' },
|
||||||
|
{ date: 'Apr 29, 2026', desc: 'Pro Plan — Monthly', amount: '$49.00', status: 'Paid' },
|
||||||
|
].map((row, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="py-2.5 text-gray-500">{row.date}</td>
|
||||||
|
<td className="py-2.5 text-gray-700">{row.desc}</td>
|
||||||
|
<td className="py-2.5 text-gray-900 font-medium">{row.amount}</td>
|
||||||
|
<td className="py-2.5">
|
||||||
|
<span className="bg-green-50 text-green-600 px-2 py-0.5 rounded-full">{row.status}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
src/pages/dashboard/Connections.tsx
Normal file
173
src/pages/dashboard/Connections.tsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { api, session } from '@/lib/api';
|
||||||
|
import { Plus, Trash2, TestTube2, Globe, CheckCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Instance {
|
||||||
|
instance_name: string;
|
||||||
|
url: string;
|
||||||
|
database: string;
|
||||||
|
username: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Connections() {
|
||||||
|
const [instances, setInstances] = useState<Instance[]>([]);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
instance_name: 'default',
|
||||||
|
url: '',
|
||||||
|
database: '',
|
||||||
|
username: '',
|
||||||
|
credential: '',
|
||||||
|
is_api_key: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
const key = session.getKey();
|
||||||
|
if (!key) return;
|
||||||
|
api.listCredentials(key).then((r) => setInstances(r.instances)).catch(console.error);
|
||||||
|
}
|
||||||
|
useEffect(load, []);
|
||||||
|
|
||||||
|
async function handleAdd(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const key = session.getKey();
|
||||||
|
if (!key) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.addCredential(key, form);
|
||||||
|
setShowForm(false);
|
||||||
|
setForm({ instance_name: 'default', url: '', database: '', username: '', credential: '', is_api_key: true });
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : 'Failed to add connection');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(name: string) {
|
||||||
|
if (!confirm(`Remove "${name}"?`)) return;
|
||||||
|
const key = session.getKey();
|
||||||
|
if (!key) return;
|
||||||
|
await api.deleteCredential(key, name);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">Odoo Connections</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">Manage your Odoo ERP instances.</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowForm(true)}
|
||||||
|
className="flex items-center gap-2 gradient-brand text-white px-4 py-2 rounded-xl text-sm font-medium hover:opacity-90 transition-opacity">
|
||||||
|
<Plus size={16} /> Add Connection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Existing connections */}
|
||||||
|
{instances.length === 0 && !showForm && (
|
||||||
|
<div className="bg-white rounded-xl border border-dashed border-gray-300 p-12 text-center">
|
||||||
|
<Globe size={32} className="mx-auto text-gray-300 mb-3" />
|
||||||
|
<p className="text-sm font-medium text-gray-600 mb-1">No connections yet</p>
|
||||||
|
<p className="text-xs text-gray-400 mb-4">Add your Odoo instance to get started.</p>
|
||||||
|
<button onClick={() => setShowForm(true)}
|
||||||
|
className="gradient-brand text-white px-4 py-2 rounded-xl text-sm font-medium">
|
||||||
|
Add Connection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{instances.map((inst) => (
|
||||||
|
<div key={inst.instance_name} className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-green-100 rounded-xl flex items-center justify-center">
|
||||||
|
<CheckCircle size={18} className="text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-sm text-gray-900">{inst.instance_name}</p>
|
||||||
|
<p className="text-xs text-gray-500">{inst.url}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => handleDelete(inst.instance_name)}
|
||||||
|
className="text-gray-400 hover:text-red-500 p-1 transition-colors">
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-4 text-xs">
|
||||||
|
<div><p className="text-gray-400 mb-0.5">Database</p><p className="text-gray-700 font-mono">{inst.database}</p></div>
|
||||||
|
<div><p className="text-gray-400 mb-0.5">Username</p><p className="text-gray-700">{inst.username}</p></div>
|
||||||
|
<div><p className="text-gray-400 mb-0.5">Auth Type</p><p className="text-gray-700 capitalize">{inst.type}</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add connection form panel */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 mb-5">Add Odoo Connection</h2>
|
||||||
|
<form onSubmit={handleAdd} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Field label="Connection Name" value={form.instance_name}
|
||||||
|
onChange={(v) => setForm({ ...form, instance_name: v })} placeholder="e.g. production" />
|
||||||
|
<Field label="Odoo URL" value={form.url}
|
||||||
|
onChange={(v) => setForm({ ...form, url: v })} placeholder="https://mycompany.odoo.com" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Field label="Database Name" value={form.database}
|
||||||
|
onChange={(v) => setForm({ ...form, database: v })} placeholder="mydb" />
|
||||||
|
<Field label="Odoo Username" value={form.username}
|
||||||
|
onChange={(v) => setForm({ ...form, username: v })} placeholder="admin@company.com" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1.5">Authentication Method</label>
|
||||||
|
<select value={form.is_api_key ? 'apikey' : 'password'}
|
||||||
|
onChange={(e) => setForm({ ...form, is_api_key: e.target.value === 'apikey' })}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500">
|
||||||
|
<option value="apikey">API Key (Recommended)</option>
|
||||||
|
<option value="password">Password</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Field
|
||||||
|
label={form.is_api_key ? 'Odoo API Key' : 'Password'}
|
||||||
|
value={form.credential}
|
||||||
|
onChange={(v) => setForm({ ...form, credential: v })}
|
||||||
|
placeholder={form.is_api_key ? 'Paste your Odoo API key' : 'Odoo password'}
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button type="submit" disabled={loading}
|
||||||
|
className="gradient-brand text-white px-5 py-2.5 rounded-xl text-sm font-medium hover:opacity-90 disabled:opacity-60 flex items-center gap-2">
|
||||||
|
{loading ? 'Saving…' : <><TestTube2 size={14} /> Test & Save</>}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setShowForm(false)}
|
||||||
|
className="border border-gray-200 text-gray-600 px-5 py-2.5 rounded-xl text-sm font-medium hover:bg-gray-50">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, value, onChange, placeholder, type = 'text' }: {
|
||||||
|
label: string; value: string; onChange: (v: string) => void; placeholder?: string; type?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1.5">{label}</label>
|
||||||
|
<input
|
||||||
|
type={type} value={value} onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder} required
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
src/pages/dashboard/DashboardHome.tsx
Normal file
192
src/pages/dashboard/DashboardHome.tsx
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api, session, type MeResult } from '@/lib/api';
|
||||||
|
import { Cpu, Globe, BarChart2, Copy, RotateCcw, ExternalLink, Activity } from 'lucide-react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
const MCP_URL = `${import.meta.env.VITE_API_URL ?? 'https://odoo-mcp.thedomainnest.com'}/mcp/sse`;
|
||||||
|
|
||||||
|
const RECENT_ACTIVITY = [
|
||||||
|
{ time: '2 mins ago', event: 'Connection sync', status: 'Success', detail: 'Odoo connection is healthy' },
|
||||||
|
{ time: '5 mins ago', event: 'MCP request', status: 'Success', detail: 'search_invoices' },
|
||||||
|
{ time: '12 mins ago', event: 'Token rotated', status: 'Success', detail: 'New token generated' },
|
||||||
|
{ time: '1 hour ago', event: 'Connection sync', status: 'Success', detail: 'Odoo connection is healthy' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DashboardHome() {
|
||||||
|
const [me, setMe] = useState<MeResult | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const key = session.getKey();
|
||||||
|
if (!key) return;
|
||||||
|
api.me(key).then(setMe).catch(console.error).finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const instance = me?.odoo_instances?.[0];
|
||||||
|
const requestsUsed = 2431;
|
||||||
|
const requestsLimit = 10000;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">Dashboard</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">Welcome back{me ? `, ${me.email}` : ''}.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stat cards */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
label="Subscription"
|
||||||
|
value="Pro Plan"
|
||||||
|
sub="Renews Jul 29, 2026"
|
||||||
|
icon={<span className="text-xl">💎</span>}
|
||||||
|
color="bg-purple-50"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="MCP Status"
|
||||||
|
value={<span className="flex items-center gap-1.5"><span className="w-2 h-2 bg-green-400 rounded-full" />Active</span>}
|
||||||
|
sub="All systems operational"
|
||||||
|
icon={<Cpu size={18} className="text-blue-500" />}
|
||||||
|
color="bg-blue-50"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Odoo Connection"
|
||||||
|
value={instance ? 'Connected' : 'Not connected'}
|
||||||
|
sub={instance ? 'Healthy' : 'Add a connection'}
|
||||||
|
icon={<Globe size={18} className="text-green-500" />}
|
||||||
|
color="bg-green-50"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Requests This Month"
|
||||||
|
value={`${requestsUsed.toLocaleString()} / ${requestsLimit.toLocaleString()}`}
|
||||||
|
sub={
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-brand-500 rounded-full"
|
||||||
|
style={{ width: `${(requestsUsed / requestsLimit) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-400">{Math.round((requestsUsed / requestsLimit) * 100)}% used</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
icon={<BarChart2 size={18} className="text-orange-500" />}
|
||||||
|
color="bg-orange-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-3 gap-6">
|
||||||
|
{/* Odoo Connection Health */}
|
||||||
|
<div className="lg:col-span-1 bg-white rounded-xl border border-gray-100 shadow-sm p-5">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 mb-4">Odoo Connection Health</h2>
|
||||||
|
{instance ? (
|
||||||
|
<div className="space-y-3 text-xs">
|
||||||
|
<Row label="Odoo URL" value={instance.url} />
|
||||||
|
<Row label="Database" value={instance.database} />
|
||||||
|
<Row label="Last Sync" value="2 minutes ago" />
|
||||||
|
<Row label="Status" value={<span className="text-green-600 font-medium">Healthy</span>} />
|
||||||
|
<button className="w-full text-center text-brand-600 text-xs font-medium border border-brand-200 rounded-lg py-2 hover:bg-brand-50 transition-colors mt-2">
|
||||||
|
View Connection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-500">No Odoo instance connected yet.{' '}
|
||||||
|
<a href="/dashboard/connections" className="text-brand-600 hover:underline">Add one →</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 mb-4">Quick Actions</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[
|
||||||
|
{ label: 'Copy MCP URL', icon: Copy, action: () => navigator.clipboard.writeText(MCP_URL) },
|
||||||
|
{ label: 'Rotate Token', icon: RotateCcw, action: () => {} },
|
||||||
|
{ label: 'Open Claude Setup', icon: ExternalLink, action: () => {} },
|
||||||
|
{ label: 'Open Codex Setup', icon: ExternalLink, action: () => {} },
|
||||||
|
{ label: 'View Logs', icon: Activity, action: () => {} },
|
||||||
|
].map(({ label, icon: Icon, action }) => (
|
||||||
|
<button key={label} onClick={action}
|
||||||
|
className="w-full flex items-center gap-3 text-sm text-gray-700 hover:text-brand-600 hover:bg-brand-50 px-3 py-2 rounded-lg transition-colors text-left">
|
||||||
|
<Icon size={14} className="text-gray-400" /> {label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usage Overview */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 mb-4">Usage Overview</h2>
|
||||||
|
<div className="flex items-end gap-1 h-24 mb-2">
|
||||||
|
{[40, 65, 45, 80, 55, 90, 70, 85, 60, 95, 75, 100, 60, 85].map((h, i) => (
|
||||||
|
<div key={i} className="flex-1 bg-brand-100 rounded-sm hover:bg-brand-400 transition-colors cursor-pointer"
|
||||||
|
style={{ height: `${h}%` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-gray-400">
|
||||||
|
<span>Jun 1</span><span>Jun 15</span><span>Jun 29</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 mt-3">{requestsUsed.toLocaleString()}</p>
|
||||||
|
<p className="text-xs text-gray-500">Requests this month</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Activity */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900">Recent Activity</h2>
|
||||||
|
<a href="/dashboard/logs" className="text-xs text-brand-600 hover:underline">View all logs →</a>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-gray-400 border-b border-gray-100">
|
||||||
|
<th className="text-left pb-2 font-medium">Time</th>
|
||||||
|
<th className="text-left pb-2 font-medium">Event</th>
|
||||||
|
<th className="text-left pb-2 font-medium">Status</th>
|
||||||
|
<th className="text-left pb-2 font-medium">Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-50">
|
||||||
|
{RECENT_ACTIVITY.map((row, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="py-2.5 text-gray-400">{row.time}</td>
|
||||||
|
<td className="py-2.5 text-gray-700">{row.event}</td>
|
||||||
|
<td className="py-2.5">
|
||||||
|
<span className="inline-flex items-center gap-1 text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
|
||||||
|
<span className="w-1.5 h-1.5 bg-green-400 rounded-full" />{row.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 text-gray-500">{row.detail}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, sub, icon, color }: {
|
||||||
|
label: string; value: React.ReactNode; sub: React.ReactNode; icon: React.ReactNode; color: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-4">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<p className="text-xs text-gray-500">{label}</p>
|
||||||
|
<div className={clsx('w-8 h-8 rounded-lg flex items-center justify-center', color)}>{icon}</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-gray-900">{value}</p>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">{sub}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between gap-2">
|
||||||
|
<span className="text-gray-500 shrink-0">{label}</span>
|
||||||
|
<span className="text-gray-900 text-right truncate">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
src/pages/dashboard/DashboardLayout.tsx
Normal file
122
src/pages/dashboard/DashboardLayout.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useLocation, Link, Outlet } from 'react-router-dom';
|
||||||
|
import { session } from '@/lib/api';
|
||||||
|
import {
|
||||||
|
LayoutDashboard, Globe, Cpu, BookOpen, Shield, BarChart2,
|
||||||
|
FileText, CreditCard, Key, Users, Settings, HelpCircle, LogOut, Menu, X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
const NAV = [
|
||||||
|
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||||
|
{ label: 'Odoo Connections', href: '/dashboard/connections', icon: Globe },
|
||||||
|
{ label: 'MCP Endpoints', href: '/dashboard/endpoints', icon: Cpu },
|
||||||
|
{ label: 'AI Setup Guides', href: '/dashboard/guides', icon: BookOpen },
|
||||||
|
{ label: 'Tools & Permissions', href: '/dashboard/tools', icon: Shield },
|
||||||
|
{ label: 'Usage', href: '/dashboard/usage', icon: BarChart2 },
|
||||||
|
{ label: 'Logs', href: '/dashboard/logs', icon: FileText },
|
||||||
|
{ label: 'Billing', href: '/dashboard/billing', icon: CreditCard },
|
||||||
|
{ label: 'API Tokens', href: '/dashboard/tokens', icon: Key },
|
||||||
|
{ label: 'Team', href: '/dashboard/team', icon: Users },
|
||||||
|
{ label: 'Settings', href: '/dashboard/settings', icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DashboardLayout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const pathname = location.pathname;
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const key = session.getKey();
|
||||||
|
const em = session.getEmail();
|
||||||
|
if (!key) { navigate('/login', { replace: true }); return; }
|
||||||
|
if (em) setEmail(em);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
session.clear();
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const Sidebar = (
|
||||||
|
<aside className="flex flex-col h-full bg-gray-900 text-gray-300 w-64 shrink-0">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="px-5 py-4 border-b border-gray-800">
|
||||||
|
<Link to="/" className="flex items-center gap-2">
|
||||||
|
<div className="w-7 h-7 rounded gradient-brand flex items-center justify-center">
|
||||||
|
<span className="text-white font-bold text-xs">O</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-white font-semibold text-sm">OdooMCP Cloud</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav */}
|
||||||
|
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-0.5">
|
||||||
|
{NAV.map(({ label, href, icon: Icon }) => {
|
||||||
|
const active = pathname === href || (href !== '/dashboard' && pathname.startsWith(href));
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
to={href}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-brand-600 text-white'
|
||||||
|
: 'hover:bg-gray-800 hover:text-white',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={16} />
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Bottom */}
|
||||||
|
<div className="px-3 pb-4 border-t border-gray-800 pt-3 space-y-0.5">
|
||||||
|
<Link to="/dashboard/support"
|
||||||
|
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm hover:bg-gray-800 hover:text-white transition-colors">
|
||||||
|
<HelpCircle size={16} /> Support
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm hover:bg-gray-800 hover:text-white transition-colors">
|
||||||
|
<LogOut size={16} /> Logout
|
||||||
|
</button>
|
||||||
|
<div className="px-3 pt-2">
|
||||||
|
<p className="text-xs text-gray-500 truncate">{email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden bg-gray-50">
|
||||||
|
{/* Desktop sidebar */}
|
||||||
|
<div className="hidden lg:flex flex-col h-full">
|
||||||
|
{Sidebar}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile sidebar overlay */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div className="lg:hidden fixed inset-0 z-50 flex">
|
||||||
|
<div className="flex flex-col h-full">{Sidebar}</div>
|
||||||
|
<div className="flex-1 bg-black/40" onClick={() => setSidebarOpen(false)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* Mobile topbar */}
|
||||||
|
<div className="lg:hidden flex items-center gap-3 px-4 py-3 bg-white border-b border-gray-100">
|
||||||
|
<button onClick={() => setSidebarOpen(true)}><Menu size={20} /></button>
|
||||||
|
<span className="font-semibold text-sm text-gray-900">OdooMCP Cloud</span>
|
||||||
|
</div>
|
||||||
|
<main className="flex-1 overflow-y-auto p-6"><Outlet /></main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
src/pages/dashboard/Endpoints.tsx
Normal file
154
src/pages/dashboard/Endpoints.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { session } from '@/lib/api';
|
||||||
|
import { Copy, Check, RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
|
const MCP_URL = `${import.meta.env.VITE_API_URL ?? 'https://odoo-mcp.thedomainnest.com'}/mcp/sse`;
|
||||||
|
|
||||||
|
const AI_CLIENTS = [
|
||||||
|
{ name: 'Claude', icon: '🤖', setup: 'claude' },
|
||||||
|
{ name: 'Codex', icon: '⚡', setup: 'codex' },
|
||||||
|
{ name: 'Cursor', icon: '🖱️', setup: 'cursor' },
|
||||||
|
{ name: 'Windsurf', icon: '🏄', setup: 'windsurf' },
|
||||||
|
{ name: 'Custom MCP', icon: '🔧', setup: 'custom' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Endpoints() {
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
const [copiedUrl, setCopiedUrl] = useState(false);
|
||||||
|
const [copiedToken, setCopiedToken] = useState(false);
|
||||||
|
const [activeSetup, setActiveSetup] = useState('claude');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const k = session.getKey();
|
||||||
|
if (k) setApiKey(k);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function copy(text: string, setter: (v: boolean) => void) {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
setter(true);
|
||||||
|
setTimeout(() => setter(false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const claudeJson = JSON.stringify({
|
||||||
|
mcpServers: {
|
||||||
|
'odoo-mcp': {
|
||||||
|
type: 'sse',
|
||||||
|
url: MCP_URL,
|
||||||
|
headers: { Authorization: `Bearer ${apiKey || 'your-api-key-here'}` },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, null, 2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">MCP Endpoints</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">Your secure MCP server connection details.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-3 gap-6">
|
||||||
|
{/* Left: URL + token */}
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 mb-4">Your MCP Server URL</h2>
|
||||||
|
<div className="flex items-center gap-2 bg-gray-50 rounded-xl border border-gray-200 px-4 py-3 mb-4">
|
||||||
|
<code className="flex-1 text-sm text-gray-800 break-all font-mono">{MCP_URL}</code>
|
||||||
|
<button onClick={() => copy(MCP_URL, setCopiedUrl)}
|
||||||
|
className="shrink-0 text-brand-600 hover:text-brand-700 p-1">
|
||||||
|
{copiedUrl ? <Check size={16} /> : <Copy size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 mb-2">Bearer Token</h2>
|
||||||
|
<div className="flex items-center gap-2 bg-gray-50 rounded-xl border border-gray-200 px-4 py-3">
|
||||||
|
<code className="flex-1 text-sm text-gray-500 break-all font-mono">
|
||||||
|
{apiKey ? `${apiKey.slice(0, 20)}${'•'.repeat(20)}` : 'Loading…'}
|
||||||
|
</code>
|
||||||
|
<button onClick={() => copy(apiKey, setCopiedToken)}
|
||||||
|
className="shrink-0 text-brand-600 hover:text-brand-700 p-1">
|
||||||
|
{copiedToken ? <Check size={16} /> : <Copy size={16} />}
|
||||||
|
</button>
|
||||||
|
<button className="shrink-0 text-gray-400 hover:text-gray-600 p-1" title="Rotate token">
|
||||||
|
<RotateCcw size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connect With */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 mb-4">Connect With</h2>
|
||||||
|
<div className="flex gap-3 flex-wrap mb-6">
|
||||||
|
{AI_CLIENTS.map((c) => (
|
||||||
|
<button key={c.setup}
|
||||||
|
onClick={() => setActiveSetup(c.setup)}
|
||||||
|
className={`flex flex-col items-center gap-1.5 px-4 py-3 rounded-xl border text-xs font-medium transition-colors ${
|
||||||
|
activeSetup === c.setup
|
||||||
|
? 'border-brand-400 bg-brand-50 text-brand-700'
|
||||||
|
: 'border-gray-200 hover:border-brand-200 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
<span className="text-xl">{c.icon}</span>
|
||||||
|
{c.name}
|
||||||
|
<span className="text-[10px] text-gray-400">Setup Guide</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeSetup === 'claude' && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-600 mb-3">
|
||||||
|
Add this to your Claude Code settings (<code className="bg-gray-100 px-1 py-0.5 rounded text-[11px]">~/.claude/settings.json</code>):
|
||||||
|
</p>
|
||||||
|
<div className="relative bg-gray-900 rounded-xl p-4">
|
||||||
|
<pre className="text-green-400 text-xs overflow-x-auto font-mono">{claudeJson}</pre>
|
||||||
|
<button onClick={() => copy(claudeJson, () => {})}
|
||||||
|
className="absolute top-3 right-3 text-gray-400 hover:text-white p-1">
|
||||||
|
<Copy size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-3">
|
||||||
|
Or run: <code className="bg-gray-100 px-1 py-0.5 rounded text-[11px]">claude mcp add --transport sse odoo-mcp {MCP_URL} -H "Authorization: Bearer YOUR_KEY"</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeSetup !== 'claude' && (
|
||||||
|
<div className="bg-gray-50 rounded-xl p-4 text-xs text-gray-500 text-center">
|
||||||
|
Setup guide for {AI_CLIENTS.find(c => c.setup === activeSetup)?.name} coming soon.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: status card */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 mb-4">Endpoint Status</h2>
|
||||||
|
<div className="space-y-3 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 bg-green-400 rounded-full" />
|
||||||
|
<span className="text-green-700 font-medium">Active</span>
|
||||||
|
</div>
|
||||||
|
<Row label="Transport" value="HTTPS Remote MCP" />
|
||||||
|
<Row label="Odoo Connection" value={<span className="text-green-600">Healthy</span>} />
|
||||||
|
<Row label="Tools Enabled" value="60+" />
|
||||||
|
<Row label="Created" value="Jun 20, 2026" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4 text-xs text-yellow-700">
|
||||||
|
<p className="font-medium mb-1">Security Notice</p>
|
||||||
|
<p>Do not commit your token to GitHub. Use environment variables in production.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between gap-2">
|
||||||
|
<span className="text-gray-500 shrink-0">{label}</span>
|
||||||
|
<span className="text-gray-900 text-right">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
src/pages/dashboard/Logs.tsx
Normal file
148
src/pages/dashboard/Logs.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { Fragment, useState, useEffect, useCallback } from 'react';
|
||||||
|
import { api, session, LogEntry } from '@/lib/api';
|
||||||
|
import { ChevronLeft, ChevronRight, CheckCircle2, XCircle, FileText } from 'lucide-react';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
export default function Logs() {
|
||||||
|
const [items, setItems] = useState<LogEntry[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [toolFilter, setToolFilter] = useState('');
|
||||||
|
const [successFilter, setSuccessFilter] = useState<'all' | 'success' | 'error'>('all');
|
||||||
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
const key = session.getKey();
|
||||||
|
if (!key) return;
|
||||||
|
setLoading(true);
|
||||||
|
api
|
||||||
|
.listLogs(key, page, PAGE_SIZE, {
|
||||||
|
tool_name: toolFilter || undefined,
|
||||||
|
success: successFilter === 'all' ? undefined : successFilter === 'success',
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
setItems(r.items);
|
||||||
|
setTotal(r.total);
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [page, toolFilter, successFilter]);
|
||||||
|
|
||||||
|
useEffect(load, [load]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">Logs</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">Every MCP tool call made against your account.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={toolFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPage(1);
|
||||||
|
setToolFilter(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="Filter by tool name…"
|
||||||
|
className="border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent w-64"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={successFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPage(1);
|
||||||
|
setSuccessFilter(e.target.value as 'all' | 'success' | 'error');
|
||||||
|
}}
|
||||||
|
className="border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||||
|
>
|
||||||
|
<option value="all">All statuses</option>
|
||||||
|
<option value="success">Success only</option>
|
||||||
|
<option value="error">Errors only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
|
||||||
|
{items.length === 0 && !loading ? (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<FileText size={32} className="mx-auto text-gray-300 mb-3" />
|
||||||
|
<p className="text-sm font-medium text-gray-600">No log entries found</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-100">
|
||||||
|
<tr className="text-left text-xs text-gray-500">
|
||||||
|
<th className="px-6 py-3 font-medium">Status</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Tool</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Instance</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Duration</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-50">
|
||||||
|
{items.map((log) => (
|
||||||
|
<Fragment key={log.id}>
|
||||||
|
<tr
|
||||||
|
className={log.error_message ? 'cursor-pointer hover:bg-gray-50' : ''}
|
||||||
|
onClick={() => log.error_message && setExpandedId(expandedId === log.id ? null : log.id)}
|
||||||
|
>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
{log.success ? (
|
||||||
|
<CheckCircle2 size={15} className="text-green-600" />
|
||||||
|
) : (
|
||||||
|
<XCircle size={15} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 font-mono text-xs text-gray-900">{log.tool_name}</td>
|
||||||
|
<td className="px-6 py-3 text-gray-600">{log.instance_name}</td>
|
||||||
|
<td className="px-6 py-3 text-gray-600">{log.duration_ms} ms</td>
|
||||||
|
<td className="px-6 py-3 text-xs text-gray-400">{log.started_at}</td>
|
||||||
|
</tr>
|
||||||
|
{expandedId === log.id && log.error_message && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-6 py-3 bg-red-50 text-xs text-red-700 font-mono">
|
||||||
|
{log.error_message}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{total > PAGE_SIZE && (
|
||||||
|
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||||
|
<span>
|
||||||
|
Page {page} of {totalPages} ({total} total)
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="p-2 border border-gray-200 rounded-lg disabled:opacity-40 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="p-2 border border-gray-200 rounded-lg disabled:opacity-40 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
src/pages/dashboard/Tokens.tsx
Normal file
197
src/pages/dashboard/Tokens.tsx
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { api, session, ApiKeyInfo } from '@/lib/api';
|
||||||
|
import { Plus, Trash2, Key, Copy, Check, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Tokens() {
|
||||||
|
const [keys, setKeys] = useState<ApiKeyInfo[]>([]);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [newKey, setNewKey] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
const key = session.getKey();
|
||||||
|
if (!key) return;
|
||||||
|
api.listApiKeys(key).then((r) => setKeys(r.keys)).catch(console.error);
|
||||||
|
}
|
||||||
|
useEffect(load, []);
|
||||||
|
|
||||||
|
async function handleCreate(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const key = session.getKey();
|
||||||
|
if (!key) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await api.createApiKey(key, name || 'Untitled key');
|
||||||
|
setNewKey(result.api_key);
|
||||||
|
setName('');
|
||||||
|
setShowForm(false);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : 'Failed to create key');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevoke(id: number, keyName: string) {
|
||||||
|
if (!confirm(`Revoke "${keyName}"? Any client using this key will immediately lose access.`)) return;
|
||||||
|
const key = session.getKey();
|
||||||
|
if (!key) return;
|
||||||
|
try {
|
||||||
|
await api.revokeApiKey(key, id);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : 'Failed to revoke key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyKey() {
|
||||||
|
if (!newKey) return;
|
||||||
|
navigator.clipboard.writeText(newKey);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">API Tokens</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
|
Create and revoke API keys used to connect MCP clients.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
className="flex items-center gap-2 gradient-brand text-white px-4 py-2 rounded-xl text-sm font-medium hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
<Plus size={16} /> New Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{newKey && (
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-5">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle size={18} className="text-yellow-600 mt-0.5 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-yellow-900 mb-1">
|
||||||
|
Save this key now — it will not be shown again
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<code className="flex-1 bg-white border border-yellow-200 rounded-lg px-3 py-2 text-xs font-mono break-all">
|
||||||
|
{newKey}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={copyKey}
|
||||||
|
className="shrink-0 border border-yellow-300 bg-white rounded-lg p-2 hover:bg-yellow-100 transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={14} className="text-green-600" /> : <Copy size={14} className="text-yellow-700" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setNewKey(null)}
|
||||||
|
className="text-xs text-yellow-700 hover:underline mt-3"
|
||||||
|
>
|
||||||
|
I've saved it, dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 mb-5">Create API Key</h2>
|
||||||
|
<form onSubmit={handleCreate} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1.5">Key Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g. Claude Desktop, Production Server"
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="gradient-brand text-white px-5 py-2.5 rounded-xl text-sm font-medium hover:opacity-90 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{loading ? 'Creating…' : 'Create Key'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowForm(false)}
|
||||||
|
className="border border-gray-200 text-gray-600 px-5 py-2.5 rounded-xl text-sm font-medium hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{keys.length === 0 && !showForm && (
|
||||||
|
<div className="bg-white rounded-xl border border-dashed border-gray-300 p-12 text-center">
|
||||||
|
<Key size={32} className="mx-auto text-gray-300 mb-3" />
|
||||||
|
<p className="text-sm font-medium text-gray-600 mb-1">No API keys yet</p>
|
||||||
|
<p className="text-xs text-gray-400 mb-4">Create one to connect an MCP client.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{keys.length > 0 && (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-100">
|
||||||
|
<tr className="text-left text-xs text-gray-500">
|
||||||
|
<th className="px-5 py-3 font-medium">Name</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Key</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Created</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Last Used</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Status</th>
|
||||||
|
<th className="px-5 py-3 font-medium"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-50">
|
||||||
|
{keys.map((k) => (
|
||||||
|
<tr key={k.id}>
|
||||||
|
<td className="px-5 py-3.5 font-medium text-gray-900">{k.name}</td>
|
||||||
|
<td className="px-5 py-3.5 font-mono text-xs text-gray-500">{k.key_prefix}...</td>
|
||||||
|
<td className="px-5 py-3.5 text-xs text-gray-500">{k.created_at}</td>
|
||||||
|
<td className="px-5 py-3.5 text-xs text-gray-500">{k.last_used_at ?? 'Never'}</td>
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
{k.is_active ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-green-700 bg-green-50 px-2 py-0.5 rounded-full">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full">
|
||||||
|
Revoked
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5 text-right">
|
||||||
|
{k.is_active && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRevoke(k.id, k.name)}
|
||||||
|
className="text-gray-400 hover:text-red-500 p-1 transition-colors"
|
||||||
|
title="Revoke key"
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
src/pages/dashboard/Usage.tsx
Normal file
210
src/pages/dashboard/Usage.tsx
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { api, session, AnalyticsSummary, ToolStat, InstanceStat, TimeseriesBucket } from '@/lib/api';
|
||||||
|
import {
|
||||||
|
LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import { Activity, CheckCircle2, XCircle, Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
const RANGES = [
|
||||||
|
{ label: '7 days', days: 7 },
|
||||||
|
{ label: '30 days', days: 30 },
|
||||||
|
{ label: '90 days', days: 90 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function isoDaysAgo(days: number): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setUTCDate(d.getUTCDate() - days);
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Usage() {
|
||||||
|
const [rangeDays, setRangeDays] = useState(30);
|
||||||
|
const [summary, setSummary] = useState<AnalyticsSummary | null>(null);
|
||||||
|
const [byTool, setByTool] = useState<ToolStat[]>([]);
|
||||||
|
const [byInstance, setByInstance] = useState<InstanceStat[]>([]);
|
||||||
|
const [timeseries, setTimeseries] = useState<TimeseriesBucket[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
const key = session.getKey();
|
||||||
|
if (!key) return;
|
||||||
|
setLoading(true);
|
||||||
|
const since = isoDaysAgo(rangeDays);
|
||||||
|
Promise.all([
|
||||||
|
api.analyticsSummary(key, since),
|
||||||
|
api.analyticsByTool(key, since),
|
||||||
|
api.analyticsByInstance(key, since),
|
||||||
|
api.analyticsTimeseries(key, since, undefined, rangeDays > 30 ? 'week' : 'day'),
|
||||||
|
])
|
||||||
|
.then(([s, t, i, ts]) => {
|
||||||
|
setSummary(s);
|
||||||
|
setByTool(t.tools);
|
||||||
|
setByInstance(i.instances);
|
||||||
|
setTimeseries(ts.buckets);
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [rangeDays]);
|
||||||
|
|
||||||
|
useEffect(load, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">Usage</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">Tool calls, durations, and activity across your MCP connections.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||||
|
{RANGES.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.days}
|
||||||
|
onClick={() => setRangeDays(r.days)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
rangeDays === r.days ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary cards */}
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<StatCard icon={Activity} label="Total Calls" value={summary?.total_calls ?? '—'} color="blue" />
|
||||||
|
<StatCard icon={CheckCircle2} label="Successful" value={summary?.success_count ?? '—'} color="green" />
|
||||||
|
<StatCard icon={XCircle} label="Errors" value={summary?.error_count ?? '—'} color="red" />
|
||||||
|
<StatCard
|
||||||
|
icon={Clock}
|
||||||
|
label="Avg Duration"
|
||||||
|
value={summary ? `${summary.avg_duration_ms.toFixed(1)} ms` : '—'}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time-series chart */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 mb-4">Calls Over Time</h2>
|
||||||
|
{timeseries.length === 0 && !loading ? (
|
||||||
|
<EmptyChart />
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<LineChart data={timeseries}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||||
|
<XAxis dataKey="period" tick={{ fontSize: 11 }} stroke="#9ca3af" />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} stroke="#9ca3af" allowDecimals={false} />
|
||||||
|
<Tooltip />
|
||||||
|
<Line type="monotone" dataKey="calls" stroke="#7c3aed" strokeWidth={2} dot={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per-tool bar chart + table */}
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 mb-4">Calls by Tool</h2>
|
||||||
|
{byTool.length === 0 && !loading ? (
|
||||||
|
<EmptyChart />
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<BarChart data={byTool.slice(0, 8)}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||||
|
<XAxis dataKey="tool_name" tick={{ fontSize: 10 }} stroke="#9ca3af" interval={0} angle={-30} textAnchor="end" height={70} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} stroke="#9ca3af" allowDecimals={false} />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="calls" fill="#7c3aed" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 mb-4">By Instance</h2>
|
||||||
|
{byInstance.length === 0 ? (
|
||||||
|
<EmptyChart />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{byInstance.map((inst) => (
|
||||||
|
<div key={inst.instance_name} className="flex items-center justify-between text-sm border-b border-gray-50 pb-2">
|
||||||
|
<span className="font-medium text-gray-900">{inst.instance_name}</span>
|
||||||
|
<div className="flex gap-4 text-xs text-gray-500">
|
||||||
|
<span>{inst.calls} calls</span>
|
||||||
|
<span>{inst.error_count} errors</span>
|
||||||
|
<span>{inst.avg_duration_ms.toFixed(0)} ms avg</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per-tool breakdown table */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900">Tool Breakdown</h2>
|
||||||
|
</div>
|
||||||
|
{byTool.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-sm text-gray-400">No tool calls in this range yet.</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-100">
|
||||||
|
<tr className="text-left text-xs text-gray-500">
|
||||||
|
<th className="px-6 py-3 font-medium">Tool</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Calls</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Success</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Errors</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Avg Duration</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Total Duration</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Last Used</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-50">
|
||||||
|
{byTool.map((t) => (
|
||||||
|
<tr key={t.tool_name}>
|
||||||
|
<td className="px-6 py-3 font-mono text-xs text-gray-900">{t.tool_name}</td>
|
||||||
|
<td className="px-6 py-3">{t.calls}</td>
|
||||||
|
<td className="px-6 py-3 text-green-700">{t.success_count}</td>
|
||||||
|
<td className="px-6 py-3 text-red-600">{t.error_count}</td>
|
||||||
|
<td className="px-6 py-3 text-gray-600">{t.avg_duration_ms.toFixed(1)} ms</td>
|
||||||
|
<td className="px-6 py-3 text-gray-600">{(t.total_duration_ms / 1000).toFixed(2)} s</td>
|
||||||
|
<td className="px-6 py-3 text-xs text-gray-400">{t.last_used_at}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ icon: Icon, label, value, color }: {
|
||||||
|
icon: typeof Activity; label: string; value: string | number; color: 'blue' | 'green' | 'red' | 'purple';
|
||||||
|
}) {
|
||||||
|
const colors = {
|
||||||
|
blue: 'bg-blue-50 text-blue-600',
|
||||||
|
green: 'bg-green-50 text-green-600',
|
||||||
|
red: 'bg-red-50 text-red-600',
|
||||||
|
purple: 'bg-purple-50 text-purple-600',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
|
||||||
|
<div className={`w-9 h-9 rounded-lg flex items-center justify-center mb-3 ${colors[color]}`}>
|
||||||
|
<Icon size={16} />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{value}</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">{label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyChart() {
|
||||||
|
return (
|
||||||
|
<div className="h-[240px] flex items-center justify-center text-sm text-gray-400">
|
||||||
|
No data in this range yet.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
23
tailwind.config.cjs
Normal file
23
tailwind.config.cjs
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx,mdx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: '#f5f3ff',
|
||||||
|
100: '#ede9fe',
|
||||||
|
200: '#ddd6fe',
|
||||||
|
300: '#c4b5fd',
|
||||||
|
400: '#a78bfa',
|
||||||
|
500: '#8b5cf6',
|
||||||
|
600: '#7c3aed',
|
||||||
|
700: '#6d28d9',
|
||||||
|
800: '#5b21b6',
|
||||||
|
900: '#4c1d95',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"paths": { "@/*": ["./src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts", "**/*.tsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
8
vite.config.ts
Normal file
8
vite.config.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tsconfigPaths()],
|
||||||
|
server: { port: 3000 },
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user