ppt-tool/frontend/app/admin/settings/page.tsx
Vadym Samoilenko c431d4ab45 Implement critical security fixes and modern design system (Pre-launch P0 tasks)
Security Improvements (P0.0-P0.4):
- P0.0: Migrate to Gemini-only AI stack (simplified, single billing)
- P0.1: Fix CORS to restrict allowed origins from env (was *)
- P0.2: Remove hardcoded dev password, require env var
- P0.3: Add rate limiting (slowapi) - 3-10 req/min on sensitive endpoints
- P0.4: Add request size limits (100MB default via middleware)

New Features:
- Unified LLM service with Google Gemini priority
- OXML geometry extractor for layout parsing
- TSX validator for generated React components
- Client ID support in presentation requests with access control
- Configurable LLM/image timeouts via env vars

Modern Design System (P0.9 - partial):
- Enhanced CSS design tokens (primary, semantic colors, shadows)
- Typography scale (h1-h4, body variants, caption)
- Modern animations (fadeIn, slideIn, scaleIn)
- Updated Button component with better variants and hover effects
- Created unified Card and StatusBadge components
- Applied design system to Dashboard and Settings pages

Backend Improvements:
- Master deck parser simplification
- Slide-to-HTML endpoint cleanup (325 lines removed)
- Better error handling in prompts endpoint

Frontend Improvements:
- Settings UI simplified to show only Google/Gemini
- Dashboard uses CSS variables instead of hardcoded colors
- Improved button transitions and hover states

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
2026-02-27 18:28:24 +00:00

355 lines
11 KiB
TypeScript

