Add Anthropic API, test environment, remove locale settings

Feature 1: Anthropic API Integration
- Add @anthropic-ai/sdk with adapter wrapping OpenAI-shaped interface
- Support Claude models (opus, sonnet, haiku) with extended thinking
- Auto-reset model on provider switch, JSON retry logic
- Add Claude model pricing to ai-usage tracker
- Update AI settings form with Anthropic provider option

Feature 2: Remove Locale Settings UI
- Strip Localization tab from admin settings
- Remove i18n settings from router inferCategory and getFeatureFlags
- Keep franc document language detection intact

Feature 3: Test Environment with Role Impersonation
- Add isTest field to User, Program, Project, Competition models
- Test environment service: create/teardown with realistic dummy data
- JWT-based impersonation for test users (@test.local emails)
- Impersonation banner with quick-switch between test roles
- Test environment panel in admin settings (SUPER_ADMIN only)
- Email redirect: @test.local emails routed to admin with [TEST] prefix
- Complete data isolation: 45+ isTest:false filters across platform
  - All global queries on User/Project/Program/Competition
  - AI services blocked from processing test data
  - Cron jobs skip test rounds/users
  - Analytics/exports exclude test data
  - Admin layout/pickers hide test programs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 17:20:48 +01:00
parent 161cd1684a
commit 87d5aea315
61 changed files with 2089 additions and 983 deletions

View File

@@ -1,5 +1,6 @@
'use client'
import { useEffect, useRef } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@@ -36,6 +37,7 @@ const formSchema = z.object({
ai_model: z.string(),
ai_send_descriptions: z.boolean(),
openai_api_key: z.string().optional(),
anthropic_api_key: z.string().optional(),
openai_base_url: z.string().optional(),
})
@@ -48,6 +50,7 @@ interface AISettingsFormProps {
ai_model?: string
ai_send_descriptions?: string
openai_api_key?: string
anthropic_api_key?: string
openai_base_url?: string
}
}
@@ -63,12 +66,29 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
ai_model: settings.ai_model || 'gpt-4o',
ai_send_descriptions: settings.ai_send_descriptions === 'true',
openai_api_key: '',
anthropic_api_key: '',
openai_base_url: settings.openai_base_url || '',
},
})
const watchProvider = form.watch('ai_provider')
const isLiteLLM = watchProvider === 'litellm'
const isAnthropic = watchProvider === 'anthropic'
const prevProviderRef = useRef(settings.ai_provider || 'openai')
// Auto-reset model when provider changes
useEffect(() => {
if (watchProvider !== prevProviderRef.current) {
prevProviderRef.current = watchProvider
if (watchProvider === 'anthropic') {
form.setValue('ai_model', 'claude-sonnet-4-5-20250514')
} else if (watchProvider === 'openai') {
form.setValue('ai_model', 'gpt-4o')
} else if (watchProvider === 'litellm') {
form.setValue('ai_model', '')
}
}
}, [watchProvider, form])
// Fetch available models from OpenAI API (skip for LiteLLM — no models.list support)
const {
@@ -119,6 +139,9 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
if (data.openai_api_key && data.openai_api_key.trim()) {
settingsToUpdate.push({ key: 'openai_api_key', value: data.openai_api_key })
}
if (data.anthropic_api_key && data.anthropic_api_key.trim()) {
settingsToUpdate.push({ key: 'anthropic_api_key', value: data.anthropic_api_key })
}
// Save base URL (empty string clears it)
settingsToUpdate.push({ key: 'openai_base_url', value: data.openai_base_url?.trim() || '' })
@@ -139,6 +162,9 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
)
const categoryLabels: Record<string, string> = {
'claude-4.5': 'Claude 4.5 Series (Latest)',
'claude-4': 'Claude 4 Series',
'claude-3.5': 'Claude 3.5 Series',
'gpt-5+': 'GPT-5+ Series (Latest)',
'gpt-4o': 'GPT-4o Series',
'gpt-4': 'GPT-4 Series',
@@ -147,7 +173,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
other: 'Other Models',
}
const categoryOrder = ['gpt-5+', 'gpt-4o', 'gpt-4', 'gpt-3.5', 'reasoning', 'other']
const categoryOrder = ['claude-4.5', 'claude-4', 'claude-3.5', 'gpt-5+', 'gpt-4o', 'gpt-4', 'gpt-3.5', 'reasoning', 'other']
return (
<Form {...form}>
@@ -187,13 +213,16 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
</FormControl>
<SelectContent>
<SelectItem value="openai">OpenAI (API Key)</SelectItem>
<SelectItem value="anthropic">Anthropic (Claude API)</SelectItem>
<SelectItem value="litellm">LiteLLM Proxy (ChatGPT Subscription)</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{field.value === 'litellm'
? 'Route AI calls through a LiteLLM proxy connected to your ChatGPT Plus/Pro subscription'
: 'Direct OpenAI API access using your API key'}
: field.value === 'anthropic'
? 'Direct Anthropic API access using Claude models'
: 'Direct OpenAI API access using your API key'}
</FormDescription>
<FormMessage />
</FormItem>
@@ -211,37 +240,71 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
</Alert>
)}
<FormField
control={form.control}
name="openai_api_key"
render={({ field }) => (
<FormItem>
<FormLabel>{isLiteLLM ? 'API Key (Optional)' : 'API Key'}</FormLabel>
<FormControl>
<Input
type="password"
placeholder={isLiteLLM
? 'Optional — leave blank for default'
: (settings.openai_api_key ? '••••••••' : 'Enter API key')}
{...field}
/>
</FormControl>
<FormDescription>
{isLiteLLM
? 'LiteLLM proxy usually does not require an API key. Leave blank to use default.'
: 'Your OpenAI API key. Leave blank to keep the existing key.'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{isAnthropic && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
<strong>Anthropic Claude Mode</strong> AI calls use the Anthropic Messages API.
Claude Opus models include extended thinking for deeper analysis.
JSON responses are validated with automatic retry.
</AlertDescription>
</Alert>
)}
{isAnthropic ? (
<FormField
control={form.control}
name="anthropic_api_key"
render={({ field }) => (
<FormItem>
<FormLabel>Anthropic API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder={settings.anthropic_api_key ? '••••••••' : 'Enter Anthropic API key'}
{...field}
/>
</FormControl>
<FormDescription>
Your Anthropic API key. Leave blank to keep the existing key.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
) : (
<FormField
control={form.control}
name="openai_api_key"
render={({ field }) => (
<FormItem>
<FormLabel>{isLiteLLM ? 'API Key (Optional)' : 'OpenAI API Key'}</FormLabel>
<FormControl>
<Input
type="password"
placeholder={isLiteLLM
? 'Optional — leave blank for default'
: (settings.openai_api_key ? '••••••••' : 'Enter API key')}
{...field}
/>
</FormControl>
<FormDescription>
{isLiteLLM
? 'LiteLLM proxy usually does not require an API key. Leave blank to use default.'
: 'Your OpenAI API key. Leave blank to keep the existing key.'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="openai_base_url"
render={({ field }) => (
<FormItem>
<FormLabel>{isLiteLLM ? 'LiteLLM Proxy URL' : 'API Base URL (Optional)'}</FormLabel>
<FormLabel>{isLiteLLM ? 'LiteLLM Proxy URL' : isAnthropic ? 'Anthropic Base URL (Optional)' : 'API Base URL (Optional)'}</FormLabel>
<FormControl>
<Input
placeholder={isLiteLLM ? 'http://localhost:4000' : 'https://api.openai.com/v1'}
@@ -255,6 +318,10 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
<code className="text-xs bg-muted px-1 rounded">http://localhost:4000</code>{' '}
or your server address.
</>
) : isAnthropic ? (
<>
Custom base URL for Anthropic API proxy or gateway. Leave blank for default Anthropic API.
</>
) : (
<>
Custom base URL for OpenAI-compatible providers. Leave blank for OpenAI.
@@ -288,7 +355,42 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
)}
</div>
{isLiteLLM || modelsData?.manualEntry ? (
{isAnthropic ? (
// Anthropic: fetch models from server (hardcoded list)
modelsLoading ? (
<Skeleton className="h-10 w-full" />
) : modelsData?.success && modelsData.models && modelsData.models.length > 0 ? (
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select Claude model" />
</SelectTrigger>
</FormControl>
<SelectContent>
{categoryOrder
.filter((cat) => groupedModels?.[cat]?.length)
.map((category) => (
<SelectGroup key={category}>
<SelectLabel className="text-xs font-semibold text-muted-foreground">
{categoryLabels[category] || category}
</SelectLabel>
{groupedModels?.[category]?.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
) : (
<Input
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
placeholder="claude-sonnet-4-5-20250514"
/>
)
) : isLiteLLM || modelsData?.manualEntry ? (
<Input
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
@@ -341,7 +443,16 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
</Select>
)}
<FormDescription>
{isLiteLLM ? (
{isAnthropic ? (
form.watch('ai_model')?.includes('opus') ? (
<span className="flex items-center gap-1 text-amber-600">
<SlidersHorizontal className="h-3 w-3" />
Opus model includes extended thinking for deeper analysis
</span>
) : (
'Anthropic Claude model to use for AI features'
)
) : isLiteLLM ? (
<>
Enter the model ID with the{' '}
<code className="text-xs bg-muted px-1 rounded">chatgpt/</code> prefix.

View File

@@ -23,14 +23,15 @@ import {
Newspaper,
BarChart3,
ShieldAlert,
Globe,
Webhook,
MessageCircle,
FlaskConical,
} from 'lucide-react'
import Link from 'next/link'
import { AnimatedCard } from '@/components/shared/animated-container'
import { AISettingsForm } from './ai-settings-form'
import { AIUsageCard } from './ai-usage-card'
import { TestEnvironmentPanel } from './test-environment-panel'
import { BrandingSettingsForm } from './branding-settings-form'
import { EmailSettingsForm } from './email-settings-form'
import { StorageSettingsForm } from './storage-settings-form'
@@ -158,11 +159,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
'whatsapp_provider',
])
const localizationSettings = getSettingsByKeys([
'localization_enabled_locales',
'localization_default_locale',
])
return (
<>
<Tabs defaultValue="defaults" className="space-y-6">
@@ -176,10 +172,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<Palette className="h-4 w-4" />
Branding
</TabsTrigger>
<TabsTrigger value="localization" className="gap-2 shrink-0">
<Globe className="h-4 w-4" />
Locale
</TabsTrigger>
{isSuperAdmin && (
<TabsTrigger value="email" className="gap-2 shrink-0">
<Mail className="h-4 w-4" />
@@ -236,6 +228,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
Webhooks
</Link>
)}
{isSuperAdmin && (
<TabsTrigger value="testenv" className="gap-2 shrink-0">
<FlaskConical className="h-4 w-4" />
Test Env
</TabsTrigger>
)}
</TabsList>
<div className="lg:flex lg:gap-8">
@@ -253,10 +251,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<Palette className="h-4 w-4" />
Branding
</TabsTrigger>
<TabsTrigger value="localization" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<Globe className="h-4 w-4" />
Locale
</TabsTrigger>
</TabsList>
</div>
<div>
@@ -333,6 +327,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
Webhooks
<ExternalLink className="ml-auto h-3 w-3 opacity-50" />
</Link>
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5 mt-1">
<TabsTrigger value="testenv" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<FlaskConical className="h-4 w-4" />
Test Env
</TabsTrigger>
</TabsList>
</div>
)}
</nav>
@@ -510,22 +510,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
</AnimatedCard>
</TabsContent>
<TabsContent value="localization" className="space-y-6">
<AnimatedCard>
<Card>
<CardHeader>
<CardTitle>Localization</CardTitle>
<CardDescription>
Configure language and locale settings
</CardDescription>
</CardHeader>
<CardContent>
<LocalizationSettingsSection settings={localizationSettings} />
</CardContent>
</Card>
</AnimatedCard>
</TabsContent>
{isSuperAdmin && (
<TabsContent value="whatsapp" className="space-y-6">
<AnimatedCard>
@@ -543,6 +527,28 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
</AnimatedCard>
</TabsContent>
)}
{isSuperAdmin && (
<TabsContent value="testenv" className="space-y-6">
<AnimatedCard>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FlaskConical className="h-5 w-5" />
Test Environment
</CardTitle>
<CardDescription>
Create a sandboxed test competition with dummy data for testing all roles and workflows.
Fully isolated from production data.
</CardDescription>
</CardHeader>
<CardContent>
<TestEnvironmentPanel />
</CardContent>
</Card>
</AnimatedCard>
</TabsContent>
)}
</div>{/* end content area */}
</div>{/* end lg:flex */}
</Tabs>
@@ -858,66 +864,3 @@ function WhatsAppSettingsSection({ settings }: { settings: Record<string, string
)
}
function LocalizationSettingsSection({ settings }: { settings: Record<string, string> }) {
const mutation = useSettingsMutation()
const enabledLocales = (settings.localization_enabled_locales || 'en').split(',')
const toggleLocale = (locale: string) => {
const current = new Set(enabledLocales)
if (current.has(locale)) {
if (current.size <= 1) {
toast.error('At least one locale must be enabled')
return
}
current.delete(locale)
} else {
current.add(locale)
}
mutation.mutate({
key: 'localization_enabled_locales',
value: Array.from(current).join(','),
})
}
return (
<div className="space-y-4">
<div className="space-y-3">
<Label className="text-sm font-medium">Enabled Languages</Label>
<div className="space-y-2">
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">EN</span>
<span className="text-sm text-muted-foreground">English</span>
</div>
<Checkbox
checked={enabledLocales.includes('en')}
onCheckedChange={() => toggleLocale('en')}
disabled={mutation.isPending}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">FR</span>
<span className="text-sm text-muted-foreground">Fran&ccedil;ais</span>
</div>
<Checkbox
checked={enabledLocales.includes('fr')}
onCheckedChange={() => toggleLocale('fr')}
disabled={mutation.isPending}
/>
</div>
</div>
</div>
<SettingSelect
label="Default Locale"
description="The default language for new users"
settingKey="localization_default_locale"
value={settings.localization_default_locale || 'en'}
options={[
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Fran\u00e7ais' },
]}
/>
</div>
)
}

View File

@@ -0,0 +1,297 @@
'use client'
import { useState } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
FlaskConical,
Plus,
Trash2,
ExternalLink,
Loader2,
Users,
UserCog,
CheckCircle2,
AlertTriangle,
} from 'lucide-react'
import type { UserRole } from '@prisma/client'
const ROLE_LABELS: Record<string, string> = {
JURY_MEMBER: 'Jury Member',
APPLICANT: 'Applicant',
MENTOR: 'Mentor',
OBSERVER: 'Observer',
AWARD_MASTER: 'Award Master',
PROGRAM_ADMIN: 'Program Admin',
}
const ROLE_COLORS: Record<string, string> = {
JURY_MEMBER: 'bg-blue-100 text-blue-800',
APPLICANT: 'bg-green-100 text-green-800',
MENTOR: 'bg-purple-100 text-purple-800',
OBSERVER: 'bg-orange-100 text-orange-800',
AWARD_MASTER: 'bg-yellow-100 text-yellow-800',
PROGRAM_ADMIN: 'bg-red-100 text-red-800',
}
const ROLE_LANDING: Record<string, string> = {
JURY_MEMBER: '/jury',
APPLICANT: '/applicant',
MENTOR: '/mentor',
OBSERVER: '/observer',
AWARD_MASTER: '/admin',
PROGRAM_ADMIN: '/admin',
}
export function TestEnvironmentPanel() {
const { update } = useSession()
const router = useRouter()
const utils = trpc.useUtils()
const { data: status, isLoading } = trpc.testEnvironment.status.useQuery()
const createMutation = trpc.testEnvironment.create.useMutation({
onSuccess: () => utils.testEnvironment.status.invalidate(),
})
const tearDownMutation = trpc.testEnvironment.tearDown.useMutation({
onSuccess: () => utils.testEnvironment.status.invalidate(),
})
const [confirmText, setConfirmText] = useState('')
const [tearDownOpen, setTearDownOpen] = useState(false)
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)
}
// No test environment — show creation card
if (!status?.active) {
return (
<div className="space-y-4">
<div className="rounded-lg border-2 border-dashed p-8 text-center">
<FlaskConical className="mx-auto h-12 w-12 text-muted-foreground/50" />
<h3 className="mt-4 text-lg font-semibold">No Test Environment</h3>
<p className="mt-2 text-sm text-muted-foreground max-w-md mx-auto">
Create a sandboxed test competition with dummy users, projects, jury assignments,
and partial evaluations. All test data is fully isolated from production.
</p>
<Button
className="mt-6"
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending}
>
{createMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating test environment...
</>
) : (
<>
<Plus className="mr-2 h-4 w-4" />
Create Test Competition
</>
)}
</Button>
{createMutation.isError && (
<p className="mt-3 text-sm text-destructive">
{createMutation.error.message}
</p>
)}
</div>
</div>
)
}
// Test environment is active
const { competition, rounds, users, emailRedirect } = status
// Group users by role for impersonation cards
const roleGroups = users.reduce(
(acc, u) => {
const role = u.role as string
if (!acc[role]) acc[role] = []
acc[role].push(u)
return acc
},
{} as Record<string, typeof users>
)
async function handleImpersonate(userId: string, role: UserRole) {
await update({ impersonateUserId: userId })
router.push((ROLE_LANDING[role] || '/admin') as any)
router.refresh()
}
function handleTearDown() {
if (confirmText !== 'DELETE TEST') return
tearDownMutation.mutate(undefined, {
onSuccess: () => {
setTearDownOpen(false)
setConfirmText('')
},
})
}
return (
<div className="space-y-6">
{/* Status header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
<CheckCircle2 className="mr-1 h-3 w-3" />
Test Active
</Badge>
<span className="text-sm text-muted-foreground">
{competition.name}
</span>
</div>
<Button variant="outline" size="sm" asChild>
<a href={`/admin/competitions/${competition.id}`} target="_blank" rel="noopener">
View Competition
<ExternalLink className="ml-1.5 h-3 w-3" />
</a>
</Button>
</div>
{/* Quick stats */}
<div className="grid grid-cols-3 gap-4 text-center">
<div className="rounded-lg border p-3">
<p className="text-2xl font-bold">{rounds.length}</p>
<p className="text-xs text-muted-foreground">Rounds</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-2xl font-bold">{users.length}</p>
<p className="text-xs text-muted-foreground">Test Users</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-2xl font-bold truncate text-sm font-mono">
{emailRedirect || '—'}
</p>
<p className="text-xs text-muted-foreground">Email Redirect</p>
</div>
</div>
{/* Impersonation section */}
<div>
<div className="flex items-center gap-2 mb-3">
<UserCog className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-semibold">Impersonate Test User</h4>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{Object.entries(roleGroups).map(([role, roleUsers]) => (
<Card key={role} className="overflow-hidden">
<CardHeader className="py-2 px-3">
<div className="flex items-center justify-between">
<Badge variant="secondary" className={ROLE_COLORS[role] || ''}>
{ROLE_LABELS[role] || role}
</Badge>
<span className="text-xs text-muted-foreground">
{roleUsers.length} user{roleUsers.length !== 1 ? 's' : ''}
</span>
</div>
</CardHeader>
<CardContent className="py-2 px-3 space-y-1.5">
{roleUsers.slice(0, 3).map((u) => (
<button
key={u.id}
onClick={() => handleImpersonate(u.id, u.role as UserRole)}
className="flex items-center justify-between w-full rounded-md px-2 py-1.5 text-sm hover:bg-muted transition-colors text-left"
>
<span className="truncate">{u.name || u.email}</span>
<span className="text-xs text-muted-foreground shrink-0 ml-2">
Impersonate
</span>
</button>
))}
{roleUsers.length > 3 && (
<p className="text-xs text-muted-foreground px-2">
+{roleUsers.length - 3} more (switch via banner)
</p>
)}
</CardContent>
</Card>
))}
</div>
</div>
{/* Tear down */}
<div className="border-t pt-4">
<AlertDialog open={tearDownOpen} onOpenChange={setTearDownOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm">
<Trash2 className="mr-2 h-4 w-4" />
Tear Down Test Environment
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Destroy Test Environment
</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete ALL test data: users, projects, competitions,
assignments, evaluations, and files. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2 py-2">
<p className="text-sm font-medium">
Type <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm">DELETE TEST</code> to confirm:
</p>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="DELETE TEST"
className="font-mono"
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setConfirmText('')}>
Cancel
</AlertDialogCancel>
<Button
variant="destructive"
onClick={handleTearDown}
disabled={confirmText !== 'DELETE TEST' || tearDownMutation.isPending}
>
{tearDownMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Tearing down...
</>
) : (
'Destroy Test Environment'
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
)
}