From c7e8720b58cb7fe32dbcd17f83d80722dfb47eb2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 19 May 2026 05:18:41 +0000 Subject: [PATCH] fix(auth): route email confirmation and password reset through callback - Add centralized getAuthCallbackUrl helpers in lib/constants/auth.ts - Point signup, resend, and reset email links at /auth/callback - Handle password recovery flow in callback and new reset-password page - Fix deleteBlogPost admin check to use users.role from database - Consolidate server getCurrentUser with lib/actions/user implementation - Allow reset-password route in proxy auth redirect logic Co-authored-by: faizntfd --- app/auth/callback/route.ts | 70 ++++++----- app/user/auth/confirm-email/page.tsx | 4 +- app/user/auth/page.tsx | 5 +- app/user/auth/reset-password/page.tsx | 175 ++++++++++++++++++++++++++ lib/actions.ts | 17 +-- lib/actions/blog.ts | 5 +- lib/constants/auth.ts | 47 +++++++ lib/server/auth.ts | 34 +++-- proxy.ts | 3 +- 9 files changed, 295 insertions(+), 65 deletions(-) create mode 100644 app/user/auth/reset-password/page.tsx diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts index 1a3d1c4..40edbed 100644 --- a/app/auth/callback/route.ts +++ b/app/auth/callback/route.ts @@ -1,10 +1,36 @@ import { type NextRequest, NextResponse } from 'next/server' import { createServerClient } from '@/lib/supabase/server' +function getSafeCallbackNextPath(value: string | null): string | null { + if (!value) return null + + const trimmed = value.trim() + if (!trimmed.startsWith('/')) return null + if (trimmed.startsWith('//')) return null + + return trimmed +} + +function redirectToPath(request: NextRequest, origin: string, path: string): NextResponse { + const forwardedHost = request.headers.get('x-forwarded-host') + const isLocalEnv = process.env.NODE_ENV === 'development' + + if (isLocalEnv) { + return NextResponse.redirect(`${origin}${path}`) + } + + if (forwardedHost) { + return NextResponse.redirect(`https://${forwardedHost}${path}`) + } + + return NextResponse.redirect(`${origin}${path}`) +} + export async function GET(request: NextRequest) { const { searchParams, origin } = new URL(request.url) const code = searchParams.get('code') - const next = '/user/auth' + const nextPath = getSafeCallbackNextPath(searchParams.get('next')) + const signInPath = '/user/auth' if (code) { const supabase = await createServerClient() @@ -109,39 +135,27 @@ export async function GET(request: NextRequest) { console.log('[Callback] User authenticated successfully:', user.email) + if (nextPath === '/user/auth/reset-password') { + console.log('[Callback] Password recovery flow, redirecting to reset password page') + return redirectToPath(request, origin, nextPath) + } + // Handle different flows based on auth method if (isOAuthUser) { // OAuth users: Keep them signed in and redirect to home console.log('[Callback] OAuth user login successful, redirecting to home') - const forwardedHost = request.headers.get('x-forwarded-host') - const isLocalEnv = process.env.NODE_ENV === 'development' - - if (isLocalEnv) { - return NextResponse.redirect(`${origin}/`) - } else if (forwardedHost) { - return NextResponse.redirect(`https://${forwardedHost}/`) - } else { - return NextResponse.redirect(`${origin}/`) - } - } else { - // Email/password users: Sign out after email confirmation to force proper login - console.log('[Callback] Email confirmed user, signing out for security') - await supabase.auth.signOut() + return redirectToPath(request, origin, '/') + } - // Redirect email/password users to auth page with success message - const forwardedHost = request.headers.get('x-forwarded-host') - const isLocalEnv = process.env.NODE_ENV === 'development' + // Email/password users: Sign out after email confirmation to force proper login + console.log('[Callback] Email confirmed user, signing out for security') + await supabase.auth.signOut() - if (isLocalEnv) { - return NextResponse.redirect(`${origin}${next}?success=Email confirmed successfully! You can now sign in.`) - } else if (forwardedHost) { - return NextResponse.redirect( - `https://${forwardedHost}${next}?success=Email confirmed successfully! You can now sign in.`, - ) - } else { - return NextResponse.redirect(`${origin}${next}?success=Email confirmed successfully! You can now sign in.`) - } - } + return redirectToPath( + request, + origin, + `${signInPath}?success=${encodeURIComponent('Email confirmed successfully! You can now sign in.')}`, + ) } } else { console.error('[Callback] Exchange code error:', error) diff --git a/app/user/auth/confirm-email/page.tsx b/app/user/auth/confirm-email/page.tsx index c6095cc..a90dc1d 100644 --- a/app/user/auth/confirm-email/page.tsx +++ b/app/user/auth/confirm-email/page.tsx @@ -8,7 +8,7 @@ import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { ThemeToggle } from '@/components/ui/theme-toggle' -import { CONFIRM_EMAIL_COOKIE } from '@/lib/constants/auth' +import { CONFIRM_EMAIL_COOKIE, getClientAuthCallbackUrl } from '@/lib/constants/auth' import { createClient } from '@/lib/supabase/client' function getCookieValue(cookieName: string): string { @@ -66,7 +66,7 @@ function ConfirmEmailContent() { type: 'signup', email: email.toString(), options: { - emailRedirectTo: process.env.NEXT_PUBLIC_DEV_SUPABASE_REDIRECT_URL || `${window.location.origin}/`, + emailRedirectTo: getClientAuthCallbackUrl(), }, }) diff --git a/app/user/auth/page.tsx b/app/user/auth/page.tsx index 2b3f4f1..7ccc309 100644 --- a/app/user/auth/page.tsx +++ b/app/user/auth/page.tsx @@ -13,6 +13,7 @@ import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { ThemeToggle } from '@/components/ui/theme-toggle' import { resetPassword, signIn } from '@/lib/actions' +import { getClientAuthCallbackUrl } from '@/lib/constants/auth' import { createClient } from '@/lib/supabase/client' // Email domain whitelist helper @@ -184,7 +185,7 @@ function AuthPageContent() { email, password, options: { - emailRedirectTo: process.env.NEXT_PUBLIC_DEV_SUPABASE_REDIRECT_URL || `${window.location.origin}/`, + emailRedirectTo: getClientAuthCallbackUrl(), data: { username: username, display_name: username, @@ -237,7 +238,7 @@ function AuthPageContent() { const { error } = await supabase.auth.signInWithOAuth({ provider, options: { - redirectTo: `${window.location.origin}/auth/callback`, + redirectTo: getClientAuthCallbackUrl(), }, }) if (error) throw error diff --git a/app/user/auth/reset-password/page.tsx b/app/user/auth/reset-password/page.tsx new file mode 100644 index 0000000..908693b --- /dev/null +++ b/app/user/auth/reset-password/page.tsx @@ -0,0 +1,175 @@ +'use client' + +import { ArrowLeft, Eye, EyeOff, Loader2 } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import type React from 'react' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { ThemeToggle } from '@/components/ui/theme-toggle' +import { createClient } from '@/lib/supabase/client' + +export default function ResetPasswordPage() { + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [isCheckingSession, setIsCheckingSession] = useState(true) + const router = useRouter() + + useEffect(() => { + let isMounted = true + const supabase = createClient() + + const verifyRecoverySession = async () => { + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!isMounted) return + + if (!user) { + router.replace('/user/auth?error=Password reset link expired or invalid. Please request a new one.') + return + } + + setIsCheckingSession(false) + } + + verifyRecoverySession() + + return () => { + isMounted = false + } + }, [router]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + + if (password.length < 8) { + setError('Password must be at least 8 characters.') + return + } + + if (password !== confirmPassword) { + setError('Passwords do not match.') + return + } + + setIsLoading(true) + const supabase = createClient() + + try { + const { error: updateError } = await supabase.auth.updateUser({ password }) + + if (updateError) { + throw updateError + } + + await supabase.auth.signOut() + toast.success('Password updated successfully. You can sign in with your new password.') + router.replace('/user/auth?success=Password updated successfully. You can now sign in.') + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Failed to update password.') + } finally { + setIsLoading(false) + } + } + + if (isCheckingSession) { + return ( +
+ +
+ ) + } + + return ( +
+
+
+
+
+ +
+ + + + +
+

Set a new password

+

Choose a strong password for your account.

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ setPassword(e.target.value)} + required + minLength={8} + className="bg-muted/30 border-border h-12 rounded-xl pr-12" + /> + +
+ setConfirmPassword(e.target.value)} + required + minLength={8} + className="bg-muted/30 border-border h-12 rounded-xl" + /> + +
+
+
+
+ ) +} diff --git a/lib/actions.ts b/lib/actions.ts index f27dad0..b98a06d 100644 --- a/lib/actions.ts +++ b/lib/actions.ts @@ -4,6 +4,7 @@ import { createServerClient } from '@supabase/ssr' import { revalidatePath } from 'next/cache' import { cookies } from 'next/headers' import { redirect } from 'next/navigation' +import { getAuthCallbackUrl } from './constants/auth' import { getCategories, getCategoryDisplayName } from './categories' import { getSupabaseConfig } from './env-config' import { fetchFavicon } from './favicon-utils' @@ -218,9 +219,7 @@ export async function signUp(prevState: any, formData: FormData) { email: email.toString(), password: password.toString(), options: { - emailRedirectTo: - process.env.NEXT_PUBLIC_DEV_SUPABASE_REDIRECT_URL || - `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}`, + emailRedirectTo: getAuthCallbackUrl(), data: { full_name: firstName && lastName ? `${firstName} ${lastName}`.trim() : email.toString().split('@')[0], }, @@ -260,9 +259,7 @@ export async function resetPassword(prevState: any, formData: FormData) { try { const { error } = await supabase.auth.resetPasswordForEmail(email.toString(), { - redirectTo: - process.env.NEXT_PUBLIC_DEV_SUPABASE_REDIRECT_URL || - `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}/user/auth`, + redirectTo: getAuthCallbackUrl({ next: '/user/auth/reset-password' }), }) if (error) { @@ -294,9 +291,7 @@ export async function resendConfirmationEmail(prevState: any, formData: FormData type: 'signup', email: email.toString(), options: { - emailRedirectTo: - process.env.NEXT_PUBLIC_DEV_SUPABASE_REDIRECT_URL || - `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}`, + emailRedirectTo: getAuthCallbackUrl(), }, }) @@ -648,7 +643,7 @@ export async function signInWithGoogle() { const { data, error } = await supabase.auth.signInWithOAuth({ provider: 'google', options: { - redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}/auth/callback`, + redirectTo: getAuthCallbackUrl(), }, }) @@ -668,7 +663,7 @@ export async function signInWithGitHub() { const { data, error } = await supabase.auth.signInWithOAuth({ provider: 'github', options: { - redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}/auth/callback`, + redirectTo: getAuthCallbackUrl(), }, }) diff --git a/lib/actions/blog.ts b/lib/actions/blog.ts index 51897b6..872a738 100644 --- a/lib/actions/blog.ts +++ b/lib/actions/blog.ts @@ -1,6 +1,7 @@ 'use server' import { revalidatePath, revalidateTag } from 'next/cache' +import { ROLES } from '@/lib/actions/admin/schemas' import { slugifyTitle } from '@/lib/slug' import { createClient } from '@/lib/supabase/server' @@ -286,8 +287,10 @@ export async function deleteBlogPost(id: string) { return { success: false, error: 'Post not found' } } + const { data: userData } = await supabase.from('users').select('role').eq('id', authData.user.id).single() + const isAuthor = post.author_id === authData.user.id - const isAdmin = authData.user.user_metadata.role === 0 + const isAdmin = userData?.role === ROLES.ADMIN if (!isAuthor && !isAdmin) { return { success: false, error: 'Not authorized' } diff --git a/lib/constants/auth.ts b/lib/constants/auth.ts index a8dcdea..ac819e3 100644 --- a/lib/constants/auth.ts +++ b/lib/constants/auth.ts @@ -1,2 +1,49 @@ +import { absoluteUrl } from '@/lib/seo/site-url' + export const CONFIRM_EMAIL_COOKIE = 'confirm_email_hint' export const CONFIRM_EMAIL_COOKIE_MAX_AGE_SECONDS = 5 * 60 + +const AUTH_CALLBACK_PATH = '/auth/callback' + +function ensureAuthCallbackPath(url: string): string { + const trimmed = url.trim().replace(/\/$/, '') + if (trimmed.endsWith(AUTH_CALLBACK_PATH)) { + return trimmed + } + + try { + const parsed = new URL(trimmed) + parsed.pathname = AUTH_CALLBACK_PATH + parsed.search = '' + parsed.hash = '' + return parsed.toString().replace(/\/$/, '') + } catch { + return `${trimmed}${AUTH_CALLBACK_PATH}` + } +} + +/** Server-side Supabase email/OAuth redirect target (signup confirm, resend, OAuth). */ +export function getAuthCallbackUrl(options?: { next?: string }): string { + const devRedirect = process.env.NEXT_PUBLIC_DEV_SUPABASE_REDIRECT_URL?.trim() + const base = devRedirect ? ensureAuthCallbackPath(devRedirect) : absoluteUrl(AUTH_CALLBACK_PATH) + + const next = options?.next?.trim() + if (!next || !next.startsWith('/') || next.startsWith('//')) { + return base + } + + const url = new URL(base) + url.searchParams.set('next', next) + return url.toString() +} + +/** Client-side Supabase email redirect target. */ +export function getClientAuthCallbackUrl(origin?: string): string { + const devRedirect = process.env.NEXT_PUBLIC_DEV_SUPABASE_REDIRECT_URL?.trim() + if (devRedirect) { + return ensureAuthCallbackPath(devRedirect) + } + + const base = origin ?? (typeof window !== 'undefined' ? window.location.origin : '') + return base ? `${base}${AUTH_CALLBACK_PATH}` : AUTH_CALLBACK_PATH +} diff --git a/lib/server/auth.ts b/lib/server/auth.ts index ec09d00..ba1d7dc 100644 --- a/lib/server/auth.ts +++ b/lib/server/auth.ts @@ -1,5 +1,6 @@ 'use server' +import { getCurrentUser as getCurrentUserFromActions } from '@/lib/actions/user' import { createClient } from '@/lib/supabase/server' export async function getServerSession() { @@ -12,29 +13,22 @@ export async function getServerSession() { if (!user) return null - // Return a partial session object containing just the user - // This satisfies the usage in getCurrentUser which only checks session.user - return { user } as any + return { user } } export async function getCurrentUser() { - const session = await getServerSession() - if (!session?.user) return null - - const supabase = await createClient() - const { data: user } = await supabase.from('users').select('*').eq('id', session.user.id).single() - - return user - ? { - id: user.id, - name: user.display_name, - email: session.user.email || '', - avatar: user.avatar_url || '/placeholder.svg', - avatar_url: user.avatar_url || '/placeholder.svg', - username: user.username, - role: user.role, - } - : null + const { user } = await getCurrentUserFromActions() + if (!user?.id) return null + + return { + id: user.id, + name: user.name, + email: user.email, + avatar: user.avatar || user.avatar_url || '/placeholder.svg', + avatar_url: user.avatar_url || user.avatar || '/placeholder.svg', + username: user.username, + role: user.role, + } } export async function checkProjectOwnership(authorUsername: string, userId: string) { diff --git a/proxy.ts b/proxy.ts index 0d8fcbb..8ed3f48 100644 --- a/proxy.ts +++ b/proxy.ts @@ -140,10 +140,11 @@ async function handleAuth(request: NextRequest, response: NextResponse): Promise // Check for email confirmation requirements const isAuthPath = pathname.startsWith('/user/auth') const isConfirmEmailPath = pathname.includes('/confirm-email') + const isResetPasswordPath = pathname === '/user/auth/reset-password' const isCallbackPath = pathname.includes('/auth/callback') const hasConfirmEmailCookie = !!request.cookies.get(CONFIRM_EMAIL_COOKIE)?.value - if (user && isAuthPath && !isConfirmEmailPath && !isCallbackPath) { + if (user && isAuthPath && !isConfirmEmailPath && !isResetPasswordPath && !isCallbackPath) { const redirectTo = getSafeRedirectPath(requestUrl.searchParams.get('redirectTo')) const redirectResponse = NextResponse.redirect(new URL(redirectTo, requestUrl.origin)) return withSupabaseCookies(response, redirectResponse)