From 35511eb877d03ade49e9c0a669bfd038b2451eac Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Thu, 2 Apr 2026 22:10:24 +0800 Subject: [PATCH] feat: opt web ux --- docker-compose.yml | 1 + packages/mcp/src/auth.ts | 31 +- packages/server/src/lib/crypto.ts | 29 ++ packages/server/src/routes/auth.ts | 84 ++++ packages/server/src/routes/projects.ts | 17 - packages/web/src/App.tsx | 3 - packages/web/src/components/SchemaView.tsx | 370 ++++++++++++++++ .../web/src/components/SettingsDialog.tsx | 402 ++++++++++++++++++ packages/web/src/index.css | 21 +- packages/web/src/pages/ImportDialog.tsx | 23 - packages/web/src/pages/Layout.tsx | 370 +++++++++++----- packages/web/src/pages/ProjectDetail.tsx | 4 +- packages/web/src/pages/Settings.tsx | 147 ------- packages/web/src/pages/tabs/DocPreview.tsx | 30 +- .../web/src/pages/tabs/McpIntegration.tsx | 80 ++-- prisma/schema.prisma | 22 +- 16 files changed, 1251 insertions(+), 383 deletions(-) create mode 100644 packages/server/src/lib/crypto.ts create mode 100644 packages/web/src/components/SchemaView.tsx create mode 100644 packages/web/src/components/SettingsDialog.tsx delete mode 100644 packages/web/src/pages/Settings.tsx diff --git a/docker-compose.yml b/docker-compose.yml index 254180f..a46cc9e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox JWT_SECRET: ${JWT_SECRET:-change-me-in-production} JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-change-me-refresh-in-production} + API_KEY_ENCRYPTION_SECRET: ${API_KEY_ENCRYPTION_SECRET:-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef} SERVER_PORT: "3000" ports: - "3000:3000" diff --git a/packages/mcp/src/auth.ts b/packages/mcp/src/auth.ts index 77f65a9..5db4138 100644 --- a/packages/mcp/src/auth.ts +++ b/packages/mcp/src/auth.ts @@ -12,22 +12,37 @@ export async function mcpAuth(req: Request, res: Response, next: NextFunction): } const apiKey = header.slice(7); - const project = await prisma.project.findUnique({ - where: { id: projectId }, + const prefix = apiKey.slice(0, 12); + + // Find user by API key prefix for fast lookup + const user = await prisma.user.findFirst({ + where: { apiKeyPrefix: prefix }, select: { id: true, apiKeyHash: true }, }); + if (!user || !user.apiKeyHash) { + res.status(401).json({ error: 'Invalid API key' }); + return; + } + + // Verify API key with bcrypt + const valid = await bcrypt.compare(apiKey, user.apiKeyHash); + if (!valid) { + res.status(401).json({ error: 'Invalid API key' }); + return; + } + + // Verify user owns the project + const project = await prisma.project.findFirst({ + where: { id: projectId, userId: user.id }, + select: { id: true }, + }); + if (!project) { res.status(404).json({ error: 'Project not found' }); return; } - const valid = await bcrypt.compare(apiKey, project.apiKeyHash); - if (!valid) { - res.status(401).json({ error: 'Invalid API key' }); - return; - } - (req as any).projectId = projectId; next(); } diff --git a/packages/server/src/lib/crypto.ts b/packages/server/src/lib/crypto.ts new file mode 100644 index 0000000..5de8e20 --- /dev/null +++ b/packages/server/src/lib/crypto.ts @@ -0,0 +1,29 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; + +const ALGORITHM = 'aes-256-gcm'; + +function getEncryptionKey(): Buffer { + const secret = process.env.API_KEY_ENCRYPTION_SECRET; + if (!secret) throw new Error('API_KEY_ENCRYPTION_SECRET environment variable is required'); + return Buffer.from(secret, 'hex'); +} + +export function encryptApiKey(plaintext: string): string { + const key = getEncryptionKey(); + const iv = randomBytes(12); + const cipher = createCipheriv(ALGORITHM, key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted.toString('base64')}`; +} + +export function decryptApiKey(ciphertext: string): string { + const key = getEncryptionKey(); + const [ivB64, tagB64, dataB64] = ciphertext.split(':'); + const iv = Buffer.from(ivB64, 'base64'); + const authTag = Buffer.from(tagB64, 'base64'); + const encrypted = Buffer.from(dataB64, 'base64'); + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + return decipher.update(encrypted) + decipher.final('utf8'); +} diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index 003cabf..0dd6f07 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -4,6 +4,8 @@ import { prisma } from '@agent-fox/shared'; import { hashPassword, verifyPassword } from '../lib/password.js'; import { generateTokenPair, verifyRefreshToken } from '../lib/jwt.js'; import { requireAuth } from '../middleware/auth.js'; +import { generateApiKey } from '../lib/api-key.js'; +import { encryptApiKey, decryptApiKey } from '../lib/crypto.js'; const router: RouterType = Router(); @@ -150,4 +152,86 @@ router.get('/me', requireAuth, async (req, res) => { res.json({ success: true, data: user }); }); +// --- API Key Management --- + +router.get('/api-key/status', requireAuth, async (req, res) => { + const user = await prisma.user.findUnique({ + where: { id: req.user!.userId }, + select: { apiKeyPrefix: true, apiKeyHash: true }, + }); + res.json({ + success: true, + data: { hasKey: !!user?.apiKeyHash, prefix: user?.apiKeyPrefix || null }, + }); +}); + +router.post('/api-key/generate', requireAuth, async (req, res) => { + const user = await prisma.user.findUnique({ + where: { id: req.user!.userId }, + select: { apiKeyHash: true }, + }); + if (user?.apiKeyHash) { + res.status(400).json({ success: false, error: { code: 'ALREADY_EXISTS', message: 'API key already exists. Use rotate to replace it.' } }); + return; + } + + const { raw, hash } = generateApiKey(); + const encrypted = encryptApiKey(raw); + const prefix = raw.slice(0, 12); + + await prisma.user.update({ + where: { id: req.user!.userId }, + data: { apiKeyHash: hash, apiKeyEncrypted: encrypted, apiKeyPrefix: prefix }, + }); + + res.json({ success: true, data: { apiKey: raw } }); +}); + +router.post('/api-key/rotate', requireAuth, async (req, res) => { + const { raw, hash } = generateApiKey(); + const encrypted = encryptApiKey(raw); + const prefix = raw.slice(0, 12); + + await prisma.user.update({ + where: { id: req.user!.userId }, + data: { apiKeyHash: hash, apiKeyEncrypted: encrypted, apiKeyPrefix: prefix }, + }); + + res.json({ success: true, data: { apiKey: raw } }); +}); + +const revealSchema = z.object({ password: z.string() }); + +router.post('/api-key/reveal', requireAuth, async (req, res) => { + const parsed = revealSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Password is required' } }); + return; + } + + const user = await prisma.user.findUnique({ + where: { id: req.user!.userId }, + select: { passwordHash: true, apiKeyEncrypted: true }, + }); + + if (!user?.passwordHash) { + res.status(400).json({ success: false, error: { code: 'NO_PASSWORD', message: 'No password set' } }); + return; + } + + const valid = await verifyPassword(parsed.data.password, user.passwordHash); + if (!valid) { + res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Incorrect password' } }); + return; + } + + if (!user.apiKeyEncrypted) { + res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'No API key generated' } }); + return; + } + + const apiKey = decryptApiKey(user.apiKeyEncrypted); + res.json({ success: true, data: { apiKey } }); +}); + export default router; diff --git a/packages/server/src/routes/projects.ts b/packages/server/src/routes/projects.ts index c117ecc..add4333 100644 --- a/packages/server/src/routes/projects.ts +++ b/packages/server/src/routes/projects.ts @@ -2,7 +2,6 @@ import { Router, type Router as RouterType } from 'express'; import { z } from 'zod'; import { prisma } from '@agent-fox/shared'; import { requireAuth } from '../middleware/auth.js'; -import { generateApiKey } from '../lib/api-key.js'; import { parseOpenApiDocument } from '../services/openapi-parser.js'; const router: RouterType = Router(); @@ -18,7 +17,6 @@ router.post('/', async (req, res) => { try { const input = specUrl || spec; const parsed = await parseOpenApiDocument(input); - const { raw: apiKey, hash: apiKeyHash } = generateApiKey(); const project = await prisma.$transaction(async (tx) => { const proj = await tx.project.create({ @@ -29,7 +27,6 @@ router.post('/', async (req, res) => { baseUrl: parsed.baseUrl, openApiSpec: parsed.spec as any, openApiVersion: parsed.openApiVersion, - apiKeyHash, }, }); @@ -64,7 +61,6 @@ router.post('/', async (req, res) => { success: true, data: { project: { id: project.id, name: project.name }, - apiKey, stats: { modules: parsed.modules.length, endpoints: parsed.endpoints.length }, }, }); @@ -136,17 +132,4 @@ router.delete('/:id', async (req, res) => { res.json({ success: true, data: { deleted: true } }); }); -router.post('/:id/api-key/rotate', async (req, res) => { - const { raw, hash } = generateApiKey(); - const result = await prisma.project.updateMany({ - where: { id: req.params.id, userId: req.user!.userId }, - data: { apiKeyHash: hash }, - }); - if (result.count === 0) { - res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } }); - return; - } - res.json({ success: true, data: { apiKey: raw } }); -}); - export default router; diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 51ca5e4..2bd79a9 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -7,8 +7,6 @@ import Register from './pages/Register'; import Layout from './pages/Layout'; import Projects from './pages/Projects'; import ProjectDetail from './pages/ProjectDetail'; -import Settings from './pages/Settings'; - const queryClient = new QueryClient(); export default function App() { @@ -23,7 +21,6 @@ export default function App() { }> } /> } /> - } /> } /> diff --git a/packages/web/src/components/SchemaView.tsx b/packages/web/src/components/SchemaView.tsx new file mode 100644 index 0000000..8c672d2 --- /dev/null +++ b/packages/web/src/components/SchemaView.tsx @@ -0,0 +1,370 @@ +/** + * Structured renderers for OpenAPI parameters, request bodies, and responses. + * Replaces raw JSON.stringify output with readable tables and schema trees. + */ + +/* ===== Helpers ===== */ + +type SchemaObj = { + type?: string; + format?: string; + description?: string; + enum?: unknown[]; + items?: SchemaObj; + properties?: Record; + required?: string[]; + additionalProperties?: boolean | SchemaObj; + oneOf?: SchemaObj[]; + anyOf?: SchemaObj[]; + allOf?: SchemaObj[]; + default?: unknown; + example?: unknown; + nullable?: boolean; + minimum?: number; + maximum?: number; + minLength?: number; + maxLength?: number; + pattern?: string; + [key: string]: unknown; +}; + +type Parameter = { + name: string; + in: string; + required?: boolean; + description?: string; + schema?: SchemaObj; + type?: string; + format?: string; + enum?: unknown[]; + [key: string]: unknown; +}; + +function resolveType(schema?: SchemaObj): string { + if (!schema) return '—'; + if (schema.type === 'array' && schema.items) { + return `${resolveType(schema.items)}[]`; + } + if (schema.oneOf) return schema.oneOf.map(resolveType).join(' | '); + if (schema.anyOf) return schema.anyOf.map(resolveType).join(' | '); + return schema.type || '—'; +} + +function TypeBadge({ type }: { type: string }) { + const colorMap: Record = { + string: 'text-[#30a46c] bg-[rgba(48,164,108,0.08)]', + integer: 'text-[#3b82f6] bg-[rgba(59,130,246,0.08)]', + number: 'text-[#3b82f6] bg-[rgba(59,130,246,0.08)]', + boolean: 'text-[#e5a000] bg-[rgba(229,160,0,0.08)]', + object: 'text-[#8b5cf6] bg-[rgba(139,92,246,0.08)]', + array: 'text-[#e5484d] bg-[rgba(229,72,77,0.08)]', + }; + const base = type.replace('[]', ''); + const cls = colorMap[base] || 'text-text-muted bg-bg-tertiary'; + return ( + + {type} + + ); +} + +function InBadge({ location }: { location: string }) { + return ( + + {location} + + ); +} + +/* ===== Parameters Table ===== */ + +export function ParametersView({ parameters }: { parameters: unknown }) { + if (!Array.isArray(parameters) || parameters.length === 0) return null; + const params = parameters as Parameter[]; + + return ( +
+

Parameters

+
+ + + + + + + + + + + + {params.map((p, i) => { + const type = resolveType(p.schema) || p.type || '—'; + const format = p.schema?.format || p.format; + const enumVals = p.schema?.enum || p.enum; + return ( + + + + + + + + ); + })} + +
NameInTypeRequiredDescription
+ {p.name} + + + +
+ + {format && ( + ({format}) + )} +
+
+ {p.required ? ( + required + ) : ( + optional + )} + +
+ {p.description && {p.description}} + {enumVals && enumVals.length > 0 && ( +
+ enum: + {enumVals.map((v, j) => ( + + {String(v)} + + ))} +
+ )} + {p.schema?.default !== undefined && ( +
+ default: {JSON.stringify(p.schema.default)} +
+ )} +
+
+
+
+ ); +} + +/* ===== Schema Properties Tree ===== */ + +function SchemaProperties({ schema, depth = 0 }: { schema: SchemaObj; depth?: number }) { + const properties = schema.properties; + const requiredSet = new Set(schema.required || []); + + if (!properties || Object.keys(properties).length === 0) { + // Just show the type if no properties + if (schema.type) { + return ( +
+ + {schema.description && {schema.description}} +
+ ); + } + return null; + } + + return ( +
0 ? 'ml-4 border-l border-border-muted pl-3 mt-1' : ''}> + {Object.entries(properties).map(([name, prop]) => { + const type = resolveType(prop); + const hasChildren = prop.type === 'object' && prop.properties; + const isArray = prop.type === 'array' && prop.items?.properties; + + return ( +
+
+ {name} + + {prop.format && ( + ({prop.format}) + )} + {requiredSet.has(name) && ( + required + )} + {prop.nullable && ( + nullable + )} + {prop.description && ( + {prop.description} + )} +
+ {prop.enum && prop.enum.length > 0 && ( +
+ enum: + {prop.enum.map((v, j) => ( + + {String(v)} + + ))} +
+ )} + {prop.default !== undefined && ( +
+ default: {JSON.stringify(prop.default)} +
+ )} + {hasChildren && } + {isArray && prop.items && } +
+ ); + })} +
+ ); +} + +/* ===== Request Body ===== */ + +export function RequestBodyView({ requestBody }: { requestBody: unknown }) { + if (!requestBody || typeof requestBody !== 'object') return null; + const body = requestBody as { + required?: boolean; + description?: string; + content?: Record; + schema?: SchemaObj; // Swagger 2.0 converted format + }; + + // Swagger 2.0 format: { schema: {...} } + if (body.schema && !body.content) { + return ( +
+

+ Request Body + {body.required && required} +

+
+ +
+
+ ); + } + + // OpenAPI 3.x format: { content: { "application/json": { schema: {...} } } } + if (!body.content) return null; + const contentTypes = Object.entries(body.content); + + return ( +
+

