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
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -747,3 +747,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- Lower Vercel Function Invocations and CPU usage
- Reduced origin data transfer for blog content
- Improved overall runtime efficiency

## [1.0.5] - 2026-02-26

### Added

- Auth improvements:
- Cross-tab authentication sync via BroadcastChannel
- Client-side auth handling in Header for faster UI updates

### Changed

- Quizzes performance:
- Quizzes page now uses ISR (revalidate: 300)
- User progress moved from SSR to client-side API (`/api/quiz/progress`)
- URL tab sync via `history.replaceState` without navigation
- GitHub stars cached in `sessionStorage` to prevent refetch and re-animation
- Rendering optimization:
- Removed `force-dynamic` from locale layout
- Reduced authentication overhead and dynamic rendering
- Replaced nested `<main>` structure with semantic `<section>`

### Fixed

- Fixed quiz timer flash when switching language
- Fixed layout shift on quizzes page (skeleton grid during progress loading)
- Fixed GitHub star button hover trembling
- Fixed React 19 `useRef` render warning (lazy `useState` initializer)
- Prevented stale auth state across tabs
- Eliminated layout shift when switching quiz tabs

### Performance

- Reduced server load by moving auth and progress logic to client
- Improved ISR caching efficiency for quizzes page
- Faster navigation and more stable UI during locale and tab changes
39 changes: 16 additions & 23 deletions frontend/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,8 @@ import { CookieBanner } from '@/components/shared/CookieBanner';
import Footer from '@/components/shared/Footer';
import { ScrollWatcher } from '@/components/shared/ScrollWatcher';
import { ThemeProvider } from '@/components/theme/ThemeProvider';
import { AuthProvider } from '@/hooks/useAuth';
import { locales } from '@/i18n/config';
import { getCurrentUser } from '@/lib/auth';

export const dynamic = 'force-dynamic';

