diff --git a/server/src/routes/post.routes.ts b/server/src/routes/post.routes.ts index 4fd5c0c..ac7fbf7 100644 --- a/server/src/routes/post.routes.ts +++ b/server/src/routes/post.routes.ts @@ -1,16 +1,22 @@ import { Router } from 'express'; import { postController } from '../controllers/post.controller'; -import { authenticate } from '../middleware/auth'; +import { authenticate, optionalAuth } from '../middleware/auth'; import { uploadMedia } from '../middleware/upload'; + const router = Router(); -router.get('/trending', authenticate, postController.getTrending); -router.get('/', authenticate, postController.list); -router.get('/:id', authenticate, postController.getById); + +// Public / optional auth read endpoints +router.get('/trending', optionalAuth, postController.getTrending); +router.get('/', optionalAuth, postController.list); +router.get('/:id', optionalAuth, postController.getById); +router.get('/:id/comments', optionalAuth, postController.getComments); + +// Protected mutation endpoints router.post('/', authenticate, postController.create); router.post('/upload', authenticate, uploadMedia, postController.uploadMedia); router.post('/poll/vote', authenticate, postController.votePoll); router.delete('/:id', authenticate, postController.delete); router.post('/:id/like', authenticate, postController.like); -router.get('/:id/comments', authenticate, postController.getComments); router.post('/:id/comment', authenticate, postController.comment); + export default router; diff --git a/server/src/routes/tournament.routes.ts b/server/src/routes/tournament.routes.ts index 22cd894..1510785 100644 --- a/server/src/routes/tournament.routes.ts +++ b/server/src/routes/tournament.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import { tournamentController } from '../controllers/tournament.controller'; -import { authenticate } from '../middleware/auth'; +import { authenticate, optionalAuth } from '../middleware/auth'; import { validate } from '../middleware/validate'; import { createTournamentValidation, @@ -16,13 +16,16 @@ import { } from '../validators/tournament'; const router = Router(); + +// Protected user-specific list router.get('/my', authenticate, tournamentController.myTournaments.bind(tournamentController)); -router.get('/', authenticate, tournamentController.list.bind(tournamentController)); -router.get('/:id', authenticate, tournamentIdParamValidation, validate, tournamentController.getById.bind(tournamentController)); -router.get('/:id/standings', authenticate, tournamentIdParamValidation, validate, tournamentController.getStandings.bind(tournamentController)); -router.get('/:id/announcements', authenticate, tournamentIdParamValidation, validate, tournamentController.getAnnouncements.bind(tournamentController)); -router.get('/:id/analytics', authenticate, tournamentIdParamValidation, validate, tournamentController.getAnalytics.bind(tournamentController)); +// Public / optional auth read endpoints +router.get('/', optionalAuth, tournamentController.list.bind(tournamentController)); +router.get('/:id', optionalAuth, tournamentIdParamValidation, validate, tournamentController.getById.bind(tournamentController)); +router.get('/:id/standings', optionalAuth, tournamentIdParamValidation, validate, tournamentController.getStandings.bind(tournamentController)); + +// Protected mutation endpoints router.post('/', authenticate, createTournamentValidation, validate, tournamentController.create.bind(tournamentController)); router.post('/:id/register', authenticate, registerTournamentValidation, validate, tournamentController.registerTeam.bind(tournamentController)); router.post('/:id/check-in', authenticate, tournamentIdParamValidation, validate, tournamentController.checkIn.bind(tournamentController)); diff --git a/web/src/app/auth/login/page.tsx b/web/src/app/auth/login/page.tsx index 4dac081..32d4b19 100644 --- a/web/src/app/auth/login/page.tsx +++ b/web/src/app/auth/login/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { useState, Suspense } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -14,8 +14,14 @@ import { useAuthStore } from '@/store/authStore'; import toast from 'react-hot-toast'; import { motion } from 'framer-motion'; -export default function LoginPage() { +function LoginForm() { const router = useRouter(); + const searchParams = useSearchParams(); + const rawRedirect = searchParams.get('redirect'); + const redirectTarget = (rawRedirect && rawRedirect.startsWith('/') && !rawRedirect.startsWith('//')) + ? rawRedirect + : '/feed'; + const { user, isAuthenticated, login } = useAuthStore(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -27,8 +33,6 @@ export default function LoginPage() { return null; } - // Redirect to feed if already logged in - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!email.trim() || !password) return; @@ -37,7 +41,7 @@ export default function LoginPage() { const { data } = await api.post('/auth/login', { email: email.trim(), password }); login(data.data.user, data.data.accessToken, data.data.refreshToken); toast.success(`Welcome back, ${data.data.user?.profile?.username || 'Gamer'}!`); - router.push('/feed'); + router.push(redirectTarget); } catch (err: any) { const msg = err.response?.data?.message || err.response?.data?.error || 'Invalid credentials'; toast.error(msg); @@ -49,8 +53,6 @@ export default function LoginPage() { const handleSocialLogin = async (provider: string) => { setSocialLoading(true); try { - // These are OAuth redirects to the external backend, not internal Next.js - // navigation — the browser must leave the app, so full navigation is required. if (provider === 'discord') { window.location.href = API_URL + '/auth/discord?action=login'; return; @@ -85,10 +87,11 @@ export default function LoginPage() { footer={ Don't have an account?{' '} - Sign up + Sign up } - >
+ > + ); } + +export default function LoginPage() { + return ( + }> + + + ); +} diff --git a/web/src/app/auth/register/page.tsx b/web/src/app/auth/register/page.tsx index 7c55df6..dfa3289 100644 --- a/web/src/app/auth/register/page.tsx +++ b/web/src/app/auth/register/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { useState, Suspense } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -14,8 +14,14 @@ import { useAuthStore } from '@/store/authStore'; import toast from 'react-hot-toast'; import { motion } from 'framer-motion'; -export default function RegisterPage() { +function RegisterForm() { const router = useRouter(); + const searchParams = useSearchParams(); + const rawRedirect = searchParams.get('redirect'); + const redirectTarget = (rawRedirect && rawRedirect.startsWith('/') && !rawRedirect.startsWith('//')) + ? rawRedirect + : '/feed'; + const { user, isAuthenticated, login } = useAuthStore(); const [username, setUsername] = useState(''); const [email, setEmail] = useState(''); @@ -51,7 +57,7 @@ export default function RegisterPage() { }); login(data.data.user, data.data.accessToken, data.data.refreshToken); toast.success('Account created! Welcome to GamerHub.'); - router.push('/feed'); + router.push(redirectTarget); } catch (err: any) { const msg = err.response?.data?.message || err.response?.data?.error || 'Registration failed'; toast.error(msg); @@ -63,8 +69,6 @@ export default function RegisterPage() { const handleSocialLogin = async (provider: string) => { setSocialLoading(true); try { - // These are OAuth redirects to the external backend, not internal Next.js - // navigation — the browser must leave the app, so full navigation is required. if (provider === 'discord') { window.location.href = API_URL + '/auth/discord?action=login'; return; @@ -103,7 +107,7 @@ export default function RegisterPage() { footer={ Already have an account?{' '} - Sign in + Sign in } > @@ -222,3 +226,11 @@ export default function RegisterPage() { ); } + +export default function RegisterPage() { + return ( + }> + + + ); +} diff --git a/web/src/app/settings/page.tsx b/web/src/app/settings/page.tsx index 48a9b78..26a1398 100644 --- a/web/src/app/settings/page.tsx +++ b/web/src/app/settings/page.tsx @@ -14,6 +14,7 @@ import { useTheme } from 'next-themes'; import { motion } from 'framer-motion'; import toast from 'react-hot-toast'; import { BackHeader } from '@/components/common/back-header'; +import { RequireAuth } from '@/components/auth/require-auth'; type SettingsSection = 'appearance' | 'notifications' | 'privacy' | 'accounts' | 'danger'; @@ -30,37 +31,39 @@ export default function SettingsPage() { ]; return ( -
- -
- {/* Sidebar */} - + +
+ +
+ {/* Sidebar */} + - {/* Content */} -
- {activeSection === 'appearance' && } - {activeSection === 'notifications' && } - {activeSection === 'privacy' && } - {activeSection === 'accounts' && } - {activeSection === 'danger' && } + {/* Content */} +
+ {activeSection === 'appearance' && } + {activeSection === 'notifications' && } + {activeSection === 'privacy' && } + {activeSection === 'accounts' && } + {activeSection === 'danger' && } +
-
+
); } diff --git a/web/src/components/auth/require-auth.tsx b/web/src/components/auth/require-auth.tsx new file mode 100644 index 0000000..d7e5ef2 --- /dev/null +++ b/web/src/components/auth/require-auth.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, usePathname } from 'next/navigation'; +import { useAuthStore } from '@/store/authStore'; +import { Loader2 } from 'lucide-react'; + +interface RequireAuthProps { + children: React.ReactNode; + fallback?: React.ReactNode; +} + +export function RequireAuth({ children, fallback }: RequireAuthProps) { + const router = useRouter(); + const pathname = usePathname(); + const { isAuthenticated } = useAuthStore(); + const [hasHydrated, setHasHydrated] = useState(false); + + useEffect(() => { + // Wait for Zustand persistence hydration to complete to prevent race conditions on page refresh + if (useAuthStore.persist?.hasHydrated()) { + setHasHydrated(true); + } else { + const unsub = useAuthStore.persist?.onFinishHydration(() => setHasHydrated(true)); + return () => { + unsub?.(); + }; + } + }, []); + + useEffect(() => { + if (hasHydrated && !isAuthenticated) { + const fullPath = typeof window !== 'undefined' ? `${window.location.pathname}${window.location.search}` : pathname; + const redirectUrl = `/auth/login?redirect=${encodeURIComponent(fullPath)}`; + router.replace(redirectUrl); + } + }, [hasHydrated, isAuthenticated, pathname, router]); + + if (!hasHydrated || !isAuthenticated) { + if (fallback) return <>{fallback}; + return ( +
+ +
+ ); + } + + return <>{children}; +} diff --git a/web/src/hooks/useAuth.ts b/web/src/hooks/useAuth.ts index 7d04d2a..e2bdfaa 100644 --- a/web/src/hooks/useAuth.ts +++ b/web/src/hooks/useAuth.ts @@ -1,10 +1,13 @@ 'use client'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useCallback } from 'react'; +import { useRouter, usePathname } from 'next/navigation'; import api from '@/lib/api'; import { useAuthStore } from '@/store/authStore'; export function useAuth() { + const router = useRouter(); + const pathname = usePathname(); const { user, isAuthenticated, setUser, logout } = useAuthStore(); const hasValidated = useRef(false); @@ -27,5 +30,27 @@ export function useAuth() { } }, [isAuthenticated, setUser, logout]); - return { user, isAuthenticated, setUser, logout }; + /** + * Helper function for protected actions embedded inside public or mixed pages. + * If the user is authenticated, executes `actionFn()`. + * Otherwise, redirects to `/auth/login?redirect=` without executing `actionFn`. + */ + const requireAuth = useCallback( + (actionFn: () => void | Promise, overrideRedirectPath?: string) => { + if (isAuthenticated) { + return actionFn(); + } + + let redirectPath = overrideRedirectPath; + if (!redirectPath && typeof window !== 'undefined') { + redirectPath = `${window.location.pathname}${window.location.search}`; + } + + const safePath = redirectPath || pathname || '/'; + router.push(`/auth/login?redirect=${encodeURIComponent(safePath)}`); + }, + [isAuthenticated, pathname, router] + ); + + return { user, isAuthenticated, setUser, logout, requireAuth }; }