From 4fbab33de496038cdd1d2950c3f8291095ee00a8 Mon Sep 17 00:00:00 2001 From: weeihan Date: Tue, 21 Jul 2026 22:36:53 +0800 Subject: [PATCH] fix(auth): add forgot/reset to public routes, use Link for basePath, add session guard Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01WMymkhHZiaYZtUeH9MEHZQ --- app/(auth)/reset-password/page.tsx | 42 +++++++++++++++++++++++------- components/auth/login-form.tsx | 5 ++-- middleware.ts | 26 ++++++++---------- 3 files changed, 47 insertions(+), 26 deletions(-) diff --git a/app/(auth)/reset-password/page.tsx b/app/(auth)/reset-password/page.tsx index 4bb80b5..e402e76 100644 --- a/app/(auth)/reset-password/page.tsx +++ b/app/(auth)/reset-password/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect } from 'react' import { useRouter } from 'next/navigation' import Link from 'next/link' import { createClient } from '@/lib/supabase/client' @@ -11,6 +11,14 @@ export default function ResetPasswordPage() { const [confirm, setConfirm] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + const [sessionReady, setSessionReady] = useState(null) + + useEffect(() => { + const supabase = createClient() + supabase.auth.getSession().then(({ data: { session } }) => { + setSessionReady(!!session) + }) + }, []) async function handleSubmit(e: React.FormEvent) { e.preventDefault() @@ -31,20 +39,36 @@ export default function ResetPasswordPage() { setLoading(false) if (updateError) { - if ( - updateError.message.toLowerCase().includes('session') || - updateError.message.toLowerCase().includes('expired') - ) { - setError('Reset link has expired. Request a new one.') - } else { - setError(updateError.message) - } + setError('Reset link has expired. Request a new one.') return } router.push('/login?message=password_reset') } + if (sessionReady === null) { + return ( +
+

Loading…

+
+ ) + } + + if (!sessionReady) { + return ( +
+
+

+ Reset link has expired or is invalid. +

+ + Request a new reset link + +
+
+ ) + } + return (
diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx index 292606a..44550d9 100644 --- a/components/auth/login-form.tsx +++ b/components/auth/login-form.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import { useRouter } from 'next/navigation' +import Link from 'next/link' import { createClient } from '@/lib/supabase/client' export function LoginForm() { @@ -68,9 +69,9 @@ export function LoginForm() { {loading ? 'Signing in…' : 'Sign in'}

- + Forgot password? - +

) diff --git a/middleware.ts b/middleware.ts index e91fce0..bc5de9f 100644 --- a/middleware.ts +++ b/middleware.ts @@ -24,30 +24,28 @@ export async function middleware(request: NextRequest) { const { data: { user } } = await supabase.auth.getUser() 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') + // 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) { - const redirectUrl = request.nextUrl.clone() - redirectUrl.pathname = '/login' - redirectUrl.searchParams.set('redirect', pathname + request.nextUrl.search) - return NextResponse.redirect(redirectUrl) + return NextResponse.redirect( + `${appBase}/login?redirect=${encodeURIComponent(pathname + request.nextUrl.search)}` + ) } if (user && (pathname === '/' || pathname === '/login')) { const redirect = request.nextUrl.searchParams.get('redirect') if (redirect && redirect.startsWith('/') && !redirect.startsWith('//')) { - const url = request.nextUrl.clone() - url.pathname = redirect - url.search = '' - return NextResponse.redirect(url) + return NextResponse.redirect(`${appBase}${redirect}`) } const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single() const role = profile?.role if (isValidRole(role)) { - const url = request.nextUrl.clone() - url.pathname = ROLE_HOME[role as UserRole] - return NextResponse.redirect(url) + return NextResponse.redirect(`${appBase}${ROLE_HOME[role as UserRole]}`) } } @@ -57,9 +55,7 @@ export async function middleware(request: NextRequest) { if (isValidRole(role) && role !== 'admin') { const allowedPrefix = ROLE_HOME[role as UserRole] if (!pathname.startsWith(allowedPrefix)) { - const url = request.nextUrl.clone() - url.pathname = allowedPrefix - return NextResponse.redirect(url) + return NextResponse.redirect(`${appBase}${allowedPrefix}`) } } }