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,654 +0,0 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Card, CardContent, CardHeader, CardTitle } 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 { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Plus, Lock, Unlock, LockKeyhole, Loader2, Pencil, Trash2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { format } from 'date-fns'
type SubmissionWindowManagerProps = {
competitionId: string
roundId: string
}
export function SubmissionWindowManager({ competitionId, roundId }: SubmissionWindowManagerProps) {
const [isCreateOpen, setIsCreateOpen] = useState(false)
const [editingWindow, setEditingWindow] = useState<string | null>(null)
const [deletingWindow, setDeletingWindow] = useState<string | null>(null)
// Create form state
const [createForm, setCreateForm] = useState({
name: '',
slug: '',
roundNumber: 1,
windowOpenAt: '',
windowCloseAt: '',
deadlinePolicy: 'HARD_DEADLINE' as 'HARD_DEADLINE' | 'FLAG' | 'GRACE',
graceHours: 0,
lockOnClose: true,
})
// Edit form state
const [editForm, setEditForm] = useState({
name: '',
slug: '',
roundNumber: 1,
windowOpenAt: '',
windowCloseAt: '',
deadlinePolicy: 'HARD_DEADLINE' as 'HARD_DEADLINE' | 'FLAG' | 'GRACE',
graceHours: 0,
lockOnClose: true,
sortOrder: 1,
})
const utils = trpc.useUtils()
const { data: competition, isLoading } = trpc.competition.getById.useQuery({
id: competitionId,
})
const createWindowMutation = trpc.round.createSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Submission window created')
setIsCreateOpen(false)
// Reset form
setCreateForm({
name: '',
slug: '',
roundNumber: 1,
windowOpenAt: '',
windowCloseAt: '',
deadlinePolicy: 'HARD_DEADLINE',
graceHours: 0,
lockOnClose: true,
})
},
onError: (err) => toast.error(err.message),
})
const updateWindowMutation = trpc.round.updateSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Submission window updated')
setEditingWindow(null)
},
onError: (err) => toast.error(err.message),
})
const deleteWindowMutation = trpc.round.deleteSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Submission window deleted')
setDeletingWindow(null)
},
onError: (err) => toast.error(err.message),
})
const openWindowMutation = trpc.round.openSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Window opened')
},
onError: (err) => toast.error(err.message),
})
const closeWindowMutation = trpc.round.closeSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Window closed')
},
onError: (err) => toast.error(err.message),
})
const lockWindowMutation = trpc.round.lockSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Window locked')
},
onError: (err) => toast.error(err.message),
})
const handleCreateNameChange = (value: string) => {
const autoSlug = value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
setCreateForm({ ...createForm, name: value, slug: autoSlug })
}
const handleEditNameChange = (value: string) => {
const autoSlug = value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
setEditForm({ ...editForm, name: value, slug: autoSlug })
}
const handleCreate = () => {
if (!createForm.name || !createForm.slug) {
toast.error('Name and slug are required')
return
}
createWindowMutation.mutate({
competitionId,
name: createForm.name,
slug: createForm.slug,
roundNumber: createForm.roundNumber,
windowOpenAt: createForm.windowOpenAt ? new Date(createForm.windowOpenAt) : undefined,
windowCloseAt: createForm.windowCloseAt ? new Date(createForm.windowCloseAt) : undefined,
deadlinePolicy: createForm.deadlinePolicy,
graceHours: createForm.deadlinePolicy === 'GRACE' ? createForm.graceHours : undefined,
lockOnClose: createForm.lockOnClose,
})
}
const handleEdit = () => {
if (!editingWindow) return
if (!editForm.name || !editForm.slug) {
toast.error('Name and slug are required')
return
}
updateWindowMutation.mutate({
id: editingWindow,
name: editForm.name,
slug: editForm.slug,
roundNumber: editForm.roundNumber,
windowOpenAt: editForm.windowOpenAt ? new Date(editForm.windowOpenAt) : null,
windowCloseAt: editForm.windowCloseAt ? new Date(editForm.windowCloseAt) : null,
deadlinePolicy: editForm.deadlinePolicy,
graceHours: editForm.deadlinePolicy === 'GRACE' ? editForm.graceHours : null,
lockOnClose: editForm.lockOnClose,
sortOrder: editForm.sortOrder,
})
}
const handleDelete = () => {
if (!deletingWindow) return
deleteWindowMutation.mutate({ id: deletingWindow })
}
const openEditDialog = (window: any) => {
setEditForm({
name: window.name,
slug: window.slug,
roundNumber: window.roundNumber,
windowOpenAt: window.windowOpenAt ? new Date(window.windowOpenAt).toISOString().slice(0, 16) : '',
windowCloseAt: window.windowCloseAt ? new Date(window.windowCloseAt).toISOString().slice(0, 16) : '',
deadlinePolicy: window.deadlinePolicy ?? 'HARD_DEADLINE',
graceHours: window.graceHours ?? 0,
lockOnClose: window.lockOnClose ?? true,
sortOrder: window.sortOrder ?? 1,
})
setEditingWindow(window.id)
}
const formatDate = (date: Date | null | undefined) => {
if (!date) return 'Not set'
return format(new Date(date), 'MMM d, yyyy h:mm a')
}
const windows = competition?.submissionWindows ?? []
return (
<div className="space-y-4">
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-base">Submission Windows</CardTitle>
<p className="text-sm text-muted-foreground">
File upload windows for this round
</p>
</div>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="w-full sm:w-auto">
<Plus className="h-4 w-4 mr-1" />
Create Window
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Submission Window</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="create-name">Window Name</Label>
<Input
id="create-name"
placeholder="e.g., Round 1 Submissions"
value={createForm.name}
onChange={(e) => handleCreateNameChange(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-slug">Slug</Label>
<Input
id="create-slug"
placeholder="e.g., round-1-submissions"
value={createForm.slug}
onChange={(e) => setCreateForm({ ...createForm, slug: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-roundNumber">Round Number</Label>
<Input
id="create-roundNumber"
type="number"
min={1}
value={createForm.roundNumber}
onChange={(e) => setCreateForm({ ...createForm, roundNumber: parseInt(e.target.value, 10) || 1 })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-windowOpenAt">Window Open At</Label>
<Input
id="create-windowOpenAt"
type="datetime-local"
value={createForm.windowOpenAt}
onChange={(e) => setCreateForm({ ...createForm, windowOpenAt: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-windowCloseAt">Window Close At</Label>
<Input
id="create-windowCloseAt"
type="datetime-local"
value={createForm.windowCloseAt}
onChange={(e) => setCreateForm({ ...createForm, windowCloseAt: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-deadlinePolicy">Deadline Policy</Label>
<Select
value={createForm.deadlinePolicy}
onValueChange={(value: 'HARD_DEADLINE' | 'FLAG' | 'GRACE') =>
setCreateForm({ ...createForm, deadlinePolicy: value })
}
>
<SelectTrigger id="create-deadlinePolicy">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HARD_DEADLINE">Hard Deadline</SelectItem>
<SelectItem value="FLAG">Flag Late Submissions</SelectItem>
<SelectItem value="GRACE">Grace Period</SelectItem>
</SelectContent>
</Select>
</div>
{createForm.deadlinePolicy === 'GRACE' && (
<div className="space-y-2">
<Label htmlFor="create-graceHours">Grace Hours</Label>
<Input
id="create-graceHours"
type="number"
min={0}
value={createForm.graceHours}
onChange={(e) => setCreateForm({ ...createForm, graceHours: parseInt(e.target.value, 10) || 0 })}
/>
</div>
)}
<div className="flex items-center gap-2">
<Switch
id="create-lockOnClose"
checked={createForm.lockOnClose}
onCheckedChange={(checked) => setCreateForm({ ...createForm, lockOnClose: checked })}
/>
<Label htmlFor="create-lockOnClose" className="cursor-pointer">
Lock window on close
</Label>
</div>
<div className="flex gap-2 pt-4">
<Button
variant="outline"
className="flex-1"
onClick={() => setIsCreateOpen(false)}
>
Cancel
</Button>
<Button
className="flex-1"
onClick={handleCreate}
disabled={createWindowMutation.isPending}
>
{createWindowMutation.isPending && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
Create
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
Loading windows...
</div>
) : windows.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
No submission windows yet. Create one to enable file uploads.
</div>
) : (
<div className="space-y-2">
{windows.map((window) => {
const isPending = !window.windowOpenAt
const isOpen = window.windowOpenAt && !window.windowCloseAt
const isClosed = window.windowCloseAt && !window.isLocked
const isLocked = window.isLocked
return (
<div
key={window.id}
className="flex flex-col gap-3 border rounded-lg p-3"
>
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium truncate">{window.name}</p>
{isPending && (
<Badge variant="secondary" className="text-[10px] bg-gray-100 text-gray-700">
Pending
</Badge>
)}
{isOpen && (
<Badge variant="secondary" className="text-[10px] bg-emerald-100 text-emerald-700">
Open
</Badge>
)}
{isClosed && (
<Badge variant="secondary" className="text-[10px] bg-blue-100 text-blue-700">
Closed
</Badge>
)}
{isLocked && (
<Badge variant="secondary" className="text-[10px] bg-red-100 text-red-700">
<LockKeyhole className="h-2.5 w-2.5 mr-1" />
Locked
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground font-mono mt-0.5">{window.slug}</p>
<div className="flex flex-wrap gap-2 mt-1 text-xs text-muted-foreground">
<span>Round {window.roundNumber}</span>
<span></span>
<span>{window._count.fileRequirements} requirements</span>
<span></span>
<span>{window._count.projectFiles} files</span>
</div>
<div className="flex flex-wrap gap-2 mt-1 text-xs text-muted-foreground">
<span>Open: {formatDate(window.windowOpenAt)}</span>
<span></span>
<span>Close: {formatDate(window.windowCloseAt)}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0 flex-wrap">
<Button
size="sm"
variant="ghost"
onClick={() => openEditDialog(window)}
className="h-8 px-2"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setDeletingWindow(window.id)}
className="h-8 px-2 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
{isPending && (
<Button
size="sm"
variant="outline"
onClick={() => openWindowMutation.mutate({ windowId: window.id })}
disabled={openWindowMutation.isPending}
>
{openWindowMutation.isPending ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<Unlock className="h-3 w-3 mr-1" />
)}
Open
</Button>
)}
{isOpen && (
<Button
size="sm"
variant="outline"
onClick={() => closeWindowMutation.mutate({ windowId: window.id })}
disabled={closeWindowMutation.isPending}
>
{closeWindowMutation.isPending ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<Lock className="h-3 w-3 mr-1" />
)}
Close
</Button>
)}
{isClosed && (
<Button
size="sm"
variant="outline"
onClick={() => lockWindowMutation.mutate({ windowId: window.id })}
disabled={lockWindowMutation.isPending}
>
{lockWindowMutation.isPending ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<LockKeyhole className="h-3 w-3 mr-1" />
)}
Lock
</Button>
)}
</div>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
{/* Edit Dialog */}
<Dialog open={!!editingWindow} onOpenChange={(open) => !open && setEditingWindow(null)}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Submission Window</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-name">Window Name</Label>
<Input
id="edit-name"
placeholder="e.g., Round 1 Submissions"
value={editForm.name}
onChange={(e) => handleEditNameChange(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-slug">Slug</Label>
<Input
id="edit-slug"
placeholder="e.g., round-1-submissions"
value={editForm.slug}
onChange={(e) => setEditForm({ ...editForm, slug: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-roundNumber">Round Number</Label>
<Input
id="edit-roundNumber"
type="number"
min={1}
value={editForm.roundNumber}
onChange={(e) => setEditForm({ ...editForm, roundNumber: parseInt(e.target.value, 10) || 1 })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-windowOpenAt">Window Open At</Label>
<Input
id="edit-windowOpenAt"
type="datetime-local"
value={editForm.windowOpenAt}
onChange={(e) => setEditForm({ ...editForm, windowOpenAt: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-windowCloseAt">Window Close At</Label>
<Input
id="edit-windowCloseAt"
type="datetime-local"
value={editForm.windowCloseAt}
onChange={(e) => setEditForm({ ...editForm, windowCloseAt: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-deadlinePolicy">Deadline Policy</Label>
<Select
value={editForm.deadlinePolicy}
onValueChange={(value: 'HARD_DEADLINE' | 'FLAG' | 'GRACE') =>
setEditForm({ ...editForm, deadlinePolicy: value })
}
>
<SelectTrigger id="edit-deadlinePolicy">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HARD_DEADLINE">Hard Deadline</SelectItem>
<SelectItem value="FLAG">Flag Late Submissions</SelectItem>
<SelectItem value="GRACE">Grace Period</SelectItem>
</SelectContent>
</Select>
</div>
{editForm.deadlinePolicy === 'GRACE' && (
<div className="space-y-2">
<Label htmlFor="edit-graceHours">Grace Hours</Label>
<Input
id="edit-graceHours"
type="number"
min={0}
value={editForm.graceHours}
onChange={(e) => setEditForm({ ...editForm, graceHours: parseInt(e.target.value, 10) || 0 })}
/>
</div>
)}
<div className="flex items-center gap-2">
<Switch
id="edit-lockOnClose"
checked={editForm.lockOnClose}
onCheckedChange={(checked) => setEditForm({ ...editForm, lockOnClose: checked })}
/>
<Label htmlFor="edit-lockOnClose" className="cursor-pointer">
Lock window on close
</Label>
</div>
<div className="space-y-2">
<Label htmlFor="edit-sortOrder">Sort Order</Label>
<Input
id="edit-sortOrder"
type="number"
min={1}
value={editForm.sortOrder}
onChange={(e) => setEditForm({ ...editForm, sortOrder: parseInt(e.target.value, 10) || 1 })}
/>
</div>
<div className="flex gap-2 pt-4">
<Button
variant="outline"
className="flex-1"
onClick={() => setEditingWindow(null)}
>
Cancel
</Button>
<Button
className="flex-1"
onClick={handleEdit}
disabled={updateWindowMutation.isPending}
>
{updateWindowMutation.isPending && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
Save Changes
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={!!deletingWindow} onOpenChange={(open) => !open && setDeletingWindow(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Submission Window</DialogTitle>
<DialogDescription>
Are you sure you want to delete this submission window? This action cannot be undone.
{(windows.find(w => w.id === deletingWindow)?._count?.projectFiles ?? 0) > 0 && (
<span className="block mt-2 text-destructive font-medium">
Warning: This window has uploaded files and cannot be deleted until they are removed.
</span>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2">
<Button
variant="outline"
onClick={() => setDeletingWindow(null)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleteWindowMutation.isPending}
>
{deleteWindowMutation.isPending && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -226,7 +226,8 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
{navigation.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/admin' && pathname.startsWith(item.href))
(item.href !== '/admin' && pathname.startsWith(item.href)) ||
(item.href === '/admin/rounds' && pathname.startsWith('/admin/competitions'))
return (
<div key={item.name}>
<Link
@@ -258,12 +259,24 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
Administration
</p>
{dynamicAdminNav.map((item) => {
const isDisabled = item.name === 'Apply Page' && !currentEdition?.id
let isActive = pathname.startsWith(item.href)
if (item.activeMatch) {
isActive = pathname.includes(item.activeMatch)
} else if (item.activeExclude && pathname.includes(item.activeExclude)) {
isActive = false
}
if (isDisabled) {
return (
<span
key={item.name}
className="group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium opacity-50 pointer-events-none text-muted-foreground"
>
<item.icon className="h-4 w-4 text-muted-foreground" />
{item.name}
</span>
)
}
return (
<Link
key={item.name}

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>
)
}

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>
)
}