diff --git a/src/app/(admin)/admin/projects/[id]/page.tsx b/src/app/(admin)/admin/projects/[id]/page.tsx index 14fb27a..0e25afd 100644 --- a/src/app/(admin)/admin/projects/[id]/page.tsx +++ b/src/app/(admin)/admin/projects/[id]/page.tsx @@ -227,6 +227,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { project={project} size="lg" fallback="initials" + clickToEnlarge />
diff --git a/src/components/admin/rounds/config/submission-config.tsx b/src/components/admin/rounds/config/submission-config.tsx index 4cff123..c940da9 100644 --- a/src/components/admin/rounds/config/submission-config.tsx +++ b/src/components/admin/rounds/config/submission-config.tsx @@ -3,71 +3,19 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' -import { Badge } from '@/components/ui/badge' type SubmissionConfigProps = { config: Record onChange: (config: Record) => 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) { const update = (key: string, value: unknown) => { 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 (
- - - Submission Eligibility - - Which project states from the previous round are eligible to submit documents in this round - - - -
- -

- Projects with these statuses from the previous round can submit -

-
- {STATUSES.map((s) => ( - toggleStatus(s.value)} - > - {s.label} - - ))} -
-
-
-
- Notifications & Locking diff --git a/src/components/admin/user-actions.tsx b/src/components/admin/user-actions.tsx index a593685..3571b37 100644 --- a/src/components/admin/user-actions.tsx +++ b/src/components/admin/user-actions.tsx @@ -6,6 +6,7 @@ import type { Route } from 'next' import { useSession } from 'next-auth/react' import { useRouter } from 'next/navigation' import { trpc } from '@/lib/trpc/client' +import { directSessionUpdate } from '@/lib/session-update' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -71,7 +72,7 @@ function getRoleHomePath(role: string): string { export function UserActions({ userId, userEmail, userStatus, userRole, userRoles, currentUserRole }: UserActionsProps) { const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [isSending, setIsSending] = useState(false) - const { data: session, update } = useSession() + const { data: session } = useSession() const router = useRouter() const utils = trpc.useUtils() @@ -125,9 +126,12 @@ export function UserActions({ userId, userEmail, userStatus, userRole, userRoles const handleImpersonate = async () => { try { const result = await startImpersonation.mutateAsync({ targetUserId: userId }) - await update({ impersonate: userId }) - // Full page navigation to ensure the updated JWT cookie is sent - // (router.push can race with cookie propagation) + // Direct POST to session endpoint — bypasses useSession().update()'s loading gate + const ok = await directSessionUpdate({ impersonate: userId }) + if (!ok) { + toast.error('Failed to update session for impersonation') + return + } window.location.href = getRoleHomePath(result.targetRole) } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to start impersonation') @@ -279,7 +283,7 @@ export function UserMobileActions({ currentUserRole, }: UserMobileActionsProps) { const [isSending, setIsSending] = useState(false) - const { data: session, update } = useSession() + const { data: session } = useSession() const router = useRouter() const utils = trpc.useUtils() const sendInvitation = trpc.user.sendInvitation.useMutation() @@ -301,7 +305,11 @@ export function UserMobileActions({ const handleImpersonateMobile = async () => { try { 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) } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to start impersonation') diff --git a/src/components/layouts/role-nav.tsx b/src/components/layouts/role-nav.tsx index 6db07b6..2033f85 100644 --- a/src/components/layouts/role-nav.tsx +++ b/src/components/layouts/role-nav.tsx @@ -8,6 +8,7 @@ import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { UserAvatar } from '@/components/shared/user-avatar' import { trpc } from '@/lib/trpc/client' +import { directSessionUpdate } from '@/lib/session-update' import { DropdownMenu, DropdownMenuContent, @@ -85,7 +86,7 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi if (isImpersonating) { try { await endImpersonation.mutateAsync() - await updateSession({ endImpersonation: true }) + await directSessionUpdate({ endImpersonation: true }) window.location.href = '/admin/members' } catch { // Fallback: just sign out completely diff --git a/src/components/shared/project-logo-with-url.tsx b/src/components/shared/project-logo-with-url.tsx index c45a094..b6fc145 100644 --- a/src/components/shared/project-logo-with-url.tsx +++ b/src/components/shared/project-logo-with-url.tsx @@ -12,6 +12,7 @@ type ProjectLogoWithUrlProps = { size?: 'sm' | 'md' | 'lg' fallback?: 'icon' | 'initials' className?: string + clickToEnlarge?: boolean } /** @@ -23,6 +24,7 @@ export function ProjectLogoWithUrl({ size = 'md', fallback = 'icon', className, + clickToEnlarge, }: ProjectLogoWithUrlProps) { const { data: logoUrl } = trpc.logo.getUrl.useQuery( { projectId: project.id }, @@ -39,6 +41,7 @@ export function ProjectLogoWithUrl({ size={size} fallback={fallback} className={className} + clickToEnlarge={clickToEnlarge} /> ) } diff --git a/src/components/shared/project-logo.tsx b/src/components/shared/project-logo.tsx index 6b7e46d..5d41539 100644 --- a/src/components/shared/project-logo.tsx +++ b/src/components/shared/project-logo.tsx @@ -3,6 +3,12 @@ import { useState, useEffect } from 'react' import { cn, getInitials } from '@/lib/utils' import { ClipboardList } from 'lucide-react' +import { + Dialog, + DialogContent, + DialogTitle, +} from '@/components/ui/dialog' +import { VisuallyHidden } from '@radix-ui/react-visually-hidden' type ProjectLogoProps = { project: { @@ -13,6 +19,8 @@ type ProjectLogoProps = { size?: 'sm' | 'md' | 'lg' fallback?: 'icon' | 'initials' className?: string + /** When true, clicking the logo opens a lightbox */ + clickToEnlarge?: boolean } const sizeClasses = { @@ -39,8 +47,10 @@ export function ProjectLogo({ size = 'md', fallback = 'icon', className, + clickToEnlarge, }: ProjectLogoProps) { const [imageError, setImageError] = useState(false) + const [lightboxOpen, setLightboxOpen] = useState(false) const initials = getInitials(project.title) // Reset error state when logoUrl changes @@ -49,28 +59,26 @@ export function ProjectLogo({ }, [logoUrl]) const showImage = logoUrl && !imageError + const canEnlarge = clickToEnlarge && showImage - if (showImage) { - return ( -
- {`${project.title} setImageError(true)} - /> -
- ) - } - - // Fallback - return ( + const logoElement = showImage ? ( +
setLightboxOpen(true) : undefined} + > + {`${project.title} setImageError(true)} + /> +
+ ) : (
) + + return ( + <> + {logoElement} + {canEnlarge && ( + + + {project.title} logo + {`${project.title} + + + )} + + ) } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index d962b86..ac26502 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -18,7 +18,8 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ logger: { error(error) { // 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) }, warn(code) { diff --git a/src/lib/session-update.ts b/src/lib/session-update.ts new file mode 100644 index 0000000..e9560bb --- /dev/null +++ b/src/lib/session-update.ts @@ -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): Promise { + 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 + } +} diff --git a/src/server/routers/dashboard.ts b/src/server/routers/dashboard.ts index c6754ad..f795283 100644 --- a/src/server/routers/dashboard.ts +++ b/src/server/routers/dashboard.ts @@ -720,17 +720,34 @@ export const dashboardRouter = router({ orderBy: { sortOrder: 'asc' }, }) - // Determine which round to show + // Determine which round to show — pick the latest round that has PASSED projects let selectedRoundId = roundId if (!selectedRoundId) { - // Pick latest active round, or if none, the most recently closed - const activeRound = allRounds.find(r => r.status === 'ROUND_ACTIVE') - if (activeRound) { - selectedRoundId = activeRound.id + // 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 { - const closedRounds = allRounds.filter(r => r.status === 'ROUND_CLOSED' || r.status === 'ROUND_ARCHIVED') - if (closedRounds.length > 0) { - selectedRoundId = closedRounds[closedRounds.length - 1].id + // Fallback: active round, then most recently closed + const activeRound = allRounds.find(r => r.status === 'ROUND_ACTIVE') + if (activeRound) { + selectedRoundId = activeRound.id + } else { + const closedRounds = allRounds.filter(r => r.status === 'ROUND_CLOSED' || r.status === 'ROUND_ARCHIVED') + if (closedRounds.length > 0) { + selectedRoundId = closedRounds[closedRounds.length - 1].id + } } } } diff --git a/src/types/competition-configs.ts b/src/types/competition-configs.ts index 4737dad..f08468a 100644 --- a/src/types/competition-configs.ts +++ b/src/types/competition-configs.ts @@ -160,19 +160,6 @@ export type EvaluationConfig = z.infer export const SubmissionConfigSchema = z.object({ ...generalSettingsFields, - eligibleStatuses: z - .array( - z.enum([ - 'PENDING', - 'IN_PROGRESS', - 'PASSED', - 'REJECTED', - 'COMPLETED', - 'WITHDRAWN', - ]), - ) - .default(['PASSED']), - notifyEligibleTeams: z.boolean().default(true), lockPreviousWindows: z.boolean().default(true), })