mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(web): protect launcher dashboard with token and SPA login (#1953)
Add token-based authentication for the Launcher's embedded Web Dashboard. - Ephemeral token generated in-memory each run (or via PICOCLAW_LAUNCHER_TOKEN env var) - HMAC-SHA256 session cookie (HttpOnly, SameSite=Lax, Secure when HTTPS) - Bearer token support for API/script access - Rate limiting on login (10 attempts/IP/min) - Referrer-Policy: no-referrer on all responses - POST-only logout with JSON content-type (CSRF-safe) - System tray "Copy dashboard token" action - Login page shows contextual help (console/tray/log file path) - Path traversal protection via path.Clean - X-Forwarded-Host/Port/Proto support for reverse proxy deployments - Full i18n support (English, Chinese) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,56 @@
|
||||
import { Outlet, createRootRoute } from "@tanstack/react-router"
|
||||
import {
|
||||
Outlet,
|
||||
createRootRoute,
|
||||
useRouterState,
|
||||
} from "@tanstack/react-router"
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { AppLayout } from "@/components/app-layout"
|
||||
import { initializeChatStore } from "@/features/chat/controller"
|
||||
import { isLauncherLoginPathname } from "@/lib/launcher-login-path"
|
||||
|
||||
const RootLayout = () => {
|
||||
// Prefer the real address bar path: stale embedded bundles may not register
|
||||
// /launcher-login 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 isLauncherLogin =
|
||||
isLauncherLoginPathname(windowPath) ||
|
||||
isLauncherLoginPathname(routerState.pathname) ||
|
||||
routerState.matches.some((m) => m.routeId === "/launcher-login")
|
||||
|
||||
useEffect(() => {
|
||||
if (isLauncherLogin) {
|
||||
return
|
||||
}
|
||||
initializeChatStore()
|
||||
}, [])
|
||||
}, [isLauncherLogin])
|
||||
|
||||
if (isLauncherLogin) {
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
{import.meta.env.DEV ? <TanStackRouterDevtools /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Outlet />
|
||||
<TanStackRouterDevtools />
|
||||
{import.meta.env.DEV ? <TanStackRouterDevtools /> : null}
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { IconLanguage, IconMoon, IconSun } from "@tabler/icons-react"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import {
|
||||
getLauncherAuthStatus,
|
||||
postLauncherDashboardLogin,
|
||||
type LauncherAuthTokenHelp,
|
||||
} from "@/api/launcher-auth"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useTheme } from "@/hooks/use-theme"
|
||||
|
||||
function LauncherLoginPage() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const [token, setToken] = React.useState("")
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
const [error, setError] = React.useState("")
|
||||
const [tokenHelp, setTokenHelp] = React.useState<LauncherAuthTokenHelp | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false
|
||||
void getLauncherAuthStatus()
|
||||
.then((s) => {
|
||||
if (cancelled || s.authenticated || !s.token_help) {
|
||||
return
|
||||
}
|
||||
setTokenHelp(s.token_help)
|
||||
})
|
||||
.catch(() => {
|
||||
/* ignore; login form still usable */
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loginWithToken = React.useCallback(
|
||||
async (tokenValue: string) => {
|
||||
setError("")
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const ok = await postLauncherDashboardLogin(tokenValue)
|
||||
if (ok) {
|
||||
globalThis.location.assign("/")
|
||||
return
|
||||
}
|
||||
setError(t("launcherLogin.errorInvalid"))
|
||||
} catch {
|
||||
setError(t("launcherLogin.errorNetwork"))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[t],
|
||||
)
|
||||
|
||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
await loginWithToken(token)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background text-foreground flex min-h-dvh flex-col">
|
||||
<header className="border-border/50 flex h-14 shrink-0 items-center justify-end gap-2 border-b px-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" aria-label="Language">
|
||||
<IconLanguage className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => i18n.changeLanguage("en")}>
|
||||
English
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => i18n.changeLanguage("zh")}>
|
||||
简体中文
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => toggleTheme()}
|
||||
aria-label={theme === "dark" ? "Light mode" : "Dark mode"}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<IconSun className="size-4" />
|
||||
) : (
|
||||
<IconMoon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md" size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("launcherLogin.title")}</CardTitle>
|
||||
<CardDescription>{t("launcherLogin.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="launcher-token">
|
||||
{t("launcherLogin.tokenLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="launcher-token"
|
||||
name="token"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder={t("launcherLogin.tokenPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? t("labels.loading") : t("launcherLogin.submit")}
|
||||
</Button>
|
||||
{error ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
{tokenHelp ? (
|
||||
<div className="border-border/60 mt-6 border-t pt-4">
|
||||
<p className="text-muted-foreground mb-2 text-sm font-medium">
|
||||
{t("launcherLogin.helpTitle")}
|
||||
</p>
|
||||
<ul className="text-muted-foreground list-inside list-disc space-y-1.5 text-sm">
|
||||
{tokenHelp.console_stdout ? (
|
||||
<li>{t("launcherLogin.helpConsole")}</li>
|
||||
) : null}
|
||||
{tokenHelp.tray_copy_menu ? (
|
||||
<li>{t("launcherLogin.helpTray")}</li>
|
||||
) : null}
|
||||
{tokenHelp.log_file ? (
|
||||
<li>
|
||||
{t("launcherLogin.helpLogFile", {
|
||||
path: tokenHelp.log_file,
|
||||
})}
|
||||
</li>
|
||||
) : null}
|
||||
{tokenHelp.env_var_name ? (
|
||||
<li>
|
||||
{t("launcherLogin.helpEnv", {
|
||||
env: tokenHelp.env_var_name,
|
||||
})}
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/launcher-login")({
|
||||
component: LauncherLoginPage,
|
||||
})
|
||||
Reference in New Issue
Block a user