const getCachedBlogCategories = unstable_cache(
async () =>
Expand All @@ -41,22 +39,18 @@ export default async function LocaleLayout({

if (!locales.includes(locale as any)) notFound();

const messages = await getMessages({ locale });
const user = await getCurrentUser();
const blogCategories = await getCachedBlogCategories();
const [messages, blogCategories] = await Promise.all([
getMessages({ locale }),
getCachedBlogCategories(),
]);

const userExists = Boolean(user);
const enableAdmin =
(
process.env.ENABLE_ADMIN_API ??
process.env.NEXT_PUBLIC_ENABLE_ADMIN ??
''
).toLowerCase() === 'true';

const isAdmin = user?.role === 'admin';
const showAdminNavLink = Boolean(user) && isAdmin && enableAdmin;
const userId = user?.id ?? null;

return (
<NextIntlClientProvider messages={messages}>
<ThemeProvider
Expand All @@ -65,20 +59,19 @@ export default async function LocaleLayout({
enableSystem
disableTransitionOnChange
>
<AppChrome
userExists={userExists}
userId={userId}
showAdminLink={showAdminNavLink}
blogCategories={blogCategories}
>
<MainSwitcher
userExists={userExists}
showAdminLink={showAdminNavLink}
<AuthProvider>
<AppChrome
enableAdminFeature={enableAdmin}
blogCategories={blogCategories}
>
{children}
</MainSwitcher>
</AppChrome>
<MainSwitcher
enableAdminFeature={enableAdmin}
blogCategories={blogCategories}
>
{children}
</MainSwitcher>
</AppChrome>
</AuthProvider>

<Footer />
<Toaster position="top-right" richColors expand />
Expand Down
9 changes: 0 additions & 9 deletions frontend/app/[locale]/quiz/[slug]/loading.tsx

This file was deleted.

18 changes: 4 additions & 14 deletions frontend/app/[locale]/quizzes/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ import QuizzesSection from '@/components/quiz/QuizzesSection';
import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground';
import {
getActiveQuizzes,
getUserQuizzesProgress,
} from '@/db/queries/quizzes/quiz';
import { getCurrentUser } from '@/lib/auth';

type PageProps = { params: Promise<{ locale: string }> };

Expand All @@ -23,22 +21,14 @@ export async function generateMetadata({
};
}

export const dynamic = 'force-dynamic';
export const revalidate = 300

export default async function QuizzesPage({ params }: PageProps) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'quiz.list' });
const session = await getCurrentUser();

const quizzes = await getActiveQuizzes(locale);

let userProgressMap: Record<string, any> = {};

if (session?.id) {
const progressMapData = await getUserQuizzesProgress(session.id);
userProgressMap = Object.fromEntries(progressMapData);
}

if (!quizzes.length) {
return (
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
Expand All @@ -50,7 +40,7 @@ export default async function QuizzesPage({ params }: PageProps) {

return (
<DynamicGridBackground className="min-h-screen bg-gray-50 py-10 transition-colors duration-300 dark:bg-transparent">
<main className="relative z-10 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<section className="relative z-10 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="mb-8">
<p className="text-sm font-semibold text-(--accent-primary)">
{t('practice')}
Expand All @@ -59,8 +49,8 @@ export default async function QuizzesPage({ params }: PageProps) {
<p className="text-gray-600 dark:text-gray-400">{t('subtitle')}</p>
</div>

<QuizzesSection quizzes={quizzes} userProgressMap={userProgressMap} />
</main>
<QuizzesSection quizzes={quizzes} userProgressMap={{}} />
</section>
</DynamicGridBackground>
);
}
13 changes: 10 additions & 3 deletions frontend/app/api/auth/me/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@ import 'server-only';

import { NextResponse } from 'next/server';

import { getCurrentUser } from '@/lib/auth';
import { getAuthSession } from '@/lib/auth';

export async function GET() {
const user = await getCurrentUser();
return NextResponse.json({ user }, { status: 200 });
const session = await getAuthSession();
const payload = session ? { id: session.id, role: session.role } : null;

Comment on lines +5 to +10
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid returning authorization role from stale token claims.
Line 8-Line 9 now return role directly from JWT session payload. If a user is downgraded/deleted in DB, this endpoint can continue exposing outdated auth state until token expiry.

✅ Safer approach (DB-backed role)
-import { getAuthSession } from '@/lib/auth';
+import { getCurrentUser } from '@/lib/auth';

 export async function GET() {
-  const session = await getAuthSession();
-  const payload = session ? { id: session.id, role: session.role } : null;
+  const user = await getCurrentUser();
+  const payload = user ? { id: user.id, role: user.role } : null;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/auth/me/route.ts` around lines 5 - 10, The GET handler
currently returns role from the JWT session (via getAuthSession) which can be
stale; update GET to fetch the current user record from the database using the
session user id (e.g., use your user lookup function or model such as
getUserById / User.findById with session.id) and derive the role from that DB
record before building payload, and if the DB user is missing return payload
with null or an appropriate unauthenticated shape instead of the token role;
keep returning id from session only if you still need it but prefer using the
DB-sourced id/role for authorization state.

return NextResponse.json(payload, {
status: 200,
headers: {
'Cache-Control': 'no-store',
},
});
}
31 changes: 31 additions & 0 deletions frontend/app/api/quiz/progress/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';

import { getUserQuizzesProgress } from '@/db/queries/quizzes/quiz';
import { getCurrentUser } from '@/lib/auth';

export const runtime = 'nodejs';

export async function GET() {
const user = await getCurrentUser();

if (!user?.id) {
return NextResponse.json({}, {
headers: { 'Cache-Control': 'no-store' },
});
}

const rawProgress = await getUserQuizzesProgress(user.id);
const progressMap: Record<string, { bestScore: number; totalQuestions: number; attemptsCount: number }> = {};

for (const [quizId, progress] of rawProgress) {
progressMap[quizId] = {
bestScore: progress.bestScore,
Comment on lines +18 to +22
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent prototype pollution in dynamic key mapping.
Line 18 and Line 21 build an object from dynamic quizId keys on {}. If a key like __proto__ is present, object prototype mutation becomes possible.

🔒 Proposed fix
-  const progressMap: Record<string, { bestScore: number; totalQuestions: number; attemptsCount: number }> = {};
+  const progressMap: Record<string, { bestScore: number; totalQuestions: number; attemptsCount: number }> =
+    Object.create(null);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const progressMap: Record<string, { bestScore: number; totalQuestions: number; attemptsCount: number }> = {};
for (const [quizId, progress] of rawProgress) {
progressMap[quizId] = {
bestScore: progress.bestScore,
const progressMap: Record<string, { bestScore: number; totalQuestions: number; attemptsCount: number }> =
Object.create(null);
for (const [quizId, progress] of rawProgress) {
progressMap[quizId] = {
bestScore: progress.bestScore,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/quiz/progress/route.ts` around lines 18 - 22, The dynamic
key assignment to progressMap using quizId can allow prototype pollution (e.g.,
"__proto__"); change progressMap from a plain object literal to a safe map —
either initialize it with Object.create(null) or use a Map — and update usages
accordingly (references to progressMap, rawProgress, and quizId) so keys are
stored/retrieved from the safe container instead of mutating the default object
prototype.

totalQuestions: progress.totalQuestions,
attemptsCount: progress.attemptsCount,
};
}

return NextResponse.json(progressMap, {
headers: { 'Cache-Control': 'no-store' },
});
}
2 changes: 2 additions & 0 deletions frontend/components/auth/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { EmailField } from '@/components/auth/fields/EmailField';
import { PasswordField } from '@/components/auth/fields/PasswordField';
import { Button } from '@/components/ui/button';
import { Link } from '@/i18n/routing';
import { broadcastAuthUpdated } from '@/lib/auth-sync';

type LoginFormProps = {
locale: string;
Expand Down Expand Up @@ -59,6 +60,7 @@ export function LoginForm({ locale, returnTo }: LoginFormProps) {
return;
}

broadcastAuthUpdated();
window.location.href = returnTo || `/${locale}/dashboard`;
} catch (err) {
console.error('Login request failed:', err);
Expand Down
2 changes: 2 additions & 0 deletions frontend/components/auth/SignupForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
PASSWORD_MIN_LEN,
PASSWORD_POLICY_REGEX,
} from '@/lib/auth/signup-constraints';
import { broadcastAuthUpdated } from '@/lib/auth-sync';

type SignupFormProps = {
locale: string;
Expand Down Expand Up @@ -173,6 +174,7 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) {
return;
}

broadcastAuthUpdated();
window.location.href = returnTo || `/${locale}/dashboard`;
} catch {
setError(t('errors.networkError'));
Expand Down
11 changes: 5 additions & 6 deletions frontend/components/header/AppChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,24 @@ import React from 'react';

import { UnifiedHeader } from '@/components/header/UnifiedHeader';
import { CartProvider } from '@/components/shop/CartProvider';
import { useAuth } from '@/hooks/useAuth';

type AppChromeProps = {
userExists: boolean;
userId?: string | null;
showAdminLink?: boolean;
enableAdminFeature?: boolean;
blogCategories?: Array<{ _id: string; title: string }>;
children: React.ReactNode;
};

export function AppChrome({
userExists,
userId = null,
showAdminLink = false,
enableAdminFeature = false,
blogCategories = [],
children,
}: AppChromeProps) {
const { userExists, userId, isAdmin } = useAuth();
const segments = useSelectedLayoutSegments();
const isShop = segments.includes('shop');
const isBlog = segments.includes('blog');
const showAdminLink = userExists && isAdmin && enableAdminFeature;

if (isShop) {
return (
Expand Down
9 changes: 5 additions & 4 deletions frontend/components/header/MainSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { usePathname } from 'next/navigation';
import type { ReactNode } from 'react';

import { UnifiedHeader } from '@/components/header/UnifiedHeader';
import { useAuth } from '@/hooks/useAuth';
import { locales } from '@/i18n/config';

function isShopPath(pathname: string): boolean {
Expand Down Expand Up @@ -52,20 +53,20 @@ function isLeaderboardPath(pathname: string): boolean {

type MainSwitcherProps = {
children: ReactNode;
userExists: boolean;
showAdminLink?: boolean;
enableAdminFeature?: boolean;
blogCategories?: Array<{ _id: string; title: string }>;
};

export function MainSwitcher({
children,
userExists,
showAdminLink = false,
enableAdminFeature = false,
blogCategories = [],
}: MainSwitcherProps) {
const { userExists, isAdmin } = useAuth();
const pathname = usePathname();
const isQa = isQaPath(pathname);
const isHome = isHomePath(pathname);
const showAdminLink = userExists && isAdmin && enableAdminFeature;

if (isShopPath(pathname)) return <>{children}</>;

Expand Down
24 changes: 3 additions & 21 deletions frontend/components/q&a/AIWordHelper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import React, { useCallback, useEffect, useRef, useState } from 'react';

import { useAuth } from '@/hooks/useAuth';
import { Link } from '@/i18n/routing';
import {
getCachedExplanation,
Expand Down Expand Up @@ -160,8 +161,8 @@ export default function AIWordHelper({
);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
const { userExists, loading: isCheckingAuth } = useAuth();
const isAuthenticated = userExists;
const [rateLimitState, setRateLimitState] = useState<RateLimitState>({
isRateLimited: false,
resetIn: 0,
Expand All @@ -186,25 +187,6 @@ export default function AIWordHelper({

const modalRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!isOpen) return;

const checkAuth = async () => {
setIsCheckingAuth(true);
try {
const response = await fetch('/api/auth/me');
const data = await response.json();
setIsAuthenticated(Boolean(data.user));
} catch {
setIsAuthenticated(false);
} finally {
setIsCheckingAuth(false);
}
};

checkAuth();
}, [isOpen]);

useEffect(() => {
if (isOpen) {
setPosition({ x: 0, y: 0 });
Expand Down
Loading