Skip to content

Commit 9130df1

Browse files
(SP: 2) [Frontend] Reduce auth overhead and sync auth state across tabs (#372)
* 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 * feat(frontend): sync auth state across tabs via BroadcastChannel
1 parent 6e3526f commit 9130df1

8 files changed

Lines changed: 129 additions & 40 deletions

File tree

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,11 @@ import 'server-only';
22

33
import { NextResponse } from 'next/server';
44

5-
import { getCurrentUser } from '@/lib/auth';
5+
import { getAuthSession } from '@/lib/auth';
66

77
export async function GET() {
8-
const user = await getCurrentUser();
9-
const payload = user
10-
? { id: user.id, role: user.role, username: user.username }
11-
: null;
8+
const session = await getAuthSession();
9+
const payload = session ? { id: session.id, role: session.role } : null;
1210

1311
return NextResponse.json(payload, {
1412
status: 200,

frontend/components/auth/LoginForm.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { EmailField } from '@/components/auth/fields/EmailField';
1111
import { PasswordField } from '@/components/auth/fields/PasswordField';
1212
import { Button } from '@/components/ui/button';
1313
import { Link } from '@/i18n/routing';
14+
import { broadcastAuthUpdated } from '@/lib/auth-sync';
1415

1516
type LoginFormProps = {
1617
locale: string;
@@ -59,6 +60,7 @@ export function LoginForm({ locale, returnTo }: LoginFormProps) {
5960
return;
6061
}
6162

63+
broadcastAuthUpdated();
6264
window.location.href = returnTo || `/${locale}/dashboard`;
6365
} catch (err) {
6466
console.error('Login request failed:', err);

frontend/components/auth/SignupForm.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
PASSWORD_MIN_LEN,
2323
PASSWORD_POLICY_REGEX,
2424
} from '@/lib/auth/signup-constraints';
25+
import { broadcastAuthUpdated } from '@/lib/auth-sync';
2526

2627
type SignupFormProps = {
2728
locale: string;
@@ -173,6 +174,7 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) {
173174
return;
174175
}
175176

177+
broadcastAuthUpdated();
176178
window.location.href = returnTo || `/${locale}/dashboard`;
177179
} catch {
178180
setError(t('errors.networkError'));

frontend/components/q&a/AIWordHelper.tsx

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { useParams } from 'next/navigation';
1919
import { useTranslations } from 'next-intl';
2020
import React, { useCallback, useEffect, useRef, useState } from 'react';
2121

22+
import { useAuth } from '@/hooks/useAuth';
2223
import { Link } from '@/i18n/routing';
2324
import {
2425
getCachedExplanation,
@@ -160,8 +161,8 @@ export default function AIWordHelper({
160161
);
161162
const [isLoading, setIsLoading] = useState(false);
162163
const [error, setError] = useState<string | null>(null);
163-
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
164-
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
164+
const { userExists, loading: isCheckingAuth } = useAuth();
165+
const isAuthenticated = userExists;
165166
const [rateLimitState, setRateLimitState] = useState<RateLimitState>({
166167
isRateLimited: false,
167168
resetIn: 0,
@@ -186,25 +187,6 @@ export default function AIWordHelper({
186187

187188
const modalRef = useRef<HTMLDivElement>(null);
188189

189-
useEffect(() => {
190-
if (!isOpen) return;
191-
192-
const checkAuth = async () => {
193-
setIsCheckingAuth(true);
194-
try {
195-
const response = await fetch('/api/auth/me');
196-
const data = await response.json();
197-
setIsAuthenticated(Boolean(data?.id));
198-
} catch {
199-
setIsAuthenticated(false);
200-
} finally {
201-
setIsCheckingAuth(false);
202-
}
203-
};
204-
205-
checkAuth();
206-
}, [isOpen]);
207-
208190
useEffect(() => {
209191
if (isOpen) {
210192
setPosition({ x: 0, y: 0 });

frontend/hooks/useAuth.tsx

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,28 @@ import {
1111
useState,
1212
} from 'react';
1313

14+
import { subscribeToAuthUpdates } from '@/lib/auth-sync';
15+
1416
type AuthApiUser = {
1517
id: string;
1618
role: 'user' | 'admin';
17-
username: string;
1819
} | null;
1920

2021
type AuthContextValue = {
2122
user: AuthApiUser;
2223
userExists: boolean;
2324
userId: string | null;
2425
isAdmin: boolean;
25-
username: string | null;
2626
loading: boolean;
2727
refresh: () => Promise<void>;
2828
};
2929

3030
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
31+
let authUserCache: AuthApiUser | undefined;
32+
let authUserCacheAt = 0;
33+
let inFlightAuthPromise: Promise<AuthApiUser> | null = null;
34+
35+
const AUTH_CACHE_TTL_MS = 60_000;
3136

3237
async function fetchAuth(signal?: AbortSignal): Promise<AuthApiUser> {
3338
const response = await fetch('/api/auth/me', {
@@ -44,22 +49,49 @@ async function fetchAuth(signal?: AbortSignal): Promise<AuthApiUser> {
4449
return (await response.json()) as AuthApiUser;
4550
}
4651

52+
function isCacheFresh(): boolean {
53+
if (authUserCache === undefined) return false;
54+
return Date.now() - authUserCacheAt < AUTH_CACHE_TTL_MS;
55+
}
56+
57+
function fetchAuthDeduped(): Promise<AuthApiUser> {
58+
if (inFlightAuthPromise) {
59+
return inFlightAuthPromise;
60+
}
61+
62+
inFlightAuthPromise = fetchAuth().finally(() => {
63+
inFlightAuthPromise = null;
64+
});
65+
66+
return inFlightAuthPromise;
67+
}
68+
4769
export function AuthProvider({ children }: { children: ReactNode }) {
48-
const [user, setUser] = useState<AuthApiUser>(null);
49-
const [loading, setLoading] = useState(true);
70+
const [user, setUser] = useState<AuthApiUser>(authUserCache ?? null);
71+
const [loading, setLoading] = useState(authUserCache === undefined);
5072
const latestRequestIdRef = useRef(0);
5173

52-
const runAuthRequest = useCallback(async (signal?: AbortSignal) => {
74+
const runAuthRequest = useCallback(async (options?: { force?: boolean }) => {
75+
const force = options?.force ?? false;
76+
77+
if (!force && isCacheFresh()) {
78+
setUser(authUserCache ?? null);
79+
setLoading(false);
80+
return;
81+
}
82+
5383
const requestId = ++latestRequestIdRef.current;
54-
setLoading(true);
84+
setLoading(authUserCache === undefined || force);
5585

5686
try {
57-
const nextUser = await fetchAuth(signal);
87+
const nextUser = await fetchAuthDeduped();
5888

5989
if (latestRequestIdRef.current !== requestId) {
6090
return;
6191
}
6292

93+
authUserCache = nextUser;
94+
authUserCacheAt = Date.now();
6395
setUser(nextUser);
6496
} catch (error) {
6597
if (error instanceof DOMException && error.name === 'AbortError') {
@@ -70,6 +102,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
70102
return;
71103
}
72104

105+
authUserCache = null;
106+
authUserCacheAt = Date.now();
73107
setUser(null);
74108
} finally {
75109
if (latestRequestIdRef.current === requestId) {
@@ -79,26 +113,36 @@ export function AuthProvider({ children }: { children: ReactNode }) {
79113
}, []);
80114

81115
const refresh = useCallback(async () => {
82-
await runAuthRequest();
116+
await runAuthRequest({ force: true });
83117
}, [runAuthRequest]);
84118

85119
useEffect(() => {
86-
const controller = new AbortController();
120+
void runAuthRequest();
121+
}, [runAuthRequest]);
87122

88-
void runAuthRequest(controller.signal);
123+
useEffect(() => {
124+
const handleFocus = () => {
125+
void runAuthRequest();
126+
};
89127

128+
window.addEventListener('focus', handleFocus);
90129
return () => {
91-
controller.abort();
130+
window.removeEventListener('focus', handleFocus);
92131
};
93132
}, [runAuthRequest]);
94133

134+
useEffect(() => {
135+
return subscribeToAuthUpdates(() => {
136+
void runAuthRequest({ force: true });
137+
});
138+
}, [runAuthRequest]);
139+
95140
const value = useMemo<AuthContextValue>(
96141
() => ({
97142
user,
98143
userExists: Boolean(user),
99144
userId: user?.id ?? null,
100145
isAdmin: user?.role === 'admin',
101-
username: user?.username ?? null,
102146
loading,
103147
refresh,
104148
}),

frontend/lib/auth-sync.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
'use client';
2+
3+
const AUTH_CHANNEL_NAME = 'devlovers-auth-sync';
4+
const AUTH_UPDATED_EVENT = 'AUTH_UPDATED';
5+
6+
type AuthSyncMessage = {
7+
type: typeof AUTH_UPDATED_EVENT;
8+
};
9+
10+
export function broadcastAuthUpdated() {
11+
if (typeof window === 'undefined' || !('BroadcastChannel' in window)) {
12+
return;
13+
}
14+
15+
const channel = new BroadcastChannel(AUTH_CHANNEL_NAME);
16+
const message: AuthSyncMessage = { type: AUTH_UPDATED_EVENT };
17+
channel.postMessage(message);
18+
channel.close();
19+
}
20+
21+
export function subscribeToAuthUpdates(onUpdate: () => void): () => void {
22+
if (typeof window === 'undefined' || !('BroadcastChannel' in window)) {
23+
return () => {};
24+
}
25+
26+
const channel = new BroadcastChannel(AUTH_CHANNEL_NAME);
27+
28+
channel.onmessage = event => {
29+
const message = event.data as AuthSyncMessage | undefined;
30+
if (message?.type === AUTH_UPDATED_EVENT) {
31+
onUpdate();
32+
}
33+
};
34+
35+
return () => {
36+
channel.close();
37+
};
38+
}

frontend/lib/auth.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ export type AuthUser = {
3232
username: string;
3333
};
3434

35+
export type AuthSession = {
36+
id: string;
37+
email: string;
38+
role: 'user' | 'admin';
39+
};
40+
3541
export function signAuthToken(payload: AuthTokenPayload): string {
3642
return jwt.sign(payload, AUTH_SECRET, {
3743
expiresIn: AUTH_TOKEN_MAX_AGE,
@@ -89,7 +95,7 @@ export async function clearAuthCookie() {
8995
cookieStore.delete(AUTH_COOKIE_NAME);
9096
}
9197

92-
export async function getCurrentUser(): Promise<AuthUser | null> {
98+
export async function getAuthSession(): Promise<AuthSession | null> {
9399
const cookieStore = await cookies();
94100
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
95101
if (!token) {
@@ -101,6 +107,19 @@ export async function getCurrentUser(): Promise<AuthUser | null> {
101107
return null;
102108
}
103109

110+
return {
111+
id: payload.userId,
112+
email: payload.email,
113+
role: payload.role,
114+
};
115+
}
116+
117+
export async function getCurrentUser(): Promise<AuthUser | null> {
118+
const payload = await getAuthSession();
119+
if (!payload) {
120+
return null;
121+
}
122+
104123
const result = await db
105124
.select({
106125
id: users.id,
@@ -109,7 +128,7 @@ export async function getCurrentUser(): Promise<AuthUser | null> {
109128
username: users.name,
110129
})
111130
.from(users)
112-
.where(eq(users.id, payload.userId))
131+
.where(eq(users.id, payload.id))
113132
.limit(1);
114133

115134
if (result.length === 0) {

frontend/lib/logout.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
'use client';
22

3+
import { broadcastAuthUpdated } from '@/lib/auth-sync';
4+
35
export async function logout() {
46
await fetch('/api/auth/logout', {
57
method: 'POST',
68
credentials: 'same-origin',
79
});
10+
11+
broadcastAuthUpdated();
812
}

0 commit comments

Comments
 (0)