Files
MOPC-Portal/src/app/(auth)/accept-invite/page.tsx
Matt 7ead21114e fix: pipeline progress, message variables, jury invite flow, accept-invite UX
- Pipeline: SUBMISSION rounds count IN_PROGRESS + COMPLETED for progress %
- Round engine: remove phantom SubmissionFileRequirement check blocking auto-transition
- Messages: implement {{userName}}, {{projectName}}, {{roundName}}, {{programName}}, {{deadline}} substitution
- Email preview: show greeting, CTA button, and footer matching actual sent email
- Message composer: add green dot indicator for active rounds in round selector
- User create: generate invite token atomically (prevents stuck INVITED state on email failure)
- Jury invites: use jury-specific email template mentioning round context
- Bulk invite: animated progress bar, batch size hint, success/failure counts
- Accept invite: distinguish server errors (retry button) from expired tokens (redirect)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:47:42 -04:00

346 lines
12 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, RefreshCw } from 'lucide-react'
import Image from 'next/image'
import { trpc } from '@/lib/trpc/client'
import { AnimatedCard } from '@/components/shared/animated-container'
type InviteState = 'loading' | 'valid' | 'accepting' | 'error'
function ErrorRedirectCard({
errorContent,
redirectTarget,
}: {
errorContent: { icon: React.ReactNode; title: string; description: string; redirect?: string }
redirectTarget: string
}) {
const router = useRouter()
useEffect(() => {
const timer = setTimeout(() => {
router.push(redirectTarget)
}, 4000)
return () => clearTimeout(timer)
}, [redirectTarget, router])
return (
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-gray-100">
{errorContent.icon}
</div>
<CardTitle className="text-xl">{errorContent.title}</CardTitle>
<CardDescription className="text-base">
{errorContent.description}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button
variant="outline"
className="w-full"
onClick={() => router.push(redirectTarget)}
>
Go to Login
</Button>
<p className="text-xs text-center text-muted-foreground">
Redirecting to login in a few seconds...
</p>
</CardContent>
</Card>
</AnimatedCard>
)
}
function NetworkErrorCard({ onRetry, isRetrying }: { onRetry: () => void; isRetrying: boolean }) {
return (
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-gray-100">
<AlertCircle className="h-6 w-6 text-amber-600" />
</div>
<CardTitle className="text-xl">Something Went Wrong</CardTitle>
<CardDescription className="text-base">
We couldn&apos;t verify your invitation due to a server or network issue.
This is not a problem with your invitation link.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button
className="w-full"
onClick={onRetry}
disabled={isRetrying}
>
{isRetrying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Retrying...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Try Again
</>
)}
</Button>
<p className="text-xs text-center text-muted-foreground">
If the problem persists, please contact your administrator.
</p>
</CardContent>
</Card>
</AnimatedCard>
)
}
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, refetch, isRefetching } = 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.',
redirect: '/login?expired=1',
}
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.',
redirect: '/login?expired=1',
}
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.',
redirect: '/login',
}
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 (
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<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>
</AnimatedCard>
)
}
// Error state
if (state === 'error') {
// Network/server errors get a retry button instead of auto-redirect
if (errorType === 'NETWORK_ERROR') {
return (
<NetworkErrorCard
onRetry={() => {
setState('loading')
setErrorType(null)
refetch()
}}
isRetrying={isRefetching}
/>
)
}
// Token validation errors auto-redirect to login after 4 seconds
const errorContent = getErrorContent()
const redirectTarget = errorContent.redirect || '/login'
return <ErrorRedirectCard errorContent={errorContent} redirectTarget={redirectTarget} />
}
// Valid invitation - show welcome
const user = data?.user
const team = data?.team
return (
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-white shadow-sm border">
<Image src="/images/MOPC-blue-small.png" alt="MOPC" width={32} height={32} className="object-contain" />
</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">
{team?.projectTitle && (
<div className="rounded-md border border-blue-200 bg-blue-50 p-3 text-center">
<p className="text-sm text-blue-700">
You&apos;ve been invited to join the team for
</p>
<p className="font-semibold text-blue-900">&ldquo;{team.projectTitle}&rdquo;</p>
</div>
)}
{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>
</AnimatedCard>
)
}
// Loading fallback for Suspense
function LoadingCard() {
return (
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<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>
</AnimatedCard>
)
}
// Export with Suspense boundary for useSearchParams
export default function AcceptInvitePage() {
return (
<Suspense fallback={<LoadingCard />}>
<AcceptInviteContent />
</Suspense>
)
}