feat: add React frontend with auth, project list, import, and project detail pages
Converts packages/web from vanilla TypeScript Vite scaffold to React with: - React 19, react-router-dom v7, @tanstack/react-query v5, Tailwind CSS v4 - JWT auth context with auto-refresh token support - Login/Register pages, protected Layout with auth guard - Projects list with grid cards and delete action - ImportDialog supporting URL or file upload with API key display - ProjectDetail with 4 tabs: Documentation, Modules, MCP Integration, Settings - All TypeScript compiles cleanly (noEmit check passes) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
96
packages/web/src/pages/ImportDialog.tsx
Normal file
96
packages/web/src/pages/ImportDialog.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../lib/api';
|
||||
|
||||
type ImportResult = {
|
||||
project: { id: string; name: string };
|
||||
apiKey: string;
|
||||
stats: { modules: number; endpoints: number };
|
||||
};
|
||||
|
||||
export default function ImportDialog({ onClose }: { onClose: () => void }) {
|
||||
const [mode, setMode] = useState<'url' | 'file'>('url');
|
||||
const [url, setUrl] = useState('');
|
||||
const [fileContent, setFileContent] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState<ImportResult | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setFileContent(reader.result as string);
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
let body: Record<string, unknown>;
|
||||
if (mode === 'url') {
|
||||
body = { specUrl: url };
|
||||
} else {
|
||||
try { body = { spec: JSON.parse(fileContent) }; } catch { body = { spec: fileContent }; }
|
||||
}
|
||||
const data = await apiFetch<ImportResult>('/projects', {
|
||||
method: 'POST', body: JSON.stringify(body),
|
||||
});
|
||||
setResult(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Import failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-lg">
|
||||
{!result ? (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold mb-4">Import OpenAPI Document</h2>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button onClick={() => setMode('url')} className={`px-3 py-1 rounded text-sm ${mode === 'url' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100'}`}>From URL</button>
|
||||
<button onClick={() => setMode('file')} className={`px-3 py-1 rounded text-sm ${mode === 'file' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100'}`}>Upload File</button>
|
||||
</div>
|
||||
{mode === 'url' ? (
|
||||
<input type="url" placeholder="https://api.example.com/openapi.json" value={url} onChange={(e) => setUrl(e.target.value)} className="w-full px-3 py-2 border rounded-md mb-4" />
|
||||
) : (
|
||||
<input type="file" accept=".json,.yaml,.yml" onChange={handleFileChange} className="w-full mb-4" />
|
||||
)}
|
||||
{error && <p className="text-red-500 text-sm mb-4">{error}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-md">Cancel</button>
|
||||
<button onClick={handleImport} disabled={loading || (mode === 'url' ? !url : !fileContent)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
|
||||
{loading ? 'Importing...' : 'Import'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold mb-4 text-green-600">Import Successful!</h2>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p><strong>Project:</strong> {result.project.name}</p>
|
||||
<p><strong>Modules:</strong> {result.stats.modules}</p>
|
||||
<p><strong>Endpoints:</strong> {result.stats.endpoints}</p>
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded">
|
||||
<p className="font-medium text-yellow-800 mb-1">API Key (save it now):</p>
|
||||
<code className="text-xs break-all">{result.apiKey}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end mt-4">
|
||||
<button onClick={() => navigate(`/projects/${result.project.id}`)} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Go to Project</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
packages/web/src/pages/Layout.tsx
Normal file
22
packages/web/src/pages/Layout.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
export default function Layout() {
|
||||
const { user, loading, logout } = useAuth();
|
||||
|
||||
if (loading) return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white border-b px-6 py-3 flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold">Agent Fox</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">{user.name}</span>
|
||||
<button onClick={logout} className="text-sm text-red-600 hover:underline">Sign Out</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="p-6"><Outlet /></main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
packages/web/src/pages/Login.tsx
Normal file
39
packages/web/src/pages/Login.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="w-full max-w-md p-8 bg-white rounded-lg shadow">
|
||||
<h1 className="text-2xl font-bold text-center mb-6">Sign In to Agent Fox</h1>
|
||||
{error && <p className="text-red-500 text-sm mb-4">{error}</p>}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} className="w-full px-3 py-2 border rounded-md" required />
|
||||
<input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} className="w-full px-3 py-2 border rounded-md" required />
|
||||
<button type="submit" className="w-full py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Sign In</button>
|
||||
</form>
|
||||
<p className="text-center text-sm mt-4">
|
||||
Don't have an account? <Link to="/register" className="text-blue-600 hover:underline">Sign Up</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
packages/web/src/pages/ProjectDetail.tsx
Normal file
58
packages/web/src/pages/ProjectDetail.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import DocPreview from './tabs/DocPreview';
|
||||
import ModuleManagement from './tabs/ModuleManagement';
|
||||
import McpIntegration from './tabs/McpIntegration';
|
||||
import ProjectSettings from './tabs/ProjectSettings';
|
||||
|
||||
type ProjectData = {
|
||||
id: string; name: string; description: string | null; baseUrl: string | null;
|
||||
openApiVersion: string;
|
||||
modules: Array<{ id: string; name: string; description: string | null; _count: { endpoints: number } }>;
|
||||
_count: { endpoints: number };
|
||||
};
|
||||
|
||||
const tabs = ['Documentation', 'Modules', 'MCP Integration', 'Settings'] as const;
|
||||
type Tab = (typeof tabs)[number];
|
||||
|
||||
export default function ProjectDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('Documentation');
|
||||
|
||||
const { data: project, isLoading } = useQuery({
|
||||
queryKey: ['project', id],
|
||||
queryFn: () => apiFetch<ProjectData>(`/projects/${id}`),
|
||||
});
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (!project) return <div>Project not found</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4"><Link to="/" className="text-sm text-blue-600 hover:underline">← Back to projects</Link></div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{project.name}</h2>
|
||||
{project.description && <p className="text-sm text-gray-500 mt-1">{project.description}</p>}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">OpenAPI {project.openApiVersion} · {project._count.endpoints} endpoints</div>
|
||||
</div>
|
||||
<div className="border-b mb-6">
|
||||
<div className="flex gap-6">
|
||||
{tabs.map((tab) => (
|
||||
<button key={tab} onClick={() => setActiveTab(tab)}
|
||||
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${activeTab === tab ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{activeTab === 'Documentation' && <DocPreview projectId={project.id} />}
|
||||
{activeTab === 'Modules' && <ModuleManagement projectId={project.id} />}
|
||||
{activeTab === 'MCP Integration' && <McpIntegration project={project} />}
|
||||
{activeTab === 'Settings' && <ProjectSettings project={project} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
packages/web/src/pages/Projects.tsx
Normal file
55
packages/web/src/pages/Projects.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import ImportDialog from './ImportDialog';
|
||||
|
||||
type ProjectSummary = {
|
||||
id: string; name: string; description: string | null; openApiVersion: string;
|
||||
updatedAt: string; _count: { endpoints: number; modules: number };
|
||||
};
|
||||
|
||||
export default function Projects() {
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: projects, isLoading } = useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: () => apiFetch<ProjectSummary[]>('/projects'),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiFetch(`/projects/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }),
|
||||
});
|
||||
|
||||
if (isLoading) return <div>Loading projects...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold">Projects</h2>
|
||||
<button onClick={() => setShowImport(true)} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Import API Doc</button>
|
||||
</div>
|
||||
{projects?.length === 0 && <p className="text-gray-500 text-center py-12">No projects yet. Import an OpenAPI document to get started.</p>}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{projects?.map((p) => (
|
||||
<div key={p.id} className="p-4 bg-white rounded-lg shadow hover:shadow-md transition-shadow">
|
||||
<Link to={`/projects/${p.id}`}>
|
||||
<h3 className="font-medium text-lg">{p.name}</h3>
|
||||
{p.description && <p className="text-sm text-gray-500 mt-1 line-clamp-2">{p.description}</p>}
|
||||
<div className="mt-3 flex items-center gap-4 text-xs text-gray-400">
|
||||
<span>OpenAPI {p.openApiVersion}</span>
|
||||
<span>{p._count.modules} modules</span>
|
||||
<span>{p._count.endpoints} endpoints</span>
|
||||
</div>
|
||||
</Link>
|
||||
<button onClick={() => { if (confirm('Delete this project?')) deleteMutation.mutate(p.id); }}
|
||||
className="mt-2 text-xs text-red-500 hover:underline">Delete</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showImport && <ImportDialog onClose={() => setShowImport(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
packages/web/src/pages/Register.tsx
Normal file
41
packages/web/src/pages/Register.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
export default function Register() {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await register(email, password, name);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="w-full max-w-md p-8 bg-white rounded-lg shadow">
|
||||
<h1 className="text-2xl font-bold text-center mb-6">Create Account</h1>
|
||||
{error && <p className="text-red-500 text-sm mb-4">{error}</p>}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<input type="text" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} className="w-full px-3 py-2 border rounded-md" required />
|
||||
<input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} className="w-full px-3 py-2 border rounded-md" required />
|
||||
<input type="password" placeholder="Password (min 8 chars)" value={password} onChange={(e) => setPassword(e.target.value)} className="w-full px-3 py-2 border rounded-md" minLength={8} required />
|
||||
<button type="submit" className="w-full py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Create Account</button>
|
||||
</form>
|
||||
<p className="text-center text-sm mt-4">
|
||||
Already have an account? <Link to="/login" className="text-blue-600 hover:underline">Sign In</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
packages/web/src/pages/tabs/DocPreview.tsx
Normal file
78
packages/web/src/pages/tabs/DocPreview.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
|
||||
type Module = { id: string; name: string; description: string | null; _count: { endpoints: number } };
|
||||
type EndpointSummary = { id: string; method: string; path: string; summary: string | null; deprecated: boolean; module: { name: string } };
|
||||
type EndpointFull = EndpointSummary & { description: string | null; operationId: string | null; parameters: unknown; requestBody: unknown; responses: unknown };
|
||||
|
||||
const methodColors: Record<string, string> = {
|
||||
GET: 'bg-green-100 text-green-800', POST: 'bg-blue-100 text-blue-800',
|
||||
PUT: 'bg-yellow-100 text-yellow-800', DELETE: 'bg-red-100 text-red-800',
|
||||
PATCH: 'bg-purple-100 text-purple-800',
|
||||
};
|
||||
|
||||
export default function DocPreview({ projectId }: { projectId: string }) {
|
||||
const [selectedModule, setSelectedModule] = useState<string | null>(null);
|
||||
const [expandedEndpoint, setExpandedEndpoint] = useState<string | null>(null);
|
||||
|
||||
const { data: modules } = useQuery({
|
||||
queryKey: ['modules', projectId],
|
||||
queryFn: () => apiFetch<Module[]>(`/projects/${projectId}/modules`),
|
||||
});
|
||||
|
||||
const { data: endpoints } = useQuery({
|
||||
queryKey: ['endpoints', projectId, selectedModule],
|
||||
queryFn: () => apiFetch<EndpointSummary[]>(`/projects/${projectId}/endpoints${selectedModule ? `?moduleId=${selectedModule}` : ''}`),
|
||||
});
|
||||
|
||||
const { data: endpointDetail } = useQuery({
|
||||
queryKey: ['endpoint-detail', projectId, expandedEndpoint],
|
||||
queryFn: () => apiFetch<EndpointFull>(`/projects/${projectId}/endpoints/${expandedEndpoint}`),
|
||||
enabled: !!expandedEndpoint,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex gap-6">
|
||||
<div className="w-56 shrink-0">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">Modules</h3>
|
||||
<button onClick={() => setSelectedModule(null)}
|
||||
className={`block w-full text-left px-3 py-2 rounded text-sm ${!selectedModule ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-100'}`}>
|
||||
All ({modules?.reduce((sum, m) => sum + m._count.endpoints, 0) || 0})
|
||||
</button>
|
||||
{modules?.map((m) => (
|
||||
<button key={m.id} onClick={() => setSelectedModule(m.id)}
|
||||
className={`block w-full text-left px-3 py-2 rounded text-sm ${selectedModule === m.id ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-100'}`}>
|
||||
{m.name} ({m._count.endpoints})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
{endpoints?.map((ep) => (
|
||||
<div key={ep.id} className="bg-white rounded-lg border">
|
||||
<button onClick={() => setExpandedEndpoint(expandedEndpoint === ep.id ? null : ep.id)} className="w-full text-left px-4 py-3 flex items-center gap-3">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-mono font-medium ${methodColors[ep.method] || 'bg-gray-100'}`}>{ep.method}</span>
|
||||
<span className="font-mono text-sm">{ep.path}</span>
|
||||
{ep.summary && <span className="text-sm text-gray-500 ml-auto truncate max-w-xs">{ep.summary}</span>}
|
||||
{ep.deprecated && <span className="text-xs text-orange-500 ml-2">deprecated</span>}
|
||||
</button>
|
||||
{expandedEndpoint === ep.id && endpointDetail && (
|
||||
<div className="border-t px-4 py-3 text-sm space-y-3">
|
||||
{endpointDetail.description && <p className="text-gray-600">{endpointDetail.description}</p>}
|
||||
{Array.isArray(endpointDetail.parameters) && (endpointDetail.parameters as unknown[]).length > 0 && (
|
||||
<div><h4 className="font-medium mb-1">Parameters</h4><pre className="bg-gray-50 p-2 rounded text-xs overflow-auto">{JSON.stringify(endpointDetail.parameters, null, 2)}</pre></div>
|
||||
)}
|
||||
{endpointDetail.requestBody != null && (
|
||||
<div><h4 className="font-medium mb-1">Request Body</h4><pre className="bg-gray-50 p-2 rounded text-xs overflow-auto">{JSON.stringify(endpointDetail.requestBody, null, 2)}</pre></div>
|
||||
)}
|
||||
{endpointDetail.responses != null && (
|
||||
<div><h4 className="font-medium mb-1">Responses</h4><pre className="bg-gray-50 p-2 rounded text-xs overflow-auto">{JSON.stringify(endpointDetail.responses, null, 2)}</pre></div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
packages/web/src/pages/tabs/McpIntegration.tsx
Normal file
74
packages/web/src/pages/tabs/McpIntegration.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
|
||||
type Project = { id: string; name: string };
|
||||
|
||||
export default function McpIntegration({ project }: { project: Project }) {
|
||||
const [apiKey, setApiKey] = useState<string | null>(null);
|
||||
const mcpBaseUrl = window.location.origin;
|
||||
const mcpUrl = `${mcpBaseUrl}/mcp/${project.id}`;
|
||||
|
||||
const rotateMutation = useMutation({
|
||||
mutationFn: () => apiFetch<{ apiKey: string }>(`/projects/${project.id}/api-key/rotate`, { method: 'POST' }),
|
||||
onSuccess: (data) => setApiKey(data.apiKey),
|
||||
});
|
||||
|
||||
const configSnippet = JSON.stringify({
|
||||
mcpServers: {
|
||||
[project.name.toLowerCase().replace(/\s+/g, '-')]: {
|
||||
url: mcpUrl,
|
||||
headers: { Authorization: `Bearer ${apiKey || '<your-api-key>'}` },
|
||||
},
|
||||
},
|
||||
}, null, 2);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">MCP Service URL</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 bg-gray-100 rounded text-sm font-mono">{mcpUrl}</code>
|
||||
<button onClick={() => navigator.clipboard.writeText(mcpUrl)} className="px-3 py-2 text-sm bg-gray-200 rounded hover:bg-gray-300">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">API Key</h3>
|
||||
{apiKey ? (
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded">
|
||||
<p className="text-xs text-yellow-700 mb-1">Save this key — it won't be shown again.</p>
|
||||
<code className="text-sm break-all">{apiKey}</code>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">API key is hidden. Rotate to generate a new one.</p>
|
||||
)}
|
||||
<button onClick={() => { if (confirm('This will invalidate the current API key. Continue?')) rotateMutation.mutate(); }}
|
||||
className="mt-2 px-3 py-1 text-sm bg-orange-100 text-orange-700 rounded hover:bg-orange-200">Rotate API Key</button>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">Configuration for Claude Code / Cursor</h3>
|
||||
<div className="relative">
|
||||
<pre className="bg-gray-900 text-green-400 p-4 rounded text-sm overflow-auto">{configSnippet}</pre>
|
||||
<button onClick={() => navigator.clipboard.writeText(configSnippet)} className="absolute top-2 right-2 px-2 py-1 text-xs bg-gray-700 text-white rounded hover:bg-gray-600">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">Available Tools</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
{[
|
||||
{ name: 'get_project_overview', desc: 'Get project name, version, base URL, and module summary. Call this first.' },
|
||||
{ name: 'list_modules', desc: 'List all modules with descriptions and endpoint counts.' },
|
||||
{ name: 'list_endpoints', desc: 'List endpoints in a module. Provide moduleId.' },
|
||||
{ name: 'get_endpoint_detail', desc: 'Get full endpoint details: parameters, request body, responses.' },
|
||||
{ name: 'search_endpoints', desc: 'Search by keyword across all endpoints. Optional moduleId filter.' },
|
||||
].map((t) => (
|
||||
<div key={t.name} className="p-3 bg-gray-50 rounded">
|
||||
<code className="font-medium">{t.name}</code>
|
||||
<p className="text-gray-500 mt-1">{t.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
packages/web/src/pages/tabs/ModuleManagement.tsx
Normal file
48
packages/web/src/pages/tabs/ModuleManagement.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
|
||||
type Module = { id: string; name: string; description: string | null; sortOrder: number; source: string; _count: { endpoints: number } };
|
||||
|
||||
export default function ModuleManagement({ projectId }: { projectId: string }) {
|
||||
const [newModuleName, setNewModuleName] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: modules } = useQuery({
|
||||
queryKey: ['modules', projectId],
|
||||
queryFn: () => apiFetch<Module[]>(`/projects/${projectId}/modules`),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) => apiFetch(`/projects/${projectId}/modules`, { method: 'POST', body: JSON.stringify({ name }) }),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['modules', projectId] }); setNewModuleName(''); },
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (moduleId: string) => apiFetch(`/projects/${projectId}/modules/${moduleId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['modules', projectId] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<input type="text" placeholder="New module name" value={newModuleName} onChange={(e) => setNewModuleName(e.target.value)} className="flex-1 px-3 py-2 border rounded-md text-sm" />
|
||||
<button onClick={() => newModuleName && createMutation.mutate(newModuleName)} disabled={!newModuleName}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700 disabled:opacity-50">Add Module</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{modules?.map((m) => (
|
||||
<div key={m.id} className="flex items-center justify-between p-3 bg-white rounded-lg border">
|
||||
<div>
|
||||
<span className="font-medium">{m.name}</span>
|
||||
<span className="text-xs text-gray-400 ml-2">({m.source})</span>
|
||||
<span className="text-xs text-gray-400 ml-2">{m._count.endpoints} endpoints</span>
|
||||
</div>
|
||||
<button onClick={() => { if (confirm(`Delete "${m.name}"?`)) deleteMutation.mutate(m.id); }}
|
||||
className="text-xs text-red-500 hover:underline">Delete</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
packages/web/src/pages/tabs/ProjectSettings.tsx
Normal file
40
packages/web/src/pages/tabs/ProjectSettings.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
|
||||
type Project = { id: string; name: string; description: string | null };
|
||||
|
||||
export default function ProjectSettings({ project }: { project: Project }) {
|
||||
const [name, setName] = useState(project.name);
|
||||
const [description, setDescription] = useState(project.description || '');
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () => apiFetch(`/projects/${project.id}`, { method: 'PUT', body: JSON.stringify({ name, description: description || undefined }) }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['project', project.id] }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => apiFetch(`/projects/${project.id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }); navigate('/'); },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-lg space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div><label className="block text-sm font-medium mb-1">Project Name</label>
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} className="w-full px-3 py-2 border rounded-md" /></div>
|
||||
<div><label className="block text-sm font-medium mb-1">Description</label>
|
||||
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} className="w-full px-3 py-2 border rounded-md" /></div>
|
||||
<button onClick={() => updateMutation.mutate()} className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700">Save Changes</button>
|
||||
</div>
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="text-red-600 font-medium mb-2">Danger Zone</h3>
|
||||
<button onClick={() => { if (confirm('Permanently delete this project?')) deleteMutation.mutate(); }}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-md text-sm hover:bg-red-700">Delete Project</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user