perf: optimize login first screen (#148)

This commit is contained in:
cxymds
2026-07-05 18:09:13 +08:00
committed by GitHub
parent d8d974fe97
commit d5d54c1a57
13 changed files with 276 additions and 58 deletions
+35 -11
View File
@@ -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) => {
+2 -6
View File
@@ -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">
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+42
View File
@@ -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>
)
}
+2 -2
View File
@@ -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>
))}
+2 -1
View File
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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\)\)/)
})
+19
View File
@@ -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"\) \?\? "", "\/"\)/)
})