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,5 +1,7 @@
|
||||
// API client for channels navigation and channel-specific config flows.
|
||||
|
||||
import { launcherFetch } from "@/api/http"
|
||||
|
||||
export type ChannelConfig = Record<string, unknown>
|
||||
export type AppConfig = Record<string, unknown>
|
||||
|
||||
@@ -22,7 +24,7 @@ interface ConfigActionResponse {
|
||||
const BASE_URL = ""
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, options)
|
||||
const res = await launcherFetch(`${BASE_URL}${path}`, options)
|
||||
if (!res.ok) {
|
||||
let message = `API error: ${res.status} ${res.statusText}`
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { launcherFetch } from "@/api/http"
|
||||
|
||||
// API client for gateway process management.
|
||||
|
||||
interface GatewayStatusResponse {
|
||||
@@ -27,7 +29,7 @@ interface GatewayActionResponse {
|
||||
const BASE_URL = ""
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, options)
|
||||
const res = await launcherFetch(`${BASE_URL}${path}`, options)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { isLauncherLoginPathname } from "@/lib/launcher-login-path"
|
||||
|
||||
function isLauncherLoginPath(): boolean {
|
||||
if (typeof globalThis.location === "undefined") {
|
||||
return false
|
||||
}
|
||||
if (isLauncherLoginPathname(globalThis.location.pathname || "/")) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
return isLauncherLoginPathname(
|
||||
new URL(globalThis.location.href).pathname || "/",
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export async function launcherFetch(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const res = await fetch(input, {
|
||||
credentials: "same-origin",
|
||||
...init,
|
||||
})
|
||||
if (res.status === 401) {
|
||||
const ct = res.headers.get("content-type") || ""
|
||||
if (
|
||||
ct.includes("application/json") &&
|
||||
typeof globalThis.location !== "undefined" &&
|
||||
!isLauncherLoginPath()
|
||||
) {
|
||||
globalThis.location.assign("/launcher-login")
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Dashboard launcher token login. Uses plain fetch (not launcherFetch) to avoid
|
||||
* redirect loops on 401 while on the login page.
|
||||
*/
|
||||
export async function postLauncherDashboardLogin(
|
||||
token: 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() }),
|
||||
})
|
||||
return res.ok
|
||||
}
|
||||
|
||||
export type LauncherAuthTokenHelp = {
|
||||
env_var_name: string
|
||||
log_file?: string
|
||||
tray_copy_menu: boolean
|
||||
console_stdout: boolean
|
||||
}
|
||||
|
||||
export type LauncherAuthStatus = {
|
||||
authenticated: boolean
|
||||
token_help?: LauncherAuthTokenHelp
|
||||
}
|
||||
|
||||
export async function getLauncherAuthStatus(): Promise<LauncherAuthStatus> {
|
||||
const res = await fetch("/api/auth/status", {
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`status ${res.status}`)
|
||||
}
|
||||
return (await res.json()) as LauncherAuthStatus
|
||||
}
|
||||
|
||||
export async function postLauncherDashboardLogout(): Promise<boolean> {
|
||||
const res = await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: "{}",
|
||||
})
|
||||
return res.ok
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { launcherFetch } from "@/api/http"
|
||||
import { refreshGatewayState } from "@/store/gateway"
|
||||
|
||||
// API client for model list management.
|
||||
@@ -39,7 +40,7 @@ interface ModelActionResponse {
|
||||
const BASE_URL = ""
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, options)
|
||||
const res = await launcherFetch(`${BASE_URL}${path}`, options)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { launcherFetch } from "@/api/http"
|
||||
|
||||
export type OAuthProvider = "openai" | "anthropic" | "google-antigravity"
|
||||
export type OAuthMethod = "browser" | "device_code" | "token"
|
||||
|
||||
@@ -51,7 +53,7 @@ interface OAuthProvidersResponse {
|
||||
const BASE_URL = ""
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, options)
|
||||
const res = await launcherFetch(`${BASE_URL}${path}`, options)
|
||||
if (!res.ok) {
|
||||
const message = await res.text()
|
||||
throw new Error(message || `API error: ${res.status} ${res.statusText}`)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { launcherFetch } from "@/api/http"
|
||||
|
||||
// API client for Pico Channel configuration.
|
||||
|
||||
interface PicoTokenResponse {
|
||||
@@ -16,7 +18,7 @@ interface PicoSetupResponse {
|
||||
const BASE_URL = ""
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, options)
|
||||
const res = await launcherFetch(`${BASE_URL}${path}`, options)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Sessions API — list and retrieve chat session history
|
||||
|
||||
import { launcherFetch } from "@/api/http"
|
||||
|
||||
export interface SessionSummary {
|
||||
id: string
|
||||
title: string
|
||||
@@ -26,7 +28,7 @@ export async function getSessions(
|
||||
limit: limit.toString(),
|
||||
})
|
||||
|
||||
const res = await fetch(`/api/sessions?${params.toString()}`)
|
||||
const res = await launcherFetch(`/api/sessions?${params.toString()}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch sessions: ${res.status}`)
|
||||
}
|
||||
@@ -34,7 +36,7 @@ export async function getSessions(
|
||||
}
|
||||
|
||||
export async function getSessionHistory(id: string): Promise<SessionDetail> {
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`)
|
||||
const res = await launcherFetch(`/api/sessions/${encodeURIComponent(id)}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch session ${id}: ${res.status}`)
|
||||
}
|
||||
@@ -42,7 +44,7 @@ export async function getSessionHistory(id: string): Promise<SessionDetail> {
|
||||
}
|
||||
|
||||
export async function deleteSession(id: string): Promise<void> {
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`, {
|
||||
const res = await launcherFetch(`/api/sessions/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { launcherFetch } from "@/api/http"
|
||||
|
||||
export interface SkillSupportItem {
|
||||
name: string
|
||||
path: string
|
||||
@@ -22,7 +24,7 @@ interface SkillActionResponse {
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, options)
|
||||
const res = await launcherFetch(path, options)
|
||||
if (!res.ok) {
|
||||
throw new Error(await extractErrorMessage(res))
|
||||
}
|
||||
@@ -41,7 +43,7 @@ export async function importSkill(file: File): Promise<SkillActionResponse> {
|
||||
const formData = new FormData()
|
||||
formData.set("file", file)
|
||||
|
||||
const res = await fetch("/api/skills/import", {
|
||||
const res = await launcherFetch("/api/skills/import", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { launcherFetch } from "@/api/http"
|
||||
|
||||
export interface AutoStartStatus {
|
||||
enabled: boolean
|
||||
supported: boolean
|
||||
@@ -12,7 +14,7 @@ export interface LauncherConfig {
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, options)
|
||||
const res = await launcherFetch(path, options)
|
||||
if (!res.ok) {
|
||||
let message = `API error: ${res.status} ${res.statusText}`
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { launcherFetch } from "@/api/http"
|
||||
|
||||
export interface ToolSupportItem {
|
||||
name: string
|
||||
description: string
|
||||
@@ -16,7 +18,7 @@ interface ToolActionResponse {
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, options)
|
||||
const res = await launcherFetch(path, options)
|
||||
if (!res.ok) {
|
||||
let message = `API error: ${res.status} ${res.statusText}`
|
||||
try {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { patchAppConfig } from "@/api/channels"
|
||||
import { launcherFetch } from "@/api/http"
|
||||
import {
|
||||
getAutoStartStatus,
|
||||
getLauncherConfig,
|
||||
@@ -50,7 +51,7 @@ export function ConfigPage() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["config"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/config")
|
||||
const res = await launcherFetch("/api/config")
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to load config")
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { launcherFetch } from "@/api/http"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -28,7 +29,7 @@ export function RawConfigPage() {
|
||||
const { data: config, isLoading } = useQuery({
|
||||
queryKey: ["config"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/config")
|
||||
const res = await launcherFetch("/api/config")
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch config")
|
||||
}
|
||||
@@ -38,7 +39,7 @@ export function RawConfigPage() {
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (newConfig: string) => {
|
||||
const res = await fetch("/api/config", {
|
||||
const res = await launcherFetch("/api/config", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: newConfig,
|
||||
|
||||
@@ -14,6 +14,18 @@ export function normalizeWsUrlForBrowser(wsUrl: string): string {
|
||||
if (isLocalHost && !isBrowserLocal) {
|
||||
parsedUrl.hostname = window.location.hostname
|
||||
finalWsUrl = parsedUrl.toString()
|
||||
} else if (
|
||||
isLocalHost &&
|
||||
isBrowserLocal &&
|
||||
parsedUrl.hostname !== window.location.hostname &&
|
||||
(parsedUrl.hostname === "127.0.0.1" ||
|
||||
parsedUrl.hostname === "localhost") &&
|
||||
(window.location.hostname === "127.0.0.1" ||
|
||||
window.location.hostname === "localhost")
|
||||
) {
|
||||
// Same machine, but cookies are host-specific; match the page origin.
|
||||
parsedUrl.hostname = window.location.hostname
|
||||
finalWsUrl = parsedUrl.toString()
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Could not parse ws_url:", error)
|
||||
|
||||
@@ -14,6 +14,20 @@
|
||||
"config": "Config",
|
||||
"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).",
|
||||
"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».",
|
||||
"helpLogFile": "Log file (startup line includes the token): {{path}}",
|
||||
"helpEnv": "Stable token: set {{env}}."
|
||||
},
|
||||
"chat": {
|
||||
"welcome": "How can I help you today?",
|
||||
"welcomeDesc": "Ask me about weather, settings, or any other tasks. I'm here to assist you.",
|
||||
|
||||
@@ -14,6 +14,20 @@
|
||||
"config": "配置",
|
||||
"logs": "日志"
|
||||
},
|
||||
"launcherLogin": {
|
||||
"title": "Launcher 访问验证",
|
||||
"description": "请使用当前 Launcher 进程的访问口令登录(每次重启可能变化,除非用环境变量固定)。",
|
||||
"tokenLabel": "令牌",
|
||||
"tokenPlaceholder": "输入访问令牌",
|
||||
"submit": "进入 Dashboard",
|
||||
"errorInvalid": "令牌错误,请重试。",
|
||||
"errorNetwork": "网络错误,请重试。",
|
||||
"helpTitle": "口令在哪里",
|
||||
"helpConsole": "控制台模式:启动时在终端输出。",
|
||||
"helpTray": "托盘模式:菜单「复制控制台口令」。",
|
||||
"helpLogFile": "日志文件(启动时会写入口令):{{path}}",
|
||||
"helpEnv": "固定口令:设置环境变量 {{env}}。"
|
||||
},
|
||||
"chat": {
|
||||
"welcome": "今天我能为您做些什么?",
|
||||
"welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Normalize URL pathname for comparisons (trailing slashes, empty). */
|
||||
export function normalizePathname(p: string): string {
|
||||
const t = p.replace(/\/+$/, "")
|
||||
return t === "" ? "/" : t
|
||||
}
|
||||
|
||||
export function isLauncherLoginPathname(pathname: string): boolean {
|
||||
return normalizePathname(pathname) === "/launcher-login"
|
||||
}
|
||||
@@ -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 LauncherLoginRouteImport } from './routes/launcher-login'
|
||||
import { Route as CredentialsRouteImport } from './routes/credentials'
|
||||
import { Route as ConfigRouteImport } from './routes/config'
|
||||
import { Route as AgentRouteImport } from './routes/agent'
|
||||
@@ -31,6 +32,11 @@ const LogsRoute = LogsRouteImport.update({
|
||||
path: '/logs',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LauncherLoginRoute = LauncherLoginRouteImport.update({
|
||||
id: '/launcher-login',
|
||||
path: '/launcher-login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const CredentialsRoute = CredentialsRouteImport.update({
|
||||
id: '/credentials',
|
||||
path: '/credentials',
|
||||
@@ -83,6 +89,7 @@ export interface FileRoutesByFullPath {
|
||||
'/agent': typeof AgentRouteWithChildren
|
||||
'/config': typeof ConfigRouteWithChildren
|
||||
'/credentials': typeof CredentialsRoute
|
||||
'/launcher-login': typeof LauncherLoginRoute
|
||||
'/logs': typeof LogsRoute
|
||||
'/models': typeof ModelsRoute
|
||||
'/agent/skills': typeof AgentSkillsRoute
|
||||
@@ -96,6 +103,7 @@ export interface FileRoutesByTo {
|
||||
'/agent': typeof AgentRouteWithChildren
|
||||
'/config': typeof ConfigRouteWithChildren
|
||||
'/credentials': typeof CredentialsRoute
|
||||
'/launcher-login': typeof LauncherLoginRoute
|
||||
'/logs': typeof LogsRoute
|
||||
'/models': typeof ModelsRoute
|
||||
'/agent/skills': typeof AgentSkillsRoute
|
||||
@@ -110,6 +118,7 @@ export interface FileRoutesById {
|
||||
'/agent': typeof AgentRouteWithChildren
|
||||
'/config': typeof ConfigRouteWithChildren
|
||||
'/credentials': typeof CredentialsRoute
|
||||
'/launcher-login': typeof LauncherLoginRoute
|
||||
'/logs': typeof LogsRoute
|
||||
'/models': typeof ModelsRoute
|
||||
'/agent/skills': typeof AgentSkillsRoute
|
||||
@@ -125,6 +134,7 @@ export interface FileRouteTypes {
|
||||
| '/agent'
|
||||
| '/config'
|
||||
| '/credentials'
|
||||
| '/launcher-login'
|
||||
| '/logs'
|
||||
| '/models'
|
||||
| '/agent/skills'
|
||||
@@ -138,6 +148,7 @@ export interface FileRouteTypes {
|
||||
| '/agent'
|
||||
| '/config'
|
||||
| '/credentials'
|
||||
| '/launcher-login'
|
||||
| '/logs'
|
||||
| '/models'
|
||||
| '/agent/skills'
|
||||
@@ -151,6 +162,7 @@ export interface FileRouteTypes {
|
||||
| '/agent'
|
||||
| '/config'
|
||||
| '/credentials'
|
||||
| '/launcher-login'
|
||||
| '/logs'
|
||||
| '/models'
|
||||
| '/agent/skills'
|
||||
@@ -165,6 +177,7 @@ export interface RootRouteChildren {
|
||||
AgentRoute: typeof AgentRouteWithChildren
|
||||
ConfigRoute: typeof ConfigRouteWithChildren
|
||||
CredentialsRoute: typeof CredentialsRoute
|
||||
LauncherLoginRoute: typeof LauncherLoginRoute
|
||||
LogsRoute: typeof LogsRoute
|
||||
ModelsRoute: typeof ModelsRoute
|
||||
}
|
||||
@@ -185,6 +198,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof LogsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/launcher-login': {
|
||||
id: '/launcher-login'
|
||||
path: '/launcher-login'
|
||||
fullPath: '/launcher-login'
|
||||
preLoaderRoute: typeof LauncherLoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/credentials': {
|
||||
id: '/credentials'
|
||||
path: '/credentials'
|
||||
@@ -292,6 +312,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
AgentRoute: AgentRouteWithChildren,
|
||||
ConfigRoute: ConfigRouteWithChildren,
|
||||
CredentialsRoute: CredentialsRoute,
|
||||
LauncherLoginRoute: LauncherLoginRoute,
|
||||
LogsRoute: LogsRoute,
|
||||
ModelsRoute: ModelsRoute,
|
||||
}
|
||||
|
||||
@@ -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