diff --git a/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts b/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts index 1b43056..d10870d 100644 --- a/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts +++ b/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts @@ -41,19 +41,21 @@ const ACCESS_TOKEN_MAX_AGE = 15 * 60 * 1000; // 15 minutes const REFRESH_TOKEN_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days const AUTH_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days +const SAME_SITE = IS_PRODUCTION ? 'strict' : 'lax'; + function setAuthCookies(res: Response, tokens: TokenPair): void { res.cookie('access_token', tokens.accessToken, { httpOnly: true, secure: IS_PRODUCTION, - sameSite: 'strict', + sameSite: SAME_SITE, path: '/', maxAge: ACCESS_TOKEN_MAX_AGE, }); res.cookie('refresh_token', tokens.refreshToken, { httpOnly: true, secure: IS_PRODUCTION, - sameSite: 'strict', - path: '/auth', // Only sent to auth endpoints + sameSite: SAME_SITE, + path: '/', maxAge: REFRESH_TOKEN_MAX_AGE, }); res.cookie('goodgo_authenticated', '1', { @@ -67,7 +69,7 @@ function setAuthCookies(res: Response, tokens: TokenPair): void { function clearAuthCookies(res: Response): void { res.clearCookie('access_token', { path: '/' }); - res.clearCookie('refresh_token', { path: '/auth' }); + res.clearCookie('refresh_token', { path: '/' }); res.clearCookie('goodgo_authenticated', { path: '/' }); } diff --git a/apps/api/src/modules/shared/infrastructure/middleware/csrf.middleware.ts b/apps/api/src/modules/shared/infrastructure/middleware/csrf.middleware.ts index 2b56547..5af1a08 100644 --- a/apps/api/src/modules/shared/infrastructure/middleware/csrf.middleware.ts +++ b/apps/api/src/modules/shared/infrastructure/middleware/csrf.middleware.ts @@ -38,10 +38,11 @@ export class CsrfMiddleware implements NestMiddleware { private setCsrfCookie(res: Response): void { const token = randomBytes(TOKEN_LENGTH).toString('hex'); + const isProduction = process.env['NODE_ENV'] === 'production'; res.cookie(CSRF_COOKIE, token, { httpOnly: false, // Frontend must read this cookie - secure: process.env['NODE_ENV'] === 'production', - sameSite: 'strict', + secure: isProduction, + sameSite: isProduction ? 'strict' : 'lax', path: '/', }); } diff --git a/apps/web/app/[locale]/(public)/agents/[id]/page.tsx b/apps/web/app/[locale]/(public)/agents/[id]/page.tsx index 11bf017..f893a4f 100644 --- a/apps/web/app/[locale]/(public)/agents/[id]/page.tsx +++ b/apps/web/app/[locale]/(public)/agents/[id]/page.tsx @@ -19,10 +19,11 @@ const siteUrl = process.env['NEXT_PUBLIC_SITE_URL'] || 'https://goodgo.vn'; // --------------------------------------------------------------------------- interface PageProps { - params: { locale: string; id: string }; + params: Promise<{ locale: string; id: string }>; } -export async function generateMetadata({ params }: PageProps): Promise { +export async function generateMetadata({ params: paramsPromise }: PageProps): Promise { + const params = await paramsPromise; const agent = await fetchAgentProfile(params.id); if (!agent) { return { title: 'Không tìm thấy môi giới' }; @@ -82,7 +83,8 @@ export async function generateMetadata({ params }: PageProps): Promise // Page (Server Component) // --------------------------------------------------------------------------- -export default async function AgentProfilePage({ params }: PageProps) { +export default async function AgentProfilePage({ params: paramsPromise }: PageProps) { + const params = await paramsPromise; const [agent, reviewsResult] = await Promise.all([ fetchAgentProfile(params.id), fetchAgentReviews(params.id, 1, 10), diff --git a/apps/web/app/[locale]/(public)/listings/[id]/page.tsx b/apps/web/app/[locale]/(public)/listings/[id]/page.tsx index 6dc64ae..fb6c66a 100644 --- a/apps/web/app/[locale]/(public)/listings/[id]/page.tsx +++ b/apps/web/app/[locale]/(public)/listings/[id]/page.tsx @@ -26,10 +26,11 @@ function getLabel(list: readonly { value: string; label: string }[], value: stri // --------------------------------------------------------------------------- interface PageProps { - params: { locale: string; id: string }; + params: Promise<{ locale: string; id: string }>; } -export async function generateMetadata({ params }: PageProps): Promise { +export async function generateMetadata({ params: paramsPromise }: PageProps): Promise { + const params = await paramsPromise; const listing = await fetchListingById(params.id); if (!listing) { return { title: 'Kh\u00f4ng t\u00ecm th\u1ea5y tin \u0111\u0103ng' }; @@ -92,7 +93,8 @@ export async function generateMetadata({ params }: PageProps): Promise // Page (Server Component) // --------------------------------------------------------------------------- -export default async function PublicListingDetailPage({ params }: PageProps) { +export default async function PublicListingDetailPage({ params: paramsPromise }: PageProps) { + const params = await paramsPromise; const listing = await fetchListingById(params.id); if (!listing) { diff --git a/apps/web/app/[locale]/layout.tsx b/apps/web/app/[locale]/layout.tsx index 88d462a..a5f1151 100644 --- a/apps/web/app/[locale]/layout.tsx +++ b/apps/web/app/[locale]/layout.tsx @@ -27,10 +27,11 @@ export const viewport: Viewport = { }; export async function generateMetadata({ - params: { locale }, + params, }: { - params: { locale: string }; + params: Promise<{ locale: string }>; }): Promise { + const { locale } = await params; const t = await getTranslations({ locale, namespace: 'metadata' }); return { @@ -92,11 +93,13 @@ export function generateStaticParams() { export default async function LocaleLayout({ children, - params: { locale }, + params, }: { children: React.ReactNode; - params: { locale: string }; + params: Promise<{ locale: string }>; }) { + const { locale } = await params; + // Validate locale if (!routing.locales.includes(locale as Locale)) { notFound(); diff --git a/apps/web/lib/web-vitals.ts b/apps/web/lib/web-vitals.ts index a3c770a..2e96e61 100644 --- a/apps/web/lib/web-vitals.ts +++ b/apps/web/lib/web-vitals.ts @@ -46,6 +46,12 @@ let flushTimer: ReturnType | null = null; const FLUSH_INTERVAL_MS = 5000; const MAX_BATCH_SIZE = 10; +function getCsrfToken(): string | undefined { + if (typeof document === 'undefined') return undefined; + const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/); + return match?.[1] ? decodeURIComponent(match[1]) : undefined; +} + function flushQueue(): void { if (queue.length === 0) return; @@ -65,9 +71,14 @@ function flushQueue(): void { } function sendViaFetch(body: string): void { + const headers: Record = { 'Content-Type': 'application/json' }; + const csrfToken = getCsrfToken(); + if (csrfToken) headers['X-CSRF-Token'] = csrfToken; + fetch(`${API_BASE_URL}/web-vitals`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers, + credentials: 'include', body, keepalive: true, }).catch(() => {