mirror of
https://github.com/rustfs/console.git
synced 2026-10-30 19:33:40 +08:00
perf: optimize login first screen (#148)
This commit is contained in:
@@ -5,8 +5,8 @@ import { useSearchParams } from "next/navigation"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { LoginForm, type LoginMethod } from "@/components/auth/login-form"
|
||||
import { AppLoadingShell } from "@/components/app-loading-shell"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { useFirstAccessibleDashboardRoute } from "@/hooks/use-first-accessible-dashboard-route"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import { configManager } from "@/lib/config"
|
||||
import { fetchOidcProviders, initiateOidcLogin } from "@/lib/oidc"
|
||||
@@ -14,7 +14,7 @@ import type { OidcProvider } from "@/types/config"
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen bg-background" />}>
|
||||
<Suspense fallback={<AppLoadingShell />}>
|
||||
<LoginPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
@@ -25,7 +25,6 @@ function LoginPageContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const message = useMessage()
|
||||
const { login, isAuthenticated } = useAuth()
|
||||
const { route: firstAccessibleRoute, isReady: hasResolvedFirstRoute } = useFirstAccessibleDashboardRoute()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [method, setMethod] = useState<LoginMethod>("accessKeyAndSecretKey")
|
||||
@@ -41,9 +40,9 @@ function LoginPageContent() {
|
||||
const [oidcProviders, setOidcProviders] = useState<OidcProvider[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !hasResolvedFirstRoute || !firstAccessibleRoute) return
|
||||
router.replace(firstAccessibleRoute)
|
||||
}, [isAuthenticated, hasResolvedFirstRoute, firstAccessibleRoute, router])
|
||||
if (!isAuthenticated) return
|
||||
router.replace("/")
|
||||
}, [isAuthenticated, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("unauthorized") === "true") {
|
||||
@@ -52,13 +51,38 @@ function LoginPageContent() {
|
||||
}
|
||||
}, [searchParams, message, t, router])
|
||||
|
||||
// Fetch OIDC providers on mount
|
||||
// Fetch OIDC providers after the first paint path has had a chance to settle.
|
||||
useEffect(() => {
|
||||
configManager.loadConfig().then((config) => {
|
||||
if (config?.serverHost) {
|
||||
fetchOidcProviders(config.serverHost).then(setOidcProviders)
|
||||
let cancelled = false
|
||||
const loadOidcProviders = () => {
|
||||
configManager.loadConfig().then((config) => {
|
||||
if (cancelled) return
|
||||
if (config?.serverHost) {
|
||||
fetchOidcProviders(config.serverHost).then((providers) => {
|
||||
if (!cancelled) setOidcProviders(providers)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const idleWindow = window as Window & {
|
||||
requestIdleCallback?: (callback: IdleRequestCallback) => number
|
||||
cancelIdleCallback?: (handle: number) => void
|
||||
}
|
||||
|
||||
if (typeof idleWindow.requestIdleCallback === "function") {
|
||||
const idleId = idleWindow.requestIdleCallback(loadOidcProviders)
|
||||
return () => {
|
||||
cancelled = true
|
||||
idleWindow.cancelIdleCallback?.(idleId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const timerId = window.setTimeout(loadOidcProviders, 0)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timerId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { useFirstAccessibleDashboardRoute } from "@/hooks/use-first-accessible-dashboard-route"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import { parseOidcCallback } from "@/lib/oidc"
|
||||
import { isSafeRedirectPath } from "@/lib/routes"
|
||||
@@ -12,7 +11,6 @@ import { useTranslation } from "react-i18next"
|
||||
export default function OidcCallbackPage() {
|
||||
const router = useRouter()
|
||||
const { loginWithStsCredentials, isAuthenticated } = useAuth()
|
||||
const { route: firstAccessibleRoute, isReady: hasResolvedFirstRoute } = useFirstAccessibleDashboardRoute()
|
||||
const message = useMessage()
|
||||
const { t } = useTranslation()
|
||||
const processed = useRef(false)
|
||||
@@ -63,10 +61,8 @@ export default function OidcCallbackPage() {
|
||||
return
|
||||
}
|
||||
|
||||
if (hasResolvedFirstRoute && firstAccessibleRoute) {
|
||||
router.replace(firstAccessibleRoute)
|
||||
}
|
||||
}, [credentialsSet, isAuthenticated, hasResolvedFirstRoute, firstAccessibleRoute, router])
|
||||
router.replace("/")
|
||||
}, [credentialsSet, isAuthenticated, router])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted">
|
||||
|
||||
@@ -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 (
|
||||
<Suspense fallback={<div className="min-h-screen bg-background" />}>
|
||||
<Suspense fallback={<AppLoadingShell />}>
|
||||
<ConfigPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
+25
-13
@@ -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 (
|
||||
<DashboardAuthGuard>
|
||||
<SidebarProvider defaultOpen>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<div className="flex flex-1 flex-col gap-4 px-4 pb-6 pt-0 sm:px-6">
|
||||
<AppTopNav />
|
||||
<main id="main-content" tabIndex={-1} className="min-w-0 flex-1 scroll-mt-16">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</DashboardAuthGuard>
|
||||
<ApiProvider>
|
||||
<S3Provider>
|
||||
<TaskProvider>
|
||||
<PermissionsProvider>
|
||||
<DashboardAuthGuard>
|
||||
<SidebarProvider defaultOpen>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<div className="flex flex-1 flex-col gap-4 px-4 pb-6 pt-0 sm:px-6">
|
||||
<AppTopNav />
|
||||
<main id="main-content" tabIndex={-1} className="min-w-0 flex-1 scroll-mt-16">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</DashboardAuthGuard>
|
||||
</PermissionsProvider>
|
||||
</TaskProvider>
|
||||
</S3Provider>
|
||||
</ApiProvider>
|
||||
)
|
||||
}
|
||||
|
||||
+1
-13
@@ -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({
|
||||
<ThemeProvider>
|
||||
<I18nProvider>
|
||||
<AuthProvider>
|
||||
<ApiProvider>
|
||||
<S3Provider>
|
||||
<TaskProvider>
|
||||
<PermissionsProvider>
|
||||
<AppUiProvider>{children}</AppUiProvider>
|
||||
</PermissionsProvider>
|
||||
</TaskProvider>
|
||||
</S3Provider>
|
||||
</ApiProvider>
|
||||
<AppUiProvider>{children}</AppUiProvider>
|
||||
</AuthProvider>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
export function AppLoadingShell() {
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-muted p-4 sm:p-6 lg:p-20">
|
||||
<div className="z-10 mx-auto flex w-full max-w-7xl flex-col overflow-hidden border bg-card shadow-none lg:min-h-[560px] lg:flex-row">
|
||||
<div className="hidden w-1/2 border-e bg-muted/40 p-16 lg:flex lg:flex-col lg:justify-center">
|
||||
<div className="h-6 w-28 animate-pulse bg-muted-foreground/20" />
|
||||
<div className="mt-8 space-y-3">
|
||||
<div className="h-10 w-72 max-w-full animate-pulse bg-muted-foreground/20" />
|
||||
<div className="h-10 w-56 max-w-full animate-pulse bg-muted-foreground/20" />
|
||||
<div className="h-5 w-64 max-w-full animate-pulse bg-muted-foreground/15" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center justify-center bg-card lg:w-1/2">
|
||||
<div className="w-full max-w-sm space-y-6 p-4 sm:p-7">
|
||||
<div className="h-6 w-28 animate-pulse bg-muted-foreground/20" />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="h-10 animate-pulse bg-muted" />
|
||||
<div className="h-10 animate-pulse bg-muted" />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-20 animate-pulse bg-muted-foreground/15" />
|
||||
<div className="h-10 animate-pulse bg-muted" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-16 animate-pulse bg-muted-foreground/15" />
|
||||
<div className="h-10 animate-pulse bg-muted" />
|
||||
</div>
|
||||
<div className="h-10 animate-pulse bg-primary/25" />
|
||||
</div>
|
||||
<div className="flex justify-center gap-4">
|
||||
<div className="size-8 animate-pulse bg-muted" />
|
||||
<div className="size-8 animate-pulse bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string, { text: string; icon: typeof RiTranslate2 }> = {
|
||||
en: { text: "English", icon: RiTranslate2 },
|
||||
@@ -46,7 +46,7 @@ export function LanguageSwitcher() {
|
||||
/>
|
||||
<DropdownMenuContent className="w-40" align="start">
|
||||
{options.map(({ label, key }) => (
|
||||
<DropdownMenuItem key={key} onClick={() => i18n.changeLanguage(key)}>
|
||||
<DropdownMenuItem key={key} onClick={() => void changeLocale(key)}>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
@@ -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 <div className="min-h-screen bg-background" />
|
||||
return <AppLoadingShell />
|
||||
}
|
||||
|
||||
return <DirectionProvider direction={dir}>{children}</DirectionProvider>
|
||||
|
||||
@@ -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({
|
||||
|
||||
+68
-7
@@ -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<string, string>
|
||||
}
|
||||
|
||||
let initPromise: Promise<typeof i18n> | null = null
|
||||
const loadedLocales = new Set<Locale>()
|
||||
|
||||
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<Record<string, string>> {
|
||||
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<Locale> {
|
||||
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<void> {
|
||||
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<Locale>([DEFAULT_LOCALE, initialLocale])]
|
||||
const resources: Record<string, { translation: Record<string, string> }> = {}
|
||||
|
||||
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"
|
||||
|
||||
+12
-3
@@ -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
|
||||
|
||||
// Block directory traversal sequences (e.g. /../evil.com, /../../etc/passwd)
|
||||
if (/(?:^|\/)\.\.(?:\/|$)/.test(trimmed)) return fallback
|
||||
const appPath = stripBasePath(trimmed)
|
||||
|
||||
return trimmed
|
||||
// Block directory traversal sequences (e.g. /../evil.com, /../../etc/passwd)
|
||||
if (/(?:^|\/)\.\.(?:\/|$)/.test(appPath)) return fallback
|
||||
|
||||
return appPath
|
||||
}
|
||||
|
||||
@@ -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, /<ApiProvider>/)
|
||||
assert.doesNotMatch(rootLayout, /<S3Provider>/)
|
||||
assert.doesNotMatch(rootLayout, /<TaskProvider>/)
|
||||
assert.doesNotMatch(rootLayout, /<PermissionsProvider>/)
|
||||
})
|
||||
|
||||
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("<ApiProvider>") < dashboardLayout.indexOf("<S3Provider>"))
|
||||
assert.ok(dashboardLayout.indexOf("<S3Provider>") < dashboardLayout.indexOf("<TaskProvider>"))
|
||||
assert.ok(dashboardLayout.indexOf("<TaskProvider>") < dashboardLayout.indexOf("<PermissionsProvider>"))
|
||||
assert.ok(dashboardLayout.indexOf("<PermissionsProvider>") < dashboardLayout.indexOf("<DashboardAuthGuard>"))
|
||||
})
|
||||
|
||||
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=\{<AppLoadingShell \/>\}/)
|
||||
assert.match(i18nProvider, /return <AppLoadingShell \/>/)
|
||||
assert.doesNotMatch(loginPage, /fallback=\{<div className="min-h-screen bg-background" \/>\}/)
|
||||
assert.doesNotMatch(i18nProvider, /return <div className="min-h-screen bg-background" \/>/)
|
||||
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<Locale>\(\[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\)\)/)
|
||||
})
|
||||
@@ -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"\) \?\? "", "\/"\)/)
|
||||
})
|
||||
Reference in New Issue
Block a user