From 7e21f072d5a029d0fd3481ea2dd7a610ea1bbe42 Mon Sep 17 00:00:00 2001 From: sky5454 Date: Sat, 4 Apr 2026 06:34:57 +0800 Subject: [PATCH] feat(launcher-ui): standard HTTP login/setup/logout flow for dashboard Replaces the previous "find token in logs" workflow with a proper browser-based authentication UI backed by the new /api/auth/* endpoints. ### New pages - /launcher-setup: first-run password initialization form (password + confirm, min 8 chars); calls POST /api/auth/setup; redirects to login on success - /launcher-login: standard password login form; calls POST /api/auth/login; sets HttpOnly session cookie on success ### Session guard (src/routes/__root.tsx) A useEffect on every non-auth page load calls GET /api/auth/status: - initialized=false -> redirect to /launcher-setup - authenticated=false -> redirect to /launcher-login This ensures the setup/login UI is shown even when the ?token= URL mechanism auto-logs in (first-run case). ### Logout button (src/components/app-header.tsx) IconLogout button added to the header with a confirm AlertDialog; calls POST /api/auth/logout then redirects to /launcher-login. ### API layer - src/api/launcher-auth.ts: LauncherAuthStatus gains initialized bool; postLauncherDashboardSetup() added; LauncherAuthTokenHelp removed - src/api/http.ts: 401 guard uses isLauncherAuthPathname() (covers both /launcher-login and /launcher-setup) to prevent redirect loops - src/lib/launcher-login-path.ts: isLauncherSetupPathname() and isLauncherAuthPathname() added ### Routing - src/routeTree.gen.ts: /launcher-setup route registered throughout - src/routes/launcher-login.tsx: tokenHelp UI removed; useEffect added to redirect to setup when initialized=false ### i18n - en.json / zh.json: launcherSetup block added; launcherLogin keys updated to use passwordLabel/passwordPlaceholder --- web/frontend/src/api/http.ts | 12 +- web/frontend/src/api/launcher-auth.ts | 44 ++++-- web/frontend/src/components/app-header.tsx | 45 +++++- web/frontend/src/i18n/locales/en.json | 38 +++-- web/frontend/src/i18n/locales/zh.json | 38 +++-- web/frontend/src/lib/launcher-login-path.ts | 9 ++ web/frontend/src/routeTree.gen.ts | 101 ++++++++------ web/frontend/src/routes/__root.tsx | 40 ++++-- web/frontend/src/routes/launcher-login.tsx | 64 ++------- web/frontend/src/routes/launcher-setup.tsx | 146 ++++++++++++++++++++ 10 files changed, 381 insertions(+), 156 deletions(-) create mode 100644 web/frontend/src/routes/launcher-setup.tsx diff --git a/web/frontend/src/api/http.ts b/web/frontend/src/api/http.ts index 0eb872f3f..347dd9373 100644 --- a/web/frontend/src/api/http.ts +++ b/web/frontend/src/api/http.ts @@ -1,14 +1,14 @@ -import { isLauncherLoginPathname } from "@/lib/launcher-login-path" +import { isLauncherAuthPathname } from "@/lib/launcher-login-path" -function isLauncherLoginPath(): boolean { +function isLauncherAuthPath(): boolean { if (typeof globalThis.location === "undefined") { return false } - if (isLauncherLoginPathname(globalThis.location.pathname || "/")) { + if (isLauncherAuthPathname(globalThis.location.pathname || "/")) { return true } try { - return isLauncherLoginPathname( + return isLauncherAuthPathname( new URL(globalThis.location.href).pathname || "/", ) } catch { @@ -18,7 +18,7 @@ function isLauncherLoginPath(): boolean { /** * Same-origin fetch that sends cookies; redirects to launcher login on 401 JSON responses. - * Skips redirect while already on the login page to avoid reload loops (e.g. gateway poll). + * Skips redirect while already on an auth page (login or setup) to avoid reload loops. */ export async function launcherFetch( input: RequestInfo | URL, @@ -33,7 +33,7 @@ export async function launcherFetch( if ( ct.includes("application/json") && typeof globalThis.location !== "undefined" && - !isLauncherLoginPath() + !isLauncherAuthPath() ) { globalThis.location.assign("/launcher-login") } diff --git a/web/frontend/src/api/launcher-auth.ts b/web/frontend/src/api/launcher-auth.ts index 4ca51993b..ed2e30687 100644 --- a/web/frontend/src/api/launcher-auth.ts +++ b/web/frontend/src/api/launcher-auth.ts @@ -1,30 +1,23 @@ /** - * Dashboard launcher token login. Uses plain fetch (not launcherFetch) to avoid - * redirect loops on 401 while on the login page. + * Dashboard launcher auth API. + * Uses plain fetch (not launcherFetch) to avoid redirect loops on auth pages. */ export async function postLauncherDashboardLogin( - token: string, + password: string, ): Promise { const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "same-origin", - body: JSON.stringify({ token: token.trim() }), + body: JSON.stringify({ password: password.trim() }), }) return res.ok } -export type LauncherAuthTokenHelp = { - env_var_name: string - log_file?: string - config_file?: string - tray_copy_menu: boolean - console_stdout: boolean -} - export type LauncherAuthStatus = { authenticated: boolean - token_help?: LauncherAuthTokenHelp + /** true when a bcrypt password has been stored in the DB */ + initialized: boolean } export async function getLauncherAuthStatus(): Promise { @@ -47,3 +40,28 @@ export async function postLauncherDashboardLogout(): Promise { }) return res.ok } + +export type SetupResult = + | { ok: true } + | { ok: false; error: string } + +export async function postLauncherDashboardSetup( + password: string, + confirm: string, +): Promise { + const res = await fetch("/api/auth/setup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ password: password.trim(), confirm: confirm.trim() }), + }) + if (res.ok) return { ok: true } + let msg = "Unknown error" + try { + const j = (await res.json()) as { error?: string } + if (j.error) msg = j.error + } catch { + /* ignore */ + } + return { ok: false, error: msg } +} diff --git a/web/frontend/src/components/app-header.tsx b/web/frontend/src/components/app-header.tsx index fa1b5a488..1a673f598 100644 --- a/web/frontend/src/components/app-header.tsx +++ b/web/frontend/src/components/app-header.tsx @@ -2,6 +2,7 @@ import { IconBook, IconLanguage, IconLoader2, + IconLogout, IconMenu2, IconMoon, IconPlayerPlay, @@ -39,6 +40,7 @@ import { } from "@/components/ui/tooltip" import { useGateway } from "@/hooks/use-gateway.ts" import { useTheme } from "@/hooks/use-theme.ts" +import { postLauncherDashboardLogout } from "@/api/launcher-auth" export function AppHeader() { const { i18n, t } = useTranslation() @@ -65,6 +67,12 @@ export function AppHeader() { (gwState === "stopped" || gwState === "error") const [showStopDialog, setShowStopDialog] = React.useState(false) + const [showLogoutDialog, setShowLogoutDialog] = React.useState(false) + + const handleLogout = async () => { + await postLauncherDashboardLogout() + globalThis.location.assign("/launcher-login") + } const handleGatewayToggle = () => { if (gwLoading || isRestarting || isStopping || (!isRunning && !canStart)) { @@ -134,6 +142,23 @@ export function AppHeader() { + + + + {t("header.logout.tooltip")} + + {t("header.logout.description")} + + + + {t("common.cancel")} + void handleLogout()}> + {t("header.logout.confirm")} + + + + +
{restartRequired && ( @@ -180,9 +205,8 @@ export function AppHeader() { } size="sm" data-tour="gateway-button" - className={`h-8 gap-2 px-3 ${ - isStopped ? "bg-green-500 text-white hover:bg-green-600" : "" - }`} + className={`h-8 gap-2 px-3 ${isStopped ? "bg-green-500 text-white hover:bg-green-600" : "" + }`} onClick={handleGatewayToggle} disabled={ gwLoading || isStarting || isRestarting || isStopping || !canStart @@ -241,6 +265,21 @@ export function AppHeader() { {/* Theme Toggle */} + + + + + {t("header.logout.tooltip")} + +
diff --git a/web/frontend/src/routes/launcher-setup.tsx b/web/frontend/src/routes/launcher-setup.tsx new file mode 100644 index 000000000..876af94fb --- /dev/null +++ b/web/frontend/src/routes/launcher-setup.tsx @@ -0,0 +1,146 @@ +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 { postLauncherDashboardSetup } 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 LauncherSetupPage() { + const { t, i18n } = useTranslation() + const { theme, toggleTheme } = useTheme() + const [password, setPassword] = React.useState("") + const [confirm, setConfirm] = React.useState("") + const [submitting, setSubmitting] = React.useState(false) + const [error, setError] = React.useState("") + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + if (password !== confirm) { + setError(t("launcherSetup.errorMismatch")) + return + } + setSubmitting(true) + try { + const result = await postLauncherDashboardSetup(password, confirm) + if (result.ok) { + globalThis.location.assign("/launcher-login") + return + } + setError(result.error) + } catch { + setError(t("launcherSetup.errorNetwork")) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+ + + + + + i18n.changeLanguage("en")}> + English + + i18n.changeLanguage("zh")}> + 简体中文 + + + + +
+ +
+ + + {t("launcherSetup.title")} + {t("launcherSetup.description")} + + +
+
+ + setPassword(e.target.value)} + placeholder={t("launcherSetup.passwordPlaceholder")} + /> +
+
+ + setConfirm(e.target.value)} + placeholder={t("launcherSetup.confirmPlaceholder")} + /> +
+ + {error ? ( +

+ {error} +

+ ) : null} +
+
+
+
+
+ ) +} + +export const Route = createFileRoute("/launcher-setup")({ + component: LauncherSetupPage, +})