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
This commit is contained in:
sky5454 2026-04-04 06:34:57 +08:00
parent 62bf21104c
commit 7e21f072d5
10 changed files with 381 additions and 156 deletions

View file

@ -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")
}

View file

@ -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<boolean> {
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<LauncherAuthStatus> {
@ -47,3 +40,28 @@ export async function postLauncherDashboardLogout(): Promise<boolean> {
})
return res.ok
}
export type SetupResult =
| { ok: true }
| { ok: false; error: string }
export async function postLauncherDashboardSetup(
password: string,
confirm: string,
): Promise<SetupResult> {
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 }
}

View file

@ -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() {
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("header.logout.tooltip")}</AlertDialogTitle>
<AlertDialogDescription>
{t("header.logout.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => void handleLogout()}>
{t("header.logout.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div className="text-muted-foreground flex items-center gap-1 text-sm font-medium md:gap-2">
{restartRequired && (
<Tooltip delayDuration={700}>
@ -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() {
</DropdownMenu>
{/* Theme Toggle */}
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={() => setShowLogoutDialog(true)}
aria-label={t("header.logout.tooltip")}
>
<IconLogout className="size-4.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("header.logout.tooltip")}</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"

View file

@ -16,19 +16,24 @@
"logs": "Logs"
},
"launcherLogin": {
"title": "Launcher access",
"description": "Sign in with the dashboard access token for this launcher process (it may change after each restart unless you pin it with an environment variable or launcher config).",
"tokenLabel": "Token",
"tokenPlaceholder": "Enter access token",
"submit": "Continue to Dashboard",
"errorInvalid": "Invalid token. Please try again.",
"errorNetwork": "Network error. Please try again.",
"helpTitle": "Where to find the token",
"helpConsole": "Console mode: printed in the terminal when the launcher starts.",
"helpTray": "Tray mode: menu «Copy dashboard token».",
"helpConfig": "Launcher config file: {{path}}",
"helpLogFile": "Log file (startup line includes the token): {{path}}",
"helpEnv": "Stable token: set {{env}}."
"title": "Sign in",
"description": "Enter the dashboard password to continue.",
"passwordLabel": "Password",
"passwordPlaceholder": "Enter password",
"submit": "Sign in",
"errorInvalid": "Incorrect password. Please try again.",
"errorNetwork": "Network error. Please try again."
},
"launcherSetup": {
"title": "Set dashboard password",
"description": "Choose a password to protect access to this dashboard. You will use it every time you sign in.",
"passwordLabel": "Password",
"passwordPlaceholder": "At least 8 characters",
"confirmLabel": "Confirm password",
"confirmPlaceholder": "Repeat password",
"submit": "Set password",
"errorMismatch": "Passwords do not match.",
"errorNetwork": "Network error. Please try again."
},
"chat": {
"welcome": "How can I help you today?",
@ -72,6 +77,11 @@
}
},
"header": {
"logout": {
"tooltip": "Sign out",
"confirm": "Sign out",
"description": "Are you sure you want to sign out of the dashboard?"
},
"gateway": {
"stopDialog": {
"title": "Stop Gateway Service?",

View file

@ -16,19 +16,24 @@
"logs": "日志"
},
"launcherLogin": {
"title": "Launcher 访问验证",
"description": "请使用当前 Launcher 进程的访问口令登录(每次重启可能变化,除非用环境变量或 launcher 配置固定)",
"tokenLabel": "令牌",
"tokenPlaceholder": "输入访问令牌",
"submit": "进入 Dashboard",
"errorInvalid": "令牌错误,请重试",
"errorNetwork": "网络错误,请重试",
"helpTitle": "口令在哪里",
"helpConsole": "控制台模式:启动时在终端输出",
"helpTray": "托盘模式:菜单「复制控制台口令」",
"helpConfig": "Launcher 配置文件:{{path}}",
"helpLogFile": "日志文件(启动时会写入口令):{{path}}",
"helpEnv": "固定口令:设置环境变量 {{env}}"
"title": "登录",
"description": "请输入控制台密码以继续。",
"passwordLabel": "密码",
"passwordPlaceholder": "输入密码",
"submit": "登录",
"errorInvalid": "密码错误,请重试。",
"errorNetwork": "网络错误,请重试。"
},
"launcherSetup": {
"title": "设置控制台密码",
"description": "设置一个密码来保护控制台访问权限,登录时需要输入此密码。",
"passwordLabel": "密码",
"passwordPlaceholder": "至少 8 个字符",
"confirmLabel": "确认密码",
"confirmPlaceholder": "再次输入密码",
"submit": "设置密码",
"errorMismatch": "两次输入的密码不一致。",
"errorNetwork": "网络错误,请重试。"
},
"chat": {
"welcome": "今天我能为您做些什么?",
@ -72,6 +77,11 @@
}
},
"header": {
"logout": {
"tooltip": "退出登录",
"confirm": "退出登录",
"description": "确定要退出仪表盘登录吗?"
},
"gateway": {
"stopDialog": {
"title": "停止服务?",

View file

@ -7,3 +7,12 @@ export function normalizePathname(p: string): string {
export function isLauncherLoginPathname(pathname: string): boolean {
return normalizePathname(pathname) === "/launcher-login"
}
export function isLauncherSetupPathname(pathname: string): boolean {
return normalizePathname(pathname) === "/launcher-setup"
}
/** True for any page that is part of the auth flow (login or setup). */
export function isLauncherAuthPathname(pathname: string): boolean {
return isLauncherLoginPathname(pathname) || isLauncherSetupPathname(pathname)
}

View file

@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as ModelsRouteImport } from './routes/models'
import { Route as LogsRouteImport } from './routes/logs'
import { Route as LauncherSetupRouteImport } from './routes/launcher-setup'
import { Route as LauncherLoginRouteImport } from './routes/launcher-login'
import { Route as CredentialsRouteImport } from './routes/credentials'
import { Route as ConfigRouteImport } from './routes/config'
@ -33,6 +34,11 @@ const LogsRoute = LogsRouteImport.update({
path: '/logs',
getParentRoute: () => rootRouteImport,
} as any)
const LauncherSetupRoute = LauncherSetupRouteImport.update({
id: '/launcher-setup',
path: '/launcher-setup',
getParentRoute: () => rootRouteImport,
} as any)
const LauncherLoginRoute = LauncherLoginRouteImport.update({
id: '/launcher-login',
path: '/launcher-login',
@ -96,6 +102,7 @@ export interface FileRoutesByFullPath {
'/config': typeof ConfigRouteWithChildren
'/credentials': typeof CredentialsRoute
'/launcher-login': typeof LauncherLoginRoute
'/launcher-setup': typeof LauncherSetupRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/agent/hub': typeof AgentHubRoute
@ -111,6 +118,7 @@ export interface FileRoutesByTo {
'/config': typeof ConfigRouteWithChildren
'/credentials': typeof CredentialsRoute
'/launcher-login': typeof LauncherLoginRoute
'/launcher-setup': typeof LauncherSetupRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/agent/hub': typeof AgentHubRoute
@ -127,6 +135,7 @@ export interface FileRoutesById {
'/config': typeof ConfigRouteWithChildren
'/credentials': typeof CredentialsRoute
'/launcher-login': typeof LauncherLoginRoute
'/launcher-setup': typeof LauncherSetupRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/agent/hub': typeof AgentHubRoute
@ -138,49 +147,52 @@ export interface FileRoutesById {
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/channels'
| '/agent'
| '/config'
| '/credentials'
| '/launcher-login'
| '/logs'
| '/models'
| '/agent/hub'
| '/agent/skills'
| '/agent/tools'
| '/channels/$name'
| '/config/raw'
| '/'
| '/channels'
| '/agent'
| '/config'
| '/credentials'
| '/launcher-login'
| '/launcher-setup'
| '/logs'
| '/models'
| '/agent/hub'
| '/agent/skills'
| '/agent/tools'
| '/channels/$name'
| '/config/raw'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/channels'
| '/agent'
| '/config'
| '/credentials'
| '/launcher-login'
| '/logs'
| '/models'
| '/agent/hub'
| '/agent/skills'
| '/agent/tools'
| '/channels/$name'
| '/config/raw'
| '/'
| '/channels'
| '/agent'
| '/config'
| '/credentials'
| '/launcher-login'
| '/launcher-setup'
| '/logs'
| '/models'
| '/agent/hub'
| '/agent/skills'
| '/agent/tools'
| '/channels/$name'
| '/config/raw'
id:
| '__root__'
| '/'
| '/channels'
| '/agent'
| '/config'
| '/credentials'
| '/launcher-login'
| '/logs'
| '/models'
| '/agent/hub'
| '/agent/skills'
| '/agent/tools'
| '/channels/$name'
| '/config/raw'
| '__root__'
| '/'
| '/channels'
| '/agent'
| '/config'
| '/credentials'
| '/launcher-login'
| '/launcher-setup'
| '/logs'
| '/models'
| '/agent/hub'
| '/agent/skills'
| '/agent/tools'
| '/channels/$name'
| '/config/raw'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@ -190,6 +202,7 @@ export interface RootRouteChildren {
ConfigRoute: typeof ConfigRouteWithChildren
CredentialsRoute: typeof CredentialsRoute
LauncherLoginRoute: typeof LauncherLoginRoute
LauncherSetupRoute: typeof LauncherSetupRoute
LogsRoute: typeof LogsRoute
ModelsRoute: typeof ModelsRoute
}
@ -210,6 +223,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LogsRouteImport
parentRoute: typeof rootRouteImport
}
'/launcher-setup': {
id: '/launcher-setup'
path: '/launcher-setup'
fullPath: '/launcher-setup'
preLoaderRoute: typeof LauncherSetupRouteImport
parentRoute: typeof rootRouteImport
}
'/launcher-login': {
id: '/launcher-login'
path: '/launcher-login'
@ -334,6 +354,7 @@ const rootRouteChildren: RootRouteChildren = {
ConfigRoute: ConfigRouteWithChildren,
CredentialsRoute: CredentialsRoute,
LauncherLoginRoute: LauncherLoginRoute,
LauncherSetupRoute: LauncherSetupRoute,
LogsRoute: LogsRoute,
ModelsRoute: ModelsRoute,
}

View file

@ -2,14 +2,15 @@ import { Outlet, createRootRoute, useRouterState } from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
import { useEffect } from "react"
import { getLauncherAuthStatus } from "@/api/launcher-auth"
import { AppLayout } from "@/components/app-layout"
import { initializeChatStore } from "@/features/chat/controller"
import { isLauncherLoginPathname } from "@/lib/launcher-login-path"
import { isLauncherAuthPathname } 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.
// /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,
@ -22,19 +23,38 @@ const RootLayout = () => {
? globalThis.location.pathname || "/"
: routerState.pathname
const isLauncherLogin =
isLauncherLoginPathname(windowPath) ||
isLauncherLoginPathname(routerState.pathname) ||
routerState.matches.some((m) => m.routeId === "/launcher-login")
const isAuthPage =
isLauncherAuthPathname(windowPath) ||
isLauncherAuthPathname(routerState.pathname) ||
routerState.matches.some(
(m) => m.routeId === "/launcher-login" || m.routeId === "/launcher-setup",
)
// Session guard: proactively check auth status on every page load.
// This catches the case where ?token= auto-login bypassed the login/setup UI.
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(() => {
// Network error or 401 — launcherFetch will handle redirect on real API calls.
})
}, [isAuthPage])
useEffect(() => {
if (isLauncherLogin) {
if (isAuthPage) {
return
}
initializeChatStore()
}, [isLauncherLogin])
}, [isAuthPage])
if (isLauncherLogin) {
if (isAuthPage) {
return (
<>
<Outlet />

View file

@ -3,11 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"
import * as React from "react"
import { useTranslation } from "react-i18next"
import {
type LauncherAuthTokenHelp,
getLauncherAuthStatus,
postLauncherDashboardLogin,
} from "@/api/launcher-auth"
import { postLauncherDashboardLogin, getLauncherAuthStatus } from "@/api/launcher-auth"
import { Button } from "@/components/ui/button"
import {
Card,
@ -32,24 +28,16 @@ function LauncherLoginPage() {
const [token, setToken] = React.useState("")
const [submitting, setSubmitting] = React.useState(false)
const [error, setError] = React.useState("")
const [tokenHelp, setTokenHelp] =
React.useState<LauncherAuthTokenHelp | null>(null)
// If the password store has never been initialized, go to setup instead.
React.useEffect(() => {
let cancelled = false
void getLauncherAuthStatus()
.then((s) => {
if (cancelled || s.authenticated || !s.token_help) {
return
if (!s.initialized) {
globalThis.location.assign("/launcher-setup")
}
setTokenHelp(s.token_help)
})
.catch(() => {
/* ignore; login form still usable */
})
return () => {
cancelled = true
}
.catch(() => { /* network error — stay on login page */ })
}, [])
const loginWithToken = React.useCallback(
@ -120,17 +108,17 @@ function LauncherLoginPage() {
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="launcher-token">
{t("launcherLogin.tokenLabel")}
{t("launcherLogin.passwordLabel")}
</Label>
<Input
id="launcher-token"
name="token"
name="password"
type="password"
autoComplete="current-password"
required
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder={t("launcherLogin.tokenPlaceholder")}
placeholder={t("launcherLogin.passwordPlaceholder")}
/>
</div>
<Button type="submit" disabled={submitting}>
@ -142,42 +130,6 @@ function LauncherLoginPage() {
</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.config_file ? (
<li>
{t("launcherLogin.helpConfig", {
path: tokenHelp.config_file,
})}
</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>

View file

@ -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<HTMLFormElement>) => {
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 (
<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("launcherSetup.title")}</CardTitle>
<CardDescription>{t("launcherSetup.description")}</CardDescription>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="setup-password">
{t("launcherSetup.passwordLabel")}
</Label>
<Input
id="setup-password"
name="password"
type="password"
autoComplete="new-password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("launcherSetup.passwordPlaceholder")}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="setup-confirm">
{t("launcherSetup.confirmLabel")}
</Label>
<Input
id="setup-confirm"
name="confirm"
type="password"
autoComplete="new-password"
required
minLength={8}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder={t("launcherSetup.confirmPlaceholder")}
/>
</div>
<Button type="submit" disabled={submitting}>
{submitting ? t("labels.loading") : t("launcherSetup.submit")}
</Button>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
</form>
</CardContent>
</Card>
</div>
</div>
)
}
export const Route = createFileRoute("/launcher-setup")({
component: LauncherSetupPage,
})