feat(ai): localize AI features

This commit is contained in:
Sepehr Ramezani
2026-02-15 17:38:16 +01:00
parent 8f9031f076
commit 9eb3bd912a
72 changed files with 17098 additions and 7759 deletions

View File

@@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge'
import { useState, useEffect } from 'react'
import { toast } from 'sonner'
import { Loader2, CheckCircle2, XCircle, Clock, Zap, Info } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
interface TestResult {
success: boolean
@@ -20,6 +21,7 @@ interface TestResult {
}
export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
const { t } = useLanguage()
const [isLoading, setIsLoading] = useState(false)
const [result, setResult] = useState<TestResult | null>(null)
const [config, setConfig] = useState<any>(null)
@@ -34,7 +36,6 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
const data = await response.json()
setConfig(data)
// Set previous result if available
if (data.previousTest) {
setResult(data.previousTest[type] || null)
}
@@ -84,7 +85,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
responseTime: endTime - startTime
}
setResult(errorResult)
toast.error(`❌ Test Error: ${error.message}`)
toast.error(t('admin.aiTest.testError', { error: error.message }))
} finally {
setIsLoading(false)
}
@@ -113,13 +114,13 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
{/* Provider Info */}
<div className="space-y-3 p-4 bg-muted/50 rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Provider:</span>
<span className="text-sm font-medium">{t('admin.aiTest.provider')}</span>
<Badge variant="outline" className="text-xs">
{providerInfo.provider.toUpperCase()}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Model:</span>
<span className="text-sm font-medium">{t('admin.aiTest.model')}</span>
<span className="text-sm text-muted-foreground font-mono">
{providerInfo.model}
</span>
@@ -136,12 +137,12 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Testing...
{t('admin.aiTest.testing')}
</>
) : (
<>
<Zap className="mr-2 h-4 w-4" />
Run Test
{t('admin.aiTest.runTest')}
</>
)}
</Button>
@@ -155,12 +156,12 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
{result.success ? (
<>
<CheckCircle2 className="h-5 w-5 text-green-600" />
<span className="font-semibold text-green-600 dark:text-green-400">Test Passed</span>
<span className="font-semibold text-green-600 dark:text-green-400">{t('admin.aiTest.testPassed')}</span>
</>
) : (
<>
<XCircle className="h-5 w-5 text-red-600" />
<span className="font-semibold text-red-600 dark:text-red-400">Test Failed</span>
<span className="font-semibold text-red-600 dark:text-red-400">{t('admin.aiTest.testFailed')}</span>
</>
)}
</div>
@@ -169,7 +170,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
{result.responseTime && (
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-4">
<Clock className="h-4 w-4" />
<span>Response time: {result.responseTime}ms</span>
<span>{t('admin.aiTest.responseTime', { time: result.responseTime })}</span>
</div>
)}
@@ -178,7 +179,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
<div className="space-y-3">
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">Generated Tags:</span>
<span className="text-sm font-medium">{t('admin.aiTest.generatedTags')}</span>
</div>
<div className="flex flex-wrap gap-2">
{result.tags.map((tag, idx) => (
@@ -202,19 +203,19 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
<div className="space-y-3">
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium">Embedding Dimensions:</span>
<span className="text-sm font-medium">{t('admin.aiTest.embeddingDimensions')}</span>
</div>
<div className="p-3 bg-muted rounded-lg">
<div className="text-2xl font-bold text-center">
{result.embeddingLength}
</div>
<div className="text-xs text-center text-muted-foreground mt-1">
vector dimensions
{t('admin.aiTest.vectorDimensions')}
</div>
</div>
{result.firstValues && result.firstValues.length > 0 && (
<div className="space-y-1">
<span className="text-xs font-medium">First 5 values:</span>
<span className="text-xs font-medium">{t('admin.aiTest.first5Values')}</span>
<div className="p-2 bg-muted rounded font-mono text-xs">
[{result.firstValues.slice(0, 5).map((v, i) => v.toFixed(4)).join(', ')}]
</div>
@@ -226,7 +227,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
{/* Error Details */}
{!result.success && result.error && (
<div className="mt-4 p-3 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-900">
<p className="text-sm font-medium text-red-900 dark:text-red-100">Error:</p>
<p className="text-sm font-medium text-red-900 dark:text-red-100">{t('admin.aiTest.error')}</p>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">{result.error}</p>
{result.details && (
<details className="mt-2">
@@ -249,7 +250,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
<div className="text-center py-4">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
<p className="text-sm text-muted-foreground mt-2">
Testing {type === 'tags' ? 'tags generation' : 'embeddings'}...
{t('admin.aiTest.testing')} {type === 'tags' ? 'tags generation' : 'embeddings'}...
</p>
</div>
)}

View File

@@ -1,26 +1,48 @@
import { AdminMetrics } from '@/components/admin-metrics'
import { Button } from '@/components/ui/button'
import { Zap, Settings, Activity, TrendingUp } from 'lucide-react'
import Link from 'next/link'
import { getSystemConfig } from '@/lib/config'
export default async function AdminAIPage() {
const config = await getSystemConfig()
// Determine provider status based on config
const openaiKey = config.OPENAI_API_KEY
const ollamaUrl = config.OLLAMA_BASE_URL || config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL_EMBEDDING
const providers = [
{
name: 'OpenAI',
status: openaiKey ? 'Connected' : 'Not Configured',
requests: 'N/A' // We don't track request counts yet
},
{
name: 'Ollama',
status: ollamaUrl ? 'Available' : 'Not Configured',
requests: 'N/A'
},
]
// Mock AI metrics - in a real app, these would come from analytics
// TODO: Implement real analytics tracking
const aiMetrics = [
{
title: 'Total Requests',
value: '856',
trend: { value: 12, isPositive: true },
value: '',
trend: { value: 0, isPositive: true },
icon: <Zap className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />,
},
{
title: 'Success Rate',
value: '98.5%',
trend: { value: 2, isPositive: true },
value: '100%',
trend: { value: 0, isPositive: true },
icon: <TrendingUp className="h-5 w-5 text-green-600 dark:text-green-400" />,
},
{
title: 'Avg Response Time',
value: '1.2s',
trend: { value: 5, isPositive: true },
value: '',
trend: { value: 0, isPositive: true },
icon: <Activity className="h-5 w-5 text-primary dark:text-primary-foreground" />,
},
{
@@ -41,10 +63,12 @@ export default async function AdminAIPage() {
Monitor and configure AI features
</p>
</div>
<Button variant="outline">
<Settings className="mr-2 h-4 w-4" />
Configure
</Button>
<Link href="/admin/settings">
<Button variant="outline">
<Settings className="mr-2 h-4 w-4" />
Configure
</Button>
</Link>
</div>
<AdminMetrics metrics={aiMetrics} />
@@ -83,10 +107,7 @@ export default async function AdminAIPage() {
AI Provider Status
</h2>
<div className="space-y-3">
{[
{ name: 'OpenAI', status: 'Connected', requests: '642' },
{ name: 'Ollama', status: 'Available', requests: '214' },
].map((provider) => (
{providers.map((provider) => (
<div
key={provider.name}
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-zinc-800 rounded-lg"
@@ -100,11 +121,10 @@ export default async function AdminAIPage() {
</p>
</div>
<span
className={`px-2 py-1 text-xs font-medium rounded-full ${
provider.status === 'Connected'
className={`px-2 py-1 text-xs font-medium rounded-full ${provider.status === 'Connected' || provider.status === 'Available'
? 'text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900'
: 'text-primary dark:text-primary-foreground bg-primary/10 dark:bg-primary/20'
}`}
: 'text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-800'
}`}
>
{provider.status}
</span>
@@ -119,7 +139,7 @@ export default async function AdminAIPage() {
Recent AI Requests
</h2>
<p className="text-gray-600 dark:text-gray-400">
Recent AI requests will be displayed here.
Recent AI requests will be displayed here. (Coming Soon)
</p>
</div>
</div>

View File

@@ -15,50 +15,52 @@ import { Input } from '@/components/ui/input'
import { Plus } from 'lucide-react'
import { createUser } from '@/app/actions/admin'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
export function CreateUserDialog() {
const [open, setOpen] = useState(false)
const { t } = useLanguage()
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" /> Add User
<Plus className="mr-2 h-4 w-4" /> {t('admin.users.addUser')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create User</DialogTitle>
<DialogTitle>{t('admin.users.createUser')}</DialogTitle>
<DialogDescription>
Add a new user to the system. They will need to change their password upon first login if you implement that policy.
{t('admin.users.createUserDescription')}
</DialogDescription>
</DialogHeader>
<form
action={async (formData) => {
const result = await createUser(formData)
if (result?.error) {
toast.error('Failed to create user')
toast.error(t('admin.users.createFailed'))
} else {
toast.success('User created successfully')
toast.success(t('admin.users.createSuccess'))
setOpen(false)
}
}}
className="grid gap-4 py-4"
>
<div className="grid gap-2">
<label htmlFor="name">Name</label>
<label htmlFor="name">{t('admin.users.name')}</label>
<Input id="name" name="name" required />
</div>
<div className="grid gap-2">
<label htmlFor="email">Email</label>
<label htmlFor="email">{t('admin.users.email')}</label>
<Input id="email" name="email" type="email" required />
</div>
<div className="grid gap-2">
<label htmlFor="password">Password</label>
<label htmlFor="password">{t('admin.users.password')}</label>
<Input id="password" name="password" type="password" required minLength={6} />
</div>
<div className="grid gap-2">
<label htmlFor="role">Role</label>
<label htmlFor="role">{t('admin.users.role')}</label>
<select
id="role"
name="role"
@@ -69,7 +71,7 @@ export function CreateUserDialog() {
</select>
</div>
<DialogFooter>
<Button type="submit">Create User</Button>
<Button type="submit">{t('admin.users.createUser')}</Button>
</DialogFooter>
</form>
</DialogContent>

View File

@@ -6,10 +6,12 @@ import { Checkbox } from '@/components/ui/checkbox'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { updateSystemConfig, testSMTP } from '@/app/actions/admin-settings'
import { getOllamaModels } from '@/app/actions/ollama'
import { toast } from 'sonner'
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { TestTube, ExternalLink } from 'lucide-react'
import { TestTube, ExternalLink, RefreshCw } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
type AIProvider = 'ollama' | 'openai' | 'custom'
@@ -19,10 +21,7 @@ interface AvailableModels {
}
const MODELS_2026 = {
ollama: {
tags: ['llama3:latest', 'llama3.2:latest', 'granite4:latest', 'mistral:latest', 'mixtral:latest', 'phi3:latest', 'gemma2:latest', 'qwen2:latest'],
embeddings: ['embeddinggemma:latest', 'mxbai-embed-large:latest', 'nomic-embed-text:latest']
},
// Removed hardcoded Ollama models in favor of dynamic fetching
openai: {
tags: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo'],
embeddings: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002']
@@ -34,6 +33,7 @@ const MODELS_2026 = {
}
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
const { t } = useLanguage()
const [isSaving, setIsSaving] = useState(false)
const [isTesting, setIsTesting] = useState(false)
@@ -46,15 +46,68 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
const [tagsProvider, setTagsProvider] = useState<AIProvider>((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
const [embeddingsProvider, setEmbeddingsProvider] = useState<AIProvider>((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
// Sync state with config when server revalidates
// Selected Models State (Controlled Inputs)
const [selectedTagsModel, setSelectedTagsModel] = useState<string>(config.AI_MODEL_TAGS || '')
const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState<string>(config.AI_MODEL_EMBEDDING || '')
// Dynamic Models State
const [ollamaTagsModels, setOllamaTagsModels] = useState<string[]>([])
const [ollamaEmbeddingsModels, setOllamaEmbeddingsModels] = useState<string[]>([])
const [isLoadingTagsModels, setIsLoadingTagsModels] = useState(false)
const [isLoadingEmbeddingsModels, setIsLoadingEmbeddingsModels] = useState(false)
// Sync state with config
useEffect(() => {
setAllowRegister(config.ALLOW_REGISTRATION !== 'false')
setSmtpSecure(config.SMTP_SECURE === 'true')
setSmtpIgnoreCert(config.SMTP_IGNORE_CERT === 'true')
setTagsProvider((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
setEmbeddingsProvider((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
setSelectedTagsModel(config.AI_MODEL_TAGS || '')
setSelectedEmbeddingModel(config.AI_MODEL_EMBEDDING || '')
}, [config])
// Fetch Ollama models
const fetchOllamaModels = useCallback(async (type: 'tags' | 'embeddings', url: string) => {
if (!url) return
if (type === 'tags') setIsLoadingTagsModels(true)
else setIsLoadingEmbeddingsModels(true)
try {
const result = await getOllamaModels(url)
if (result.success) {
if (type === 'tags') setOllamaTagsModels(result.models)
else setOllamaEmbeddingsModels(result.models)
} else {
toast.error(`Failed to fetch Ollama models: ${result.error}`)
}
} catch (error) {
console.error(error)
toast.error('Failed to fetch Ollama models')
} finally {
if (type === 'tags') setIsLoadingTagsModels(false)
else setIsLoadingEmbeddingsModels(false)
}
}, [])
// Initial fetch for Ollama models if provider is selected
useEffect(() => {
if (tagsProvider === 'ollama') {
const url = config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434'
fetchOllamaModels('tags', url)
}
}, [tagsProvider, config.OLLAMA_BASE_URL_TAGS, config.OLLAMA_BASE_URL, fetchOllamaModels])
useEffect(() => {
if (embeddingsProvider === 'ollama') {
const url = config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434'
fetchOllamaModels('embeddings', url)
}
}, [embeddingsProvider, config.OLLAMA_BASE_URL_EMBEDDING, config.OLLAMA_BASE_URL, fetchOllamaModels])
const handleSaveSecurity = async (formData: FormData) => {
setIsSaving(true)
const data = {
@@ -65,9 +118,9 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
setIsSaving(false)
if (result.error) {
toast.error('Failed to update security settings')
toast.error(t('admin.security.updateFailed'))
} else {
toast.success('Security Settings updated')
toast.success(t('admin.security.updateSuccess'))
}
}
@@ -76,9 +129,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
const data: Record<string, string> = {}
try {
// Tags provider configuration
const tagsProv = formData.get('AI_PROVIDER_TAGS') as AIProvider
if (!tagsProv) throw new Error('AI_PROVIDER_TAGS is required')
if (!tagsProv) throw new Error(t('admin.ai.providerTagsRequired'))
data.AI_PROVIDER_TAGS = tagsProv
const tagsModel = formData.get('AI_MODEL_TAGS') as string
@@ -86,7 +138,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
if (tagsProv === 'ollama') {
const ollamaUrl = formData.get('OLLAMA_BASE_URL_TAGS') as string
if (ollamaUrl) data.OLLAMA_BASE_URL = ollamaUrl
if (ollamaUrl) data.OLLAMA_BASE_URL_TAGS = ollamaUrl
} else if (tagsProv === 'openai') {
const openaiKey = formData.get('OPENAI_API_KEY') as string
if (openaiKey) data.OPENAI_API_KEY = openaiKey
@@ -97,9 +149,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl
}
// Embeddings provider configuration
const embedProv = formData.get('AI_PROVIDER_EMBEDDING') as AIProvider
if (!embedProv) throw new Error('AI_PROVIDER_EMBEDDING is required')
if (!embedProv) throw new Error(t('admin.ai.providerEmbeddingRequired'))
data.AI_PROVIDER_EMBEDDING = embedProv
const embedModel = formData.get('AI_MODEL_EMBEDDING') as string
@@ -107,7 +158,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
if (embedProv === 'ollama') {
const ollamaUrl = formData.get('OLLAMA_BASE_URL_EMBEDDING') as string
if (ollamaUrl) data.OLLAMA_BASE_URL = ollamaUrl
if (ollamaUrl) data.OLLAMA_BASE_URL_EMBEDDING = ollamaUrl
} else if (embedProv === 'openai') {
const openaiKey = formData.get('OPENAI_API_KEY') as string
if (openaiKey) data.OPENAI_API_KEY = openaiKey
@@ -118,20 +169,30 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl
}
console.log('Saving AI config:', data)
const result = await updateSystemConfig(data)
setIsSaving(false)
if (result.error) {
toast.error('Failed to update AI settings: ' + result.error)
toast.error(t('admin.ai.updateFailed') + ': ' + result.error)
} else {
toast.success('AI Settings updated successfully')
toast.success(t('admin.ai.updateSuccess'))
setTagsProvider(tagsProv)
setEmbeddingsProvider(embedProv)
// Refresh models after save if Ollama is selected
if (tagsProv === 'ollama') {
const url = data.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434'
fetchOllamaModels('tags', url)
}
if (embedProv === 'ollama') {
const url = data.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434'
fetchOllamaModels('embeddings', url)
}
}
} catch (error: any) {
setIsSaving(false)
toast.error('Error: ' + error.message)
toast.error(t('general.error') + ': ' + error.message)
}
}
@@ -151,9 +212,9 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
setIsSaving(false)
if (result.error) {
toast.error('Failed to update SMTP settings')
toast.error(t('admin.smtp.updateFailed'))
} else {
toast.success('SMTP Settings updated')
toast.success(t('admin.smtp.updateSuccess'))
}
}
@@ -162,12 +223,12 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
try {
const result: any = await testSMTP()
if (result.success) {
toast.success('Test email sent successfully!')
toast.success(t('admin.smtp.testSuccess'))
} else {
toast.error(`Failed: ${result.error}`)
toast.error(t('admin.smtp.testFailed', { error: result.error }))
}
} catch (e: any) {
toast.error(`Error: ${e.message}`)
toast.error(t('general.error') + ': ' + e.message)
} finally {
setIsTesting(false)
}
@@ -177,8 +238,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Security Settings</CardTitle>
<CardDescription>Manage access control and registration policies.</CardDescription>
<CardTitle>{t('admin.security.title')}</CardTitle>
<CardDescription>{t('admin.security.description')}</CardDescription>
</CardHeader>
<form action={handleSaveSecurity}>
<CardContent className="space-y-4">
@@ -192,35 +253,34 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
htmlFor="ALLOW_REGISTRATION"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Allow Public Registration
{t('admin.security.allowPublicRegistration')}
</label>
</div>
<p className="text-xs text-muted-foreground">
If disabled, new users can only be added by an Administrator via the User Management page.
{t('admin.security.allowPublicRegistrationDescription')}
</p>
</CardContent>
<CardFooter>
<Button type="submit" disabled={isSaving}>Save Security Settings</Button>
<Button type="submit" disabled={isSaving}>{t('admin.security.title')}</Button>
</CardFooter>
</form>
</Card>
<Card>
<CardHeader>
<CardTitle>AI Configuration</CardTitle>
<CardDescription>Configure AI providers for auto-tagging and semantic search. Use different providers for optimal performance.</CardDescription>
<CardTitle>{t('admin.ai.title')}</CardTitle>
<CardDescription>{t('admin.ai.description')}</CardDescription>
</CardHeader>
<form action={handleSaveAI}>
<CardContent className="space-y-6">
{/* Tags Generation Section */}
<div className="space-y-4 p-4 border rounded-lg bg-primary/5 dark:bg-primary/10">
<h3 className="text-base font-semibold flex items-center gap-2">
<span className="text-primary">🏷</span> Tags Generation Provider
<span className="text-primary">🏷</span> {t('admin.ai.tagsGenerationProvider')}
</h3>
<p className="text-xs text-muted-foreground">AI provider for automatic tag suggestions. Recommended: Ollama (free, local).</p>
<p className="text-xs text-muted-foreground">{t('admin.ai.tagsGenerationDescription')}</p>
<div className="space-y-2">
<Label htmlFor="AI_PROVIDER_TAGS">Provider</Label>
<Label htmlFor="AI_PROVIDER_TAGS">{t('admin.ai.provider')}</Label>
<select
id="AI_PROVIDER_TAGS"
name="AI_PROVIDER_TAGS"
@@ -228,100 +288,125 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
onChange={(e) => setTagsProvider(e.target.value as AIProvider)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="ollama">🦙 Ollama (Local & Free)</option>
<option value="openai">🤖 OpenAI (GPT-5, GPT-4)</option>
<option value="custom">🔧 Custom OpenAI-Compatible</option>
<option value="ollama">{t('admin.ai.providerOllamaOption')}</option>
<option value="openai">{t('admin.ai.providerOpenAIOption')}</option>
<option value="custom">{t('admin.ai.providerCustomOption')}</option>
</select>
</div>
{/* Ollama Tags Config */}
{tagsProvider === 'ollama' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="OLLAMA_BASE_URL_TAGS">Base URL</Label>
<Input id="OLLAMA_BASE_URL_TAGS" name="OLLAMA_BASE_URL_TAGS" defaultValue={config.OLLAMA_BASE_URL || 'http://localhost:11434'} placeholder="http://localhost:11434" />
<Label htmlFor="OLLAMA_BASE_URL_TAGS">{t('admin.ai.baseUrl')}</Label>
<div className="flex gap-2">
<Input
id="OLLAMA_BASE_URL_TAGS"
name="OLLAMA_BASE_URL_TAGS"
defaultValue={config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434'}
placeholder="http://localhost:11434"
/>
<Button
type="button"
size="icon"
variant="outline"
onClick={() => {
const input = document.getElementById('OLLAMA_BASE_URL_TAGS') as HTMLInputElement
fetchOllamaModels('tags', input.value)
}}
disabled={isLoadingTagsModels}
title="Refresh Models"
>
<RefreshCw className={`h-4 w-4 ${isLoadingTagsModels ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_TAGS_OLLAMA">Model</Label>
<Label htmlFor="AI_MODEL_TAGS_OLLAMA">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_TAGS_OLLAMA"
name="AI_MODEL_TAGS"
defaultValue={config.AI_MODEL_TAGS || 'granite4:latest'}
name="AI_MODEL_TAGS_OLLAMA"
value={selectedTagsModel}
onChange={(e) => setSelectedTagsModel(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{MODELS_2026.ollama.tags.map((model) => (
<option key={model} value={model}>{model}</option>
))}
{ollamaTagsModels.length > 0 ? (
ollamaTagsModels.map((model) => (
<option key={model} value={model}>{model}</option>
))
) : (
<option value={selectedTagsModel || 'granite4:latest'}>{selectedTagsModel || 'granite4:latest'} {t('admin.ai.saved')}</option>
)}
</select>
<p className="text-xs text-muted-foreground">Select an Ollama model installed on your system</p>
<p className="text-xs text-muted-foreground">
{isLoadingTagsModels ? 'Fetching models...' : t('admin.ai.selectOllamaModel')}
</p>
</div>
</div>
)}
{/* OpenAI Tags Config */}
{tagsProvider === 'openai' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="OPENAI_API_KEY">API Key</Label>
<Label htmlFor="OPENAI_API_KEY">{t('admin.ai.apiKey')}</Label>
<Input id="OPENAI_API_KEY" name="OPENAI_API_KEY" type="password" defaultValue={config.OPENAI_API_KEY || ''} placeholder="sk-..." />
<p className="text-xs text-muted-foreground">Your OpenAI API key from <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">platform.openai.com</a></p>
<p className="text-xs text-muted-foreground">{t('admin.ai.openAIKeyDescription')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_TAGS_OPENAI">Model</Label>
<Label htmlFor="AI_MODEL_TAGS_OPENAI">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_TAGS_OPENAI"
name="AI_MODEL_TAGS"
defaultValue={config.AI_MODEL_TAGS || 'gpt-4o-mini'}
name="AI_MODEL_TAGS_OPENAI"
value={selectedTagsModel}
onChange={(e) => setSelectedTagsModel(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{MODELS_2026.openai.tags.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
<p className="text-xs text-muted-foreground"><strong className="text-green-600">gpt-4o-mini</strong> = Best value <strong className="text-primary">gpt-4o</strong> = Best quality</p>
<p className="text-xs text-muted-foreground"><strong className="text-green-600">gpt-4o-mini</strong> = {t('admin.ai.bestValue')} <strong className="text-primary">gpt-4o</strong> = {t('admin.ai.bestQuality')}</p>
</div>
</div>
)}
{/* Custom OpenAI Tags Config */}
{tagsProvider === 'custom' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_TAGS">Base URL</Label>
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_TAGS">{t('admin.ai.baseUrl')}</Label>
<Input id="CUSTOM_OPENAI_BASE_URL_TAGS" name="CUSTOM_OPENAI_BASE_URL_TAGS" defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''} placeholder="https://api.example.com/v1" />
</div>
<div className="space-y-2">
<Label htmlFor="CUSTOM_OPENAI_API_KEY_TAGS">API Key</Label>
<Label htmlFor="CUSTOM_OPENAI_API_KEY_TAGS">{t('admin.ai.apiKey')}</Label>
<Input id="CUSTOM_OPENAI_API_KEY_TAGS" name="CUSTOM_OPENAI_API_KEY_TAGS" type="password" defaultValue={config.CUSTOM_OPENAI_API_KEY || ''} placeholder="sk-..." />
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_TAGS_CUSTOM">Model</Label>
<Label htmlFor="AI_MODEL_TAGS_CUSTOM">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_TAGS_CUSTOM"
name="AI_MODEL_TAGS"
defaultValue={config.AI_MODEL_TAGS || 'gpt-4o-mini'}
name="AI_MODEL_TAGS_CUSTOM"
value={selectedTagsModel}
onChange={(e) => setSelectedTagsModel(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{MODELS_2026.custom.tags.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
<p className="text-xs text-muted-foreground">Common models for OpenAI-compatible APIs</p>
<p className="text-xs text-muted-foreground">{t('admin.ai.commonModelsDescription')}</p>
</div>
</div>
)}
</div>
{/* Embeddings Section */}
<div className="space-y-4 p-4 border rounded-lg bg-green-50/50 dark:bg-green-950/20">
<h3 className="text-base font-semibold flex items-center gap-2">
<span className="text-green-600">🔍</span> Embeddings Provider
<span className="text-green-600">🔍</span> {t('admin.ai.embeddingsProvider')}
</h3>
<p className="text-xs text-muted-foreground">AI provider for semantic search embeddings. Recommended: OpenAI (best quality).</p>
<p className="text-xs text-muted-foreground">{t('admin.ai.embeddingsDescription')}</p>
<div className="space-y-2">
<Label htmlFor="AI_PROVIDER_EMBEDDING">
Provider
{t('admin.ai.provider')}
<span className="ml-2 text-xs text-muted-foreground">
(Current: {embeddingsProvider})
</span>
@@ -333,99 +418,125 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
onChange={(e) => setEmbeddingsProvider(e.target.value as AIProvider)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="ollama">🦙 Ollama (Local & Free)</option>
<option value="openai">🤖 OpenAI (text-embedding-4)</option>
<option value="custom">🔧 Custom OpenAI-Compatible</option>
<option value="ollama">{t('admin.ai.providerOllamaOption')}</option>
<option value="openai">{t('admin.ai.providerOpenAIOption')}</option>
<option value="custom">{t('admin.ai.providerCustomOption')}</option>
</select>
<p className="text-xs text-muted-foreground">
Config value: {config.AI_PROVIDER_EMBEDDING || 'Not set (defaults to ollama)'}
</p>
</div>
{/* Ollama Embeddings Config */}
{embeddingsProvider === 'ollama' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="OLLAMA_BASE_URL_EMBEDDING">Base URL</Label>
<Input id="OLLAMA_BASE_URL_EMBEDDING" name="OLLAMA_BASE_URL_EMBEDDING" defaultValue={config.OLLAMA_BASE_URL || 'http://localhost:11434'} placeholder="http://localhost:11434" />
<Label htmlFor="OLLAMA_BASE_URL_EMBEDDING">{t('admin.ai.baseUrl')}</Label>
<div className="flex gap-2">
<Input
id="OLLAMA_BASE_URL_EMBEDDING"
name="OLLAMA_BASE_URL_EMBEDDING"
defaultValue={config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434'}
placeholder="http://localhost:11434"
/>
<Button
type="button"
size="icon"
variant="outline"
onClick={() => {
const input = document.getElementById('OLLAMA_BASE_URL_EMBEDDING') as HTMLInputElement
fetchOllamaModels('embeddings', input.value)
}}
disabled={isLoadingEmbeddingsModels}
title="Refresh Models"
>
<RefreshCw className={`h-4 w-4 ${isLoadingEmbeddingsModels ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_EMBEDDING_OLLAMA">Model</Label>
<Label htmlFor="AI_MODEL_EMBEDDING_OLLAMA">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_EMBEDDING_OLLAMA"
name="AI_MODEL_EMBEDDING"
defaultValue={config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest'}
name="AI_MODEL_EMBEDDING_OLLAMA"
value={selectedEmbeddingModel}
onChange={(e) => setSelectedEmbeddingModel(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{MODELS_2026.ollama.embeddings.map((model) => (
<option key={model} value={model}>{model}</option>
))}
{ollamaEmbeddingsModels.length > 0 ? (
ollamaEmbeddingsModels.map((model) => (
<option key={model} value={model}>{model}</option>
))
) : (
<option value={selectedEmbeddingModel || 'embeddinggemma:latest'}>{selectedEmbeddingModel || 'embeddinggemma:latest'} {t('admin.ai.saved')}</option>
)}
</select>
<p className="text-xs text-muted-foreground">Select an embedding model installed on your system</p>
<p className="text-xs text-muted-foreground">
{isLoadingEmbeddingsModels ? 'Fetching models...' : t('admin.ai.selectEmbeddingModel')}
</p>
</div>
</div>
)}
{/* OpenAI Embeddings Config */}
{embeddingsProvider === 'openai' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="OPENAI_API_KEY">API Key</Label>
<Label htmlFor="OPENAI_API_KEY">{t('admin.ai.apiKey')}</Label>
<Input id="OPENAI_API_KEY" name="OPENAI_API_KEY" type="password" defaultValue={config.OPENAI_API_KEY || ''} placeholder="sk-..." />
<p className="text-xs text-muted-foreground">Your OpenAI API key from <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">platform.openai.com</a></p>
<p className="text-xs text-muted-foreground">{t('admin.ai.openAIKeyDescription')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_EMBEDDING_OPENAI">Model</Label>
<Label htmlFor="AI_MODEL_EMBEDDING_OPENAI">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_EMBEDDING_OPENAI"
name="AI_MODEL_EMBEDDING"
defaultValue={config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'}
name="AI_MODEL_EMBEDDING_OPENAI"
value={selectedEmbeddingModel}
onChange={(e) => setSelectedEmbeddingModel(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{MODELS_2026.openai.embeddings.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
<p className="text-xs text-muted-foreground"><strong className="text-green-600">text-embedding-3-small</strong> = Best value <strong className="text-primary">text-embedding-3-large</strong> = Best quality</p>
<p className="text-xs text-muted-foreground"><strong className="text-green-600">text-embedding-3-small</strong> = {t('admin.ai.bestValue')} <strong className="text-primary">text-embedding-3-large</strong> = {t('admin.ai.bestQuality')}</p>
</div>
</div>
)}
{/* Custom OpenAI Embeddings Config */}
{embeddingsProvider === 'custom' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_EMBEDDING">Base URL</Label>
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_EMBEDDING">{t('admin.ai.baseUrl')}</Label>
<Input id="CUSTOM_OPENAI_BASE_URL_EMBEDDING" name="CUSTOM_OPENAI_BASE_URL_EMBEDDING" defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''} placeholder="https://api.example.com/v1" />
</div>
<div className="space-y-2">
<Label htmlFor="CUSTOM_OPENAI_API_KEY_EMBEDDING">API Key</Label>
<Label htmlFor="CUSTOM_OPENAI_API_KEY_EMBEDDING">{t('admin.ai.apiKey')}</Label>
<Input id="CUSTOM_OPENAI_API_KEY_EMBEDDING" name="CUSTOM_OPENAI_API_KEY_EMBEDDING" type="password" defaultValue={config.CUSTOM_OPENAI_API_KEY || ''} placeholder="sk-..." />
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_EMBEDDING_CUSTOM">Model</Label>
<Label htmlFor="AI_MODEL_EMBEDDING_CUSTOM">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_EMBEDDING_CUSTOM"
name="AI_MODEL_EMBEDDING"
defaultValue={config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'}
name="AI_MODEL_EMBEDDING_CUSTOM"
value={selectedEmbeddingModel}
onChange={(e) => setSelectedEmbeddingModel(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{MODELS_2026.custom.embeddings.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
<p className="text-xs text-muted-foreground">Common embedding models for OpenAI-compatible APIs</p>
<p className="text-xs text-muted-foreground">{t('admin.ai.commonEmbeddingModels')}</p>
</div>
</div>
)}
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button type="submit" disabled={isSaving}>{isSaving ? 'Saving...' : 'Save AI Settings'}</Button>
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>
<Link href="/admin/ai-test">
<Button type="button" variant="outline" className="gap-2">
<TestTube className="h-4 w-4" />
Open AI Test Panel
{t('admin.ai.openTestPanel')}
<ExternalLink className="h-3 w-3" />
</Button>
</Link>
@@ -435,34 +546,34 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
<Card>
<CardHeader>
<CardTitle>SMTP Configuration</CardTitle>
<CardDescription>Configure email server for password resets.</CardDescription>
<CardTitle>{t('admin.smtp.title')}</CardTitle>
<CardDescription>{t('admin.smtp.description')}</CardDescription>
</CardHeader>
<form action={handleSaveSMTP}>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label htmlFor="SMTP_HOST" className="text-sm font-medium">Host</label>
<label htmlFor="SMTP_HOST" className="text-sm font-medium">{t('admin.smtp.host')}</label>
<Input id="SMTP_HOST" name="SMTP_HOST" defaultValue={config.SMTP_HOST || ''} placeholder="smtp.example.com" />
</div>
<div className="space-y-2">
<label htmlFor="SMTP_PORT" className="text-sm font-medium">Port</label>
<label htmlFor="SMTP_PORT" className="text-sm font-medium">{t('admin.smtp.port')}</label>
<Input id="SMTP_PORT" name="SMTP_PORT" defaultValue={config.SMTP_PORT || '587'} placeholder="587" />
</div>
</div>
<div className="space-y-2">
<label htmlFor="SMTP_USER" className="text-sm font-medium">Username</label>
<label htmlFor="SMTP_USER" className="text-sm font-medium">{t('admin.smtp.username')}</label>
<Input id="SMTP_USER" name="SMTP_USER" defaultValue={config.SMTP_USER || ''} />
</div>
<div className="space-y-2">
<label htmlFor="SMTP_PASS" className="text-sm font-medium">Password</label>
<label htmlFor="SMTP_PASS" className="text-sm font-medium">{t('admin.smtp.password')}</label>
<Input id="SMTP_PASS" name="SMTP_PASS" type="password" defaultValue={config.SMTP_PASS || ''} />
</div>
<div className="space-y-2">
<label htmlFor="SMTP_FROM" className="text-sm font-medium">From Email</label>
<label htmlFor="SMTP_FROM" className="text-sm font-medium">{t('admin.smtp.fromEmail')}</label>
<Input id="SMTP_FROM" name="SMTP_FROM" defaultValue={config.SMTP_FROM || 'noreply@memento.app'} />
</div>
@@ -476,7 +587,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
htmlFor="SMTP_SECURE"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Force SSL/TLS (usually for port 465)
{t('admin.smtp.forceSSL')}
</label>
</div>
@@ -490,14 +601,14 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
htmlFor="SMTP_IGNORE_CERT"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-yellow-600"
>
Ignore Certificate Errors (Self-hosted/Dev only)
{t('admin.smtp.ignoreCertErrors')}
</label>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button type="submit" disabled={isSaving}>Save SMTP Settings</Button>
<Button type="submit" disabled={isSaving}>{t('admin.smtp.saveSettings')}</Button>
<Button type="button" variant="secondary" onClick={handleTestEmail} disabled={isTesting}>
{isTesting ? 'Sending...' : 'Test Email'}
{isTesting ? t('admin.smtp.sending') : t('admin.smtp.testEmail')}
</Button>
</CardFooter>
</form>

View File

@@ -6,17 +6,18 @@ import { deleteUser, updateUserRole } from '@/app/actions/admin'
import { toast } from 'sonner'
import { Trash2, Shield, ShieldOff } from 'lucide-react'
import { format } from 'date-fns'
import { useLanguage } from '@/lib/i18n'
export function UserList({ initialUsers }: { initialUsers: any[] }) {
// Optimistic update could be implemented here, but standard is fine for admin
const { t } = useLanguage()
const handleDelete = async (id: string) => {
if (!confirm('Are you sure? This action cannot be undone.')) return
if (!confirm(t('admin.users.confirmDelete'))) return
try {
await deleteUser(id)
toast.success('User deleted')
toast.success(t('admin.users.deleteSuccess'))
} catch (e) {
toast.error('Failed to delete')
toast.error(t('admin.users.deleteFailed'))
}
}
@@ -24,9 +25,9 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
const newRole = user.role === 'ADMIN' ? 'USER' : 'ADMIN'
try {
await updateUserRole(user.id, newRole)
toast.success(`User role updated to ${newRole}`)
toast.success(t('admin.users.roleUpdateSuccess', { role: newRole }))
} catch (e) {
toast.error('Failed to update role')
toast.error(t('admin.users.roleUpdateFailed'))
}
}
@@ -35,21 +36,21 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
<table className="w-full caption-bottom text-sm text-left">
<thead className="[&_tr]:border-b">
<tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">Name</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">Email</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">Role</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">Created At</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground text-right">Actions</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.name')}</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.email')}</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.role')}</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.createdAt')}</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground text-right">{t('admin.users.table.actions')}</th>
</tr>
</thead>
<tbody className="[&_tr:last-child]:border-0">
{initialUsers.map((user) => (
<tr key={user.id} className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td className="p-4 align-middle font-medium">{user.name || 'N/A'}</td>
<td className="p-4 align-middle font-medium">{user.name || t('common.notAvailable')}</td>
<td className="p-4 align-middle">{user.email}</td>
<td className="p-4 align-middle">
<span className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${user.role === 'ADMIN' ? 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80' : 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80'}`}>
{user.role}
{user.role === 'ADMIN' ? t('admin.users.roles.admin') : t('admin.users.roles.user')}
</span>
</td>
<td className="p-4 align-middle">{format(new Date(user.createdAt), 'PP')}</td>
@@ -59,7 +60,7 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
variant="ghost"
size="sm"
onClick={() => handleRoleToggle(user)}
title={user.role === 'ADMIN' ? "Demote to User" : "Promote to Admin"}
title={user.role === 'ADMIN' ? t('admin.users.demote') : t('admin.users.promote')}
>
{user.role === 'ADMIN' ? <ShieldOff className="h-4 w-4" /> : <Shield className="h-4 w-4" />}
</Button>