fix: impersonation, dashboard counter, logo lightbox, submission config, auth logs
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m12s
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:
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
23
src/lib/session-update.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: [] }
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user