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>
355 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|