feat: opt web ux
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
29
packages/server/src/lib/crypto.ts
Normal file
29
packages/server/src/lib/crypto.ts
Normal file
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Projects />} />
|
||||
<Route path="/projects/:id" element={<ProjectDetail />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
370
packages/web/src/components/SchemaView.tsx
Normal file
370
packages/web/src/components/SchemaView.tsx
Normal file
@@ -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<string, SchemaObj>;
|
||||
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, string> = {
|
||||
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 (
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[11px] font-mono font-medium ${cls}`}>
|
||||
{type}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function InBadge({ location }: { location: string }) {
|
||||
return (
|
||||
<span className="inline-block px-1.5 py-0.5 rounded text-[11px] font-mono text-text-muted bg-bg-tertiary">
|
||||
{location}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ===== Parameters Table ===== */
|
||||
|
||||
export function ParametersView({ parameters }: { parameters: unknown }) {
|
||||
if (!Array.isArray(parameters) || parameters.length === 0) return null;
|
||||
const params = parameters as Parameter[];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="section-label mb-2">Parameters</p>
|
||||
<div className="border border-border-default rounded-lg overflow-hidden">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr className="bg-bg-tertiary/50 text-text-muted text-[11px] uppercase tracking-wider">
|
||||
<th className="text-left px-3 py-2 font-medium">Name</th>
|
||||
<th className="text-left px-3 py-2 font-medium">In</th>
|
||||
<th className="text-left px-3 py-2 font-medium">Type</th>
|
||||
<th className="text-left px-3 py-2 font-medium">Required</th>
|
||||
<th className="text-left px-3 py-2 font-medium">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-muted">
|
||||
{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 (
|
||||
<tr key={i} className="hover:bg-bg-tertiary/30 transition-colors">
|
||||
<td className="px-3 py-2.5 font-mono text-text-primary font-medium">
|
||||
{p.name}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<InBadge location={p.in} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<TypeBadge type={type} />
|
||||
{format && (
|
||||
<span className="text-[11px] text-text-muted">({format})</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{p.required ? (
|
||||
<span className="text-[11px] font-medium text-danger">required</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-text-muted">optional</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-text-secondary max-w-xs">
|
||||
<div>
|
||||
{p.description && <span>{p.description}</span>}
|
||||
{enumVals && enumVals.length > 0 && (
|
||||
<div className="mt-1 flex items-center gap-1 flex-wrap">
|
||||
<span className="text-[11px] text-text-muted">enum:</span>
|
||||
{enumVals.map((v, j) => (
|
||||
<code key={j} className="text-[11px] font-mono bg-bg-tertiary px-1 py-0.5 rounded text-text-secondary">
|
||||
{String(v)}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{p.schema?.default !== undefined && (
|
||||
<div className="mt-0.5 text-[11px] text-text-muted">
|
||||
default: <code className="font-mono">{JSON.stringify(p.schema.default)}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ===== 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 (
|
||||
<div className="px-3 py-2 text-[13px] text-text-muted">
|
||||
<TypeBadge type={resolveType(schema)} />
|
||||
{schema.description && <span className="ml-2">{schema.description}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={depth > 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 (
|
||||
<div key={name} className="py-1.5 first:pt-0">
|
||||
<div className="flex items-start gap-2 text-[13px]">
|
||||
<code className="font-mono text-text-primary font-medium shrink-0">{name}</code>
|
||||
<TypeBadge type={type} />
|
||||
{prop.format && (
|
||||
<span className="text-[11px] text-text-muted">({prop.format})</span>
|
||||
)}
|
||||
{requiredSet.has(name) && (
|
||||
<span className="text-[11px] font-medium text-danger">required</span>
|
||||
)}
|
||||
{prop.nullable && (
|
||||
<span className="text-[11px] text-text-muted">nullable</span>
|
||||
)}
|
||||
{prop.description && (
|
||||
<span className="text-text-secondary text-[12px] leading-snug">{prop.description}</span>
|
||||
)}
|
||||
</div>
|
||||
{prop.enum && prop.enum.length > 0 && (
|
||||
<div className="ml-0 mt-0.5 flex items-center gap-1 flex-wrap">
|
||||
<span className="text-[11px] text-text-muted">enum:</span>
|
||||
{prop.enum.map((v, j) => (
|
||||
<code key={j} className="text-[11px] font-mono bg-bg-tertiary px-1 py-0.5 rounded text-text-secondary">
|
||||
{String(v)}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{prop.default !== undefined && (
|
||||
<div className="text-[11px] text-text-muted mt-0.5">
|
||||
default: <code className="font-mono">{JSON.stringify(prop.default)}</code>
|
||||
</div>
|
||||
)}
|
||||
{hasChildren && <SchemaProperties schema={prop} depth={depth + 1} />}
|
||||
{isArray && prop.items && <SchemaProperties schema={prop.items} depth={depth + 1} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ===== 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<string, { schema?: SchemaObj }>;
|
||||
schema?: SchemaObj; // Swagger 2.0 converted format
|
||||
};
|
||||
|
||||
// Swagger 2.0 format: { schema: {...} }
|
||||
if (body.schema && !body.content) {
|
||||
return (
|
||||
<div>
|
||||
<p className="section-label mb-2">
|
||||
Request Body
|
||||
{body.required && <span className="text-danger ml-2 normal-case tracking-normal text-[11px]">required</span>}
|
||||
</p>
|
||||
<div className="border border-border-default rounded-lg p-3">
|
||||
<SchemaProperties schema={body.schema} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// OpenAPI 3.x format: { content: { "application/json": { schema: {...} } } }
|
||||
if (!body.content) return null;
|
||||
const contentTypes = Object.entries(body.content);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="section-label mb-2">
|
||||
Request Body
|
||||
{body.required && <span className="text-danger ml-2 normal-case tracking-normal text-[11px]">required</span>}
|
||||
</p>
|
||||
{body.description && (
|
||||
<p className="text-[13px] text-text-secondary mb-2">{body.description}</p>
|
||||
)}
|
||||
{contentTypes.map(([contentType, media]) => (
|
||||
<div key={contentType} className="border border-border-default rounded-lg overflow-hidden mb-2 last:mb-0">
|
||||
<div className="px-3 py-1.5 bg-bg-tertiary/50 border-b border-border-muted">
|
||||
<code className="text-[11px] font-mono text-text-muted">{contentType}</code>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{media.schema ? (
|
||||
media.schema.properties ? (
|
||||
<SchemaProperties schema={media.schema} />
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-[13px]">
|
||||
<TypeBadge type={resolveType(media.schema)} />
|
||||
{media.schema.description && <span className="text-text-secondary">{media.schema.description}</span>}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<span className="text-[13px] text-text-muted">No schema</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ===== 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 (
|
||||
<span className={`inline-block px-2 py-0.5 rounded text-[12px] font-mono font-semibold ${cls}`}>
|
||||
{code}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResponsesView({ responses }: { responses: unknown }) {
|
||||
if (!responses || typeof responses !== 'object') return null;
|
||||
const entries = Object.entries(responses as Record<string, unknown>);
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="section-label mb-2">Responses</p>
|
||||
<div className="space-y-2">
|
||||
{entries.map(([code, resp]) => {
|
||||
const response = resp as {
|
||||
description?: string;
|
||||
content?: Record<string, { schema?: SchemaObj }>;
|
||||
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 (
|
||||
<div key={code} className="border border-border-default rounded-lg overflow-hidden">
|
||||
<div className="px-3 py-2 bg-bg-tertiary/50 border-b border-border-muted flex items-center gap-2.5">
|
||||
<StatusBadge code={code} />
|
||||
{response.description && (
|
||||
<span className="text-[13px] text-text-secondary">{response.description}</span>
|
||||
)}
|
||||
{contentType && (
|
||||
<code className="text-[11px] font-mono text-text-muted ml-auto">{contentType}</code>
|
||||
)}
|
||||
</div>
|
||||
{schema && (schema.properties || schema.items?.properties || schema.type) && (
|
||||
<div className="p-3">
|
||||
{schema.properties ? (
|
||||
<SchemaProperties schema={schema} />
|
||||
) : schema.type === 'array' && schema.items?.properties ? (
|
||||
<div>
|
||||
<div className="text-[11px] text-text-muted mb-1">
|
||||
<TypeBadge type="array" /> of objects:
|
||||
</div>
|
||||
<SchemaProperties schema={schema.items} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-[13px]">
|
||||
<TypeBadge type={resolveType(schema)} />
|
||||
{schema.description && <span className="text-text-secondary">{schema.description}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
402
packages/web/src/components/SettingsDialog.tsx
Normal file
402
packages/web/src/components/SettingsDialog.tsx
Normal file
@@ -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<HTMLDialogElement>(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<ApiKeyStatus>('/auth/api-key/status'),
|
||||
enabled: open,
|
||||
});
|
||||
const [freshKey, setFreshKey] = useState<string | null>(null); // just generated/rotated
|
||||
const [revealedKey, setRevealedKey] = useState<string | null>(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 (
|
||||
<>
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
onClose={onClose}
|
||||
className="max-w-[560px] w-[90vw] rounded-2xl bg-bg-elevated border border-border-default shadow-lg p-0 backdrop:bg-overlay backdrop:backdrop-blur-sm"
|
||||
>
|
||||
<div className="px-6 py-4 border-b border-border-muted flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-text-primary">Settings</h2>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg text-text-muted hover:text-text-primary hover:bg-bg-tertiary transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5 space-y-6 max-h-[70vh] overflow-y-auto">
|
||||
{/* Profile */}
|
||||
<section>
|
||||
<p className="section-title">Profile</p>
|
||||
<p className="section-desc mb-4">Manage your personal information.</p>
|
||||
<div className="flex items-center gap-3.5 mb-5">
|
||||
<div className="w-12 h-12 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-sm font-bold tracking-wide">{initials}</div>
|
||||
<div>
|
||||
<div className="text-[14px] font-medium text-text-primary">{user?.name}</div>
|
||||
<div className="text-[13px] text-text-muted">{user?.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">Display Name</label>
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} className="input-base" />
|
||||
</div>
|
||||
{profileMsg && (
|
||||
<div className={`p-3 rounded-lg text-[13px] flex items-center gap-2 ${profileMsg.type === 'success' ? 'bg-success-muted text-success' : 'bg-danger-muted text-danger'}`}>
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
{profileMsg.type === 'success' ? <path d="M5 13l4 4L19 7" /> : <path d="M6 18L18 6M6 6l12 12" />}
|
||||
</svg>
|
||||
{profileMsg.text}
|
||||
</div>
|
||||
)}
|
||||
<button onClick={handleProfileSave} disabled={profileLoading || !name.trim()} className="btn-primary">
|
||||
{profileLoading ? 'Saving...' : 'Save Profile'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* API Key */}
|
||||
<section className="border-t border-border-default pt-5">
|
||||
<p className="section-title">API Key</p>
|
||||
<p className="section-desc mb-4">Used to authenticate all MCP requests across your projects.</p>
|
||||
|
||||
{/* Fresh key display (just generated or rotated) */}
|
||||
{freshKey ? (
|
||||
<div className="space-y-3">
|
||||
<div className="p-4 rounded-lg bg-warning-muted border border-warning/20 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-warning shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
|
||||
<p className="text-[13px] font-medium text-warning">Save this key now — you won't be able to see it again.</p>
|
||||
</div>
|
||||
<code className="block text-xs break-all text-text-primary font-mono bg-bg-primary/50 rounded p-2.5">{freshKey}</code>
|
||||
<button onClick={copyFreshKey} className="btn-outline w-full text-[13px]">
|
||||
{keyCopied ? (
|
||||
<><svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg> Copied</>
|
||||
) : (
|
||||
<><svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> Copy to Clipboard</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => setFreshKey(null)} className="text-[13px] text-text-muted hover:text-text-secondary transition-colors">
|
||||
I've saved it, continue
|
||||
</button>
|
||||
</div>
|
||||
) : !keyStatus?.hasKey ? (
|
||||
/* No key generated yet */
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 px-3.5 py-3 rounded-lg bg-bg-tertiary border border-border-muted">
|
||||
<svg className="w-4 h-4 text-text-muted shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
|
||||
<p className="text-[13px] text-text-secondary">No API key generated yet. Generate one to use MCP services.</p>
|
||||
</div>
|
||||
<button onClick={handleGenerateKey} disabled={keyLoading} className="btn-primary">
|
||||
{keyLoading ? (
|
||||
<><svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg> Generating...</>
|
||||
) : (
|
||||
<><svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg> Generate API Key</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Key exists — show masked with actions */
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3.5 py-2.5 rounded-lg bg-bg-tertiary border border-border-muted text-[13px] font-mono text-text-secondary truncate">
|
||||
{revealedKey || maskedKey}
|
||||
</code>
|
||||
{/* Reveal button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (revealedKey) { setRevealedKey(null); }
|
||||
else { setShowPasswordPrompt('reveal'); setVerifyPassword(''); setVerifyError(''); }
|
||||
}}
|
||||
className="btn-outline shrink-0 px-2.5"
|
||||
title={revealedKey ? 'Hide' : 'Reveal'}
|
||||
>
|
||||
{revealedKey ? (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" /></svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
|
||||
)}
|
||||
</button>
|
||||
{/* Copy button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (revealedKey) {
|
||||
navigator.clipboard.writeText(revealedKey);
|
||||
setKeyCopied(true);
|
||||
setTimeout(() => setKeyCopied(false), 2000);
|
||||
} else {
|
||||
setShowPasswordPrompt('copy');
|
||||
setVerifyPassword('');
|
||||
setVerifyError('');
|
||||
}
|
||||
}}
|
||||
className="btn-outline shrink-0 px-2.5"
|
||||
title="Copy"
|
||||
>
|
||||
{keyCopied ? (
|
||||
<svg className="w-4 h-4 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Password prompt inline */}
|
||||
{showPasswordPrompt && (
|
||||
<div className="p-3 rounded-lg border border-border-default bg-bg-primary space-y-2 animate-fade-in">
|
||||
<p className="text-[13px] text-text-secondary">Enter your password to {showPasswordPrompt === 'copy' ? 'copy' : 'reveal'} the API key.</p>
|
||||
<input
|
||||
type="password"
|
||||
value={verifyPassword}
|
||||
onChange={(e) => setVerifyPassword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && verifyPassword) handleVerifyAndAction(); }}
|
||||
className="input-base"
|
||||
placeholder="Current password"
|
||||
autoFocus
|
||||
/>
|
||||
{verifyError && <p className="text-[12px] text-danger">{verifyError}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleVerifyAndAction} disabled={verifyLoading || !verifyPassword} className="btn-primary text-[13px] py-1.5">
|
||||
{verifyLoading ? 'Verifying...' : 'Confirm'}
|
||||
</button>
|
||||
<button onClick={() => setShowPasswordPrompt(null)} className="btn-ghost text-[13px] py-1.5">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowRotateConfirm(true)}
|
||||
disabled={keyLoading}
|
||||
className="btn-outline text-[13px]"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Rotate API Key
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{keyError && (
|
||||
<div className="mt-2 p-3 rounded-lg bg-danger-muted text-[13px] text-danger flex items-center gap-2">
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
{keyError}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Password */}
|
||||
<section className="border-t border-border-default pt-5">
|
||||
<p className="section-title">Change Password</p>
|
||||
<p className="section-desc mb-4">Update your password to keep your account secure.</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">Current Password</label>
|
||||
<input type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} className="input-base" placeholder="Enter current password" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">New Password</label>
|
||||
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className="input-base" placeholder="At least 8 characters" minLength={8} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">Confirm New Password</label>
|
||||
<input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} className="input-base" placeholder="Confirm new password" />
|
||||
</div>
|
||||
{passwordMsg && (
|
||||
<div className={`p-3 rounded-lg text-[13px] flex items-center gap-2 ${passwordMsg.type === 'success' ? 'bg-success-muted text-success' : 'bg-danger-muted text-danger'}`}>
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
{passwordMsg.type === 'success' ? <path d="M5 13l4 4L19 7" /> : <path d="M6 18L18 6M6 6l12 12" />}
|
||||
</svg>
|
||||
{passwordMsg.text}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handlePasswordChange}
|
||||
disabled={passwordLoading || !currentPassword || !newPassword || newPassword.length < 8}
|
||||
className="btn-primary"
|
||||
>
|
||||
{passwordLoading ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showRotateConfirm}
|
||||
onCancel={() => 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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ImportResult | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(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 (
|
||||
<Modal open onClose={onClose} size="md">
|
||||
{!result ? (
|
||||
@@ -149,19 +139,6 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-warning-muted border border-warning/20">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
|
||||
<p className="text-[13px] font-medium text-warning">API Key — save it now</p>
|
||||
</div>
|
||||
<button onClick={copyKey} className="text-[11px] font-medium text-warning hover:underline">
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<code className="text-xs break-all text-text-primary font-mono bg-bg-primary/30 rounded p-2 block">{result.apiKey}</code>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => navigate(`/projects/${result.project.id}`)} className="btn-primary">Go to Project</button>
|
||||
</div>
|
||||
|
||||
@@ -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<LayoutContext>(); }
|
||||
|
||||
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<HTMLDivElement>(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 (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-bg-tertiary transition-colors"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-[10px] font-bold tracking-wide">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="hidden md:block text-left">
|
||||
<div className="text-[13px] font-medium text-text-primary leading-tight">{user.name}</div>
|
||||
</div>
|
||||
<svg className="w-3.5 h-3.5 text-text-muted hidden md:block" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="user-dropdown">
|
||||
{/* User info */}
|
||||
<div className="px-3 py-2.5 border-b border-border-muted">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-9 h-9 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-[11px] font-bold tracking-wide shrink-0">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-medium text-text-primary truncate">{user.name}</div>
|
||||
<div className="text-[11px] text-text-muted truncate">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => { setOpen(false); onOpenSettings(); }}
|
||||
className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13px] text-text-secondary hover:text-text-primary hover:bg-bg-tertiary transition-colors mx-1"
|
||||
style={{ width: 'calc(100% - 8px)' }}
|
||||
>
|
||||
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="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" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setOpen(false); logout(); }}
|
||||
className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13px] text-text-muted hover:text-danger hover:bg-danger-muted transition-colors mx-1"
|
||||
style={{ width: 'calc(100% - 8px)' }}
|
||||
>
|
||||
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectSidebar() {
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
const activeProjectId = params.id;
|
||||
|
||||
const { data: projects, isLoading } = useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: () => apiFetch<ProjectSummary[]>('/projects'),
|
||||
});
|
||||
|
||||
const isProjectsRoot = location.pathname === '/';
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:flex w-[240px] shrink-0 flex-col border-r border-border-default bg-bg-sidebar">
|
||||
{/* Sidebar header */}
|
||||
<div className="px-4 h-12 flex items-center justify-between border-b border-border-muted shrink-0">
|
||||
<span className="section-label">Projects</span>
|
||||
</div>
|
||||
|
||||
{/* Project list */}
|
||||
<nav className="flex-1 overflow-y-auto px-2 py-2 space-y-0.5">
|
||||
<NavLink
|
||||
to="/"
|
||||
end
|
||||
className={`flex items-center gap-2 px-2.5 py-[7px] rounded-lg text-[13px] font-medium transition-all duration-150 ${
|
||||
isProjectsRoot
|
||||
? 'bg-accent-muted text-accent'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-[15px] h-[15px] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
All Projects
|
||||
</NavLink>
|
||||
|
||||
{projects && projects.length > 0 && (
|
||||
<div className="border-t border-border-muted my-2!" />
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="space-y-1.5 px-1">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-8 rounded-lg skeleton" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projects?.map((p) => (
|
||||
<NavLink
|
||||
key={p.id}
|
||||
to={`/projects/${p.id}`}
|
||||
className={`flex items-center gap-2 px-2.5 py-[7px] rounded-lg text-[13px] transition-all duration-150 group ${
|
||||
activeProjectId === p.id
|
||||
? 'bg-accent-muted text-accent font-medium'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-[15px] h-[15px] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<span className="truncate">{p.name}</span>
|
||||
<span className="ml-auto text-[11px] text-text-muted opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
{p._count.endpoints}
|
||||
</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mb-6 p-4 rounded-xl bg-accent-muted border border-accent/20 flex items-center gap-4 animate-fade-in">
|
||||
<svg className="w-5 h-5 text-accent shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13px] text-text-primary font-medium">Welcome! Generate an API key to start using MCP services.</p>
|
||||
<p className="text-[12px] text-text-secondary mt-0.5">You'll need an API key to connect your LLM client to your projects.</p>
|
||||
</div>
|
||||
<button onClick={onOpenSettings} className="btn-primary shrink-0 text-[13px] py-1.5">
|
||||
Generate API Key
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setDismissed(true); localStorage.setItem('agent-fox-onboarding-dismissed', 'true'); }}
|
||||
className="p-1 rounded text-text-muted hover:text-text-primary transition-colors shrink-0"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <Navigate to="/login" replace />;
|
||||
|
||||
const initials = user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
||||
const isSettings = location.pathname === '/settings';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-secondary flex">
|
||||
{/* Mobile overlay */}
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 z-40 bg-overlay lg:hidden" onClick={() => setSidebarOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className={`fixed inset-y-0 left-0 z-50 w-[220px] bg-bg-sidebar border-r border-border-default flex flex-col transition-transform duration-200 lg:translate-x-0 lg:static lg:z-auto ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
|
||||
{/* Brand */}
|
||||
<div className="px-4 h-14 flex items-center gap-2.5 border-b border-border-default">
|
||||
<div className="w-7 h-7 rounded-lg bg-accent flex items-center justify-center shadow-sm">
|
||||
<svg className="w-[14px] h-[14px] text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-semibold text-[15px] text-text-primary tracking-[-0.01em]">Agent Fox</span>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-2.5 py-3 space-y-0.5">
|
||||
<NavLink
|
||||
to="/"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2.5 px-2.5 py-[7px] rounded-lg text-[13px] font-medium transition-all duration-150 ${
|
||||
isActive && !isSettings
|
||||
? 'bg-accent-muted text-accent'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
|
||||
}`
|
||||
}
|
||||
<div className="h-screen bg-bg-secondary flex flex-col overflow-hidden">
|
||||
{/* Top Header — fixed */}
|
||||
<header className="h-14 border-b border-border-default bg-bg-sidebar flex items-center px-4 lg:px-5 shrink-0 z-30">
|
||||
{/* Left: mobile menu + logo */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="lg:hidden p-1.5 -ml-1.5 text-text-secondary hover:text-text-primary rounded-md"
|
||||
>
|
||||
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
Projects
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2.5 px-2.5 py-[7px] rounded-lg text-[13px] font-medium transition-all duration-150 ${
|
||||
isActive
|
||||
? 'bg-accent-muted text-accent'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="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" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
Settings
|
||||
</NavLink>
|
||||
</nav>
|
||||
|
||||
{/* Bottom section */}
|
||||
<div className="px-2.5 pb-3 space-y-2.5">
|
||||
<div className="px-1">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div className="border-t border-border-default pt-2.5">
|
||||
<Link
|
||||
to="/settings"
|
||||
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg hover:bg-bg-tertiary transition-colors"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-[10px] font-bold tracking-wide">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-medium text-text-primary truncate leading-tight">{user.name}</div>
|
||||
<div className="text-[11px] text-text-muted truncate leading-tight mt-0.5">{user.email}</div>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex items-center gap-2.5 w-full px-2.5 py-[7px] rounded-lg text-[13px] text-text-muted hover:text-danger hover:bg-danger-muted transition-colors mt-0.5"
|
||||
>
|
||||
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Mobile header */}
|
||||
<header className="lg:hidden h-14 border-b border-border-default bg-bg-sidebar px-4 flex items-center">
|
||||
<button onClick={() => setSidebarOpen(true)} className="p-2 -ml-2 text-text-secondary hover:text-text-primary">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="ml-3 font-semibold text-[15px] text-text-primary">Agent Fox</span>
|
||||
</header>
|
||||
<main className="flex-1 p-5 lg:p-8 overflow-auto">
|
||||
<div className="animate-fade-in">
|
||||
<Outlet />
|
||||
<Link to="/" className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-lg bg-accent flex items-center justify-center shadow-sm">
|
||||
<svg className="w-[14px] h-[14px] text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-semibold text-[15px] text-text-primary tracking-[-0.01em]">Agent Fox</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Right: theme toggle + user */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<div className="w-px h-5 bg-border-default mx-1" />
|
||||
<UserDropdown user={user} logout={logout} onOpenSettings={() => setSettingsOpen(true)} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Body: sidebar + main — fills remaining height */}
|
||||
<div className="flex-1 flex min-h-0">
|
||||
{/* Mobile sidebar overlay */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="fixed inset-0 z-40 bg-overlay lg:hidden" onClick={() => setMobileMenuOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* Mobile sidebar */}
|
||||
<aside className={`fixed inset-y-0 left-0 z-50 w-[260px] bg-bg-sidebar border-r border-border-default flex flex-col transition-transform duration-200 lg:hidden ${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full'}`}>
|
||||
<div className="px-4 h-14 flex items-center gap-2.5 border-b border-border-default">
|
||||
<div className="w-7 h-7 rounded-lg bg-accent flex items-center justify-center shadow-sm">
|
||||
<svg className="w-[14px] h-[14px] text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-semibold text-[15px] text-text-primary tracking-[-0.01em]">Agent Fox</span>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto px-2.5 py-3 space-y-0.5">
|
||||
<NavLink
|
||||
to="/"
|
||||
end
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2.5 px-2.5 py-[7px] rounded-lg text-[13px] font-medium transition-all duration-150 ${
|
||||
isActive
|
||||
? 'bg-accent-muted text-accent'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
Projects
|
||||
</NavLink>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Desktop project sidebar — stays fixed, has its own scroll */}
|
||||
<ProjectSidebar />
|
||||
|
||||
{/* Main content — only this area scrolls */}
|
||||
<main className="flex-1 overflow-y-auto min-w-0">
|
||||
<div className="p-5 lg:p-8 animate-fade-in">
|
||||
<OnboardingBanner onOpenSettings={() => setSettingsOpen(true)} />
|
||||
<Outlet context={{ onOpenSettings: () => setSettingsOpen(true) } satisfies LayoutContext} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Settings dialog */}
|
||||
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<TabKey>('docs');
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('mcp');
|
||||
|
||||
const { data: project, isLoading } = useQuery({
|
||||
queryKey: ['project', id],
|
||||
|
||||
@@ -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 (
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-xl font-semibold text-text-primary tracking-[-0.01em] mb-8">Settings</h2>
|
||||
|
||||
{/* Profile */}
|
||||
<section className="mb-8">
|
||||
<p className="section-title">Profile</p>
|
||||
<p className="section-desc mb-5">Manage your personal information.</p>
|
||||
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-14 h-14 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-lg font-bold tracking-wide">
|
||||
{initials}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[14px] font-medium text-text-primary">{user?.name}</div>
|
||||
<div className="text-[13px] text-text-muted">{user?.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">Display Name</label>
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} className="input-base max-w-sm" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">Email</label>
|
||||
<div className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg bg-bg-tertiary border border-border-muted text-[13px] text-text-muted max-w-sm">
|
||||
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
|
||||
{user?.email}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profileMsg && (
|
||||
<div className={`p-3 rounded-lg text-[13px] flex items-center gap-2 max-w-sm ${profileMsg.type === 'success' ? 'bg-success-muted text-success' : 'bg-danger-muted text-danger'}`}>
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
{profileMsg.type === 'success' ? <path d="M5 13l4 4L19 7" /> : <path d="M6 18L18 6M6 6l12 12" />}
|
||||
</svg>
|
||||
{profileMsg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={handleProfileSave} disabled={profileLoading || !name.trim()} className="btn-primary">
|
||||
{profileLoading ? 'Saving...' : 'Save Profile'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Password */}
|
||||
<section className="border-t border-border-default pt-8">
|
||||
<p className="section-title">Change Password</p>
|
||||
<p className="section-desc mb-5">Update your password to keep your account secure.</p>
|
||||
|
||||
<div className="space-y-4 max-w-sm">
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">Current Password</label>
|
||||
<input type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} className="input-base" placeholder="Enter current password" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">New Password</label>
|
||||
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className="input-base" placeholder="At least 8 characters" minLength={8} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">Confirm New Password</label>
|
||||
<input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} className="input-base" placeholder="Confirm new password" />
|
||||
</div>
|
||||
|
||||
{passwordMsg && (
|
||||
<div className={`p-3 rounded-lg text-[13px] flex items-center gap-2 ${passwordMsg.type === 'success' ? 'bg-success-muted text-success' : 'bg-danger-muted text-danger'}`}>
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
{passwordMsg.type === 'success' ? <path d="M5 13l4 4L19 7" /> : <path d="M6 18L18 6M6 6l12 12" />}
|
||||
</svg>
|
||||
{passwordMsg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handlePasswordChange}
|
||||
disabled={passwordLoading || !currentPassword || !newPassword || newPassword.length < 8}
|
||||
className="btn-primary"
|
||||
>
|
||||
{passwordLoading ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex gap-6 min-h-[400px]">
|
||||
<div className="flex gap-6 h-[calc(100vh-280px)] min-h-[400px]">
|
||||
{/* Module sidebar */}
|
||||
<div className="w-52 shrink-0">
|
||||
<div className="sticky top-0">
|
||||
<div className="w-52 shrink-0 overflow-y-auto">
|
||||
<div>
|
||||
<p className="section-label px-3 mb-3">Modules</p>
|
||||
{modulesLoading ? (
|
||||
<div className="space-y-1.5 px-1">{[1,2,3].map(i => <Skeleton key={i} className="h-9 w-full" />)}</div>
|
||||
@@ -71,7 +72,7 @@ export default function DocPreview({ projectId }: { projectId: string }) {
|
||||
</div>
|
||||
|
||||
{/* Endpoints */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex-1 min-w-0 overflow-y-auto">
|
||||
{endpointsLoading ? (
|
||||
<div className="space-y-2">{[1,2,3,4,5].map(i => <Skeleton key={i} className="h-[52px] w-full" />)}</div>
|
||||
) : endpoints?.length === 0 ? (
|
||||
@@ -107,24 +108,9 @@ export default function DocPreview({ projectId }: { projectId: string }) {
|
||||
<code className="text-xs font-mono text-text-secondary bg-bg-tertiary px-1.5 py-0.5 rounded">{endpointDetail.operationId}</code>
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(endpointDetail.parameters) && (endpointDetail.parameters as unknown[]).length > 0 && (
|
||||
<div>
|
||||
<p className="section-label mb-2">Parameters</p>
|
||||
<pre className="code-block text-xs">{JSON.stringify(endpointDetail.parameters, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
{endpointDetail.requestBody != null && (
|
||||
<div>
|
||||
<p className="section-label mb-2">Request Body</p>
|
||||
<pre className="code-block text-xs">{JSON.stringify(endpointDetail.requestBody, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
{endpointDetail.responses != null && (
|
||||
<div>
|
||||
<p className="section-label mb-2">Responses</p>
|
||||
<pre className="code-block text-xs">{JSON.stringify(endpointDetail.responses, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
<ParametersView parameters={endpointDetail.parameters} />
|
||||
<RequestBodyView requestBody={endpointDetail.requestBody} />
|
||||
<ResponsesView responses={endpointDetail.responses} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [showRotateConfirm, setShowRotateConfirm] = useState(false);
|
||||
const [copied, setCopied] = useState<string | null>(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 || '<your-api-key>'}` },
|
||||
headers: { Authorization: 'Bearer <your-api-key>' },
|
||||
},
|
||||
},
|
||||
}, null, 2);
|
||||
@@ -52,37 +51,6 @@ export default function McpIntegration({ project }: { project: Project }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* API Key */}
|
||||
<section>
|
||||
<p className="section-title">API Key</p>
|
||||
<p className="section-desc mb-3">Used to authenticate MCP requests. Each project has its own key.</p>
|
||||
{apiKey ? (
|
||||
<div className="p-4 rounded-lg bg-warning-muted border border-warning/20 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
|
||||
<p className="text-xs font-medium text-warning">Save this key — it won't be shown again.</p>
|
||||
</div>
|
||||
<button onClick={() => copyText(apiKey, 'key')} className="text-xs font-medium text-warning hover:underline">
|
||||
{copied === 'key' ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<code className="block text-xs break-all text-text-primary font-mono bg-bg-primary/50 rounded p-2">{apiKey}</code>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 px-3.5 py-2.5 rounded-lg bg-bg-tertiary border border-border-muted">
|
||||
<svg className="w-4 h-4 text-text-muted shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
|
||||
<p className="text-[13px] text-text-muted">API key is hidden. Rotate to generate a new one.</p>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => setShowRotateConfirm(true)} className="btn-outline mt-3">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Rotate API Key
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* Config snippet */}
|
||||
<section>
|
||||
<p className="section-title">Configuration for Claude Code / Cursor</p>
|
||||
@@ -93,6 +61,30 @@ export default function McpIntegration({ project }: { project: Project }) {
|
||||
{copied === 'config' ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* API Key guidance */}
|
||||
{keyStatus && (
|
||||
<div className="mt-3">
|
||||
{keyStatus.hasKey ? (
|
||||
<div className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg bg-bg-tertiary border border-border-muted">
|
||||
<svg className="w-4 h-4 text-success shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M5 13l4 4L19 7" /></svg>
|
||||
<p className="text-[13px] text-text-secondary">
|
||||
API key generated. Copy it from{' '}
|
||||
<button onClick={onOpenSettings} className="text-accent hover:underline font-medium">Settings</button>
|
||||
{' '}and replace <code className="text-xs font-mono bg-bg-inset px-1 py-0.5 rounded"><your-api-key></code> above.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 p-3.5 rounded-lg bg-warning-muted border border-warning/20">
|
||||
<svg className="w-4 h-4 text-warning shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
|
||||
<p className="text-[13px] text-text-secondary flex-1">You need to generate an API key before using MCP.</p>
|
||||
<button onClick={onOpenSettings} className="btn-primary shrink-0 text-[13px] py-1.5 px-3">
|
||||
Open Settings
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Available tools */}
|
||||
@@ -117,16 +109,6 @@ export default function McpIntegration({ project }: { project: Project }) {
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showRotateConfirm}
|
||||
onCancel={() => 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"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user