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>
150 lines
4.7 KiB
TypeScript
150 lines
4.7 KiB
TypeScript
'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>
|
|
)
|
|
}
|