feat: opt web ux

This commit is contained in:
2026-04-02 22:10:24 +08:00
parent 143b1e8c4b
commit 35511eb877
16 changed files with 1251 additions and 383 deletions

View 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>
);
}

View 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"
/>
</>
);
}