fix(auth): add forgot/reset to public routes, use Link for basePath, add session guard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WMymkhHZiaYZtUeH9MEHZQ
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { createClient } from '@/lib/supabase/client'
|
import { createClient } from '@/lib/supabase/client'
|
||||||
@@ -11,6 +11,14 @@ export default function ResetPasswordPage() {
|
|||||||
const [confirm, setConfirm] = useState('')
|
const [confirm, setConfirm] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [sessionReady, setSessionReady] = useState<boolean | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const supabase = createClient()
|
||||||
|
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||||
|
setSessionReady(!!session)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -31,20 +39,36 @@ export default function ResetPasswordPage() {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|
||||||
if (updateError) {
|
if (updateError) {
|
||||||
if (
|
|
||||||
updateError.message.toLowerCase().includes('session') ||
|
|
||||||
updateError.message.toLowerCase().includes('expired')
|
|
||||||
) {
|
|
||||||
setError('Reset link has expired. Request a new one.')
|
setError('Reset link has expired. Request a new one.')
|
||||||
} else {
|
|
||||||
setError(updateError.message)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push('/login?message=password_reset')
|
router.push('/login?message=password_reset')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sessionReady === null) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
|
||||||
|
<p className="text-sm text-gray-500">Loading…</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionReady) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
|
||||||
|
<div className="w-full max-w-sm text-center space-y-3">
|
||||||
|
<p className="bg-red-50 border border-red-200 text-red-700 text-sm rounded px-3 py-2">
|
||||||
|
Reset link has expired or is invalid.
|
||||||
|
</p>
|
||||||
|
<Link href="/forgot-password" className="text-sm text-blue-600 hover:underline">
|
||||||
|
Request a new reset link
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
|
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 w-full max-w-sm">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 w-full max-w-sm">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
import { createClient } from '@/lib/supabase/client'
|
import { createClient } from '@/lib/supabase/client'
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
@@ -68,9 +69,9 @@ export function LoginForm() {
|
|||||||
{loading ? 'Signing in…' : 'Sign in'}
|
{loading ? 'Signing in…' : 'Sign in'}
|
||||||
</button>
|
</button>
|
||||||
<p className="text-center text-sm text-gray-500">
|
<p className="text-center text-sm text-gray-500">
|
||||||
<a href="/forgot-password" className="text-blue-600 hover:underline">
|
<Link href="/forgot-password" className="text-blue-600 hover:underline">
|
||||||
Forgot password?
|
Forgot password?
|
||||||
</a>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
|
|||||||
+11
-15
@@ -24,30 +24,28 @@ export async function middleware(request: NextRequest) {
|
|||||||
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
const { pathname } = request.nextUrl
|
const { pathname } = request.nextUrl
|
||||||
const isPublicRoute = pathname.startsWith('/login') || pathname.startsWith('/auth') || pathname.startsWith('/api/cron')
|
const isPublicRoute = pathname.startsWith('/login') || pathname.startsWith('/auth') || pathname.startsWith('/api/cron') || pathname.startsWith('/api/users') || pathname.startsWith('/forgot-password') || pathname.startsWith('/reset-password')
|
||||||
const isSharedRoute = pathname.startsWith('/report') || pathname.startsWith('/account')
|
const isSharedRoute = pathname.startsWith('/report') || pathname.startsWith('/account')
|
||||||
|
|
||||||
|
// Use NEXT_PUBLIC_APP_URL to ensure redirects use the public host, not Next.js's internal host
|
||||||
|
const appBase = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, '')
|
||||||
|
?? `${request.nextUrl.protocol}//${request.nextUrl.host}${request.nextUrl.basePath ?? ''}`
|
||||||
|
|
||||||
if (!user && !isPublicRoute) {
|
if (!user && !isPublicRoute) {
|
||||||
const redirectUrl = request.nextUrl.clone()
|
return NextResponse.redirect(
|
||||||
redirectUrl.pathname = '/login'
|
`${appBase}/login?redirect=${encodeURIComponent(pathname + request.nextUrl.search)}`
|
||||||
redirectUrl.searchParams.set('redirect', pathname + request.nextUrl.search)
|
)
|
||||||
return NextResponse.redirect(redirectUrl)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user && (pathname === '/' || pathname === '/login')) {
|
if (user && (pathname === '/' || pathname === '/login')) {
|
||||||
const redirect = request.nextUrl.searchParams.get('redirect')
|
const redirect = request.nextUrl.searchParams.get('redirect')
|
||||||
if (redirect && redirect.startsWith('/') && !redirect.startsWith('//')) {
|
if (redirect && redirect.startsWith('/') && !redirect.startsWith('//')) {
|
||||||
const url = request.nextUrl.clone()
|
return NextResponse.redirect(`${appBase}${redirect}`)
|
||||||
url.pathname = redirect
|
|
||||||
url.search = ''
|
|
||||||
return NextResponse.redirect(url)
|
|
||||||
}
|
}
|
||||||
const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single()
|
const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single()
|
||||||
const role = profile?.role
|
const role = profile?.role
|
||||||
if (isValidRole(role)) {
|
if (isValidRole(role)) {
|
||||||
const url = request.nextUrl.clone()
|
return NextResponse.redirect(`${appBase}${ROLE_HOME[role as UserRole]}`)
|
||||||
url.pathname = ROLE_HOME[role as UserRole]
|
|
||||||
return NextResponse.redirect(url)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,9 +55,7 @@ export async function middleware(request: NextRequest) {
|
|||||||
if (isValidRole(role) && role !== 'admin') {
|
if (isValidRole(role) && role !== 'admin') {
|
||||||
const allowedPrefix = ROLE_HOME[role as UserRole]
|
const allowedPrefix = ROLE_HOME[role as UserRole]
|
||||||
if (!pathname.startsWith(allowedPrefix)) {
|
if (!pathname.startsWith(allowedPrefix)) {
|
||||||
const url = request.nextUrl.clone()
|
return NextResponse.redirect(`${appBase}${allowedPrefix}`)
|
||||||
url.pathname = allowedPrefix
|
|
||||||
return NextResponse.redirect(url)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user