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:
parent
62bf21104c
commit
7e21f072d5
10 changed files with 381 additions and 156 deletions
|
|
@ -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") {
|
if (typeof globalThis.location === "undefined") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (isLauncherLoginPathname(globalThis.location.pathname || "/")) {
|
if (isLauncherAuthPathname(globalThis.location.pathname || "/")) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return isLauncherLoginPathname(
|
return isLauncherAuthPathname(
|
||||||
new URL(globalThis.location.href).pathname || "/",
|
new URL(globalThis.location.href).pathname || "/",
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -18,7 +18,7 @@ function isLauncherLoginPath(): boolean {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same-origin fetch that sends cookies; redirects to launcher login on 401 JSON responses.
|
* 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(
|
export async function launcherFetch(
|
||||||
input: RequestInfo | URL,
|
input: RequestInfo | URL,
|
||||||
|
|
@ -33,7 +33,7 @@ export async function launcherFetch(
|
||||||
if (
|
if (
|
||||||
ct.includes("application/json") &&
|
ct.includes("application/json") &&
|
||||||
typeof globalThis.location !== "undefined" &&
|
typeof globalThis.location !== "undefined" &&
|
||||||
!isLauncherLoginPath()
|
!isLauncherAuthPath()
|
||||||
) {
|
) {
|
||||||
globalThis.location.assign("/launcher-login")
|
globalThis.location.assign("/launcher-login")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,23 @@
|
||||||
/**
|
/**
|
||||||
* Dashboard launcher token login. Uses plain fetch (not launcherFetch) to avoid
|
* Dashboard launcher auth API.
|
||||||
* redirect loops on 401 while on the login page.
|
* Uses plain fetch (not launcherFetch) to avoid redirect loops on auth pages.
|
||||||
*/
|
*/
|
||||||
export async function postLauncherDashboardLogin(
|
export async function postLauncherDashboardLogin(
|
||||||
token: string,
|
password: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const res = await fetch("/api/auth/login", {
|
const res = await fetch("/api/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
body: JSON.stringify({ token: token.trim() }),
|
body: JSON.stringify({ password: password.trim() }),
|
||||||
})
|
})
|
||||||
return res.ok
|
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 = {
|
export type LauncherAuthStatus = {
|
||||||
authenticated: boolean
|
authenticated: boolean
|
||||||
token_help?: LauncherAuthTokenHelp
|
/** true when a bcrypt password has been stored in the DB */
|
||||||
|
initialized: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLauncherAuthStatus(): Promise<LauncherAuthStatus> {
|
export async function getLauncherAuthStatus(): Promise<LauncherAuthStatus> {
|
||||||
|
|
@ -47,3 +40,28 @@ export async function postLauncherDashboardLogout(): Promise<boolean> {
|
||||||
})
|
})
|
||||||
return res.ok
|
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 }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import {
|
||||||
IconBook,
|
IconBook,
|
||||||
IconLanguage,
|
IconLanguage,
|
||||||
IconLoader2,
|
IconLoader2,
|
||||||
|
IconLogout,
|
||||||
IconMenu2,
|
IconMenu2,
|
||||||
IconMoon,
|
IconMoon,
|
||||||
IconPlayerPlay,
|
IconPlayerPlay,
|
||||||
|
|
@ -39,6 +40,7 @@ import {
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
import { useGateway } from "@/hooks/use-gateway.ts"
|
import { useGateway } from "@/hooks/use-gateway.ts"
|
||||||
import { useTheme } from "@/hooks/use-theme.ts"
|
import { useTheme } from "@/hooks/use-theme.ts"
|
||||||
|
import { postLauncherDashboardLogout } from "@/api/launcher-auth"
|
||||||
|
|
||||||
export function AppHeader() {
|
export function AppHeader() {
|
||||||
const { i18n, t } = useTranslation()
|
const { i18n, t } = useTranslation()
|
||||||
|
|
@ -65,6 +67,12 @@ export function AppHeader() {
|
||||||
(gwState === "stopped" || gwState === "error")
|
(gwState === "stopped" || gwState === "error")
|
||||||
|
|
||||||
const [showStopDialog, setShowStopDialog] = React.useState(false)
|
const [showStopDialog, setShowStopDialog] = React.useState(false)
|
||||||
|
const [showLogoutDialog, setShowLogoutDialog] = React.useState(false)
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await postLauncherDashboardLogout()
|
||||||
|
globalThis.location.assign("/launcher-login")
|
||||||
|
}
|
||||||
|
|
||||||
const handleGatewayToggle = () => {
|
const handleGatewayToggle = () => {
|
||||||
if (gwLoading || isRestarting || isStopping || (!isRunning && !canStart)) {
|
if (gwLoading || isRestarting || isStopping || (!isRunning && !canStart)) {
|
||||||
|
|
@ -134,6 +142,23 @@ export function AppHeader() {
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</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">
|
<div className="text-muted-foreground flex items-center gap-1 text-sm font-medium md:gap-2">
|
||||||
{restartRequired && (
|
{restartRequired && (
|
||||||
<Tooltip delayDuration={700}>
|
<Tooltip delayDuration={700}>
|
||||||
|
|
@ -180,8 +205,7 @@ export function AppHeader() {
|
||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
data-tour="gateway-button"
|
data-tour="gateway-button"
|
||||||
className={`h-8 gap-2 px-3 ${
|
className={`h-8 gap-2 px-3 ${isStopped ? "bg-green-500 text-white hover:bg-green-600" : ""
|
||||||
isStopped ? "bg-green-500 text-white hover:bg-green-600" : ""
|
|
||||||
}`}
|
}`}
|
||||||
onClick={handleGatewayToggle}
|
onClick={handleGatewayToggle}
|
||||||
disabled={
|
disabled={
|
||||||
|
|
@ -241,6 +265,21 @@ export function AppHeader() {
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
{/* Theme Toggle */}
|
{/* 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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
|
||||||
|
|
@ -16,19 +16,24 @@
|
||||||
"logs": "Logs"
|
"logs": "Logs"
|
||||||
},
|
},
|
||||||
"launcherLogin": {
|
"launcherLogin": {
|
||||||
"title": "Launcher access",
|
"title": "Sign in",
|
||||||
"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).",
|
"description": "Enter the dashboard password to continue.",
|
||||||
"tokenLabel": "Token",
|
"passwordLabel": "Password",
|
||||||
"tokenPlaceholder": "Enter access token",
|
"passwordPlaceholder": "Enter password",
|
||||||
"submit": "Continue to Dashboard",
|
"submit": "Sign in",
|
||||||
"errorInvalid": "Invalid token. Please try again.",
|
"errorInvalid": "Incorrect password. Please try again.",
|
||||||
"errorNetwork": "Network error. 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.",
|
"launcherSetup": {
|
||||||
"helpTray": "Tray mode: menu «Copy dashboard token».",
|
"title": "Set dashboard password",
|
||||||
"helpConfig": "Launcher config file: {{path}}",
|
"description": "Choose a password to protect access to this dashboard. You will use it every time you sign in.",
|
||||||
"helpLogFile": "Log file (startup line includes the token): {{path}}",
|
"passwordLabel": "Password",
|
||||||
"helpEnv": "Stable token: set {{env}}."
|
"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": {
|
"chat": {
|
||||||
"welcome": "How can I help you today?",
|
"welcome": "How can I help you today?",
|
||||||
|
|
@ -72,6 +77,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
|
"logout": {
|
||||||
|
"tooltip": "Sign out",
|
||||||
|
"confirm": "Sign out",
|
||||||
|
"description": "Are you sure you want to sign out of the dashboard?"
|
||||||
|
},
|
||||||
"gateway": {
|
"gateway": {
|
||||||
"stopDialog": {
|
"stopDialog": {
|
||||||
"title": "Stop Gateway Service?",
|
"title": "Stop Gateway Service?",
|
||||||
|
|
|
||||||
|
|
@ -16,19 +16,24 @@
|
||||||
"logs": "日志"
|
"logs": "日志"
|
||||||
},
|
},
|
||||||
"launcherLogin": {
|
"launcherLogin": {
|
||||||
"title": "Launcher 访问验证",
|
"title": "登录",
|
||||||
"description": "请使用当前 Launcher 进程的访问口令登录(每次重启可能变化,除非用环境变量或 launcher 配置固定)",
|
"description": "请输入控制台密码以继续。",
|
||||||
"tokenLabel": "令牌",
|
"passwordLabel": "密码",
|
||||||
"tokenPlaceholder": "输入访问令牌",
|
"passwordPlaceholder": "输入密码",
|
||||||
"submit": "进入 Dashboard",
|
"submit": "登录",
|
||||||
"errorInvalid": "令牌错误,请重试",
|
"errorInvalid": "密码错误,请重试。",
|
||||||
"errorNetwork": "网络错误,请重试",
|
"errorNetwork": "网络错误,请重试。"
|
||||||
"helpTitle": "口令在哪里",
|
},
|
||||||
"helpConsole": "控制台模式:启动时在终端输出",
|
"launcherSetup": {
|
||||||
"helpTray": "托盘模式:菜单「复制控制台口令」",
|
"title": "设置控制台密码",
|
||||||
"helpConfig": "Launcher 配置文件:{{path}}",
|
"description": "设置一个密码来保护控制台访问权限,登录时需要输入此密码。",
|
||||||
"helpLogFile": "日志文件(启动时会写入口令):{{path}}",
|
"passwordLabel": "密码",
|
||||||
"helpEnv": "固定口令:设置环境变量 {{env}}"
|
"passwordPlaceholder": "至少 8 个字符",
|
||||||
|
"confirmLabel": "确认密码",
|
||||||
|
"confirmPlaceholder": "再次输入密码",
|
||||||
|
"submit": "设置密码",
|
||||||
|
"errorMismatch": "两次输入的密码不一致。",
|
||||||
|
"errorNetwork": "网络错误,请重试。"
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"welcome": "今天我能为您做些什么?",
|
"welcome": "今天我能为您做些什么?",
|
||||||
|
|
@ -72,6 +77,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
|
"logout": {
|
||||||
|
"tooltip": "退出登录",
|
||||||
|
"confirm": "退出登录",
|
||||||
|
"description": "确定要退出仪表盘登录吗?"
|
||||||
|
},
|
||||||
"gateway": {
|
"gateway": {
|
||||||
"stopDialog": {
|
"stopDialog": {
|
||||||
"title": "停止服务?",
|
"title": "停止服务?",
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,12 @@ export function normalizePathname(p: string): string {
|
||||||
export function isLauncherLoginPathname(pathname: string): boolean {
|
export function isLauncherLoginPathname(pathname: string): boolean {
|
||||||
return normalizePathname(pathname) === "/launcher-login"
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as ModelsRouteImport } from './routes/models'
|
import { Route as ModelsRouteImport } from './routes/models'
|
||||||
import { Route as LogsRouteImport } from './routes/logs'
|
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 LauncherLoginRouteImport } from './routes/launcher-login'
|
||||||
import { Route as CredentialsRouteImport } from './routes/credentials'
|
import { Route as CredentialsRouteImport } from './routes/credentials'
|
||||||
import { Route as ConfigRouteImport } from './routes/config'
|
import { Route as ConfigRouteImport } from './routes/config'
|
||||||
|
|
@ -33,6 +34,11 @@ const LogsRoute = LogsRouteImport.update({
|
||||||
path: '/logs',
|
path: '/logs',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const LauncherSetupRoute = LauncherSetupRouteImport.update({
|
||||||
|
id: '/launcher-setup',
|
||||||
|
path: '/launcher-setup',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const LauncherLoginRoute = LauncherLoginRouteImport.update({
|
const LauncherLoginRoute = LauncherLoginRouteImport.update({
|
||||||
id: '/launcher-login',
|
id: '/launcher-login',
|
||||||
path: '/launcher-login',
|
path: '/launcher-login',
|
||||||
|
|
@ -96,6 +102,7 @@ export interface FileRoutesByFullPath {
|
||||||
'/config': typeof ConfigRouteWithChildren
|
'/config': typeof ConfigRouteWithChildren
|
||||||
'/credentials': typeof CredentialsRoute
|
'/credentials': typeof CredentialsRoute
|
||||||
'/launcher-login': typeof LauncherLoginRoute
|
'/launcher-login': typeof LauncherLoginRoute
|
||||||
|
'/launcher-setup': typeof LauncherSetupRoute
|
||||||
'/logs': typeof LogsRoute
|
'/logs': typeof LogsRoute
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
'/agent/hub': typeof AgentHubRoute
|
'/agent/hub': typeof AgentHubRoute
|
||||||
|
|
@ -111,6 +118,7 @@ export interface FileRoutesByTo {
|
||||||
'/config': typeof ConfigRouteWithChildren
|
'/config': typeof ConfigRouteWithChildren
|
||||||
'/credentials': typeof CredentialsRoute
|
'/credentials': typeof CredentialsRoute
|
||||||
'/launcher-login': typeof LauncherLoginRoute
|
'/launcher-login': typeof LauncherLoginRoute
|
||||||
|
'/launcher-setup': typeof LauncherSetupRoute
|
||||||
'/logs': typeof LogsRoute
|
'/logs': typeof LogsRoute
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
'/agent/hub': typeof AgentHubRoute
|
'/agent/hub': typeof AgentHubRoute
|
||||||
|
|
@ -127,6 +135,7 @@ export interface FileRoutesById {
|
||||||
'/config': typeof ConfigRouteWithChildren
|
'/config': typeof ConfigRouteWithChildren
|
||||||
'/credentials': typeof CredentialsRoute
|
'/credentials': typeof CredentialsRoute
|
||||||
'/launcher-login': typeof LauncherLoginRoute
|
'/launcher-login': typeof LauncherLoginRoute
|
||||||
|
'/launcher-setup': typeof LauncherSetupRoute
|
||||||
'/logs': typeof LogsRoute
|
'/logs': typeof LogsRoute
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
'/agent/hub': typeof AgentHubRoute
|
'/agent/hub': typeof AgentHubRoute
|
||||||
|
|
@ -144,6 +153,7 @@ export interface FileRouteTypes {
|
||||||
| '/config'
|
| '/config'
|
||||||
| '/credentials'
|
| '/credentials'
|
||||||
| '/launcher-login'
|
| '/launcher-login'
|
||||||
|
| '/launcher-setup'
|
||||||
| '/logs'
|
| '/logs'
|
||||||
| '/models'
|
| '/models'
|
||||||
| '/agent/hub'
|
| '/agent/hub'
|
||||||
|
|
@ -159,6 +169,7 @@ export interface FileRouteTypes {
|
||||||
| '/config'
|
| '/config'
|
||||||
| '/credentials'
|
| '/credentials'
|
||||||
| '/launcher-login'
|
| '/launcher-login'
|
||||||
|
| '/launcher-setup'
|
||||||
| '/logs'
|
| '/logs'
|
||||||
| '/models'
|
| '/models'
|
||||||
| '/agent/hub'
|
| '/agent/hub'
|
||||||
|
|
@ -174,6 +185,7 @@ export interface FileRouteTypes {
|
||||||
| '/config'
|
| '/config'
|
||||||
| '/credentials'
|
| '/credentials'
|
||||||
| '/launcher-login'
|
| '/launcher-login'
|
||||||
|
| '/launcher-setup'
|
||||||
| '/logs'
|
| '/logs'
|
||||||
| '/models'
|
| '/models'
|
||||||
| '/agent/hub'
|
| '/agent/hub'
|
||||||
|
|
@ -190,6 +202,7 @@ export interface RootRouteChildren {
|
||||||
ConfigRoute: typeof ConfigRouteWithChildren
|
ConfigRoute: typeof ConfigRouteWithChildren
|
||||||
CredentialsRoute: typeof CredentialsRoute
|
CredentialsRoute: typeof CredentialsRoute
|
||||||
LauncherLoginRoute: typeof LauncherLoginRoute
|
LauncherLoginRoute: typeof LauncherLoginRoute
|
||||||
|
LauncherSetupRoute: typeof LauncherSetupRoute
|
||||||
LogsRoute: typeof LogsRoute
|
LogsRoute: typeof LogsRoute
|
||||||
ModelsRoute: typeof ModelsRoute
|
ModelsRoute: typeof ModelsRoute
|
||||||
}
|
}
|
||||||
|
|
@ -210,6 +223,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof LogsRouteImport
|
preLoaderRoute: typeof LogsRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/launcher-setup': {
|
||||||
|
id: '/launcher-setup'
|
||||||
|
path: '/launcher-setup'
|
||||||
|
fullPath: '/launcher-setup'
|
||||||
|
preLoaderRoute: typeof LauncherSetupRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/launcher-login': {
|
'/launcher-login': {
|
||||||
id: '/launcher-login'
|
id: '/launcher-login'
|
||||||
path: '/launcher-login'
|
path: '/launcher-login'
|
||||||
|
|
@ -334,6 +354,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||||
ConfigRoute: ConfigRouteWithChildren,
|
ConfigRoute: ConfigRouteWithChildren,
|
||||||
CredentialsRoute: CredentialsRoute,
|
CredentialsRoute: CredentialsRoute,
|
||||||
LauncherLoginRoute: LauncherLoginRoute,
|
LauncherLoginRoute: LauncherLoginRoute,
|
||||||
|
LauncherSetupRoute: LauncherSetupRoute,
|
||||||
LogsRoute: LogsRoute,
|
LogsRoute: LogsRoute,
|
||||||
ModelsRoute: ModelsRoute,
|
ModelsRoute: ModelsRoute,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,15 @@ import { Outlet, createRootRoute, useRouterState } from "@tanstack/react-router"
|
||||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
|
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
import { getLauncherAuthStatus } from "@/api/launcher-auth"
|
||||||
import { AppLayout } from "@/components/app-layout"
|
import { AppLayout } from "@/components/app-layout"
|
||||||
import { initializeChatStore } from "@/features/chat/controller"
|
import { initializeChatStore } from "@/features/chat/controller"
|
||||||
import { isLauncherLoginPathname } from "@/lib/launcher-login-path"
|
import { isLauncherAuthPathname } from "@/lib/launcher-login-path"
|
||||||
|
|
||||||
const RootLayout = () => {
|
const RootLayout = () => {
|
||||||
// Prefer the real address bar path: stale embedded bundles may not register
|
// Prefer the real address bar path: stale embedded bundles may not register
|
||||||
// /launcher-login in the route tree, which would otherwise keep AppLayout +
|
// /launcher-login or /launcher-setup in the route tree, which would otherwise
|
||||||
// gateway polling → 401 → launcherFetch redirect loop.
|
// keep AppLayout + gateway polling → 401 → launcherFetch redirect loop.
|
||||||
const routerState = useRouterState({
|
const routerState = useRouterState({
|
||||||
select: (s) => ({
|
select: (s) => ({
|
||||||
pathname: s.location.pathname,
|
pathname: s.location.pathname,
|
||||||
|
|
@ -22,19 +23,38 @@ const RootLayout = () => {
|
||||||
? globalThis.location.pathname || "/"
|
? globalThis.location.pathname || "/"
|
||||||
: routerState.pathname
|
: routerState.pathname
|
||||||
|
|
||||||
const isLauncherLogin =
|
const isAuthPage =
|
||||||
isLauncherLoginPathname(windowPath) ||
|
isLauncherAuthPathname(windowPath) ||
|
||||||
isLauncherLoginPathname(routerState.pathname) ||
|
isLauncherAuthPathname(routerState.pathname) ||
|
||||||
routerState.matches.some((m) => m.routeId === "/launcher-login")
|
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(() => {
|
useEffect(() => {
|
||||||
if (isLauncherLogin) {
|
if (isAuthPage) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
initializeChatStore()
|
initializeChatStore()
|
||||||
}, [isLauncherLogin])
|
}, [isAuthPage])
|
||||||
|
|
||||||
if (isLauncherLogin) {
|
if (isAuthPage) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import {
|
import { postLauncherDashboardLogin, getLauncherAuthStatus } from "@/api/launcher-auth"
|
||||||
type LauncherAuthTokenHelp,
|
|
||||||
getLauncherAuthStatus,
|
|
||||||
postLauncherDashboardLogin,
|
|
||||||
} from "@/api/launcher-auth"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -32,24 +28,16 @@ function LauncherLoginPage() {
|
||||||
const [token, setToken] = React.useState("")
|
const [token, setToken] = React.useState("")
|
||||||
const [submitting, setSubmitting] = React.useState(false)
|
const [submitting, setSubmitting] = React.useState(false)
|
||||||
const [error, setError] = React.useState("")
|
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(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false
|
|
||||||
void getLauncherAuthStatus()
|
void getLauncherAuthStatus()
|
||||||
.then((s) => {
|
.then((s) => {
|
||||||
if (cancelled || s.authenticated || !s.token_help) {
|
if (!s.initialized) {
|
||||||
return
|
globalThis.location.assign("/launcher-setup")
|
||||||
}
|
}
|
||||||
setTokenHelp(s.token_help)
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => { /* network error — stay on login page */ })
|
||||||
/* ignore; login form still usable */
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loginWithToken = React.useCallback(
|
const loginWithToken = React.useCallback(
|
||||||
|
|
@ -120,17 +108,17 @@ function LauncherLoginPage() {
|
||||||
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
|
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="launcher-token">
|
<Label htmlFor="launcher-token">
|
||||||
{t("launcherLogin.tokenLabel")}
|
{t("launcherLogin.passwordLabel")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="launcher-token"
|
id="launcher-token"
|
||||||
name="token"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
required
|
required
|
||||||
value={token}
|
value={token}
|
||||||
onChange={(e) => setToken(e.target.value)}
|
onChange={(e) => setToken(e.target.value)}
|
||||||
placeholder={t("launcherLogin.tokenPlaceholder")}
|
placeholder={t("launcherLogin.passwordPlaceholder")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" disabled={submitting}>
|
<Button type="submit" disabled={submitting}>
|
||||||
|
|
@ -142,42 +130,6 @@ function LauncherLoginPage() {
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</form>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
146
web/frontend/src/routes/launcher-setup.tsx
Normal file
146
web/frontend/src/routes/launcher-setup.tsx
Normal 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,
|
||||||
|
})
|
||||||
Loading…
Add table
Reference in a new issue