Skip to content
Merged
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
16 changes: 11 additions & 5 deletions server/src/routes/post.routes.ts
Original file line number Diff line number Diff line change
@@ -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;
15 changes: 9 additions & 6 deletions server/src/routes/tournament.routes.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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));
Expand Down
31 changes: 21 additions & 10 deletions web/src/app/auth/login/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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('');
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -85,10 +87,11 @@ export default function LoginPage() {
footer={
<span>
Don't have an account?{' '}
<Link href="/auth/register" className="text-primary hover:underline font-medium">Sign up</Link>
<Link href={rawRedirect ? `/auth/register?redirect=${encodeURIComponent(rawRedirect)}` : "/auth/register"} className="text-primary hover:underline font-medium">Sign up</Link>
</span>
}
> <form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
>
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
<motion.div className="space-y-2" initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }}>
<Label htmlFor="email">Email</Label>
<Input
Expand Down Expand Up @@ -154,3 +157,11 @@ export default function LoginPage() {
</AuthFormWrapper>
);
}

export default function LoginPage() {
return (
<Suspense fallback={<div className="flex justify-center items-center min-h-[50vh]"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>}>
<LoginForm />
</Suspense>
);
}
26 changes: 19 additions & 7 deletions web/src/app/auth/register/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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('');
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -103,7 +107,7 @@ export default function RegisterPage() {
footer={
<span>
Already have an account?{' '}
<Link href="/auth/login" className="text-primary hover:underline font-medium">Sign in</Link>
<Link href={rawRedirect ? `/auth/login?redirect=${encodeURIComponent(rawRedirect)}` : "/auth/login"} className="text-primary hover:underline font-medium">Sign in</Link>
</span>
}
>
Expand Down Expand Up @@ -222,3 +226,11 @@ export default function RegisterPage() {
</AuthFormWrapper>
);
}

export default function RegisterPage() {
return (
<Suspense fallback={<div className="flex justify-center items-center min-h-[50vh]"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>}>
<RegisterForm />
</Suspense>
);
}
59 changes: 31 additions & 28 deletions web/src/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -30,37 +31,39 @@ export default function SettingsPage() {
];

return (
<div className="max-w-4xl mx-auto p-4 space-y-6">
<BackHeader title="Settings" />
<div className="flex flex-col md:flex-row gap-6">
{/* Sidebar */}
<nav className="md:w-56 shrink-0 space-y-1" aria-label="Settings navigation">
{sections.map((s) => (
<button
key={s.id}
onClick={() => setActiveSection(s.id)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${
activeSection === s.id
? 'bg-primary/10 text-primary border border-primary/20'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
>
<s.icon className="h-4 w-4" />
{s.label}
</button>
))}
</nav>
<RequireAuth>
<div className="max-w-4xl mx-auto p-4 space-y-6">
<BackHeader title="Settings" />
<div className="flex flex-col md:flex-row gap-6">
{/* Sidebar */}
<nav className="md:w-56 shrink-0 space-y-1" aria-label="Settings navigation">
{sections.map((s) => (
<button
key={s.id}
onClick={() => setActiveSection(s.id)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${
activeSection === s.id
? 'bg-primary/10 text-primary border border-primary/20'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
>
<s.icon className="h-4 w-4" />
{s.label}
</button>
))}
</nav>

{/* Content */}
<div className="flex-1 min-w-0">
{activeSection === 'appearance' && <AppearanceSettings theme={theme} setTheme={setTheme} />}
{activeSection === 'notifications' && <NotificationSettings />}
{activeSection === 'privacy' && <PrivacySettings />}
{activeSection === 'accounts' && <AccountSettings />}
{activeSection === 'danger' && <DangerZone />}
{/* Content */}
<div className="flex-1 min-w-0">
{activeSection === 'appearance' && <AppearanceSettings theme={theme} setTheme={setTheme} />}
{activeSection === 'notifications' && <NotificationSettings />}
{activeSection === 'privacy' && <PrivacySettings />}
{activeSection === 'accounts' && <AccountSettings />}
{activeSection === 'danger' && <DangerZone />}
</div>
</div>
</div>
</div>
</RequireAuth>
);
}

Expand Down
49 changes: 49 additions & 0 deletions web/src/components/auth/require-auth.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
);
}

return <>{children}</>;
}
29 changes: 27 additions & 2 deletions web/src/hooks/useAuth.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand All @@ -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=<targetPath>` without executing `actionFn`.
*/
const requireAuth = useCallback(
(actionFn: () => void | Promise<void>, 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 };
}
Loading