feat(launcher): standard HTTP login/setup/logout flow for dashboard, frontend and backend impl. and fix windows pid lock for ws (#2339)

* feat(launcher): replace token-in-logs auth with standard HTTP login flow

## Problem

Previously users had to find the one-time token from console logs or
log files to access the dashboard - a non-standard, error-prone workflow
with no clear path for changing credentials.

## Solution: standard HTTP API login with bcrypt-backed password store

### Auth flow (new)
1. First run: browser opens, session guard detects uninitialized state,
   redirects to /launcher-setup
2. User sets a password (min 8 chars) via POST /api/auth/setup {password, confirm},
   bcrypt(cost=12) hash stored in ~/.picoclaw/launcher-auth.db (SQLite)
3. Subsequent logins: POST /api/auth/login {password}, HttpOnly cookie
   picoclaw_launcher_auth (HMAC-SHA256 signed, 7-day expiry)
4. 401 on any API call, frontend redirects to /launcher-login
5. Logout: POST /api/auth/logout, cookie cleared, redirect to login

### Backend changes
- web/backend/api/auth.go: renamed Token to Password; added handleSetup;
  launcherAuthStatusResponse now includes Initialized bool; PasswordStore
  interface wires bcrypt store into handlers
- web/backend/dashboardauth/: new package - Store with New(dir) / Open(path);
  SetPassword (bcrypt cost=12), VerifyPassword, IsInitialized
  - sql.go: all DB-layer constants (DBFilename, sqliteDriver, bcryptCost,
    four SQL query strings) - compile-time constants, zero runtime overhead
- web/backend/middleware/launcher_dashboard_auth.go: /launcher-setup and
  /api/auth/setup added to public paths
- web/backend/main.go:
  - dashboardauth.New(picoHome) replaces manual path construction
  - maskSecret(): suffix only revealed when >=5 chars hidden (length >= 12),
    preventing 8-char minimum passwords from leaking their tail
- web/backend/main_test.go: TestMaskSecret updated with boundary cases

### Forward-compatibility: pkg/credential integration

If the dashboard password is later reused as the enc:// passphrase,
the bcrypt hash in launcher-auth.db becomes an offline oracle.
Recommended mitigation (not yet implemented): derive two independent
subkeys via HKDF before use:

  bcrypt(HKDF(password, info="picoclaw-dashboard-login-v1"))  stored in DB
  HKDF(password, info="picoclaw-credential-enc-v1")           passed to PassphraseProvider

This isolates the two domains: cracking the bcrypt hash yields only the
login subkey, which is computationally independent of the enc:// subkey.

* fix(auth): replace wastedassign ok := false with var ok bool

* refactor(tray): remove copy-token clipboard feature

Dashboard login now uses standard web auth (bcrypt + session cookie).
The system tray 'Copy dashboard token' menu item is no longer needed.

- Delete tray_offers_copy.go and tray_offers_copy_stub.go
- Remove mCopyTok menu item and clipboard handler from systray.go
- Remove launcherDashboardTokenForClipboard var from main.go
- Remove MenuCopyToken/MenuCopyTokenHint keys from i18n.go

* 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

* fix(lint): ts lint fixed 1

* fix(auth): detail auth error handle

* fix(login):  frontend web auth error handle

* fix(frontend): auth error handler 5xx
This commit is contained in:
sky5454
2026-04-08 21:43:51 +08:00
committed by GitHub
parent 3e3b6aed90
commit 06023c79fa
23 changed files with 795 additions and 248 deletions
+171 -28
View File
@@ -1,8 +1,10 @@
package api
import (
"context"
"crypto/subtle"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
@@ -10,34 +12,47 @@ import (
"github.com/sipeed/picoclaw/web/backend/middleware"
)
// LauncherAuthRouteOpts configures dashboard token login handlers.
// PasswordStore is the interface for bcrypt-backed dashboard password persistence.
// Implemented by dashboardauth.Store; a nil value falls back to the legacy
// static-token comparison.
type PasswordStore interface {
IsInitialized(ctx context.Context) (bool, error)
SetPassword(ctx context.Context, plain string) error
VerifyPassword(ctx context.Context, plain string) (bool, error)
}
// LauncherAuthRouteOpts configures dashboard auth handlers.
type LauncherAuthRouteOpts struct {
// DashboardToken is the fallback plaintext token used when PasswordStore is
// nil or not yet initialized (env-var / config-file source, and ?token= auto-login).
DashboardToken string
SessionCookie string
SecureCookie func(*http.Request) bool
// TokenHelp is returned on unauthenticated /api/auth/status responses (no secrets).
TokenHelp LauncherAuthTokenHelp
}
// LauncherAuthTokenHelp tells the login UI where users can find the dashboard token.
type LauncherAuthTokenHelp struct {
EnvVarName string `json:"env_var_name"`
LogFileAbs string `json:"log_file,omitempty"`
ConfigFileAbs string `json:"config_file,omitempty"`
TrayCopyMenu bool `json:"tray_copy_menu"`
ConsoleStdout bool `json:"console_stdout"`
// PasswordStore enables bcrypt-backed password persistence. When non-nil and
// initialized, web-form login verifies against the stored hash instead of
// the plaintext DashboardToken.
PasswordStore PasswordStore
// StoreError holds the error returned when opening the password store. When
// non-nil and PasswordStore is nil, the auth endpoints surface a recovery
// message instead of an opaque 501/503.
StoreError error
}
type launcherAuthLoginBody struct {
Token string `json:"token"`
Password string `json:"password"`
}
type launcherAuthSetupBody struct {
Password string `json:"password"`
Confirm string `json:"confirm"`
}
type launcherAuthStatusResponse struct {
Authenticated bool `json:"authenticated"`
TokenHelp *LauncherAuthTokenHelp `json:"token_help,omitempty"`
Authenticated bool `json:"authenticated"`
Initialized bool `json:"initialized"`
}
// RegisterLauncherAuthRoutes registers /api/auth/login|logout|status.
// RegisterLauncherAuthRoutes registers /api/auth/login|logout|status|setup.
func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts) {
secure := opts.SecureCookie
if secure == nil {
@@ -47,22 +62,44 @@ func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts)
token: opts.DashboardToken,
sessionCookie: opts.SessionCookie,
secureCookie: secure,
tokenHelp: opts.TokenHelp,
store: opts.PasswordStore,
storeErr: opts.StoreError,
loginLimit: newLoginRateLimiter(),
}
mux.HandleFunc("POST /api/auth/login", h.handleLogin)
mux.HandleFunc("POST /api/auth/logout", h.handleLogout)
mux.HandleFunc("GET /api/auth/status", h.handleStatus)
mux.HandleFunc("POST /api/auth/setup", h.handleSetup)
}
type launcherAuthHandlers struct {
token string
sessionCookie string
secureCookie func(*http.Request) bool
tokenHelp LauncherAuthTokenHelp
store PasswordStore
storeErr error // set when the store failed to open; drives recovery messages
loginLimit *loginRateLimiter
}
// isStoreInitialized safely queries the store.
// Returns (false, nil) when no store is configured (storeErr also nil).
// Returns (false, err) on store errors — callers must treat this as a 5xx, not as
// "uninitialized", to keep auth fail-closed.
// Exception: handleLogin swallows storeErr and falls back to token auth so
// that a corrupt DB does not lock out all access.
func (h *launcherAuthHandlers) isStoreInitialized(ctx context.Context) (bool, error) {
if h.store == nil {
if h.storeErr != nil {
return false, fmt.Errorf(
"password store unavailable (%w); "+
"to recover, stop the application, delete the database file and restart ",
h.storeErr)
}
return false, nil
}
return h.store.IsInitialized(ctx)
}
func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var body launcherAuthLoginBody
@@ -77,10 +114,39 @@ func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Reques
_, _ = w.Write([]byte(`{"error":"too many login attempts"}`))
return
}
in := strings.TrimSpace(body.Token)
if len(in) != len(h.token) || subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) != 1 {
in := strings.TrimSpace(body.Password)
var ok bool
initialized, initErr := h.isStoreInitialized(r.Context())
if initErr != nil {
if h.storeErr != nil {
// Store failed to open at startup — token login remains available.
initialized = false
} else {
w.WriteHeader(http.StatusInternalServerError)
writeErrorf(w, "%v", initErr)
return
}
}
if initialized {
// Bcrypt path: verify against the stored hash.
var err error
ok, err = h.store.VerifyPassword(r.Context(), in)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
writeErrorf(w, "password verification failed: %v", err)
return
}
} else {
// Fallback: constant-time compare against the plaintext token.
ok = len(in) == len(h.token) &&
subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) == 1
}
if !ok {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"invalid token"}`))
_, _ = w.Write([]byte(`{"error":"invalid password"}`))
return
}
@@ -121,23 +187,100 @@ func (h *launcherAuthHandlers) handleLogout(w http.ResponseWriter, r *http.Reque
func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
ok := false
authed := false
if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil {
ok = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1
authed = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1
}
if ok {
_, _ = w.Write([]byte(`{"authenticated":true}`))
initialized, initErr := h.isStoreInitialized(r.Context())
if initErr != nil {
w.WriteHeader(http.StatusServiceUnavailable)
writeErrorf(w, "%v", initErr)
return
}
resp := launcherAuthStatusResponse{
Authenticated: false,
TokenHelp: &h.tokenHelp,
Authenticated: authed,
Initialized: initialized,
}
enc, err := json.Marshal(resp)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"internal error"}`))
writeErrorf(w, "marshal response failed: %v", err)
return
}
_, _ = w.Write(enc)
}
// handleSetup sets or changes the dashboard password.
//
// Rules:
// - If the store has no password yet, the endpoint is open (no session required).
// - If a password is already set, the caller must hold a valid session cookie.
func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if h.store == nil {
w.WriteHeader(http.StatusNotImplemented)
_, _ = w.Write([]byte(`{"error":"password store not configured"}`))
return
}
initialized, initErr := h.isStoreInitialized(r.Context())
if initErr != nil {
w.WriteHeader(http.StatusServiceUnavailable)
writeErrorf(w, "%v", initErr)
return
}
// If already initialized, require an active session (change-password flow).
if initialized {
authed := false
if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil {
authed = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1
}
if !authed {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"must be authenticated to change password"}`))
return
}
}
var body launcherAuthSetupBody
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid JSON"}`))
return
}
pw := strings.TrimSpace(body.Password)
if pw == "" {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"password must not be empty"}`))
return
}
if pw != strings.TrimSpace(body.Confirm) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"passwords do not match"}`))
return
}
if len([]rune(pw)) < 8 {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"password must be at least 8 characters"}`))
return
}
if err := h.store.SetPassword(r.Context(), pw); err != nil {
w.WriteHeader(http.StatusInternalServerError)
writeErrorf(w, "failed to save password: %v", err)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}
// writeErrorf writes a JSON error response with a formatted message.
// json.Marshal is used to safely escape the message string.
func writeErrorf(w http.ResponseWriter, format string, args ...any) {
msg, _ := json.Marshal(fmt.Sprintf(format, args...))
_, _ = w.Write([]byte(`{"error":` + string(msg) + `}`))
}
+6 -19
View File
@@ -23,12 +23,6 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: tok,
SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{
EnvVarName: "PICOCLAW_LAUNCHER_TOKEN",
LogFileAbs: "/tmp/launcher.log",
TrayCopyMenu: true,
ConsoleStdout: false,
},
})
t.Run("status_unauthenticated", func(t *testing.T) {
@@ -38,23 +32,20 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
t.Fatalf("status code = %d", rec.Code)
}
var body struct {
Authenticated bool `json:"authenticated"`
TokenHelp *LauncherAuthTokenHelp `json:"token_help"`
Authenticated bool `json:"authenticated"`
Initialized bool `json:"initialized"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Authenticated || body.TokenHelp == nil {
t.Fatalf("unexpected body: %+v", body)
}
if body.TokenHelp.EnvVarName != "PICOCLAW_LAUNCHER_TOKEN" || body.TokenHelp.LogFileAbs != "/tmp/launcher.log" {
t.Fatalf("token_help = %+v", body.TokenHelp)
if body.Authenticated {
t.Fatalf("unexpected authenticated=true: %+v", body)
}
})
t.Run("login_ok", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"token":"`+tok+`"}`))
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+tok+`"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "127.0.0.1:12345"
mux.ServeHTTP(rec, req)
@@ -91,7 +82,6 @@ func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{EnvVarName: "PICOCLAW_LAUNCHER_TOKEN"},
})
rec := httptest.NewRecorder()
@@ -125,11 +115,10 @@ func TestLauncherAuthLoginRateLimit(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: tok,
SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
})
// 11 failing logins by wrong token; each consumes allow() slot after valid JSON.
wrongBody := `{"token":"wrong"}`
wrongBody := `{"password":"wrong"}`
for i := 0; i < loginAttemptsPerIP; i++ {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(wrongBody))
@@ -187,7 +176,6 @@ func TestLauncherAuthLogoutEmptyBody(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
@@ -206,7 +194,6 @@ func TestLauncherAuthLogoutRejectsTrailingJSON(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`))
+24
View File
@@ -0,0 +1,24 @@
package dashboardauth
const (
// DBFilename is the SQLite database file stored under the PicoClaw home directory.
DBFilename = "launcher-auth.db"
sqliteDriver = "sqlite"
// bcryptCost is deliberately high enough to slow brute-force attempts.
bcryptCost = 12
sqlCreateTable = `
CREATE TABLE IF NOT EXISTS dashboard_credentials (
id INTEGER PRIMARY KEY CHECK (id = 1),
bcrypt_hash TEXT NOT NULL
)`
sqlCountCredentials = `SELECT COUNT(*) FROM dashboard_credentials WHERE id = 1`
sqlUpsertHash = `
INSERT INTO dashboard_credentials (id, bcrypt_hash) VALUES (1, ?)
ON CONFLICT(id) DO UPDATE SET bcrypt_hash = excluded.bcrypt_hash`
sqlSelectHash = `SELECT bcrypt_hash FROM dashboard_credentials WHERE id = 1`
)
+94
View File
@@ -0,0 +1,94 @@
// Package dashboardauth provides a bcrypt-backed SQLite store for the
// launcher dashboard password. The database contains a single row (id=1)
// with the bcrypt hash; no plaintext is ever persisted.
package dashboardauth
import (
"context"
"database/sql"
"errors"
"fmt"
"path/filepath"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite" // register "sqlite" driver
)
// Store holds a handle to the SQLite database that stores the bcrypt hash.
type Store struct {
db *sql.DB
path string // absolute path to the SQLite file
}
// New opens (or creates) the database inside dir, using the package's
// canonical filename. This is the preferred constructor for most callers.
// Any error is wrapped with the resolved path so callers get actionable output.
func New(dir string) (*Store, error) {
path := filepath.Join(dir, DBFilename)
s, err := Open(path)
if err != nil {
return nil, fmt.Errorf("open %q: %w", path, err)
}
return s, nil
}
// Open opens (or creates) the SQLite database at path and migrates the schema.
func Open(path string) (*Store, error) {
db, err := sql.Open(sqliteDriver, path)
if err != nil {
return nil, err
}
if _, err = db.Exec(sqlCreateTable); err != nil {
_ = db.Close()
return nil, err
}
return &Store{db: db, path: path}, nil
}
// Close releases the database handle.
func (s *Store) Close() error { return s.db.Close() }
// DBPath returns the absolute path to the SQLite database file.
func (s *Store) DBPath() string { return s.path }
// IsInitialized reports whether a password hash has been stored.
func (s *Store) IsInitialized(ctx context.Context) (bool, error) {
var n int
err := s.db.QueryRowContext(ctx, sqlCountCredentials).Scan(&n)
if err != nil {
return false, err
}
return n > 0, nil
}
// SetPassword hashes plain with bcrypt (cost 12) and stores (or replaces) it.
// The plaintext is never written to disk.
func (s *Store) SetPassword(ctx context.Context, plain string) error {
if len([]rune(plain)) == 0 {
return errors.New("password must not be empty")
}
hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx, sqlUpsertHash, string(hash))
return err
}
// VerifyPassword returns true iff plain matches the stored bcrypt hash.
// Returns (false, nil) when no password has been set yet.
func (s *Store) VerifyPassword(ctx context.Context, plain string) (bool, error) {
var hash string
err := s.db.QueryRowContext(ctx, sqlSelectHash).Scan(&hash)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain))
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return false, nil
}
return err == nil, err
}
-6
View File
@@ -24,8 +24,6 @@ const (
AppTooltip TranslationKey = "AppTooltip"
MenuOpen TranslationKey = "MenuOpen"
MenuOpenTooltip TranslationKey = "MenuOpenTooltip"
MenuCopyToken TranslationKey = "MenuCopyToken"
MenuCopyTokenHint TranslationKey = "MenuCopyTokenHint"
MenuAbout TranslationKey = "MenuAbout"
MenuAboutTooltip TranslationKey = "MenuAboutTooltip"
MenuVersion TranslationKey = "MenuVersion"
@@ -49,8 +47,6 @@ var translations = map[Language]map[TranslationKey]string{
AppTooltip: "%s - Web Console",
MenuOpen: "Open Console",
MenuOpenTooltip: "Open PicoClaw console in browser",
MenuCopyToken: "Copy dashboard token",
MenuCopyTokenHint: "Copy the current web console access token to the clipboard",
MenuAbout: "About",
MenuAboutTooltip: "About PicoClaw",
MenuVersion: "Version: %s",
@@ -68,8 +64,6 @@ var translations = map[Language]map[TranslationKey]string{
AppTooltip: "%s - Web Console",
MenuOpen: "打开控制台",
MenuOpenTooltip: "在浏览器中打开 PicoClaw 控制台",
MenuCopyToken: "复制控制台口令",
MenuCopyTokenHint: "将当前 Web 控制台访问口令复制到剪贴板",
MenuAbout: "关于",
MenuAboutTooltip: "关于 PicoClaw",
MenuVersion: "版本: %s",
+36 -20
View File
@@ -27,6 +27,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/web/backend/api"
"github.com/sipeed/picoclaw/web/backend/dashboardauth"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
"github.com/sipeed/picoclaw/web/backend/middleware"
"github.com/sipeed/picoclaw/web/backend/utils"
@@ -49,8 +50,6 @@ var (
// Includes ?token= for same-machine dashboard login; keep serverAddr without secrets for other use.
browserLaunchURL string
apiHandler *api.Handler
// launcherDashboardTokenForClipboard is read by the system tray "copy token" action (GUI mode).
launcherDashboardTokenForClipboard string
noBrowser *bool
)
@@ -66,6 +65,24 @@ func dashboardTokenConfigHelpPath(source launcherconfig.DashboardTokenSource, la
return launcherPath
}
// maskSecret masks a secret for display. It always shows up to the first 3
// runes. The last 4 runes are only appended when at least 5 runes remain
// hidden in the middle (i.e. string length >= 12), so an 8-char minimum
// password never exposes its tail. Strings of 3 chars or fewer are fully
// masked.
func maskSecret(s string) string {
runes := []rune(s)
n := len(runes)
const prefixLen, suffixLen, minHidden = 3, 4, 5
if n < prefixLen+suffixLen+minHidden {
if n <= prefixLen {
return "**********"
}
return string(runes[:prefixLen]) + "**********"
}
return string(runes[:prefixLen]) + "**********" + string(runes[n-suffixLen:])
}
func main() {
port := flag.String("port", "18800", "Port to listen on")
public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
@@ -209,7 +226,15 @@ func main() {
logger.Fatalf("Dashboard auth setup failed: %v", dashErr)
}
dashboardSessionCookie := middleware.SessionCookieValue(dashboardSigningKey, dashboardToken)
launcherDashboardTokenForClipboard = dashboardToken
// Open the bcrypt password store (creates the DB file on first run).
authStore, authStoreErr := dashboardauth.New(picoHome)
if authStoreErr != nil {
logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr))
authStore = nil
} else {
defer authStore.Close()
}
// Determine listen address
var addr string
@@ -222,20 +247,11 @@ func main() {
// Initialize Server components
mux := http.NewServeMux()
tokenLogFileAbs := ""
if fileLoggingEnabled {
tokenLogFileAbs = filepath.Join(picoHome, logPath, logFile)
}
api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{
DashboardToken: dashboardToken,
SessionCookie: dashboardSessionCookie,
TokenHelp: api.LauncherAuthTokenHelp{
EnvVarName: "PICOCLAW_LAUNCHER_TOKEN",
LogFileAbs: tokenLogFileAbs,
ConfigFileAbs: dashboardTokenConfigHelpPath(dashboardTokenSource, launcherPath),
TrayCopyMenu: trayOffersDashboardTokenCopy(),
ConsoleStdout: enableConsole,
},
PasswordStore: authStore,
StoreError: authStoreErr,
})
// API Routes (e.g. /api/status)
@@ -284,23 +300,23 @@ func main() {
fmt.Println()
switch dashboardTokenSource {
case launcherconfig.DashboardTokenSourceRandom:
fmt.Printf(" Dashboard token (this run): %s\n", dashboardToken)
fmt.Printf(" Dashboard password (this run): %s\n", maskSecret(dashboardToken))
case launcherconfig.DashboardTokenSourceEnv:
fmt.Printf(" Dashboard token: %s (from PICOCLAW_LAUNCHER_TOKEN)\n", dashboardToken)
fmt.Printf(" Dashboard password: from environment variable PICOCLAW_LAUNCHER_TOKEN\n")
case launcherconfig.DashboardTokenSourceConfig:
fmt.Printf(" Dashboard token: %s (from %s)\n", dashboardToken, launcherPath)
fmt.Printf(" Dashboard password: configured in %s\n", launcherPath)
}
fmt.Println()
}
switch dashboardTokenSource {
case launcherconfig.DashboardTokenSourceEnv:
logger.InfoC("web", "Dashboard token: environment PICOCLAW_LAUNCHER_TOKEN")
logger.InfoC("web", "Dashboard password: environment PICOCLAW_LAUNCHER_TOKEN")
case launcherconfig.DashboardTokenSourceConfig:
logger.InfoC("web", fmt.Sprintf("Dashboard token: configured in %s", launcherPath))
logger.InfoC("web", fmt.Sprintf("Dashboard password: configured in %s", launcherPath))
case launcherconfig.DashboardTokenSourceRandom:
if !enableConsole {
logger.InfoC("web", "Dashboard token (this run): "+dashboardToken)
logger.InfoC("web", "Dashboard password (this run): "+maskSecret(dashboardToken))
}
}
+28
View File
@@ -67,3 +67,31 @@ func TestDashboardTokenConfigHelpPath(t *testing.T) {
})
}
}
func TestMaskSecret(t *testing.T) {
tests := []struct {
input string
want string
}{
// Long token (>=12 chars): first 3 + 10 stars + last 4
{"sdhjflsjdflksdf", "sdh**********ksdf"},
{"abcdefghijklmnopqrstuvwxyz", "abc**********wxyz"},
// Exactly 12 chars (3+4+5 hidden): suffix shown
{"abcdefghijkl", "abc**********ijkl"},
// 8 chars (minimum password length): suffix NOT shown — only prefix+stars
{"abcdefgh", "abc**********"},
// 11 chars (one below threshold): suffix NOT shown
{"abcdefghijk", "abc**********"},
// 4..3 chars: prefix shown, no suffix
{"abcdefg", "abc**********"},
{"abcd", "abc**********"},
// <=3 chars: fully masked
{"abc", "**********"},
{"", "**********"},
}
for _, tt := range tests {
if got := maskSecret(tt.input); got != tt.want {
t.Errorf("maskSecret(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
@@ -173,6 +173,8 @@ func isPublicLauncherDashboardPath(method, p string) bool {
return method == http.MethodPost
case "/api/auth/status":
return method == http.MethodGet
case "/api/auth/setup":
return method == http.MethodPost
}
return false
}
@@ -183,7 +185,7 @@ func isPublicLauncherDashboardStatic(method, p string) bool {
if method != http.MethodGet && method != http.MethodHead {
return false
}
if p == "/launcher-login" {
if p == "/launcher-login" || p == "/launcher-setup" {
return true
}
if strings.HasPrefix(p, "/assets/") {
-13
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"fyne.io/systray"
"github.com/atotto/clipboard"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/web/backend/utils"
@@ -24,7 +23,6 @@ func onReady() {
// Create menu items
mOpen := systray.AddMenuItem(T(MenuOpen), T(MenuOpenTooltip))
mCopyTok := systray.AddMenuItem(T(MenuCopyToken), T(MenuCopyTokenHint))
mAbout := systray.AddMenuItem(T(MenuAbout), T(MenuAboutTooltip))
// Add version info under About menu
@@ -52,17 +50,6 @@ func onReady() {
logger.Errorf("Failed to open browser: %v", err)
}
case <-mCopyTok.ClickedCh:
if launcherDashboardTokenForClipboard == "" {
logger.WarnC("web", "Dashboard token is empty; cannot copy")
continue
}
if err := clipboard.WriteAll(launcherDashboardTokenForClipboard); err != nil {
logger.Errorf("Failed to copy dashboard token: %v", err)
} else {
logger.InfoC("web", "Dashboard token copied to clipboard")
}
case <-mVersion.ClickedCh:
// Version info - do nothing, just shows current version
-5
View File
@@ -1,5 +0,0 @@
//go:build (!darwin && !freebsd) || cgo
package main
func trayOffersDashboardTokenCopy() bool { return true }
-5
View File
@@ -1,5 +0,0 @@
//go:build (darwin || freebsd) && !cgo
package main
func trayOffersDashboardTokenCopy() bool { return false }
+6 -6
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")
}
+31 -13
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 }
}
+84 -30
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()
@@ -47,10 +49,12 @@ export function AppHeader() {
state: gwState,
loading: gwLoading,
canStart,
startReason,
restartRequired,
start,
restart,
stop,
error: gwError,
} = useGateway()
const isRunning = gwState === "running"
@@ -65,6 +69,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 +144,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}>
@@ -171,38 +198,50 @@ export function AppHeader() {
<IconPower className="h-4 w-4 opacity-80" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("header.gateway.action.stop")}</TooltipContent>
<TooltipContent>{gwError ?? t("header.gateway.action.stop")}</TooltipContent>
</Tooltip>
) : (
<Button
variant={
isStarting || isRestarting || isStopping ? "secondary" : "default"
}
size="sm"
data-tour="gateway-button"
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
}
>
{gwLoading || isStarting || isRestarting || isStopping ? (
<IconLoader2 className="h-4 w-4 animate-spin opacity-70" />
) : (
<IconPlayerPlay className="h-4 w-4 opacity-80" />
)}
<span className="text-xs font-semibold">
{isStopping
? t("header.gateway.status.stopping")
: isRestarting
? t("header.gateway.status.restarting")
: isStarting
? t("header.gateway.status.starting")
: t("header.gateway.action.start")}
</span>
</Button>
<Tooltip delayDuration={(gwError || (!canStart && startReason)) ? 0 : 700}>
<TooltipTrigger asChild>
{/* Wrap in span so the tooltip still fires when the button is disabled */}
<span
className={!canStart && startReason ? "cursor-not-allowed" : undefined}
tabIndex={!canStart && startReason ? 0 : undefined}
>
<Button
variant={
isStarting || isRestarting || isStopping ? "secondary" : "default"
}
size="sm"
data-tour="gateway-button"
className={`h-8 gap-2 px-3 ${isStopped ? "bg-green-500 text-white hover:bg-green-600" : ""
} ${!canStart ? "pointer-events-none" : ""}`}
onClick={handleGatewayToggle}
disabled={
gwLoading || isStarting || isRestarting || isStopping || !canStart
}
>
{gwLoading || isStarting || isRestarting || isStopping ? (
<IconLoader2 className="h-4 w-4 animate-spin opacity-70" />
) : (
<IconPlayerPlay className="h-4 w-4 opacity-80" />
)}
<span className="text-xs font-semibold">
{isStopping
? t("header.gateway.status.stopping")
: isRestarting
? t("header.gateway.status.restarting")
: isStarting
? t("header.gateway.status.starting")
: t("header.gateway.action.start")}
</span>
</Button>
</span>
</TooltipTrigger>
{(gwError || (!canStart && startReason)) ? (
<TooltipContent>{gwError ?? startReason}</TooltipContent>
) : null}
</Tooltip>
)}
<Separator
@@ -241,6 +280,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"
+9 -2
View File
@@ -13,8 +13,9 @@ import {
export function useGateway() {
const gateway = useAtomValue(gatewayAtom)
const { status: state, canStart, restartRequired } = gateway
const { status: state, canStart, startReason, restartRequired } = gateway
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
return subscribeGatewayPolling()
@@ -23,6 +24,7 @@ export function useGateway() {
const start = useCallback(async () => {
if (!canStart) return
setError(null)
setLoading(true)
try {
await startGateway()
@@ -32,6 +34,7 @@ export function useGateway() {
})
} catch (err) {
console.error("Failed to start gateway:", err)
setError(err instanceof Error ? err.message : String(err))
} finally {
await refreshGatewayState({ force: true })
setLoading(false)
@@ -39,12 +42,14 @@ export function useGateway() {
}, [canStart])
const stop = useCallback(async () => {
setError(null)
setLoading(true)
beginGatewayStoppingTransition()
try {
await stopGateway()
} catch (err) {
console.error("Failed to stop gateway:", err)
setError(err instanceof Error ? err.message : String(err))
cancelGatewayStoppingTransition()
} finally {
await refreshGatewayState({ force: true })
@@ -55,6 +60,7 @@ export function useGateway() {
const restart = useCallback(async () => {
if (state !== "running") return
setError(null)
setLoading(true)
try {
await restartGateway()
@@ -64,11 +70,12 @@ export function useGateway() {
})
} catch (err) {
console.error("Failed to restart gateway:", err)
setError(err instanceof Error ? err.message : String(err))
} finally {
await refreshGatewayState({ force: true })
setLoading(false)
}
}, [state])
return { state, loading, canStart, restartRequired, start, stop, restart }
return { state, loading, canStart, startReason, restartRequired, start, stop, restart, error }
}
+24 -14
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?",
@@ -645,4 +655,4 @@
"description": "Need more help? Click the documentation button in the top right corner to view detailed guides and configuration docs."
}
}
}
}
+24 -14
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": "停止服务?",
@@ -645,4 +655,4 @@
"description": "需要更多帮助?点击右上角的文档按钮,查看详细的使用文档和配置指南。"
}
}
}
}
@@ -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)
}
+21
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
@@ -144,6 +153,7 @@ export interface FileRouteTypes {
| '/config'
| '/credentials'
| '/launcher-login'
| '/launcher-setup'
| '/logs'
| '/models'
| '/agent/hub'
@@ -159,6 +169,7 @@ export interface FileRouteTypes {
| '/config'
| '/credentials'
| '/launcher-login'
| '/launcher-setup'
| '/logs'
| '/models'
| '/agent/hub'
@@ -174,6 +185,7 @@ export interface FileRouteTypes {
| '/config'
| '/credentials'
| '/launcher-login'
| '/launcher-setup'
| '/logs'
| '/models'
| '/agent/hub'
@@ -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,
}
+61 -15
View File
@@ -1,15 +1,16 @@
import { Outlet, createRootRoute, useRouterState } from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
import { useEffect } from "react"
import { useEffect, useState } 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,50 @@ 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",
)
const [authError, setAuthError] = useState<string | null>(null)
// 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((err: unknown) => {
// On 401/403, redirect to login — the session is invalid.
// On 5xx (e.g. 503 when the auth store is unavailable) or network errors,
// do NOT redirect: a subsequent successful login would loop straight back here.
// launcherFetch handles 401 on real API calls regardless.
if (err instanceof Error && /^status 40[13]$/.test(err.message)) {
globalThis.location.assign("/launcher-login")
} else {
setAuthError(
err instanceof Error ? err.message : "Auth service unavailable, please try to delete the launcher-auth.db at picoclaw home directory and restart the application.",
)
}
})
}, [isAuthPage])
useEffect(() => {
if (isLauncherLogin) {
if (isAuthPage) {
return
}
initializeChatStore()
}, [isLauncherLogin])
}, [isAuthPage])
if (isLauncherLogin) {
if (isAuthPage) {
return (
<>
<Outlet />
@@ -44,10 +76,24 @@ const RootLayout = () => {
}
return (
<AppLayout>
<Outlet />
{import.meta.env.DEV ? <TanStackRouterDevtools /> : null}
</AppLayout>
<>
{authError && (
<div className="bg-destructive text-destructive-foreground fixed inset-x-0 top-0 z-[100] flex items-center justify-between px-4 py-2 text-sm shadow-md">
<span>Auth service error: {authError}</span>
<button
className="ml-4 opacity-70 hover:opacity-100"
onClick={() => setAuthError(null)}
aria-label="Dismiss"
>
</button>
</div>
)}
<AppLayout>
<Outlet />
{import.meta.env.DEV ? <TanStackRouterDevtools /> : null}
</AppLayout>
</>
)
}
+8 -56
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>
+146
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,
})
+10 -1
View File
@@ -14,6 +14,7 @@ export type GatewayState =
export interface GatewayStoreState {
status: GatewayState
canStart: boolean
startReason?: string
restartRequired: boolean
}
@@ -57,6 +58,7 @@ function normalizeGatewayStoreState(
if (
next.status === prev.status &&
next.canStart === prev.canStart &&
next.startReason === prev.startReason &&
next.restartRequired === prev.restartRequired
) {
return prev
@@ -108,7 +110,10 @@ export function applyGatewayStatusToStore(
data: Partial<
Pick<
GatewayStatusResponse,
"gateway_status" | "gateway_start_allowed" | "gateway_restart_required"
| "gateway_status"
| "gateway_start_allowed"
| "gateway_start_reason"
| "gateway_restart_required"
>
>,
) {
@@ -121,6 +126,10 @@ export function applyGatewayStatusToStore(
prev.status === "stopping" && data.gateway_status === "running"
? false
: (data.gateway_start_allowed ?? prev.canStart),
startReason:
prev.status === "stopping" && data.gateway_status === "running"
? prev.startReason
: (data.gateway_start_reason ?? prev.startReason),
restartRequired:
prev.status === "stopping" && data.gateway_status === "running"
? false