fix: impersonation, dashboard counter, logo lightbox, submission config, auth logs
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m12s

- Fix impersonation by bypassing useSession().update() loading gate with direct session POST
- Fix dashboard account counter defaulting to latest round with PASSED projects
- Add clickToEnlarge lightbox for project logos on admin detail page
- Remove submission eligibility config (all passed projects must upload)
- Suppress CredentialsSignin auth errors in production (minified name check)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 15:40:08 +01:00
parent fd2624f198
commit b7905a82e1
10 changed files with 117 additions and 102 deletions

View File

@@ -227,6 +227,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
project={project} project={project}
size="lg" size="lg"
fallback="initials" fallback="initials"
clickToEnlarge
/> />
<div className="space-y-1"> <div className="space-y-1">
<div className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground"> <div className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground">

View File

@@ -3,71 +3,19 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
type SubmissionConfigProps = { type SubmissionConfigProps = {
config: Record<string, unknown> config: Record<string, unknown>
onChange: (config: Record<string, unknown>) => void onChange: (config: Record<string, unknown>) => void
} }
const STATUSES = [
{ value: 'PENDING', label: 'Pending', color: 'bg-gray-100 text-gray-700' },
{ value: 'IN_PROGRESS', label: 'In Progress', color: 'bg-blue-100 text-blue-700' },
{ value: 'PASSED', label: 'Passed', color: 'bg-emerald-100 text-emerald-700' },
{ value: 'REJECTED', label: 'Rejected', color: 'bg-red-100 text-red-700' },
{ value: 'COMPLETED', label: 'Completed', color: 'bg-purple-100 text-purple-700' },
{ value: 'WITHDRAWN', label: 'Withdrawn', color: 'bg-amber-100 text-amber-700' },
]
export function SubmissionConfig({ config, onChange }: SubmissionConfigProps) { export function SubmissionConfig({ config, onChange }: SubmissionConfigProps) {
const update = (key: string, value: unknown) => { const update = (key: string, value: unknown) => {
onChange({ ...config, [key]: value }) onChange({ ...config, [key]: value })
} }
const eligible = (config.eligibleStatuses as string[]) ?? ['PASSED']
const toggleStatus = (status: string) => {
const current = [...eligible]
const idx = current.indexOf(status)
if (idx >= 0) {
current.splice(idx, 1)
} else {
current.push(status)
}
update('eligibleStatuses', current)
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Submission Eligibility</CardTitle>
<CardDescription>
Which project states from the previous round are eligible to submit documents in this round
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Eligible Project Statuses</Label>
<p className="text-xs text-muted-foreground">
Projects with these statuses from the previous round can submit
</p>
<div className="flex flex-wrap gap-2">
{STATUSES.map((s) => (
<Badge
key={s.value}
variant={eligible.includes(s.value) ? 'default' : 'outline'}
className="cursor-pointer select-none"
onClick={() => toggleStatus(s.value)}
>
{s.label}
</Badge>
))}
</div>
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Notifications & Locking</CardTitle> <CardTitle className="text-base">Notifications & Locking</CardTitle>

View File

@@ -6,6 +6,7 @@ import type { Route } from 'next'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { directSessionUpdate } from '@/lib/session-update'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
DropdownMenu, DropdownMenu,
@@ -71,7 +72,7 @@ function getRoleHomePath(role: string): string {
export function UserActions({ userId, userEmail, userStatus, userRole, userRoles, currentUserRole }: UserActionsProps) { export function UserActions({ userId, userEmail, userStatus, userRole, userRoles, currentUserRole }: UserActionsProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isSending, setIsSending] = useState(false) const [isSending, setIsSending] = useState(false)
const { data: session, update } = useSession() const { data: session } = useSession()
const router = useRouter() const router = useRouter()
const utils = trpc.useUtils() const utils = trpc.useUtils()
@@ -125,9 +126,12 @@ export function UserActions({ userId, userEmail, userStatus, userRole, userRoles
const handleImpersonate = async () => { const handleImpersonate = async () => {
try { try {
const result = await startImpersonation.mutateAsync({ targetUserId: userId }) const result = await startImpersonation.mutateAsync({ targetUserId: userId })
await update({ impersonate: userId }) // Direct POST to session endpoint — bypasses useSession().update()'s loading gate
// Full page navigation to ensure the updated JWT cookie is sent const ok = await directSessionUpdate({ impersonate: userId })
// (router.push can race with cookie propagation) if (!ok) {
toast.error('Failed to update session for impersonation')
return
}
window.location.href = getRoleHomePath(result.targetRole) window.location.href = getRoleHomePath(result.targetRole)
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to start impersonation') toast.error(error instanceof Error ? error.message : 'Failed to start impersonation')
@@ -279,7 +283,7 @@ export function UserMobileActions({
currentUserRole, currentUserRole,
}: UserMobileActionsProps) { }: UserMobileActionsProps) {
const [isSending, setIsSending] = useState(false) const [isSending, setIsSending] = useState(false)
const { data: session, update } = useSession() const { data: session } = useSession()
const router = useRouter() const router = useRouter()
const utils = trpc.useUtils() const utils = trpc.useUtils()
const sendInvitation = trpc.user.sendInvitation.useMutation() const sendInvitation = trpc.user.sendInvitation.useMutation()
@@ -301,7 +305,11 @@ export function UserMobileActions({
const handleImpersonateMobile = async () => { const handleImpersonateMobile = async () => {
try { try {
const result = await startImpersonation.mutateAsync({ targetUserId: userId }) const result = await startImpersonation.mutateAsync({ targetUserId: userId })
await update({ impersonate: userId }) const ok = await directSessionUpdate({ impersonate: userId })
if (!ok) {
toast.error('Failed to update session for impersonation')
return
}
window.location.href = getRoleHomePath(result.targetRole) window.location.href = getRoleHomePath(result.targetRole)
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to start impersonation') toast.error(error instanceof Error ? error.message : 'Failed to start impersonation')

View File

@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { UserAvatar } from '@/components/shared/user-avatar' import { UserAvatar } from '@/components/shared/user-avatar'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { directSessionUpdate } from '@/lib/session-update'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -85,7 +86,7 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
if (isImpersonating) { if (isImpersonating) {
try { try {
await endImpersonation.mutateAsync() await endImpersonation.mutateAsync()
await updateSession({ endImpersonation: true }) await directSessionUpdate({ endImpersonation: true })
window.location.href = '/admin/members' window.location.href = '/admin/members'
} catch { } catch {
// Fallback: just sign out completely // Fallback: just sign out completely

View File

@@ -12,6 +12,7 @@ type ProjectLogoWithUrlProps = {
size?: 'sm' | 'md' | 'lg' size?: 'sm' | 'md' | 'lg'
fallback?: 'icon' | 'initials' fallback?: 'icon' | 'initials'
className?: string className?: string
clickToEnlarge?: boolean
} }
/** /**
@@ -23,6 +24,7 @@ export function ProjectLogoWithUrl({
size = 'md', size = 'md',
fallback = 'icon', fallback = 'icon',
className, className,
clickToEnlarge,
}: ProjectLogoWithUrlProps) { }: ProjectLogoWithUrlProps) {
const { data: logoUrl } = trpc.logo.getUrl.useQuery( const { data: logoUrl } = trpc.logo.getUrl.useQuery(
{ projectId: project.id }, { projectId: project.id },
@@ -39,6 +41,7 @@ export function ProjectLogoWithUrl({
size={size} size={size}
fallback={fallback} fallback={fallback}
className={className} className={className}
clickToEnlarge={clickToEnlarge}
/> />
) )
} }

View File

@@ -3,6 +3,12 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { cn, getInitials } from '@/lib/utils' import { cn, getInitials } from '@/lib/utils'
import { ClipboardList } from 'lucide-react' import { ClipboardList } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog'
import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
type ProjectLogoProps = { type ProjectLogoProps = {
project: { project: {
@@ -13,6 +19,8 @@ type ProjectLogoProps = {
size?: 'sm' | 'md' | 'lg' size?: 'sm' | 'md' | 'lg'
fallback?: 'icon' | 'initials' fallback?: 'icon' | 'initials'
className?: string className?: string
/** When true, clicking the logo opens a lightbox */
clickToEnlarge?: boolean
} }
const sizeClasses = { const sizeClasses = {
@@ -39,8 +47,10 @@ export function ProjectLogo({
size = 'md', size = 'md',
fallback = 'icon', fallback = 'icon',
className, className,
clickToEnlarge,
}: ProjectLogoProps) { }: ProjectLogoProps) {
const [imageError, setImageError] = useState(false) const [imageError, setImageError] = useState(false)
const [lightboxOpen, setLightboxOpen] = useState(false)
const initials = getInitials(project.title) const initials = getInitials(project.title)
// Reset error state when logoUrl changes // Reset error state when logoUrl changes
@@ -49,15 +59,17 @@ export function ProjectLogo({
}, [logoUrl]) }, [logoUrl])
const showImage = logoUrl && !imageError const showImage = logoUrl && !imageError
const canEnlarge = clickToEnlarge && showImage
if (showImage) { const logoElement = showImage ? (
return (
<div <div
className={cn( className={cn(
'relative overflow-hidden rounded-lg bg-muted', 'relative overflow-hidden rounded-lg bg-muted',
sizeClasses[size], sizeClasses[size],
canEnlarge && 'cursor-pointer ring-offset-background transition-all hover:ring-2 hover:ring-primary/30',
className className
)} )}
onClick={canEnlarge ? () => setLightboxOpen(true) : undefined}
> >
<img <img
src={logoUrl} src={logoUrl}
@@ -66,11 +78,7 @@ export function ProjectLogo({
onError={() => setImageError(true)} onError={() => setImageError(true)}
/> />
</div> </div>
) ) : (
}
// Fallback
return (
<div <div
className={cn( className={cn(
'flex items-center justify-center rounded-lg bg-muted', 'flex items-center justify-center rounded-lg bg-muted',
@@ -87,4 +95,22 @@ export function ProjectLogo({
)} )}
</div> </div>
) )
return (
<>
{logoElement}
{canEnlarge && (
<Dialog open={lightboxOpen} onOpenChange={setLightboxOpen}>
<DialogContent className="max-w-lg p-2">
<VisuallyHidden><DialogTitle>{project.title} logo</DialogTitle></VisuallyHidden>
<img
src={logoUrl!}
alt={`${project.title} logo`}
className="w-full rounded-lg object-contain"
/>
</DialogContent>
</Dialog>
)}
</>
)
} }

View File

@@ -18,7 +18,8 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
logger: { logger: {
error(error) { error(error) {
// CredentialsSignin is expected (wrong password, bots) — already logged to AuditLog with detail // CredentialsSignin is expected (wrong password, bots) — already logged to AuditLog with detail
if (error?.name === 'CredentialsSignin') return // Check both name and type: name gets minified in production builds
if (error?.name === 'CredentialsSignin' || (error as unknown as { type?: string })?.type === 'CredentialsSignin') return
console.error('[auth][error]', error) console.error('[auth][error]', error)
}, },
warn(code) { warn(code) {

23
src/lib/session-update.ts Normal file
View File

@@ -0,0 +1,23 @@
/**
* Direct session update that bypasses useSession().update()'s `if (loading) return;` guard.
* This ensures the JWT cookie is always updated, even when another session refresh is in-flight.
*/
export async function directSessionUpdate(data: Record<string, unknown>): Promise<boolean> {
try {
// Get CSRF token
const csrfResp = await fetch('/api/auth/csrf')
if (!csrfResp.ok) return false
const { csrfToken } = await csrfResp.json()
// POST session update
const resp = await fetch('/api/auth/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ csrfToken, data }),
})
return resp.ok
} catch {
return false
}
}

View File

@@ -720,10 +720,26 @@ export const dashboardRouter = router({
orderBy: { sortOrder: 'asc' }, orderBy: { sortOrder: 'asc' },
}) })
// Determine which round to show // Determine which round to show — pick the latest round that has PASSED projects
let selectedRoundId = roundId let selectedRoundId = roundId
if (!selectedRoundId) { if (!selectedRoundId) {
// Pick latest active round, or if none, the most recently closed // Find rounds that actually have PASSED projects (most useful default)
const roundsWithPassed = await ctx.prisma.projectRoundState.groupBy({
by: ['roundId'],
where: {
state: 'PASSED',
roundId: { in: allRounds.map(r => r.id) },
},
})
const passedRoundIds = new Set(roundsWithPassed.map(r => r.roundId))
// Pick the latest round (highest sortOrder) that has PASSED projects
const roundsWithPassedSorted = allRounds
.filter(r => passedRoundIds.has(r.id))
.sort((a, b) => b.sortOrder - a.sortOrder)
if (roundsWithPassedSorted.length > 0) {
selectedRoundId = roundsWithPassedSorted[0].id
} else {
// Fallback: active round, then most recently closed
const activeRound = allRounds.find(r => r.status === 'ROUND_ACTIVE') const activeRound = allRounds.find(r => r.status === 'ROUND_ACTIVE')
if (activeRound) { if (activeRound) {
selectedRoundId = activeRound.id selectedRoundId = activeRound.id
@@ -734,6 +750,7 @@ export const dashboardRouter = router({
} }
} }
} }
}
if (!selectedRoundId) { if (!selectedRoundId) {
return { rounds: allRounds, selectedRoundId: null, byCategory: [], unactivatedProjects: [] } return { rounds: allRounds, selectedRoundId: null, byCategory: [], unactivatedProjects: [] }

View File

@@ -160,19 +160,6 @@ export type EvaluationConfig = z.infer<typeof EvaluationConfigSchema>
export const SubmissionConfigSchema = z.object({ export const SubmissionConfigSchema = z.object({
...generalSettingsFields, ...generalSettingsFields,
eligibleStatuses: z
.array(
z.enum([
'PENDING',
'IN_PROGRESS',
'PASSED',
'REJECTED',
'COMPLETED',
'WITHDRAWN',
]),
)
.default(['PASSED']),
notifyEligibleTeams: z.boolean().default(true), notifyEligibleTeams: z.boolean().default(true),
lockPreviousWindows: z.boolean().default(true), lockPreviousWindows: z.boolean().default(true),
}) })