picoclaw/web/frontend/src/routes/__root.tsx
wenjie 71c877a67f
refactor(web): switch dashboard auth from tokens to passwords (#2608)
- replace token-based launcher auth with password-based login and sessions
- migrate legacy launcher_token values into bcrypt-backed password storage
- add one-shot local auto-login bootstrap
- update config UI, i18n strings, docs, and auth-related tests
2026-04-21 18:04:15 +08:00

101 lines
3.3 KiB
TypeScript

import { Outlet, createRootRoute, useRouterState } from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
import { useEffect, useState } from "react"
import { getLauncherAuthStatus } from "@/api/launcher-auth"
import { AppLayout } from "@/components/app-layout"
import { initializeChatStore } from "@/features/chat/controller"
import { isLauncherAuthPathname } from "@/lib/launcher-login-path"
const RootLayout = () => {
// Prefer the real address bar path: stale embedded bundles may not register
// /launcher-login or /launcher-setup in the route tree, which would otherwise
// keep AppLayout + gateway polling → 401 → launcherFetch redirect loop.
const routerState = useRouterState({
select: (s) => ({
pathname: s.location.pathname,
matches: s.matches,
}),
})
const windowPath =
typeof globalThis.location !== "undefined"
? globalThis.location.pathname || "/"
: routerState.pathname
const isAuthPage =
isLauncherAuthPathname(windowPath) ||
isLauncherAuthPathname(routerState.pathname) ||
routerState.matches.some(
(m) => m.routeId === "/launcher-login" || m.routeId === "/launcher-setup",
)
const [authError, setAuthError] = useState<string | null>(null)
// Session guard: proactively check auth status on every page load.
useEffect(() => {
if (isAuthPage) return
void getLauncherAuthStatus()
.then((s) => {
if (!s.initialized) {
globalThis.location.assign("/launcher-setup")
} else if (!s.authenticated) {
globalThis.location.assign("/launcher-login")
}
})
.catch((err: unknown) => {
// On 401/403, redirect to login — the session is invalid.
// On 5xx (e.g. 503 when the auth store is unavailable) or network errors,
// do NOT redirect: a subsequent successful login would loop straight back here.
// launcherFetch handles 401 on real API calls regardless.
if (err instanceof Error && /^status 40[13]$/.test(err.message)) {
globalThis.location.assign("/launcher-login")
} else {
setAuthError(
err instanceof Error
? err.message
: "Auth service unavailable. Reset dashboard password storage and restart the application.",
)
}
})
}, [isAuthPage])
useEffect(() => {
if (isAuthPage) {
return
}
initializeChatStore()
}, [isAuthPage])
if (isAuthPage) {
return (
<>
<Outlet />
{import.meta.env.DEV ? <TanStackRouterDevtools /> : null}
</>
)
}
return (
<>
{authError && (
<div className="bg-destructive text-destructive-foreground fixed inset-x-0 top-0 z-[100] flex items-center justify-between px-4 py-2 text-sm shadow-md">
<span>Auth service error: {authError}</span>
<button
className="ml-4 opacity-70 hover:opacity-100"
onClick={() => setAuthError(null)}
aria-label="Dismiss"
>
</button>
</div>
)}
<AppLayout>
<Outlet />
{import.meta.env.DEV ? <TanStackRouterDevtools /> : null}
</AppLayout>
</>
)
}
export const Route = createRootRoute({ component: RootLayout })