Skip to content

Commit 6e3526f

Browse files
(SP: 2) [Frontend] Remove [locale] layout force-dynamic and move auth to client-side (#370)
* refactor(frontend): remove locale layout dynamic auth and move header auth client-side * fix(frontend): prevent stale auth responses in useAuth and remove redundant dashboard dynamic layout
1 parent 006b39b commit 6e3526f

6 files changed

Lines changed: 154 additions & 35 deletions

File tree

frontend/app/[locale]/layout.tsx

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,8 @@ import { CookieBanner } from '@/components/shared/CookieBanner';
1313
import Footer from '@/components/shared/Footer';
1414
import { ScrollWatcher } from '@/components/shared/ScrollWatcher';
1515
import { ThemeProvider } from '@/components/theme/ThemeProvider';
16+
import { AuthProvider } from '@/hooks/useAuth';
1617
import { locales } from '@/i18n/config';
17-
import { getCurrentUser } from '@/lib/auth';
18-
19-
export const dynamic = 'force-dynamic';
2018

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

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

44-
const messages = await getMessages({ locale });
45-
const user = await getCurrentUser();
46-
const blogCategories = await getCachedBlogCategories();
42+
const [messages, blogCategories] = await Promise.all([
43+
getMessages({ locale }),
44+
getCachedBlogCategories(),
45+
]);
4746

48-
const userExists = Boolean(user);
4947
const enableAdmin =
5048
(
5149
process.env.ENABLE_ADMIN_API ??
5250
process.env.NEXT_PUBLIC_ENABLE_ADMIN ??
5351
''
5452
).toLowerCase() === 'true';
5553

56-
const isAdmin = user?.role === 'admin';
57-
const showAdminNavLink = Boolean(user) && isAdmin && enableAdmin;
58-
const userId = user?.id ?? null;
59-
6054
return (
6155
<NextIntlClientProvider messages={messages}>
6256
<ThemeProvider
@@ -65,20 +59,19 @@ export default async function LocaleLayout({
6559
enableSystem
6660
disableTransitionOnChange
6761
>
68-
<AppChrome
69-
userExists={userExists}
70-
userId={userId}
71-
showAdminLink={showAdminNavLink}
72-
blogCategories={blogCategories}
73-
>
74-
<MainSwitcher
75-
userExists={userExists}
76-
showAdminLink={showAdminNavLink}
62+
<AuthProvider>
63+
<AppChrome
64+
enableAdminFeature={enableAdmin}
7765
blogCategories={blogCategories}
7866
>
79-
{children}
80-
</MainSwitcher>
81-
</AppChrome>
67+
<MainSwitcher
68+
enableAdminFeature={enableAdmin}
69+
blogCategories={blogCategories}
70+
>
71+
{children}
72+
</MainSwitcher>
73+
</AppChrome>
74+
</AuthProvider>
8275

8376
<Footer />
8477
<Toaster position="top-right" richColors expand />

frontend/app/api/auth/me/route.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,14 @@ import { getCurrentUser } from '@/lib/auth';
66

77
export async function GET() {
88
const user = await getCurrentUser();
9-
return NextResponse.json({ user }, { status: 200 });
9+
const payload = user
10+
? { id: user.id, role: user.role, username: user.username }
11+
: null;
12+
13+
return NextResponse.json(payload, {
14+
status: 200,
15+
headers: {
16+
'Cache-Control': 'no-store',
17+
},
18+
});
1019
}

frontend/components/header/AppChrome.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,24 @@ import React from 'react';
55

66
import { UnifiedHeader } from '@/components/header/UnifiedHeader';
77
import { CartProvider } from '@/components/shop/CartProvider';
8+
import { useAuth } from '@/hooks/useAuth';
89

910
type AppChromeProps = {
10-
userExists: boolean;
11-
userId?: string | null;
12-
showAdminLink?: boolean;
11+
enableAdminFeature?: boolean;
1312
blogCategories?: Array<{ _id: string; title: string }>;
1413
children: React.ReactNode;
1514
};
1615

1716
export function AppChrome({
18-
userExists,
19-
userId = null,
20-
showAdminLink = false,
17+
enableAdminFeature = false,
2118
blogCategories = [],
2219
children,
2320
}: AppChromeProps) {
21+
const { userExists, userId, isAdmin } = useAuth();
2422
const segments = useSelectedLayoutSegments();
2523
const isShop = segments.includes('shop');
2624
const isBlog = segments.includes('blog');
25+
const showAdminLink = userExists && isAdmin && enableAdminFeature;
2726

2827
if (isShop) {
2928
return (

frontend/components/header/MainSwitcher.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { usePathname } from 'next/navigation';
44
import type { ReactNode } from 'react';
55

66
import { UnifiedHeader } from '@/components/header/UnifiedHeader';
7+
import { useAuth } from '@/hooks/useAuth';
78
import { locales } from '@/i18n/config';
89

910
function isShopPath(pathname: string): boolean {
@@ -52,20 +53,20 @@ function isLeaderboardPath(pathname: string): boolean {
5253

5354
type MainSwitcherProps = {
5455
children: ReactNode;
55-
userExists: boolean;
56-
showAdminLink?: boolean;
56+
enableAdminFeature?: boolean;
5757
blogCategories?: Array<{ _id: string; title: string }>;
5858
};
5959

6060
export function MainSwitcher({
6161
children,
62-
userExists,
63-
showAdminLink = false,
62+
enableAdminFeature = false,
6463
blogCategories = [],
6564
}: MainSwitcherProps) {
65+
const { userExists, isAdmin } = useAuth();
6666
const pathname = usePathname();
6767
const isQa = isQaPath(pathname);
6868
const isHome = isHomePath(pathname);
69+
const showAdminLink = userExists && isAdmin && enableAdminFeature;
6970

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

frontend/components/q&a/AIWordHelper.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ export default function AIWordHelper({
194194
try {
195195
const response = await fetch('/api/auth/me');
196196
const data = await response.json();
197-
setIsAuthenticated(Boolean(data.user));
197+
setIsAuthenticated(Boolean(data?.id));
198198
} catch {
199199
setIsAuthenticated(false);
200200
} finally {

frontend/hooks/useAuth.tsx

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
'use client';
2+
3+
import {
4+
createContext,
5+
type ReactNode,
6+
useCallback,
7+
useContext,
8+
useEffect,
9+
useMemo,
10+
useRef,
11+
useState,
12+
} from 'react';
13+
14+
type AuthApiUser = {
15+
id: string;
16+
role: 'user' | 'admin';
17+
username: string;
18+
} | null;
19+
20+
type AuthContextValue = {
21+
user: AuthApiUser;
22+
userExists: boolean;
23+
userId: string | null;
24+
isAdmin: boolean;
25+
username: string | null;
26+
loading: boolean;
27+
refresh: () => Promise<void>;
28+
};
29+
30+
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
31+
32+
async function fetchAuth(signal?: AbortSignal): Promise<AuthApiUser> {
33+
const response = await fetch('/api/auth/me', {
34+
method: 'GET',
35+
cache: 'no-store',
36+
credentials: 'include',
37+
signal,
38+
});
39+
40+
if (!response.ok) {
41+
throw new Error(`Failed to fetch auth state: ${response.status}`);
42+
}
43+
44+
return (await response.json()) as AuthApiUser;
45+
}
46+
47+
export function AuthProvider({ children }: { children: ReactNode }) {
48+
const [user, setUser] = useState<AuthApiUser>(null);
49+
const [loading, setLoading] = useState(true);
50+
const latestRequestIdRef = useRef(0);
51+
52+
const runAuthRequest = useCallback(async (signal?: AbortSignal) => {
53+
const requestId = ++latestRequestIdRef.current;
54+
setLoading(true);
55+
56+
try {
57+
const nextUser = await fetchAuth(signal);
58+
59+
if (latestRequestIdRef.current !== requestId) {
60+
return;
61+
}
62+
63+
setUser(nextUser);
64+
} catch (error) {
65+
if (error instanceof DOMException && error.name === 'AbortError') {
66+
return;
67+
}
68+
69+
if (latestRequestIdRef.current !== requestId) {
70+
return;
71+
}
72+
73+
setUser(null);
74+
} finally {
75+
if (latestRequestIdRef.current === requestId) {
76+
setLoading(false);
77+
}
78+
}
79+
}, []);
80+
81+
const refresh = useCallback(async () => {
82+
await runAuthRequest();
83+
}, [runAuthRequest]);
84+
85+
useEffect(() => {
86+
const controller = new AbortController();
87+
88+
void runAuthRequest(controller.signal);
89+
90+
return () => {
91+
controller.abort();
92+
};
93+
}, [runAuthRequest]);
94+
95+
const value = useMemo<AuthContextValue>(
96+
() => ({
97+
user,
98+
userExists: Boolean(user),
99+
userId: user?.id ?? null,
100+
isAdmin: user?.role === 'admin',
101+
username: user?.username ?? null,
102+
loading,
103+
refresh,
104+
}),
105+
[user, loading, refresh]
106+
);
107+
108+
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
109+
}
110+
111+
export function useAuth(): AuthContextValue {
112+
const context = useContext(AuthContext);
113+
if (!context) {
114+
throw new Error('useAuth must be used within AuthProvider');
115+
}
116+
return context;
117+
}

0 commit comments

Comments
 (0)