Files
MOPC-Portal/src/app/(auth)/accept-invite/page.tsx
Matt 09091d7c08 Jury dashboard compact layout, assignment redesign, auth fixes
- Jury dashboard: collapse zero-assignment state into single welcome card
  with inline quick actions; merge completion bar into stats row; tighten spacing
- Manual assignment: replace tiny Dialog modal with inline collapsible section
  featuring searchable juror combobox and multi-select project list with bulk assign
- Fix applicant invite URL path (/auth/accept-invite -> /accept-invite)
- Add APPLICANT role redirect to /my-submission from root page
- Add Applicant label to accept-invite role display
- Fix a/an grammar in invitation emails and accept-invite page
- Set-password page: use MOPC logo instead of lock icon
- Notification bell: remove filter tabs, always show all notifications

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 01:26:19 +01:00

239 lines
7.3 KiB
TypeScript

'use client'
import { useState, useEffect, Suspense } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { signIn } from 'next-auth/react'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Loader2, CheckCircle2, AlertCircle, XCircle, Clock } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
type InviteState = 'loading' | 'valid' | 'accepting' | 'error'
function AcceptInviteContent() {
const [state, setState] = useState<InviteState>('loading')
const [errorType, setErrorType] = useState<string | null>(null)
const searchParams = useSearchParams()
const router = useRouter()
const token = searchParams.get('token') || ''
const { data, isLoading, error } = trpc.user.validateInviteToken.useQuery(
{ token },
{ enabled: !!token, retry: false }
)
useEffect(() => {
if (!token) {
setState('error')
setErrorType('MISSING_TOKEN')
return
}
if (isLoading) {
setState('loading')
return
}
if (error) {
setState('error')
setErrorType('NETWORK_ERROR')
return
}
if (data) {
if (data.valid) {
setState('valid')
} else {
setState('error')
setErrorType(data.error || 'UNKNOWN')
}
}
}, [token, data, isLoading, error])
const handleAccept = async () => {
setState('accepting')
try {
const result = await signIn('credentials', {
inviteToken: token,
redirect: false,
})
if (result?.error) {
setState('error')
setErrorType('AUTH_FAILED')
} else if (result?.ok) {
// Redirect to set-password (middleware will enforce this since mustSetPassword=true)
window.location.href = '/set-password'
}
} catch {
setState('error')
setErrorType('AUTH_FAILED')
}
}
const getRoleLabel = (role: string): string => {
switch (role) {
case 'JURY_MEMBER': return 'Jury Member'
case 'PROGRAM_ADMIN': return 'Program Admin'
case 'MENTOR': return 'Mentor'
case 'OBSERVER': return 'Observer'
case 'APPLICANT': return 'Applicant'
default: return role
}
}
const getErrorContent = () => {
switch (errorType) {
case 'MISSING_TOKEN':
return {
icon: <XCircle className="h-6 w-6 text-red-600" />,
title: 'Invalid Link',
description: 'This invitation link is incomplete. Please check your email for the correct link.',
}
case 'INVALID_TOKEN':
return {
icon: <XCircle className="h-6 w-6 text-red-600" />,
title: 'Invalid Invitation',
description: 'This invitation link is not valid. It may have already been used or the link is incorrect.',
}
case 'EXPIRED_TOKEN':
return {
icon: <Clock className="h-6 w-6 text-amber-600" />,
title: 'Invitation Expired',
description: 'This invitation has expired. Please contact your administrator to receive a new invitation.',
}
case 'ALREADY_ACCEPTED':
return {
icon: <CheckCircle2 className="h-6 w-6 text-blue-600" />,
title: 'Already Accepted',
description: 'This invitation has already been accepted. You can sign in with your credentials.',
}
case 'AUTH_FAILED':
return {
icon: <AlertCircle className="h-6 w-6 text-red-600" />,
title: 'Something Went Wrong',
description: 'We couldn\'t complete your account setup. The invitation may have expired. Please try again or contact your administrator.',
}
default:
return {
icon: <AlertCircle className="h-6 w-6 text-red-600" />,
title: 'Something Went Wrong',
description: 'An unexpected error occurred. Please try again or contact your administrator.',
}
}
}
// Loading state
if (state === 'loading') {
return (
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="mt-4 text-sm text-muted-foreground">Verifying your invitation...</p>
</CardContent>
</Card>
)
}
// Error state
if (state === 'error') {
const errorContent = getErrorContent()
return (
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
{errorContent.icon}
</div>
<CardTitle className="text-xl">{errorContent.title}</CardTitle>
<CardDescription className="text-base">
{errorContent.description}
</CardDescription>
</CardHeader>
<CardContent>
<Button
variant="outline"
className="w-full"
onClick={() => router.push('/login')}
>
Go to Login
</Button>
</CardContent>
</Card>
)
}
// Valid invitation - show welcome
const user = data?.user
return (
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<CheckCircle2 className="h-6 w-6 text-green-600" />
</div>
<CardTitle className="text-xl">
{user?.name ? `Welcome, ${user.name}!` : 'Welcome!'}
</CardTitle>
<CardDescription className="text-base">
You&apos;ve been invited to join the Monaco Ocean Protection Challenge platform
{user?.role ? ` as ${/^[aeiou]/i.test(getRoleLabel(user.role)) ? 'an' : 'a'} ${getRoleLabel(user.role)}.` : '.'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{user?.email && (
<div className="rounded-md bg-muted/50 p-3 text-center">
<p className="text-sm text-muted-foreground">Signing in as</p>
<p className="font-medium">{user.email}</p>
</div>
)}
<Button
className="w-full"
size="lg"
onClick={handleAccept}
disabled={state === 'accepting'}
>
{state === 'accepting' ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Setting up your account...
</>
) : (
'Get Started'
)}
</Button>
<p className="text-xs text-center text-muted-foreground">
You&apos;ll be asked to set a password after accepting.
</p>
</CardContent>
</Card>
)
}
// Loading fallback for Suspense
function LoadingCard() {
return (
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="mt-4 text-sm text-muted-foreground">Loading...</p>
</CardContent>
</Card>
)
}
// Export with Suspense boundary for useSearchParams
export default function AcceptInvitePage() {
return (
<Suspense fallback={<LoadingCard />}>
<AcceptInviteContent />
</Suspense>
)
}