diff --git a/app/(auth)/config/page.tsx b/app/(auth)/config/page.tsx
index 352cd98b..d57e2df7 100644
--- a/app/(auth)/config/page.tsx
+++ b/app/(auth)/config/page.tsx
@@ -5,6 +5,7 @@ import Link from "next/link"
import Image from "next/image"
import { useRouter } from "next/navigation"
import { useTranslation } from "react-i18next"
+import { AppLoadingShell } from "@/components/app-loading-shell"
import { Button } from "@/components/ui/button"
import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
@@ -20,7 +21,7 @@ import { getThemeManifest } from "@/lib/theme/manifest"
export default function ConfigPage() {
return (
-
}>
+
}>
)
diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx
index b4e12a58..1eca4f02 100644
--- a/app/(dashboard)/layout.tsx
+++ b/app/(dashboard)/layout.tsx
@@ -2,21 +2,33 @@ import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar"
import { AppSidebar } from "@/components/app-sidebar"
import { AppTopNav } from "@/components/app-top-nav"
import { DashboardAuthGuard } from "@/components/dashboard-auth-guard"
+import { ApiProvider } from "@/contexts/api-context"
+import { S3Provider } from "@/contexts/s3-context"
+import { TaskProvider } from "@/contexts/task-context"
+import { PermissionsProvider } from "@/hooks/use-permissions"
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
}
diff --git a/app/layout.tsx b/app/layout.tsx
index 9ab28918..4cfe9a29 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -3,10 +3,6 @@ import "./globals.css"
import { ThemeProvider } from "@/components/providers/theme-provider"
import { I18nProvider } from "@/components/providers/i18n-provider"
import { AuthProvider } from "@/contexts/auth-context"
-import { ApiProvider } from "@/contexts/api-context"
-import { S3Provider } from "@/contexts/s3-context"
-import { TaskProvider } from "@/contexts/task-context"
-import { PermissionsProvider } from "@/hooks/use-permissions"
import { AppUiProvider } from "@/components/providers/app-ui-provider"
import { getThemeManifest } from "@/lib/theme/manifest"
@@ -28,15 +24,7 @@ export default function RootLayout({
-
-
-
-
- {children}
-
-
-
-
+ {children}
diff --git a/components/app-loading-shell.tsx b/components/app-loading-shell.tsx
new file mode 100644
index 00000000..c67970bf
--- /dev/null
+++ b/components/app-loading-shell.tsx
@@ -0,0 +1,42 @@
+"use client"
+
+export function AppLoadingShell() {
+ return (
+
+ )
+}
diff --git a/components/language-switcher.tsx b/components/language-switcher.tsx
index bbef183d..e22ca52d 100644
--- a/components/language-switcher.tsx
+++ b/components/language-switcher.tsx
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"
import { RiTranslate2 } from "@remixicon/react"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
-import { LOCALE_CODES, type Locale } from "@/lib/i18n"
+import { changeLocale, LOCALE_CODES, type Locale } from "@/lib/i18n"
const languageConfig: Record
= {
en: { text: "English", icon: RiTranslate2 },
@@ -46,7 +46,7 @@ export function LanguageSwitcher() {
/>
{options.map(({ label, key }) => (
- i18n.changeLanguage(key)}>
+ void changeLocale(key)}>
{label}
))}
diff --git a/components/providers/i18n-provider.tsx b/components/providers/i18n-provider.tsx
index a8f93595..70be6d7b 100644
--- a/components/providers/i18n-provider.tsx
+++ b/components/providers/i18n-provider.tsx
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from "react"
import { DEFAULT_LOCALE, initI18n, isRtlLocale, normalizeLocale } from "@/lib/i18n"
import { DirectionProvider } from "@/components/ui/direction"
+import { AppLoadingShell } from "@/components/app-loading-shell"
import i18n from "i18next"
type Dir = "ltr" | "rtl"
@@ -50,7 +51,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
}, [])
if (!ready) {
- return
+ return
}
return {children}
diff --git a/contexts/auth-context.tsx b/contexts/auth-context.tsx
index 693f1909..84d8c70f 100644
--- a/contexts/auth-context.tsx
+++ b/contexts/auth-context.tsx
@@ -2,7 +2,6 @@
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react"
import type { AwsCredentialIdentity, AwsCredentialIdentityProvider } from "@aws-sdk/types"
-import { getStsToken } from "@/lib/sts"
import type { SiteConfig } from "@/types/config"
import { getLoginRoute } from "@/lib/routes"
import { useLocalStorage } from "@/hooks/use-local-storage"
@@ -95,6 +94,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
customConfig = await configManager.loadConfig()
}
+ const { getStsToken } = await import("@/lib/sts")
const credentialsResponse = await getStsToken(credentials, "arn:aws:iam::*:role/Admin", customConfig)
setCredentials({
diff --git a/lib/i18n.ts b/lib/i18n.ts
index 7b91aa5d..b298583d 100644
--- a/lib/i18n.ts
+++ b/lib/i18n.ts
@@ -4,14 +4,62 @@ import i18n from "i18next"
import { initReactI18next } from "react-i18next"
import LanguageDetector from "i18next-browser-languagedetector"
import { loadThemeLocaleOverride } from "@/lib/theme/locales"
-import { DEFAULT_LOCALE, LOCALE_CODES, LOCALE_FILE_MAP, normalizeLocale } from "@/lib/i18n-config"
+import { DEFAULT_LOCALE, LOCALE_CODES, LOCALE_FILE_MAP, normalizeLocale, type Locale } from "@/lib/i18n-config"
const loadLocale = async (file: string) => {
const mod = await import(`@/i18n/locales/${file}.json`)
- return mod.default
+ return mod.default as Record
}
let initPromise: Promise | null = null
+const loadedLocales = new Set()
+
+function getCookieValue(name: string): string | undefined {
+ if (typeof document === "undefined") return undefined
+
+ const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+ const match = document.cookie.match(new RegExp(`(?:^|; )${escapedName}=([^;]*)`))
+ return match ? decodeURIComponent(match[1]) : undefined
+}
+
+function detectInitialLocale(): Locale {
+ if (typeof window === "undefined") return DEFAULT_LOCALE
+
+ const queryLocale = new URLSearchParams(window.location.search).get("lang")
+ if (queryLocale) return normalizeLocale(queryLocale)
+
+ const legacyStorage = window.localStorage.getItem("i18n_redirected") ?? window.localStorage.getItem("lang")
+ if (legacyStorage) return normalizeLocale(legacyStorage)
+
+ const cookieLocale = getCookieValue("i18n_redirected")
+ if (cookieLocale) return normalizeLocale(cookieLocale)
+
+ const navigatorLocale = window.navigator.languages?.[0] ?? window.navigator.language
+ if (navigatorLocale) return normalizeLocale(navigatorLocale)
+
+ return normalizeLocale(document.documentElement.lang)
+}
+
+async function loadLocaleResource(locale: Locale): Promise> {
+ const file = LOCALE_FILE_MAP[locale]
+ const [baseLocale, themeOverride] = await Promise.all([loadLocale(file), loadThemeLocaleOverride(file)])
+ return themeOverride ? { ...baseLocale, ...themeOverride } : baseLocale
+}
+
+export async function ensureLocaleResources(localeInput: string | null | undefined): Promise {
+ const locale = normalizeLocale(localeInput)
+ if (loadedLocales.has(locale) && i18n.hasResourceBundle(locale, "translation")) return locale
+
+ const resources = await loadLocaleResource(locale)
+ i18n.addResourceBundle(locale, "translation", resources, true, true)
+ loadedLocales.add(locale)
+ return locale
+}
+
+export async function changeLocale(localeInput: string | null | undefined): Promise {
+ const locale = await ensureLocaleResources(localeInput)
+ await i18n.changeLanguage(locale)
+}
function createLanguageDetector() {
const detector = new LanguageDetector()
@@ -40,14 +88,21 @@ export async function initI18n() {
if (initPromise) return initPromise
initPromise = (async () => {
+ const initialLocale = detectInitialLocale()
+ const initialLocales = [...new Set([DEFAULT_LOCALE, initialLocale])]
const resources: Record }> = {}
- for (const [code, file] of Object.entries(LOCALE_FILE_MAP)) {
- const baseLocale = await loadLocale(file)
- const themeOverride = await loadThemeLocaleOverride(file)
+ await Promise.all(
+ initialLocales.map(async (code) => {
+ const translation = await loadLocaleResource(code)
+ loadedLocales.add(code)
+ resources[code] = { translation }
+ }),
+ )
+ for (const code of initialLocales) {
resources[code] = {
- translation: themeOverride ? { ...baseLocale, ...themeOverride } : baseLocale,
+ translation: resources[code].translation,
}
}
@@ -58,6 +113,7 @@ export async function initI18n() {
.use(initReactI18next)
.init({
resources,
+ lng: initialLocale,
fallbackLng: DEFAULT_LOCALE,
supportedLngs: LOCALE_CODES,
nonExplicitSupportedLngs: true,
@@ -81,7 +137,12 @@ export async function initI18n() {
return i18n
})()
- return initPromise
+ try {
+ return await initPromise
+ } catch (error) {
+ initPromise = null
+ throw error
+ }
}
export { DEFAULT_LOCALE, LOCALE_CODES, RTL_LOCALES, isRtlLocale, normalizeLocale, type Locale } from "@/lib/i18n-config"
diff --git a/lib/routes.ts b/lib/routes.ts
index 6a89d217..d854e557 100644
--- a/lib/routes.ts
+++ b/lib/routes.ts
@@ -28,6 +28,13 @@ export function getLoginRoute(): string {
return buildRoute("/auth/login")
}
+function stripBasePath(path: string): string {
+ if (!BASE_PATH) return path
+ if (path === BASE_PATH) return "/"
+ if (path.startsWith(`${BASE_PATH}/`)) return path.slice(BASE_PATH.length) || "/"
+ return path
+}
+
/**
* Validate that a redirect path is safe (relative, no protocol, no external domain).
* Returns the path if safe, or the fallback otherwise.
@@ -53,8 +60,10 @@ export function isSafeRedirectPath(path: string, fallback = "/"): string {
// Ensure it starts with / (relative path)
if (!trimmed.startsWith("/")) return fallback
+ const appPath = stripBasePath(trimmed)
+
// Block directory traversal sequences (e.g. /../evil.com, /../../etc/passwd)
- if (/(?:^|\/)\.\.(?:\/|$)/.test(trimmed)) return fallback
+ if (/(?:^|\/)\.\.(?:\/|$)/.test(appPath)) return fallback
- return trimmed
+ return appPath
}
diff --git a/tests/lib/login-performance-source.test.js b/tests/lib/login-performance-source.test.js
new file mode 100644
index 00000000..9a25d593
--- /dev/null
+++ b/tests/lib/login-performance-source.test.js
@@ -0,0 +1,65 @@
+import test from "node:test"
+import assert from "node:assert/strict"
+import fs from "node:fs"
+
+const rootLayout = fs.readFileSync("app/layout.tsx", "utf8")
+const dashboardLayout = fs.readFileSync("app/(dashboard)/layout.tsx", "utf8")
+const authContext = fs.readFileSync("contexts/auth-context.tsx", "utf8")
+const loginPage = fs.readFileSync("app/(auth)/auth/login/page.tsx", "utf8")
+const oidcCallbackPage = fs.readFileSync("app/(auth)/auth/oidc-callback/page.tsx", "utf8")
+const i18nProvider = fs.readFileSync("components/providers/i18n-provider.tsx", "utf8")
+const i18nSource = fs.readFileSync("lib/i18n.ts", "utf8")
+const loadingShell = fs.readFileSync("components/app-loading-shell.tsx", "utf8")
+
+test("root layout does not load dashboard-only providers on auth routes", () => {
+ assert.doesNotMatch(rootLayout, /@\/contexts\/api-context/)
+ assert.doesNotMatch(rootLayout, /@\/contexts\/s3-context/)
+ assert.doesNotMatch(rootLayout, /@\/contexts\/task-context/)
+ assert.doesNotMatch(rootLayout, /@\/hooks\/use-permissions/)
+ assert.doesNotMatch(rootLayout, //)
+ assert.doesNotMatch(rootLayout, //)
+ assert.doesNotMatch(rootLayout, //)
+ assert.doesNotMatch(rootLayout, //)
+})
+
+test("dashboard layout owns dashboard providers before the auth guard", () => {
+ for (const symbol of ["ApiProvider", "S3Provider", "TaskProvider", "PermissionsProvider", "DashboardAuthGuard"]) {
+ assert.match(dashboardLayout, new RegExp(symbol))
+ }
+
+ assert.ok(dashboardLayout.indexOf("") < dashboardLayout.indexOf(""))
+ assert.ok(dashboardLayout.indexOf("") < dashboardLayout.indexOf(""))
+ assert.ok(dashboardLayout.indexOf("") < dashboardLayout.indexOf(""))
+ assert.ok(dashboardLayout.indexOf("") < dashboardLayout.indexOf(""))
+})
+
+test("auth routes do not depend on dashboard permission resolution", () => {
+ assert.doesNotMatch(loginPage, /useFirstAccessibleDashboardRoute/)
+ assert.doesNotMatch(oidcCallbackPage, /useFirstAccessibleDashboardRoute/)
+})
+
+test("STS client is loaded only when login is submitted", () => {
+ assert.doesNotMatch(authContext, /import\s+\{\s*getStsToken\s*\}\s+from\s+["']@\/lib\/sts["']/)
+ assert.match(authContext, /await import\(["']@\/lib\/sts["']\)/)
+})
+
+test("login and i18n fallbacks render a visible loading shell", () => {
+ assert.match(loginPage, /fallback=\{\}/)
+ assert.match(i18nProvider, /return /)
+ assert.doesNotMatch(loginPage, /fallback=\{\}/)
+ assert.doesNotMatch(i18nProvider, /return /)
+ assert.match(loadingShell, /animate-pulse/)
+})
+
+test("OIDC provider discovery is deferred off the first render path", () => {
+ assert.match(loginPage, /requestIdleCallback/)
+ assert.doesNotMatch(loginPage, /fetchOidcProviders\(config\.serverHost\)\.then\(setOidcProviders\)/)
+})
+
+test("i18n initializes only the detected locale plus fallback and lazy-loads later languages", () => {
+ assert.match(i18nSource, /function detectInitialLocale/)
+ assert.match(i18nSource, /const initialLocales = \[\.\.\.new Set\(\[DEFAULT_LOCALE, initialLocale\]\)\]/)
+ assert.match(i18nSource, /export async function ensureLocaleResources/)
+ assert.match(i18nSource, /export async function changeLocale/)
+ assert.doesNotMatch(i18nSource, /for \(const \[code, file\] of Object\.entries\(LOCALE_FILE_MAP\)\)/)
+})
diff --git a/tests/lib/routes-source.test.js b/tests/lib/routes-source.test.js
new file mode 100644
index 00000000..1f41e602
--- /dev/null
+++ b/tests/lib/routes-source.test.js
@@ -0,0 +1,19 @@
+import test from "node:test"
+import assert from "node:assert/strict"
+import fs from "node:fs"
+
+const routesSource = fs.readFileSync("lib/routes.ts", "utf8")
+const oidcSource = fs.readFileSync("lib/oidc.ts", "utf8")
+
+test("safe redirects normalize the configured base path before router navigation", () => {
+ assert.match(routesSource, /function stripBasePath\(path: string\): string/)
+ assert.match(routesSource, /if \(path === BASE_PATH\) return "\/"/)
+ assert.match(routesSource, /path\.startsWith\(`\$\{BASE_PATH\}\/`\)/)
+ assert.match(routesSource, /const appPath = stripBasePath\(trimmed\)/)
+ assert.match(routesSource, /return appPath/)
+})
+
+test("OIDC callback continues to validate redirect paths through the shared route guard", () => {
+ assert.match(oidcSource, /import \{ isSafeRedirectPath \} from "@\/lib\/routes"/)
+ assert.match(oidcSource, /redirect: isSafeRedirectPath\(params\.get\("redirect"\) \?\? "", "\/"\)/)
+})