feat: expired link UX — auto-redirect to login with friendly notice
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- /error?error=Verification: shows "Link Expired" with amber icon, auto-redirects to /login?expired=1 after 5 seconds - /accept-invite: expired/invalid/already-accepted tokens auto-redirect to login after 4 seconds with "Redirecting..." message - /login: amber banner when ?expired=1 explains the link expired and prompts to sign in again or request a new magic link Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,52 @@ import { AnimatedCard } from '@/components/shared/animated-container'
|
|||||||
|
|
||||||
type InviteState = 'loading' | 'valid' | 'accepting' | 'error'
|
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 AcceptInviteContent() {
|
function AcceptInviteContent() {
|
||||||
const [state, setState] = useState<InviteState>('loading')
|
const [state, setState] = useState<InviteState>('loading')
|
||||||
const [errorType, setErrorType] = useState<string | null>(null)
|
const [errorType, setErrorType] = useState<string | null>(null)
|
||||||
@@ -105,18 +151,21 @@ function AcceptInviteContent() {
|
|||||||
icon: <XCircle className="h-6 w-6 text-red-600" />,
|
icon: <XCircle className="h-6 w-6 text-red-600" />,
|
||||||
title: 'Invalid Invitation',
|
title: 'Invalid Invitation',
|
||||||
description: 'This invitation link is not valid. It may have already been used or the link is incorrect.',
|
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':
|
case 'EXPIRED_TOKEN':
|
||||||
return {
|
return {
|
||||||
icon: <Clock className="h-6 w-6 text-amber-600" />,
|
icon: <Clock className="h-6 w-6 text-amber-600" />,
|
||||||
title: 'Invitation Expired',
|
title: 'Invitation Expired',
|
||||||
description: 'This invitation has expired. Please contact your administrator to receive a new invitation.',
|
description: 'This invitation has expired. Please contact your administrator to receive a new invitation.',
|
||||||
|
redirect: '/login?expired=1',
|
||||||
}
|
}
|
||||||
case 'ALREADY_ACCEPTED':
|
case 'ALREADY_ACCEPTED':
|
||||||
return {
|
return {
|
||||||
icon: <CheckCircle2 className="h-6 w-6 text-blue-600" />,
|
icon: <CheckCircle2 className="h-6 w-6 text-blue-600" />,
|
||||||
title: 'Already Accepted',
|
title: 'Already Accepted',
|
||||||
description: 'This invitation has already been accepted. You can sign in with your credentials.',
|
description: 'This invitation has already been accepted. You can sign in with your credentials.',
|
||||||
|
redirect: '/login',
|
||||||
}
|
}
|
||||||
case 'AUTH_FAILED':
|
case 'AUTH_FAILED':
|
||||||
return {
|
return {
|
||||||
@@ -148,34 +197,12 @@ function AcceptInviteContent() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error state
|
// Error state — auto-redirect to login after 4 seconds for known errors
|
||||||
if (state === 'error') {
|
if (state === 'error') {
|
||||||
const errorContent = getErrorContent()
|
const errorContent = getErrorContent()
|
||||||
return (
|
const redirectTarget = errorContent.redirect || '/login'
|
||||||
<AnimatedCard>
|
|
||||||
<Card className="w-full max-w-md overflow-hidden">
|
return <ErrorRedirectCard errorContent={errorContent} redirectTarget={redirectTarget} />
|
||||||
<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>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => router.push('/login')}
|
|
||||||
>
|
|
||||||
Go to Login
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valid invitation - show welcome
|
// Valid invitation - show welcome
|
||||||
|
|||||||
@@ -1,50 +1,96 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams, useRouter } from 'next/navigation'
|
||||||
|
import { useEffect, Suspense } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Logo } from '@/components/shared/logo'
|
import { Logo } from '@/components/shared/logo'
|
||||||
import { AlertCircle } from 'lucide-react'
|
import { AlertCircle, Clock, Loader2 } from 'lucide-react'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
|
||||||
const errorMessages: Record<string, string> = {
|
const errorMessages: Record<string, string> = {
|
||||||
Configuration: 'There is a problem with the server configuration.',
|
Configuration: 'There is a problem with the server configuration.',
|
||||||
AccessDenied: 'You do not have access to this resource.',
|
AccessDenied: 'You do not have access to this resource.',
|
||||||
Verification: 'The verification link has expired or already been used.',
|
Verification: 'This sign-in link has expired or has already been used. Please request a new one.',
|
||||||
Default: 'An error occurred during authentication.',
|
Default: 'An error occurred during authentication.',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AuthErrorPage() {
|
function AuthErrorContent() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
const router = useRouter()
|
||||||
const error = searchParams.get('error') || 'Default'
|
const error = searchParams.get('error') || 'Default'
|
||||||
const message = errorMessages[error] || errorMessages.Default
|
const message = errorMessages[error] || errorMessages.Default
|
||||||
|
const isExpired = error === 'Verification'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isExpired) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
router.push('/login?expired=1')
|
||||||
|
}, 5000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [isExpired, router])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedCard>
|
<AnimatedCard>
|
||||||
<Card className="w-full max-w-md overflow-hidden">
|
<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" />
|
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<div className="mx-auto mb-4">
|
<div className="mx-auto mb-4">
|
||||||
<Logo variant="small" />
|
<Logo variant="small" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-destructive/10">
|
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-destructive/10">
|
||||||
<AlertCircle className="h-6 w-6 text-destructive" />
|
{isExpired ? (
|
||||||
</div>
|
<Clock className="h-6 w-6 text-amber-600" />
|
||||||
<CardTitle className="text-xl">Authentication Error</CardTitle>
|
) : (
|
||||||
</CardHeader>
|
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||||
<CardContent className="space-y-4 text-center">
|
)}
|
||||||
<p className="text-muted-foreground">{message}</p>
|
</div>
|
||||||
<div className="flex gap-3 justify-center border-t pt-4">
|
<CardTitle className="text-xl">
|
||||||
<Button asChild>
|
{isExpired ? 'Link Expired' : 'Authentication Error'}
|
||||||
<Link href="/login">Return to Login</Link>
|
</CardTitle>
|
||||||
</Button>
|
</CardHeader>
|
||||||
<Button variant="outline" asChild>
|
<CardContent className="space-y-4 text-center">
|
||||||
<Link href="/">Home</Link>
|
<p className="text-muted-foreground">{message}</p>
|
||||||
</Button>
|
{isExpired && (
|
||||||
</div>
|
<p className="text-xs text-muted-foreground">
|
||||||
</CardContent>
|
Redirecting to login in 5 seconds...
|
||||||
</Card>
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3 justify-center border-t pt-4">
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/login">
|
||||||
|
{isExpired ? 'Sign In Again' : 'Return to Login'}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{!isExpired && (
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/">Home</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function AuthErrorPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<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" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AuthErrorContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Mail, Loader2, CheckCircle2, AlertCircle, Lock, KeyRound } from 'lucide-react'
|
import { Mail, Loader2, CheckCircle2, AlertCircle, Lock, KeyRound, Clock } from 'lucide-react'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
|
||||||
type LoginMode = 'password' | 'magic-link'
|
type LoginMode = 'password' | 'magic-link'
|
||||||
@@ -32,6 +32,7 @@ export default function LoginPage() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const callbackUrl = searchParams.get('callbackUrl') || '/'
|
const callbackUrl = searchParams.get('callbackUrl') || '/'
|
||||||
const errorParam = searchParams.get('error')
|
const errorParam = searchParams.get('error')
|
||||||
|
const isExpiredLink = searchParams.get('expired') === '1'
|
||||||
|
|
||||||
const handlePasswordLogin = async (e: React.FormEvent) => {
|
const handlePasswordLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -172,6 +173,17 @@ export default function LoginPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{isExpiredLink && (
|
||||||
|
<div className="mb-4 flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm">
|
||||||
|
<Clock className="h-4 w-4 mt-0.5 text-amber-600 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-amber-900">Your link has expired</p>
|
||||||
|
<p className="text-amber-700 mt-0.5">
|
||||||
|
Sign-in links expire after 15 minutes for security. Please sign in below or request a new magic link.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{mode === 'password' ? (
|
{mode === 'password' ? (
|
||||||
// Password login form
|
// Password login form
|
||||||
<form onSubmit={handlePasswordLogin} className="space-y-4">
|
<form onSubmit={handlePasswordLogin} className="space-y-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user