'use client';
import React, { useEffect, useState, useCallback } from 'react';
import {
Settings,
Loader2,
Check,
Cpu,
Image as ImageIcon,
Key,
Zap,
CheckCircle2,
XCircle,
} from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getHeader } from '@/app/(presentation-generator)/services/api/header';
import { toast } from 'sonner';
interface SystemSettings {
llm_provider: string;
llm_model: string;
image_provider: string;
anthropic_api_key_set: boolean;
openai_api_key_set: boolean;
google_api_key_set: boolean;
available_llm_providers: string[];
available_image_providers: string[];
}
const PROVIDER_LABELS: Record<string, string> = {
google: 'Google Gemini',
gemini_flash: 'Gemini Flash (Image Generation)',
pexels: 'Pexels (Stock Photos)',
pixabay: 'Pixabay (Stock Photos)',
};
interface ConnectionTestResult {
ok: boolean;
error?: string;
latency_ms?: number;
}
export default function SettingsPage() {
const [settings, setSettings] = useState<SystemSettings | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Form state
const [llmProvider, setLlmProvider] = useState('');
const [llmModel, setLlmModel] = useState('');
const [imageProvider, setImageProvider] = useState('');
const [googleKey, setGoogleKey] = useState('');
// Model listing
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [loadingModels, setLoadingModels] = useState(false);
// Connection tests
const [testResults, setTestResults] = useState<Record<string, ConnectionTestResult>>({});
const [testingProvider, setTestingProvider] = useState<string | null>(null);
useEffect(() => {
loadSettings();
}, []);
const loadModels = useCallback(async (provider: string) => {
setLoadingModels(true);
setAvailableModels([]);
try {
const res = await fetch(`/api/v1/admin/settings/models?provider=${provider}`, {
headers: getHeader(),
});
if (res.ok) {
const data = await res.json();
setAvailableModels(data.models || []);
}
} catch {
// Silently fail — user can still type manually
} finally {
setLoadingModels(false);
}
}, []);
// Load models when provider changes
useEffect(() => {
if (llmProvider) {
loadModels(llmProvider);
}
}, [llmProvider, loadModels]);
const loadSettings = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/v1/admin/settings', { headers: getHeader() });
if (res.status === 403) {
setError('Super admin access required');
return;
}
if (!res.ok) throw new Error('Failed to load settings');
const data: SystemSettings = await res.json();
setSettings(data);
setLlmProvider(data.llm_provider);
setLlmModel(data.llm_model);
setImageProvider(data.image_provider);
} catch (e) {
setError('Failed to load settings');
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
const body: Record<string, string> = {};
if (llmProvider !== settings?.llm_provider) body.llm_provider = llmProvider;
if (llmModel !== settings?.llm_model) body.llm_model = llmModel;
if (imageProvider !== settings?.image_provider) body.image_provider = imageProvider;
if (googleKey) body.google_api_key = googleKey;
if (Object.keys(body).length === 0) {
toast.info('No changes to save');
setSaving(false);
return;
}
const res = await fetch('/api/v1/admin/settings', {
method: 'PUT',
headers: { ...getHeader(), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Save failed');
}
toast.success('Settings saved');
setGoogleKey('');
loadSettings();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Save failed');
} finally {
setSaving(false);
}
};
const testConnection = async () => {
setTestingProvider('google');
try {
const body: Record<string, string> = { provider: 'google' };
if (googleKey) body.api_key = googleKey;
const res = await fetch('/api/v1/admin/settings/test-connection', {
method: 'POST',
headers: { ...getHeader(), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const result: ConnectionTestResult = await res.json();
setTestResults((prev) => ({ ...prev, google: result }));
if (result.ok) {
toast.success(`Google Gemini: Connected (${result.latency_ms}ms)`);
} else {
toast.error(`Google Gemini: ${result.error}`);
}
} catch {
setTestResults((prev) => ({ ...prev, google: { ok: false, error: 'Request failed' } }));
toast.error('Connection test failed');
} finally {
setTestingProvider(null);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
</div>
);
}
if (error) {
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">System Settings</h1>
<div className="flex items-center justify-center h-64 border-2 border-dashed border-gray-300 rounded-lg">
<div className="text-center text-gray-400">
<Settings className="w-10 h-10 mx-auto mb-2" />
<p>{error}</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6 max-w-2xl">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">System Settings</h1>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Check className="w-4 h-4 mr-2" />}
Save Changes
</Button>
</div>
<p className="text-sm text-gray-500">
Oliver DeckForge uses <strong>Google Gemini</strong> for all AI operations.
Settings are persisted to the database and survive container restarts.
</p>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-800">
<strong>Get your Google AI API key:</strong>{' '}
<a
href="https://aistudio.google.com/app/apikey"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-blue-600"
>
https://aistudio.google.com/app/apikey
</a>
</p>
</div>
{/* Gemini Configuration */}
<Card className="p-6 space-y-4">
<div className="flex items-center gap-2 mb-2">
<Cpu className="w-5 h-5 text-[#5146E5]" />
<h2 className="text-lg font-semibold">Google Gemini (LLM)</h2>
</div>
<div className="space-y-1">
<Label>Model</Label>
{availableModels.length > 0 ? (
<Select value={llmModel} onValueChange={setLlmModel}>
<SelectTrigger>
{loadingModels ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<SelectValue placeholder="Select model" />
)}
</SelectTrigger>
<SelectContent>
{availableModels.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={llmModel}
onChange={(e) => setLlmModel(e.target.value)}
placeholder={loadingModels ? 'Loading models...' : 'e.g. gemini-2.0-flash-exp'}
/>
)}
<p className="text-xs text-gray-500 mt-1">
Recommended: <code className="bg-gray-100 px-1 rounded">gemini-2.0-flash-exp</code> (fast & cheap)
</p>
</div>
</Card>
{/* Image Generation */}
<Card className="p-6 space-y-4">
<div className="flex items-center gap-2 mb-2">
<ImageIcon className="w-5 h-5 text-green-600" />
<h2 className="text-lg font-semibold">Image Generation</h2>
</div>
<div className="space-y-1">
<Label>Provider</Label>
<Select value={imageProvider} onValueChange={setImageProvider}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{settings?.available_image_providers.map((p) => (
<SelectItem key={p} value={p}>
{PROVIDER_LABELS[p] || p}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</Card>
{/* API Key */}
<Card className="p-6 space-y-4">
<div className="flex items-center gap-2 mb-2">
<Key className="w-5 h-5 text-orange-600" />
<h2 className="text-lg font-semibold">Google API Key</h2>
</div>
<p className="text-xs text-gray-400">
Leave blank to keep existing key. Enter a new value to update.
</p>
<div className="space-y-1">
<Label className="flex items-center gap-2">
API Key
{settings?.google_api_key_set && (
<span className="text-[10px] px-1.5 py-0.5 bg-green-100 text-green-700 rounded-full">Set</span>
)}
{testResults.google && (
testResults.google.ok ? (
<span className="text-[10px] px-1.5 py-0.5 bg-green-100 text-green-700 rounded-full flex items-center gap-0.5">
<CheckCircle2 className="w-2.5 h-2.5" /> OK {testResults.google.latency_ms && `(${testResults.google.latency_ms}ms)`}
</span>
) : (
<span className="text-[10px] px-1.5 py-0.5 bg-red-100 text-red-700 rounded-full flex items-center gap-0.5">
<XCircle className="w-2.5 h-2.5" /> Failed
</span>
)
)}
</Label>
<div className="flex gap-2">
<Input
type="password"
value={googleKey}
onChange={(e) => setGoogleKey(e.target.value)}
placeholder={settings?.google_api_key_set ? '••••••••••••' : 'AIza...'}
className="flex-1"
/>
<Button
variant="outline"
size="sm"
onClick={testConnection}
disabled={testingProvider === 'google'}
className="h-9 px-3 text-xs whitespace-nowrap"
title="Test connection"
>
{testingProvider === 'google' ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Zap className="w-3.5 h-3.5" />
)}
Test
</Button>
</div>
</div>
</Card>
</div>
);
}