Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 42 additions & 28 deletions app/auth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions app/user/auth/confirm-email/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(),
},
})

Expand Down
5 changes: 3 additions & 2 deletions app/user/auth/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
175 changes: 175 additions & 0 deletions app/user/auth/reset-password/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<div className="bg-grid-pattern flex min-h-screen items-center justify-center p-4">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
)
}

return (
<div className="bg-grid-pattern flex min-h-screen items-center justify-center p-4">
<div className="from-background/80 via-background/60 to-background/80 absolute inset-0 bg-gradient-to-b" />
<div className="relative w-full max-w-md">
<div className="bg-background/80 border-border rounded-3xl border p-8 shadow-2xl backdrop-blur-xl">
<div className="absolute top-6 left-6">
<ThemeToggle />
</div>
<Link
href="/user/auth"
className="absolute top-6 right-6"
>
<Button
variant="ghost"
size="sm"
className="bg-muted/50 hover:bg-muted text-muted-foreground hover:text-foreground h-10 w-10 rounded-full border-0"
>
<ArrowLeft className="h-5 w-5" />
</Button>
</Link>

<div className="mt-6 mb-8 text-center">
<h1 className="text-foreground mb-2 text-3xl font-bold tracking-tight">Set a new password</h1>
<p className="text-muted-foreground text-sm">Choose a strong password for your account.</p>
</div>

{error && (
<div className="mb-4 rounded-xl border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}

<form
onSubmit={handleSubmit}
className="space-y-4"
>
<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
placeholder="New password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="bg-muted/30 border-border h-12 rounded-xl pr-12"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-1/2 right-2 h-8 w-8 -translate-y-1/2 p-0"
onClick={() => setShowPassword((value) => !value)}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
<Input
type={showPassword ? 'text' : 'password'}
placeholder="Confirm new password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
className="bg-muted/30 border-border h-12 rounded-xl"
/>
<Button
type="submit"
disabled={isLoading}
className="bg-primary hover:bg-primary/90 h-12 w-full rounded-xl"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Updating...
</>
) : (
'Update password'
)}
</Button>
</form>
</div>
</div>
</div>
)
}
17 changes: 6 additions & 11 deletions lib/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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],
},
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(),
},
})

Expand Down Expand Up @@ -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(),
},
})

Expand All @@ -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(),
},
})

Expand Down
5 changes: 4 additions & 1 deletion lib/actions/blog.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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' }
Expand Down
Loading
Loading