+ Request Body + {body.required && required} +

+ {body.description && ( +

{body.description}

+ )} + {contentTypes.map(([contentType, media]) => ( +
+
+ {contentType} +
+
+ {media.schema ? ( + media.schema.properties ? ( + + ) : ( +
+ + {media.schema.description && {media.schema.description}} +
+ ) + ) : ( + No schema + )} +
+
+ ))} +
+ ); +} + +/* ===== Responses ===== */ + +function StatusBadge({ code }: { code: string }) { + const n = parseInt(code, 10); + let cls = 'text-text-muted bg-bg-tertiary'; + if (n >= 200 && n < 300) cls = 'text-[#30a46c] bg-[rgba(48,164,108,0.08)]'; + else if (n >= 300 && n < 400) cls = 'text-[#3b82f6] bg-[rgba(59,130,246,0.08)]'; + else if (n >= 400 && n < 500) cls = 'text-[#e5a000] bg-[rgba(229,160,0,0.08)]'; + else if (n >= 500) cls = 'text-[#e5484d] bg-[rgba(229,72,77,0.08)]'; + return ( + + {code} + + ); +} + +export function ResponsesView({ responses }: { responses: unknown }) { + if (!responses || typeof responses !== 'object') return null; + const entries = Object.entries(responses as Record); + if (entries.length === 0) return null; + + return ( +
+

Responses

+
+ {entries.map(([code, resp]) => { + const response = resp as { + description?: string; + content?: Record; + schema?: SchemaObj; // Swagger 2.0 + }; + + // Find schema from content or direct schema (Swagger 2) + let schema: SchemaObj | undefined; + let contentType: string | undefined; + if (response.content) { + const firstEntry = Object.entries(response.content)[0]; + if (firstEntry) { + contentType = firstEntry[0]; + schema = firstEntry[1].schema; + } + } else if (response.schema) { + schema = response.schema; + } + + return ( +
+
+ + {response.description && ( + {response.description} + )} + {contentType && ( + {contentType} + )} +
+ {schema && (schema.properties || schema.items?.properties || schema.type) && ( +
+ {schema.properties ? ( + + ) : schema.type === 'array' && schema.items?.properties ? ( +
+
+ of objects: +
+ +
+ ) : ( +
+ + {schema.description && {schema.description}} +
+ )} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/packages/web/src/components/SettingsDialog.tsx b/packages/web/src/components/SettingsDialog.tsx new file mode 100644 index 0000000..f03d3d4 --- /dev/null +++ b/packages/web/src/components/SettingsDialog.tsx @@ -0,0 +1,402 @@ +import { useState, useRef, useEffect } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useAuth } from '../lib/auth'; +import { apiFetch } from '../lib/api'; +import ConfirmDialog from './ConfirmDialog'; + +type ApiKeyStatus = { hasKey: boolean; prefix: string | null }; + +export default function SettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const { user, updateUser } = useAuth(); + const dialogRef = useRef(null); + const queryClient = useQueryClient(); + + // Profile state + const [name, setName] = useState(user?.name || ''); + const [profileLoading, setProfileLoading] = useState(false); + const [profileMsg, setProfileMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + // Password state + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [passwordLoading, setPasswordLoading] = useState(false); + const [passwordMsg, setPasswordMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + // API Key state + const { data: keyStatus } = useQuery({ + queryKey: ['api-key-status'], + queryFn: () => apiFetch('/auth/api-key/status'), + enabled: open, + }); + const [freshKey, setFreshKey] = useState(null); // just generated/rotated + const [revealedKey, setRevealedKey] = useState(null); // revealed via password + const [keyLoading, setKeyLoading] = useState(false); + const [keyError, setKeyError] = useState(''); + const [keyCopied, setKeyCopied] = useState(false); + const [showRotateConfirm, setShowRotateConfirm] = useState(false); + const [showPasswordPrompt, setShowPasswordPrompt] = useState<'reveal' | 'copy' | null>(null); + const [verifyPassword, setVerifyPassword] = useState(''); + const [verifyError, setVerifyError] = useState(''); + const [verifyLoading, setVerifyLoading] = useState(false); + + useEffect(() => { + const el = dialogRef.current; + if (!el) return; + if (open && !el.open) el.showModal(); + else if (!open && el.open) el.close(); + }, [open]); + + useEffect(() => { + if (open) { + setName(user?.name || ''); + setProfileMsg(null); + setPasswordMsg(null); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setFreshKey(null); + setRevealedKey(null); + setKeyError(''); + setKeyCopied(false); + setShowPasswordPrompt(null); + setVerifyPassword(''); + setVerifyError(''); + } + }, [open, user?.name]); + + // Profile handlers + const handleProfileSave = async () => { + setProfileLoading(true); + setProfileMsg(null); + try { + const data = await apiFetch<{ id: string; email: string; name: string }>('/auth/profile', { + method: 'PUT', body: JSON.stringify({ name }), + }); + updateUser({ name: data.name }); + setProfileMsg({ type: 'success', text: 'Profile updated' }); + setTimeout(() => setProfileMsg(null), 3000); + } catch (err) { + setProfileMsg({ type: 'error', text: err instanceof Error ? err.message : 'Failed to update profile' }); + } finally { + setProfileLoading(false); + } + }; + + const handlePasswordChange = async () => { + if (newPassword !== confirmPassword) { + setPasswordMsg({ type: 'error', text: 'Passwords do not match' }); + return; + } + setPasswordLoading(true); + setPasswordMsg(null); + try { + await apiFetch('/auth/change-password', { + method: 'POST', body: JSON.stringify({ currentPassword, newPassword }), + }); + setPasswordMsg({ type: 'success', text: 'Password changed successfully' }); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setTimeout(() => setPasswordMsg(null), 3000); + } catch (err) { + setPasswordMsg({ type: 'error', text: err instanceof Error ? err.message : 'Failed to change password' }); + } finally { + setPasswordLoading(false); + } + }; + + // API Key handlers + const handleGenerateKey = async () => { + setKeyLoading(true); + setKeyError(''); + try { + const data = await apiFetch<{ apiKey: string }>('/auth/api-key/generate', { method: 'POST' }); + setFreshKey(data.apiKey); + queryClient.invalidateQueries({ queryKey: ['api-key-status'] }); + } catch (err) { + setKeyError(err instanceof Error ? err.message : 'Failed to generate key'); + } finally { + setKeyLoading(false); + } + }; + + const handleRotateKey = async () => { + setShowRotateConfirm(false); + setKeyLoading(true); + setKeyError(''); + try { + const data = await apiFetch<{ apiKey: string }>('/auth/api-key/rotate', { method: 'POST' }); + setFreshKey(data.apiKey); + setRevealedKey(null); + queryClient.invalidateQueries({ queryKey: ['api-key-status'] }); + } catch (err) { + setKeyError(err instanceof Error ? err.message : 'Failed to rotate key'); + } finally { + setKeyLoading(false); + } + }; + + const handleVerifyAndAction = async () => { + setVerifyLoading(true); + setVerifyError(''); + try { + const data = await apiFetch<{ apiKey: string }>('/auth/api-key/reveal', { + method: 'POST', body: JSON.stringify({ password: verifyPassword }), + }); + if (showPasswordPrompt === 'copy') { + navigator.clipboard.writeText(data.apiKey); + setKeyCopied(true); + setTimeout(() => setKeyCopied(false), 2000); + } else { + setRevealedKey(data.apiKey); + } + setShowPasswordPrompt(null); + setVerifyPassword(''); + } catch (err) { + setVerifyError(err instanceof Error ? err.message : 'Verification failed'); + } finally { + setVerifyLoading(false); + } + }; + + const copyFreshKey = () => { + if (freshKey) { + navigator.clipboard.writeText(freshKey); + setKeyCopied(true); + setTimeout(() => setKeyCopied(false), 2000); + } + }; + + const maskedKey = keyStatus?.prefix + ? `${keyStatus.prefix}${'·'.repeat(16)}` : null; + + const initials = user?.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2) || '?'; + + return ( + <> + +
+

Settings

+ +
+ +
+ {/* Profile */} +
+

Profile

+

Manage your personal information.

+
+
{initials}
+
+
{user?.name}
+
{user?.email}
+
+
+
+
+ + setName(e.target.value)} className="input-base" /> +
+ {profileMsg && ( +
+ + {profileMsg.type === 'success' ? : } + + {profileMsg.text} +
+ )} + +
+
+ + {/* API Key */} +
+

API Key

+

Used to authenticate all MCP requests across your projects.

+ + {/* Fresh key display (just generated or rotated) */} + {freshKey ? ( +
+
+
+ +

Save this key now — you won't be able to see it again.

+
+ {freshKey} + +
+ +
+ ) : !keyStatus?.hasKey ? ( + /* No key generated yet */ +
+
+ +

No API key generated yet. Generate one to use MCP services.

+
+ +
+ ) : ( + /* Key exists — show masked with actions */ +
+
+ + {revealedKey || maskedKey} + + {/* Reveal button */} + + {/* Copy button */} + +
+ + {/* Password prompt inline */} + {showPasswordPrompt && ( +
+

Enter your password to {showPasswordPrompt === 'copy' ? 'copy' : 'reveal'} the API key.

+ setVerifyPassword(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && verifyPassword) handleVerifyAndAction(); }} + className="input-base" + placeholder="Current password" + autoFocus + /> + {verifyError &&

{verifyError}

} +
+ + +
+
+ )} + + +
+ )} + + {keyError && ( +
+ + {keyError} +
+ )} +
+ + {/* Password */} +
+

Change Password

+

Update your password to keep your account secure.

+
+
+ + setCurrentPassword(e.target.value)} className="input-base" placeholder="Enter current password" /> +
+
+ + setNewPassword(e.target.value)} className="input-base" placeholder="At least 8 characters" minLength={8} /> +
+
+ + setConfirmPassword(e.target.value)} className="input-base" placeholder="Confirm new password" /> +
+ {passwordMsg && ( +
+ + {passwordMsg.type === 'success' ? : } + + {passwordMsg.text} +
+ )} + +
+
+
+
+ + setShowRotateConfirm(false)} + onConfirm={handleRotateKey} + title="Rotate API Key" + description="The current API key will be invalidated immediately. All MCP clients using the old key will stop working. A new key will be generated." + confirmText="Rotate Key" + variant="warning" + /> + + ); +} diff --git a/packages/web/src/index.css b/packages/web/src/index.css index 1ec1fb4..36a4b3b 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -175,6 +175,7 @@ --animate-fade-in: fade-in 0.2s ease-out both; --animate-slide-up: slide-up 0.3s cubic-bezier(0.16, 1, 0.3, 1) both; + --animate-slide-down: slide-down 0.2s cubic-bezier(0.16, 1, 0.3, 1) both; --animate-shimmer: shimmer 1.8s ease-in-out infinite; --animate-pulse-soft: pulse-soft 2s ease-in-out infinite; @@ -194,6 +195,10 @@ 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } } + @keyframes slide-down { + from { opacity: 0; transform: translateY(-4px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } + } } /* ===== Base ===== */ @@ -296,6 +301,21 @@ body { } } +/* ===== User Dropdown ===== */ +.user-dropdown { + position: absolute; + top: calc(100% + 6px); + right: 0; + min-width: 220px; + background: var(--bg-elevated); + border: 1px solid var(--border-default); + border-radius: 12px; + box-shadow: var(--shadow-lg); + padding: 4px; + z-index: 100; + animation: slide-down 0.2s cubic-bezier(0.16, 1, 0.3, 1) both; +} + /* ===== Method Badges ===== */ .method-badge { @apply inline-flex items-center px-2 py-0.5 rounded text-[11px] font-bold font-mono tracking-wide; @@ -310,7 +330,6 @@ body { dialog { color: var(--text-secondary); max-height: calc(100vh - 4rem); - max-width: calc(100vw - 2rem); } dialog[open] { position: fixed; diff --git a/packages/web/src/pages/ImportDialog.tsx b/packages/web/src/pages/ImportDialog.tsx index 85a6032..804ac6e 100644 --- a/packages/web/src/pages/ImportDialog.tsx +++ b/packages/web/src/pages/ImportDialog.tsx @@ -6,7 +6,6 @@ import Modal from '../components/Modal'; type ImportResult = { project: { id: string; name: string }; - apiKey: string; stats: { modules: number; endpoints: number }; }; @@ -18,7 +17,6 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) { const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [result, setResult] = useState(null); - const [copied, setCopied] = useState(false); const [dragging, setDragging] = useState(false); const fileInputRef = useRef(null); const navigate = useNavigate(); @@ -60,14 +58,6 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) { } }; - const copyKey = () => { - if (result?.apiKey) { - navigator.clipboard.writeText(result.apiKey); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - }; - return ( {!result ? ( @@ -149,19 +139,6 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) { -
-
-
- -

API Key — save it now

-
- -
- {result.apiKey} -
-
diff --git a/packages/web/src/pages/Layout.tsx b/packages/web/src/pages/Layout.tsx index e03dd10..e5ed573 100644 --- a/packages/web/src/pages/Layout.tsx +++ b/packages/web/src/pages/Layout.tsx @@ -1,12 +1,202 @@ -import { useState } from 'react'; -import { Navigate, Outlet, NavLink, Link, useLocation } from 'react-router-dom'; +import { useState, useRef, useEffect } from 'react'; +import { Navigate, Outlet, NavLink, Link, useLocation, useParams, useOutletContext } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; import { useAuth } from '../lib/auth'; +import { apiFetch } from '../lib/api'; import ThemeToggle from '../components/ThemeToggle'; +import SettingsDialog from '../components/SettingsDialog'; + +type LayoutContext = { onOpenSettings: () => void }; +export function useLayoutContext() { return useOutletContext(); } + +type ProjectSummary = { + id: string; name: string; description: string | null; + _count: { endpoints: number; modules: number }; +}; + +function UserDropdown({ user, logout, onOpenSettings }: { user: { name: string; email: string }; logout: () => void; onOpenSettings: () => void }) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const initials = user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + return ( +
+ + + {open && ( +
+ {/* User info */} +
+
+
+ {initials} +
+
+
{user.name}
+
{user.email}
+
+
+
+ + {/* Actions */} +
+ + +
+
+ )} +
+ ); +} + +function ProjectSidebar() { + const location = useLocation(); + const params = useParams(); + const activeProjectId = params.id; + + const { data: projects, isLoading } = useQuery({ + queryKey: ['projects'], + queryFn: () => apiFetch('/projects'), + }); + + const isProjectsRoot = location.pathname === '/'; + + return ( + + ); +} + +function OnboardingBanner({ onOpenSettings }: { onOpenSettings: () => void }) { + const [dismissed, setDismissed] = useState(() => localStorage.getItem('agent-fox-onboarding-dismissed') === 'true'); + const { data: keyStatus } = useQuery({ + queryKey: ['api-key-status'], + queryFn: () => apiFetch<{ hasKey: boolean }>('/auth/api-key/status'), + }); + + if (dismissed || keyStatus?.hasKey) return null; + // Still loading + if (!keyStatus) return null; + + return ( +
+ +
+

Welcome! Generate an API key to start using MCP services.

+

You'll need an API key to connect your LLM client to your projects.

+
+ + +
+ ); +} export default function Layout() { const { user, loading, logout } = useAuth(); - const [sidebarOpen, setSidebarOpen] = useState(false); - const location = useLocation(); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); if (loading) { return ( @@ -17,112 +207,90 @@ export default function Layout() { } if (!user) return ; - const initials = user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2); - const isSettings = location.pathname === '/settings'; - return ( -
- {/* Mobile overlay */} - {sidebarOpen && ( -
setSidebarOpen(false)} /> - )} - - {/* Sidebar */} - - - {/* Main */} -
- {/* Mobile header */} -
- - Agent Fox -
-
-
- + +
+ + + +
+ Agent Fox + +
+ + {/* Right: theme toggle + user */} +
+ +
+ setSettingsOpen(true)} /> +
+ + + {/* Body: sidebar + main — fills remaining height */} +
+ {/* Mobile sidebar overlay */} + {mobileMenuOpen && ( +
setMobileMenuOpen(false)} /> + )} + + {/* Mobile sidebar */} + + + {/* Desktop project sidebar — stays fixed, has its own scroll */} + + + {/* Main content — only this area scrolls */} +
+
+ setSettingsOpen(true)} /> + setSettingsOpen(true) } satisfies LayoutContext} />
+ + {/* Settings dialog */} + setSettingsOpen(false)} />
); } diff --git a/packages/web/src/pages/ProjectDetail.tsx b/packages/web/src/pages/ProjectDetail.tsx index c21e7ae..f806d25 100644 --- a/packages/web/src/pages/ProjectDetail.tsx +++ b/packages/web/src/pages/ProjectDetail.tsx @@ -17,9 +17,9 @@ type ProjectData = { }; const tabs = [ + { key: 'mcp', label: 'MCP', icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1' }, { key: 'docs', label: 'Documentation', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' }, { key: 'modules', label: 'Modules', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' }, - { key: 'mcp', label: 'MCP', icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1' }, { key: 'settings', label: 'Settings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z' }, ] as const; @@ -27,7 +27,7 @@ type TabKey = (typeof tabs)[number]['key']; export default function ProjectDetail() { const { id } = useParams<{ id: string }>(); - const [activeTab, setActiveTab] = useState('docs'); + const [activeTab, setActiveTab] = useState('mcp'); const { data: project, isLoading } = useQuery({ queryKey: ['project', id], diff --git a/packages/web/src/pages/Settings.tsx b/packages/web/src/pages/Settings.tsx deleted file mode 100644 index 870c93a..0000000 --- a/packages/web/src/pages/Settings.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { useState } from 'react'; -import { useAuth } from '../lib/auth'; -import { apiFetch } from '../lib/api'; - -export default function Settings() { - const { user, updateUser } = useAuth(); - - const [name, setName] = useState(user?.name || ''); - const [profileLoading, setProfileLoading] = useState(false); - const [profileMsg, setProfileMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null); - - const [currentPassword, setCurrentPassword] = useState(''); - const [newPassword, setNewPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - const [passwordLoading, setPasswordLoading] = useState(false); - const [passwordMsg, setPasswordMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null); - - const handleProfileSave = async () => { - setProfileLoading(true); - setProfileMsg(null); - try { - const data = await apiFetch<{ id: string; email: string; name: string }>('/auth/profile', { - method: 'PUT', body: JSON.stringify({ name }), - }); - updateUser({ name: data.name }); - setProfileMsg({ type: 'success', text: 'Profile updated' }); - setTimeout(() => setProfileMsg(null), 3000); - } catch (err) { - setProfileMsg({ type: 'error', text: err instanceof Error ? err.message : 'Failed to update profile' }); - } finally { - setProfileLoading(false); - } - }; - - const handlePasswordChange = async () => { - if (newPassword !== confirmPassword) { - setPasswordMsg({ type: 'error', text: 'Passwords do not match' }); - return; - } - setPasswordLoading(true); - setPasswordMsg(null); - try { - await apiFetch('/auth/change-password', { - method: 'POST', body: JSON.stringify({ currentPassword, newPassword }), - }); - setPasswordMsg({ type: 'success', text: 'Password changed successfully' }); - setCurrentPassword(''); - setNewPassword(''); - setConfirmPassword(''); - setTimeout(() => setPasswordMsg(null), 3000); - } catch (err) { - setPasswordMsg({ type: 'error', text: err instanceof Error ? err.message : 'Failed to change password' }); - } finally { - setPasswordLoading(false); - } - }; - - const initials = user?.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2) || '?'; - - return ( -
-

Settings

- - {/* Profile */} -
-

Profile

-

Manage your personal information.

- -
-
- {initials} -
-
-
{user?.name}
-
{user?.email}
-
-
- -
-
- - setName(e.target.value)} className="input-base max-w-sm" /> -
- -
- -
- - {user?.email} -
-
- - {profileMsg && ( -
- - {profileMsg.type === 'success' ? : } - - {profileMsg.text} -
- )} - - -
-
- - {/* Password */} -
-

Change Password

-

Update your password to keep your account secure.

- -
-
- - setCurrentPassword(e.target.value)} className="input-base" placeholder="Enter current password" /> -
-
- - setNewPassword(e.target.value)} className="input-base" placeholder="At least 8 characters" minLength={8} /> -
-
- - setConfirmPassword(e.target.value)} className="input-base" placeholder="Confirm new password" /> -
- - {passwordMsg && ( -
- - {passwordMsg.type === 'success' ? : } - - {passwordMsg.text} -
- )} - - -
-
-
- ); -} diff --git a/packages/web/src/pages/tabs/DocPreview.tsx b/packages/web/src/pages/tabs/DocPreview.tsx index 3686cae..a5e8d43 100644 --- a/packages/web/src/pages/tabs/DocPreview.tsx +++ b/packages/web/src/pages/tabs/DocPreview.tsx @@ -4,6 +4,7 @@ import { apiFetch } from '../../lib/api'; import Badge from '../../components/Badge'; import Skeleton from '../../components/Skeleton'; import EmptyState from '../../components/EmptyState'; +import { ParametersView, RequestBodyView, ResponsesView } from '../../components/SchemaView'; 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 } }; @@ -36,10 +37,10 @@ export default function DocPreview({ projectId }: { projectId: string }) { const totalEndpoints = modules?.reduce((sum, m) => sum + m._count.endpoints, 0) || 0; return ( -
+
{/* Module sidebar */} -
-
+
+

Modules

{modulesLoading ? (
{[1,2,3].map(i => )}
@@ -71,7 +72,7 @@ export default function DocPreview({ projectId }: { projectId: string }) {
{/* Endpoints */} -
+
{endpointsLoading ? (
{[1,2,3,4,5].map(i => )}
) : endpoints?.length === 0 ? ( @@ -107,24 +108,9 @@ export default function DocPreview({ projectId }: { projectId: string }) { {endpointDetail.operationId}
)} - {Array.isArray(endpointDetail.parameters) && (endpointDetail.parameters as unknown[]).length > 0 && ( -
-

Parameters

-
{JSON.stringify(endpointDetail.parameters, null, 2)}
-
- )} - {endpointDetail.requestBody != null && ( -
-

Request Body

-
{JSON.stringify(endpointDetail.requestBody, null, 2)}
-
- )} - {endpointDetail.responses != null && ( -
-

Responses

-
{JSON.stringify(endpointDetail.responses, null, 2)}
-
- )} + + +
)}
diff --git a/packages/web/src/pages/tabs/McpIntegration.tsx b/packages/web/src/pages/tabs/McpIntegration.tsx index 7ec095b..454d661 100644 --- a/packages/web/src/pages/tabs/McpIntegration.tsx +++ b/packages/web/src/pages/tabs/McpIntegration.tsx @@ -1,20 +1,19 @@ import { useState } from 'react'; -import { useMutation } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { apiFetch } from '../../lib/api'; -import ConfirmDialog from '../../components/ConfirmDialog'; +import { useLayoutContext } from '../Layout'; type Project = { id: string; name: string }; export default function McpIntegration({ project }: { project: Project }) { - const [apiKey, setApiKey] = useState(null); - const [showRotateConfirm, setShowRotateConfirm] = useState(false); const [copied, setCopied] = useState(null); + const { onOpenSettings } = useLayoutContext(); const mcpHost = window.location.hostname; const mcpUrl = `http://${mcpHost}:3001/mcp/${project.id}`; - const rotateMutation = useMutation({ - mutationFn: () => apiFetch<{ apiKey: string }>(`/projects/${project.id}/api-key/rotate`, { method: 'POST' }), - onSuccess: (data) => { setApiKey(data.apiKey); setShowRotateConfirm(false); }, + const { data: keyStatus } = useQuery({ + queryKey: ['api-key-status'], + queryFn: () => apiFetch<{ hasKey: boolean; prefix: string | null }>('/auth/api-key/status'), }); const serverName = project.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); @@ -23,7 +22,7 @@ export default function McpIntegration({ project }: { project: Project }) { [serverName]: { type: 'http', url: mcpUrl, - headers: { Authorization: `Bearer ${apiKey || ''}` }, + headers: { Authorization: 'Bearer ' }, }, }, }, null, 2); @@ -52,37 +51,6 @@ export default function McpIntegration({ project }: { project: Project }) {
- {/* API Key */} -
-

API Key

-

Used to authenticate MCP requests. Each project has its own key.

- {apiKey ? ( -
-
-
- -

Save this key — it won't be shown again.

-
- -
- {apiKey} -
- ) : ( -
- -

API key is hidden. Rotate to generate a new one.

-
- )} - -
- {/* Config snippet */}

Configuration for Claude Code / Cursor

@@ -93,6 +61,30 @@ export default function McpIntegration({ project }: { project: Project }) { {copied === 'config' ? 'Copied!' : 'Copy'}
+ + {/* API Key guidance */} + {keyStatus && ( +
+ {keyStatus.hasKey ? ( +
+ +

+ API key generated. Copy it from{' '} + + {' '}and replace <your-api-key> above. +

+
+ ) : ( +
+ +

You need to generate an API key before using MCP.

+ +
+ )} +
+ )} {/* Available tools */} @@ -117,16 +109,6 @@ export default function McpIntegration({ project }: { project: Project }) { ))}
- - setShowRotateConfirm(false)} - onConfirm={() => rotateMutation.mutate()} - title="Rotate API Key" - description="This will invalidate the current API key immediately. Any MCP clients using the old key will stop working." - confirmText="Rotate Key" - variant="warning" - />
); } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3b7a081..699a601 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,15 +8,18 @@ datasource db { } model User { - id String @id @default(uuid()) - email String @unique - passwordHash String? - name String - avatarUrl String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - oauthAccounts OAuthAccount[] - projects Project[] + id String @id @default(uuid()) + email String @unique + passwordHash String? + name String + avatarUrl String? + apiKeyHash String? + apiKeyEncrypted String? + apiKeyPrefix String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + oauthAccounts OAuthAccount[] + projects Project[] } model OAuthAccount { @@ -38,7 +41,6 @@ model Project { baseUrl String? openApiSpec Json openApiVersion String - apiKeyHash String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade)