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 f42b452899
commit 3e70de3a5a
55 changed files with 1630 additions and 770 deletions

View File

@@ -0,0 +1,149 @@
'use client'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { ChevronDown, LogOut, UserCog } 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',
SUPER_ADMIN: 'Super Admin',
}
const ROLE_LANDING: Record<string, string> = {
JURY_MEMBER: '/jury',
APPLICANT: '/applicant',
MENTOR: '/mentor',
OBSERVER: '/observer',
AWARD_MASTER: '/admin',
PROGRAM_ADMIN: '/admin',
SUPER_ADMIN: '/admin',
}
export function ImpersonationBanner() {
const { data: session, update } = useSession()
const router = useRouter()
const [switching, setSwitching] = useState(false)
// Only fetch test users when impersonating (realRole check happens server-side)
const { data: testEnv } = trpc.testEnvironment.status.useQuery(undefined, {
enabled: !!session?.user?.isImpersonating,
staleTime: 60_000,
})
if (!session?.user?.isImpersonating) return null
const currentRole = session.user.role
const currentName = session.user.impersonatedName || session.user.name || 'Unknown'
// Group available test users by role (exclude currently impersonated user)
const availableUsers = testEnv?.active
? testEnv.users.filter((u) => u.id !== session.user.id)
: []
const roleGroups = availableUsers.reduce(
(acc, u) => {
const role = u.role as string
if (!acc[role]) acc[role] = []
acc[role].push(u)
return acc
},
{} as Record<string, typeof availableUsers>
)
async function handleSwitch(userId: string, role: UserRole) {
setSwitching(true)
await update({ impersonateUserId: userId })
router.push((ROLE_LANDING[role] || '/admin') as any)
router.refresh()
setSwitching(false)
}
async function handleStopImpersonation() {
setSwitching(true)
await update({ stopImpersonation: true })
router.push('/admin/settings' as any)
router.refresh()
setSwitching(false)
}
return (
<div className="fixed top-0 left-0 right-0 z-50 bg-amber-500 text-amber-950 shadow-md">
<div className="mx-auto flex items-center justify-between px-4 py-1.5 text-sm font-medium">
<div className="flex items-center gap-2">
<UserCog className="h-4 w-4" />
<span>
Viewing as <strong>{currentName}</strong>{' '}
<span className="rounded bg-amber-600/30 px-1.5 py-0.5 text-xs font-semibold">
{ROLE_LABELS[currentRole] || currentRole}
</span>
</span>
</div>
<div className="flex items-center gap-2">
{/* Quick-switch dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 bg-amber-600/20 text-amber-950 hover:bg-amber-600/40"
disabled={switching}
>
Switch Role
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{Object.entries(roleGroups).map(([role, users]) => (
<div key={role}>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{ROLE_LABELS[role] || role}
</DropdownMenuLabel>
{users.map((u) => (
<DropdownMenuItem
key={u.id}
onClick={() => handleSwitch(u.id, u.role as UserRole)}
disabled={switching}
>
<span className="truncate">{u.name || u.email}</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Return to admin */}
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 bg-amber-600/20 text-amber-950 hover:bg-amber-600/40"
onClick={handleStopImpersonation}
disabled={switching}
>
<LogOut className="h-3 w-3" />
Return to Admin
</Button>
</div>
</div>
</div>
)
}