diff --git a/CleanArchitecture.Presentation/admin/src/app/layout.tsx b/CleanArchitecture.Presentation/admin/src/app/layout.tsx index a2bbccb..2375b25 100644 --- a/CleanArchitecture.Presentation/admin/src/app/layout.tsx +++ b/CleanArchitecture.Presentation/admin/src/app/layout.tsx @@ -7,6 +7,8 @@ import { ThemeProvider } from "@/context/ThemeContext"; import { LanguageProvider } from "@/context/LanguageContext"; import { Outfit } from "next/font/google"; import localFont from "next/font/local"; +import { cookies } from "next/headers"; +import { defaultLocale, isLocale, isRtl } from "@/i18n"; import "./globals.css"; import "flatpickr/dist/flatpickr.css"; @@ -38,17 +40,23 @@ interface RootLayoutProps { children: ReactNode; } -export default function RootLayout({ children }: Readonly) { +export default async function RootLayout({ children }: Readonly) { + const cookieStore = await cookies(); + const localeCookie = cookieStore.get("admin-locale")?.value; + const initialLocale = isLocale(localeCookie) ? localeCookie : defaultLocale; + const htmlDirection = isRtl(initialLocale) ? "rtl" : "ltr"; + return ( - + {children} diff --git a/CleanArchitecture.Presentation/admin/src/components/header/UserDropdown.tsx b/CleanArchitecture.Presentation/admin/src/components/header/UserDropdown.tsx index 975f65f..24227e0 100644 --- a/CleanArchitecture.Presentation/admin/src/components/header/UserDropdown.tsx +++ b/CleanArchitecture.Presentation/admin/src/components/header/UserDropdown.tsx @@ -6,6 +6,27 @@ import { DropdownItem } from "../ui/dropdown/DropdownItem"; import { useSession, signOut } from "next-auth/react"; import { useLanguage } from "@/context/LanguageContext"; +function resolveSafeRedirectUrl(logoutUrl: string | undefined): string { + if (!logoutUrl) { + return "/"; + } + + if (typeof window === "undefined") { + return "/"; + } + + try { + const parsedLogoutUrl = new URL(logoutUrl, window.location.origin); + const isSameOrigin = parsedLogoutUrl.origin === window.location.origin; + const isSecureExternalUrl = parsedLogoutUrl.protocol === "https:"; + const isTrustedRedirect = isSameOrigin || isSecureExternalUrl; + + return isTrustedRedirect ? parsedLogoutUrl.toString() : "/"; + } catch { + return "/"; + } +} + export default function UserDropdown() { const { data: session } = useSession(); const [isOpen, setIsOpen] = useState(false); @@ -34,6 +55,7 @@ export default function UserDropdown() { } const { logoutUrl } = (await response.json()) as { logoutUrl?: string }; + const safeLogoutUrl = resolveSafeRedirectUrl(logoutUrl); await signOut({ redirect: false, @@ -41,7 +63,7 @@ export default function UserDropdown() { }); setTimeout(() => { - window.location.assign(logoutUrl || "/"); + window.location.assign(safeLogoutUrl); }, 100); } catch { await signOut({ diff --git a/CleanArchitecture.Presentation/admin/src/context/LanguageContext.tsx b/CleanArchitecture.Presentation/admin/src/context/LanguageContext.tsx index 401e567..55296d6 100644 --- a/CleanArchitecture.Presentation/admin/src/context/LanguageContext.tsx +++ b/CleanArchitecture.Presentation/admin/src/context/LanguageContext.tsx @@ -1,7 +1,10 @@ "use client"; import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; -import { Locale, defaultLocale, isRtl, dictionaries } from "@/i18n"; +import { Locale, defaultLocale, isLocale, isRtl, dictionaries } from "@/i18n"; + +const localeStorageKey = "locale"; +const localeCookieName = "admin-locale"; type LanguageContextType = { locale: Locale; @@ -12,19 +15,30 @@ type LanguageContextType = { const LanguageContext = createContext(undefined); -export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [locale, setLocaleState] = useState(defaultLocale); +type LanguageProviderProps = { + children: React.ReactNode; + initialLocale?: Locale; +}; + +export const LanguageProvider: React.FC = ({ + children, + initialLocale = defaultLocale, +}) => { + const [locale, setLocaleState] = useState(initialLocale); useEffect(() => { - const savedLocale = localStorage.getItem("locale") as Locale; - if (savedLocale && dictionaries[savedLocale]) { - setLocaleState(savedLocale); - } - }, []); + const persistedLocale = isLocale(window.localStorage.getItem(localeStorageKey)) + ? locale + : initialLocale; + + window.localStorage.setItem(localeStorageKey, persistedLocale); + document.cookie = `${localeCookieName}=${persistedLocale}; path=/; max-age=31536000; samesite=lax`; + }, [initialLocale, locale]); const setLocale = useCallback((newLocale: Locale) => { setLocaleState(newLocale); - localStorage.setItem("locale", newLocale); + window.localStorage.setItem(localeStorageKey, newLocale); + document.cookie = `${localeCookieName}=${newLocale}; path=/; max-age=31536000; samesite=lax`; }, []); const t = useCallback( diff --git a/CleanArchitecture.Presentation/admin/src/context/QueryContext.tsx b/CleanArchitecture.Presentation/admin/src/context/QueryContext.tsx index 0c6180d..61d248f 100644 --- a/CleanArchitecture.Presentation/admin/src/context/QueryContext.tsx +++ b/CleanArchitecture.Presentation/admin/src/context/QueryContext.tsx @@ -1,11 +1,16 @@ "use client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import dynamic from "next/dynamic"; import type { PropsWithChildren } from "react"; import { useState } from "react"; import { isApiError } from "@/lib/utils/api-error"; +const ReactQueryDevtools = dynamic( + () => import("@tanstack/react-query-devtools").then((module) => module.ReactQueryDevtools), + { ssr: false }, +); + const queryStaleTime = 60_000; const queryGcTime = 5 * 60_000; const maxQueryRetries = 2; diff --git a/CleanArchitecture.Presentation/admin/src/i18n/index.ts b/CleanArchitecture.Presentation/admin/src/i18n/index.ts index 3b10148..d80e458 100644 --- a/CleanArchitecture.Presentation/admin/src/i18n/index.ts +++ b/CleanArchitecture.Presentation/admin/src/i18n/index.ts @@ -9,9 +9,14 @@ export const dictionaries = { }; export type Locale = keyof typeof dictionaries; +export const supportedLocales = Object.keys(dictionaries) as Locale[]; export const defaultLocale: Locale = 'en'; export const isRtl = (locale: Locale) => { return locale === 'fa' || locale === 'ar'; }; + +export function isLocale(value: string | null | undefined): value is Locale { + return Boolean(value) && supportedLocales.includes(value as Locale); +}