feat(web): add authentication for web

This commit is contained in:
SiYue 2026-03-24 19:30:25 +08:00
parent f2f6987f00
commit 53236ec118
26 changed files with 1423 additions and 2 deletions

260
web/backend/api/auth.go Normal file
View file

@ -0,0 +1,260 @@
package api
import (
"encoding/json"
"net/http"
"time"
"github.com/sipeed/picoclaw/web/backend/auth"
)
type AuthHandler struct {
configStore *auth.AuthConfigStore
sessionStore auth.SessionStore
}
func NewAuthHandler(configStore *auth.AuthConfigStore, sessionStore auth.SessionStore) *AuthHandler {
return &AuthHandler{
configStore: configStore,
sessionStore: sessionStore,
}
}
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type LoginResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
}
type StatusResponse struct {
Enabled bool `json:"enabled"`
Configured bool `json:"configured"`
LoggedIn bool `json:"logged_in"`
}
type SetupRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type SetupResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
}
type ChangePasswordRequest struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
func (h *AuthHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/auth/login", h.Login)
mux.HandleFunc("POST /api/auth/logout", h.Logout)
mux.HandleFunc("GET /api/auth/status", h.Status)
mux.HandleFunc("POST /api/auth/setup", h.Setup)
mux.HandleFunc("POST /api/auth/change-password", h.ChangePassword)
mux.HandleFunc("GET /api/auth/check", h.Check)
}
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, "invalid request body", http.StatusBadRequest)
return
}
config := h.configStore.Get()
if !config.IsConfigured() {
writeJSONError(w, "authentication not configured", http.StatusServiceUnavailable)
return
}
if req.Username != config.Username || !auth.VerifyPassword(req.Password, config.PasswordHash) {
writeJSON(w, LoginResponse{Success: false, Message: "invalid credentials"}, http.StatusUnauthorized)
return
}
sessionID, err := auth.GenerateSessionID()
if err != nil {
writeJSONError(w, "failed to create session", http.StatusInternalServerError)
return
}
_, err = h.sessionStore.Create(sessionID, config.SessionTTL)
if err != nil {
writeJSONError(w, "failed to create session", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookieName,
Value: sessionID,
Path: "/",
HttpOnly: true,
Secure: false,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(config.SessionTTL),
})
writeJSON(w, LoginResponse{Success: true}, http.StatusOK)
}
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
sessionID := extractSessionID(r)
if sessionID != "" {
h.sessionStore.Delete(sessionID)
}
http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
})
writeJSON(w, LoginResponse{Success: true}, http.StatusOK)
}
func (h *AuthHandler) Status(w http.ResponseWriter, r *http.Request) {
config := h.configStore.Get()
loggedIn := false
if config.IsConfigured() {
sessionID := extractSessionID(r)
if sessionID != "" {
loggedIn = h.sessionStore.Validate(sessionID)
}
}
writeJSON(w, StatusResponse{
Enabled: config.Enabled,
Configured: config.IsConfigured(),
LoggedIn: loggedIn,
}, http.StatusOK)
}
func (h *AuthHandler) Setup(w http.ResponseWriter, r *http.Request) {
config := h.configStore.Get()
if config.IsConfigured() {
writeJSONError(w, "authentication already configured", http.StatusBadRequest)
return
}
var req SetupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Username == "" || req.Password == "" {
writeJSONError(w, "username and password are required", http.StatusBadRequest)
return
}
if len(req.Password) < 6 {
writeJSONError(w, "password must be at least 6 characters", http.StatusBadRequest)
return
}
if err := h.configStore.SetCredentials(req.Username, req.Password); err != nil {
writeJSONError(w, "failed to save credentials", http.StatusInternalServerError)
return
}
sessionID, err := auth.GenerateSessionID()
if err != nil {
writeJSONError(w, "failed to create session", http.StatusInternalServerError)
return
}
newConfig := h.configStore.Get()
_, err = h.sessionStore.Create(sessionID, newConfig.SessionTTL)
if err != nil {
writeJSONError(w, "failed to create session", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookieName,
Value: sessionID,
Path: "/",
HttpOnly: true,
Secure: false,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(newConfig.SessionTTL),
})
writeJSON(w, SetupResponse{Success: true}, http.StatusOK)
}
func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
config := h.configStore.Get()
if !config.IsConfigured() {
writeJSONError(w, "authentication not configured", http.StatusServiceUnavailable)
return
}
sessionID := extractSessionID(r)
if sessionID == "" || !h.sessionStore.Validate(sessionID) {
writeJSONError(w, "unauthorized", http.StatusUnauthorized)
return
}
var req ChangePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, "invalid request body", http.StatusBadRequest)
return
}
if !auth.VerifyPassword(req.CurrentPassword, config.PasswordHash) {
writeJSONError(w, "current password is incorrect", http.StatusBadRequest)
return
}
if len(req.NewPassword) < 6 {
writeJSONError(w, "new password must be at least 6 characters", http.StatusBadRequest)
return
}
if err := h.configStore.SetCredentials(config.Username, req.NewPassword); err != nil {
writeJSONError(w, "failed to update password", http.StatusInternalServerError)
return
}
writeJSON(w, map[string]bool{"success": true}, http.StatusOK)
}
func (h *AuthHandler) Check(w http.ResponseWriter, r *http.Request) {
config := h.configStore.Get()
if !config.IsConfigured() {
writeJSON(w, map[string]bool{"authenticated": true}, http.StatusOK)
return
}
sessionID := extractSessionID(r)
authenticated := sessionID != "" && h.sessionStore.Validate(sessionID)
writeJSON(w, map[string]bool{"authenticated": authenticated}, http.StatusOK)
}
func extractSessionID(r *http.Request) string {
if cookie, err := r.Cookie(auth.SessionCookieName); err == nil && cookie.Value != "" {
return cookie.Value
}
return ""
}
func writeJSON(w http.ResponseWriter, data interface{}, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func writeJSONError(w http.ResponseWriter, message string, status int) {
writeJSON(w, map[string]string{"error": message}, status)
}

View file

@ -12,6 +12,7 @@ type launcherConfigPayload struct {
Port int `json:"port"`
Public bool `json:"public"`
AllowedCIDRs []string `json:"allowed_cidrs"`
AuthEnabled bool `json:"auth_enabled"`
}
func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) {
@ -32,6 +33,7 @@ func (h *Handler) launcherFallbackConfig() launcherconfig.Config {
Port: port,
Public: h.serverPublic,
AllowedCIDRs: append([]string(nil), h.serverCIDRs...),
AuthEnabled: false,
}
}
@ -51,6 +53,7 @@ func (h *Handler) handleGetLauncherConfig(w http.ResponseWriter, r *http.Request
Port: cfg.Port,
Public: cfg.Public,
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
AuthEnabled: cfg.AuthEnabled,
})
}
@ -65,6 +68,7 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ
Port: payload.Port,
Public: payload.Public,
AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...),
AuthEnabled: payload.AuthEnabled,
}
if err := launcherconfig.Validate(cfg); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
@ -81,5 +85,6 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ
Port: cfg.Port,
Public: cfg.Public,
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
AuthEnabled: cfg.AuthEnabled,
})
}

54
web/backend/auth/auth.go Normal file
View file

@ -0,0 +1,54 @@
package auth
import (
"crypto/rand"
"encoding/hex"
"time"
)
type Session struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
type Credentials struct {
Username string `json:"username"`
Password string `json:"password"`
}
type Config struct {
Enabled bool `json:"enabled"`
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
SessionTTL time.Duration `json:"session_ttl"`
}
func DefaultConfig() Config {
return Config{
Enabled: false,
Username: "",
PasswordHash: "",
SessionTTL: 24 * time.Hour,
}
}
func (c *Config) IsConfigured() bool {
return c.Enabled && c.Username != "" && c.PasswordHash != ""
}
func GenerateSessionID() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
func (s *Session) IsExpired() bool {
return time.Now().After(s.ExpiresAt)
}
func (s *Session) Refresh(ttl time.Duration) {
s.ExpiresAt = time.Now().Add(ttl)
}

View file

@ -0,0 +1,103 @@
package auth
import (
"net/http"
"strings"
)
const (
SessionCookieName = "picoclaw_session"
AuthorizationHeader = "Authorization"
)
type AuthMiddleware struct {
configStore *AuthConfigStore
sessionStore SessionStore
}
func NewAuthMiddleware(configStore *AuthConfigStore, sessionStore SessionStore) *AuthMiddleware {
return &AuthMiddleware{
configStore: configStore,
sessionStore: sessionStore,
}
}
func (m *AuthMiddleware) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !m.configStore.IsEnabled() {
next.ServeHTTP(w, r)
return
}
if IsPublicRoute(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
sessionID := m.extractSessionID(r)
if sessionID == "" {
m.unauthorized(w, r)
return
}
if !m.sessionStore.Validate(sessionID) {
m.unauthorized(w, r)
return
}
m.sessionStore.Refresh(sessionID, m.configStore.Get().SessionTTL)
next.ServeHTTP(w, r)
})
}
func (m *AuthMiddleware) extractSessionID(r *http.Request) string {
if cookie, err := r.Cookie(SessionCookieName); err == nil && cookie.Value != "" {
return cookie.Value
}
authHeader := r.Header.Get(AuthorizationHeader)
if authHeader != "" {
if strings.HasPrefix(authHeader, "Bearer ") {
return strings.TrimPrefix(authHeader, "Bearer ")
}
return authHeader
}
return ""
}
func (m *AuthMiddleware) unauthorized(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized","code":401}`))
return
}
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
func IsAuthRoute(path string) bool {
return path == "/api/auth/login" ||
path == "/api/auth/status" ||
path == "/api/auth/setup" ||
strings.HasPrefix(path, "/login")
}
func IsPublicRoute(path string) bool {
publicPrefixes := []string{
"/api/auth/",
"/login",
"/assets/",
"/favicon",
"/web-app-manifest",
"/site.webmanifest",
}
for _, prefix := range publicPrefixes {
if strings.HasPrefix(path, prefix) {
return true
}
}
return path == "/" || path == "/index.html"
}

112
web/backend/auth/session.go Normal file
View file

@ -0,0 +1,112 @@
package auth
import (
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
type SessionStore interface {
Create(sessionID string, ttl time.Duration) (*Session, error)
Get(sessionID string) (*Session, bool)
Delete(sessionID string)
Validate(sessionID string) bool
Refresh(sessionID string, ttl time.Duration) bool
CleanupExpired()
}
type MemorySessionStore struct {
mu sync.RWMutex
sessions map[string]*Session
}
func NewMemorySessionStore() *MemorySessionStore {
store := &MemorySessionStore{
sessions: make(map[string]*Session),
}
go store.cleanupRoutine()
return store
}
func (s *MemorySessionStore) Create(sessionID string, ttl time.Duration) (*Session, error) {
s.mu.Lock()
defer s.mu.Unlock()
session := &Session{
ID: sessionID,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(ttl),
}
s.sessions[sessionID] = session
return session, nil
}
func (s *MemorySessionStore) Get(sessionID string) (*Session, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
session, exists := s.sessions[sessionID]
if !exists || session.IsExpired() {
return nil, false
}
return session, true
}
func (s *MemorySessionStore) Delete(sessionID string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, sessionID)
}
func (s *MemorySessionStore) Validate(sessionID string) bool {
_, exists := s.Get(sessionID)
return exists
}
func (s *MemorySessionStore) Refresh(sessionID string, ttl time.Duration) bool {
s.mu.Lock()
defer s.mu.Unlock()
session, exists := s.sessions[sessionID]
if !exists || session.IsExpired() {
return false
}
session.Refresh(ttl)
return true
}
func (s *MemorySessionStore) CleanupExpired() {
s.mu.Lock()
defer s.mu.Unlock()
for id, session := range s.sessions {
if session.IsExpired() {
delete(s.sessions, id)
}
}
}
func (s *MemorySessionStore) cleanupRoutine() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
s.CleanupExpired()
}
}
const bcryptCost = 12
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func VerifyPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}

124
web/backend/auth/store.go Normal file
View file

@ -0,0 +1,124 @@
package auth
import (
"encoding/json"
"os"
"path/filepath"
"sync"
)
const AuthConfigFileName = "auth-config.json"
type AuthConfigStore struct {
mu sync.RWMutex
path string
config Config
}
func NewAuthConfigStore(path string) *AuthConfigStore {
return &AuthConfigStore{
path: path,
config: DefaultConfig(),
}
}
func (s *AuthConfigStore) Load() error {
s.mu.Lock()
defer s.mu.Unlock()
data, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
s.config = DefaultConfig()
return nil
}
return err
}
if err := json.Unmarshal(data, &s.config); err != nil {
return err
}
return nil
}
func (s *AuthConfigStore) Save() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.saveUnlocked()
}
func (s *AuthConfigStore) saveUnlocked() error {
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(s.config, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return os.WriteFile(s.path, data, 0o600)
}
func (s *AuthConfigStore) Get() Config {
s.mu.RLock()
defer s.mu.RUnlock()
return s.config
}
func (s *AuthConfigStore) Set(config Config) error {
s.mu.Lock()
defer s.mu.Unlock()
s.config = config
return s.saveUnlocked()
}
func (s *AuthConfigStore) SetCredentials(username, password string) error {
s.mu.Lock()
defer s.mu.Unlock()
hash, err := HashPassword(password)
if err != nil {
return err
}
s.config.Enabled = true
s.config.Username = username
s.config.PasswordHash = hash
return s.saveUnlocked()
}
func (s *AuthConfigStore) Enable() error {
s.mu.Lock()
defer s.mu.Unlock()
s.config.Enabled = true
return s.saveUnlocked()
}
func (s *AuthConfigStore) Disable() error {
s.mu.Lock()
defer s.mu.Unlock()
s.config.Enabled = false
return s.saveUnlocked()
}
func (s *AuthConfigStore) IsEnabled() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.config.IsConfigured()
}
func PathForAuthConfig(appConfigPath string) string {
dir := filepath.Dir(appConfigPath)
if dir == "" || dir == "." {
dir = "."
}
return filepath.Join(dir, AuthConfigFileName)
}

View file

@ -21,6 +21,7 @@ type Config struct {
Port int `json:"port"`
Public bool `json:"public"`
AllowedCIDRs []string `json:"allowed_cidrs,omitempty"`
AuthEnabled bool `json:"auth_enabled,omitempty"`
}
// Default returns default launcher settings.

View file

@ -26,6 +26,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/auth"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
"github.com/sipeed/picoclaw/web/backend/middleware"
"github.com/sipeed/picoclaw/web/backend/utils"
@ -167,6 +168,24 @@ func main() {
// Initialize Server components
mux := http.NewServeMux()
// Initialize authentication system
authConfigPath := auth.PathForAuthConfig(absPath)
authConfigStore := auth.NewAuthConfigStore(authConfigPath)
if err := authConfigStore.Load(); err != nil {
logger.ErrorC("web", fmt.Sprintf("Warning: Failed to load auth config: %v", err))
}
// Sync auth enabled state from launcher config
if launcherCfg.AuthEnabled && !authConfigStore.Get().Enabled {
cfg := authConfigStore.Get()
cfg.Enabled = true
if err := authConfigStore.Set(cfg); err != nil {
logger.ErrorC("web", fmt.Sprintf("Warning: Failed to enable auth: %v", err))
}
}
sessionStore := auth.NewMemorySessionStore()
authMiddleware := auth.NewAuthMiddleware(authConfigStore, sessionStore)
authHandler := api.NewAuthHandler(authConfigStore, sessionStore)
// API Routes (e.g. /api/status)
apiHandler = api.NewHandler(absPath)
if _, err = apiHandler.EnsurePicoChannel(""); err != nil {
@ -175,6 +194,9 @@ func main() {
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
apiHandler.RegisterRoutes(mux)
// Auth API Routes
authHandler.RegisterRoutes(mux)
// Frontend Embedded Assets
registerEmbedRoutes(mux)
@ -183,10 +205,13 @@ func main() {
logger.Fatalf("Invalid allowed CIDR configuration: %v", err)
}
// Apply authentication middleware (before other middlewares)
authProtectedHandler := authMiddleware.RequireAuth(accessControlledMux)
// Apply middleware stack
handler := middleware.Recoverer(
middleware.Logger(
middleware.JSONContentType(accessControlledMux),
middleware.JSONContentType(authProtectedHandler),
),
)

View file

@ -9,6 +9,7 @@ export interface LauncherConfig {
port: number
public: boolean
allowed_cidrs: string[]
auth_enabled: boolean
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {

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 { useAuth } from "@/features/auth"
export function AppHeader() {
const { i18n, t } = useTranslation()
@ -52,6 +54,7 @@ export function AppHeader() {
restart,
stop,
} = useGateway()
const { status: authStatus, logout } = useAuth()
const isRunning = gwState === "running"
const isStarting = gwState === "starting"
@ -245,6 +248,22 @@ export function AppHeader() {
<IconMoon className="size-4.5" />
)}
</Button>
{/* User Menu (only show when auth is enabled) */}
{authStatus.enabled && authStatus.configured && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<IconLogout className="size-4.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={logout}>
{t("auth.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</header>
)

View file

@ -0,0 +1,2 @@
export { LoginForm } from "./login-form"
export { SetupForm } from "./setup-form"

View file

@ -0,0 +1,108 @@
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { IconEye, IconEyeOff, IconLoader2 } from "@tabler/icons-react"
import { useAuth } from "@/features/auth"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Alert, AlertDescription } from "@/components/ui/alert"
export function LoginForm() {
const { t } = useTranslation()
const { login } = useAuth()
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const isNotSecure = typeof window !== "undefined" && window.location.protocol === "http:" && window.location.hostname !== "localhost" && window.location.hostname !== "127.0.0.1"
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setLoading(true)
try {
const result = await login(username, password)
if (!result.success) {
setError(result.message || t("auth.loginFailed", "Login failed"))
}
} catch {
setError(t("auth.networkError", "Network error"))
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{isNotSecure && (
<Alert variant="destructive">
<AlertDescription>
{t("auth.insecureConnection", "Warning: Connection is not secure. Passwords will be sent in plain text.")}
</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="username">{t("auth.username", "Username")}</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoComplete="username"
autoFocus
placeholder={t("auth.usernamePlaceholder", "Enter your username")}
disabled={loading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">{t("auth.password", "Password")}</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
placeholder={t("auth.passwordPlaceholder", "Enter your password")}
disabled={loading}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
tabIndex={-1}
>
{showPassword ? <IconEyeOff className="h-4 w-4" /> : <IconEye className="h-4 w-4" />}
</button>
</div>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button type="submit" className="w-full" disabled={loading || !username || !password}>
{loading ? (
<>
<IconLoader2 className="mr-2 h-4 w-4 animate-spin" />
{t("auth.loggingIn", "Logging in...")}
</>
) : (
t("auth.login", "Login")
)}
</Button>
</form>
)
}

View file

@ -0,0 +1,222 @@
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { IconEye, IconEyeOff, IconLoader2, IconCheck, IconX } from "@tabler/icons-react"
import { useAuth } from "@/features/auth"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Alert, AlertDescription } from "@/components/ui/alert"
export function SetupForm() {
const { t } = useTranslation()
const { setup } = useAuth()
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const isNotSecure = typeof window !== "undefined" && window.location.protocol === "http:" && window.location.hostname !== "localhost" && window.location.hostname !== "127.0.0.1"
const passwordStrength = getPasswordStrength(password)
const passwordsMatch = password === confirmPassword && confirmPassword !== ""
const isValid = username.length >= 2 && password.length >= 6 && passwordsMatch
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
if (password !== confirmPassword) {
setError(t("auth.passwordMismatch", "Passwords do not match"))
return
}
if (password.length < 6) {
setError(t("auth.passwordTooShort", "Password must be at least 6 characters"))
return
}
if (username.length < 2) {
setError(t("auth.usernameTooShort", "Username must be at least 2 characters"))
return
}
setLoading(true)
try {
const result = await setup(username, password)
if (!result.success) {
setError(result.message || t("auth.setupFailed", "Setup failed"))
}
} catch {
setError(t("auth.networkError", "Network error"))
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{isNotSecure && (
<Alert variant="destructive">
<AlertDescription>
{t("auth.insecureConnection", "Warning: Connection is not secure. Passwords will be sent in plain text.")}
</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="setup-username">{t("auth.username", "Username")}</Label>
<Input
id="setup-username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoComplete="username"
autoFocus
placeholder={t("auth.usernamePlaceholder", "Enter your username")}
disabled={loading}
/>
{username.length > 0 && (
<p className={`text-xs ${username.length >= 2 ? "text-green-600" : "text-muted-foreground"}`}>
{username.length >= 2 ? (
<IconCheck className="inline h-3 w-3 mr-1" />
) : (
<IconX className="inline h-3 w-3 mr-1" />
)}
{t("auth.usernameRequirement", "At least 2 characters")}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="setup-password">{t("auth.password", "Password")}</Label>
<div className="relative">
<Input
id="setup-password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="new-password"
placeholder={t("auth.passwordPlaceholder", "Enter your password")}
disabled={loading}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
tabIndex={-1}
>
{showPassword ? <IconEyeOff className="h-4 w-4" /> : <IconEye className="h-4 w-4" />}
</button>
</div>
{password.length > 0 && (
<div className="space-y-1">
<div className="flex gap-1">
{[1, 2, 3, 4].map((level) => (
<div
key={level}
className={`h-1 flex-1 rounded ${
passwordStrength >= level
? passwordStrength <= 1
? "bg-red-500"
: passwordStrength === 2
? "bg-yellow-500"
: passwordStrength === 3
? "bg-blue-500"
: "bg-green-500"
: "bg-gray-200"
}`}
/>
))}
</div>
<p className="text-xs text-muted-foreground">
{password.length < 6 ? (
<>
<IconX className="inline h-3 w-3 mr-1" />
{t("auth.passwordRequirement", "At least 6 characters")}
</>
) : (
<>
<IconCheck className="inline h-3 w-3 mr-1 text-green-600" />
{t("auth.passwordValid", "Password is valid")}
</>
)}
</p>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">{t("auth.confirmPassword", "Confirm Password")}</Label>
<div className="relative">
<Input
id="confirm-password"
type={showConfirmPassword ? "text" : "password"}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
autoComplete="new-password"
placeholder={t("auth.confirmPasswordPlaceholder", "Confirm your password")}
disabled={loading}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
tabIndex={-1}
>
{showConfirmPassword ? <IconEyeOff className="h-4 w-4" /> : <IconEye className="h-4 w-4" />}
</button>
</div>
{confirmPassword.length > 0 && (
<p className={`text-xs ${passwordsMatch ? "text-green-600" : "text-red-500"}`}>
{passwordsMatch ? (
<IconCheck className="inline h-3 w-3 mr-1" />
) : (
<IconX className="inline h-3 w-3 mr-1" />
)}
{passwordsMatch
? t("auth.passwordsMatch", "Passwords match")
: t("auth.passwordsDoNotMatch", "Passwords do not match")}
</p>
)}
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button type="submit" className="w-full" disabled={loading || !isValid}>
{loading ? (
<>
<IconLoader2 className="mr-2 h-4 w-4 animate-spin" />
{t("auth.settingUp", "Setting up...")}
</>
) : (
t("auth.setup", "Create Account")
)}
</Button>
</form>
)
}
function getPasswordStrength(password: string): number {
if (!password) return 0
let strength = 0
if (password.length >= 6) strength++
if (password.length >= 10) strength++
if (/[A-Z]/.test(password) && /[a-z]/.test(password)) strength++
if (/[0-9]/.test(password) && /[^A-Za-z0-9]/.test(password)) strength++
return strength
}

View file

@ -84,6 +84,7 @@ export function ConfigPage() {
port: String(launcherConfig.port),
publicAccess: launcherConfig.public,
allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"),
authEnabled: launcherConfig.auth_enabled ?? false,
}
setLauncherForm(parsed)
setLauncherBaseline(parsed)
@ -253,6 +254,7 @@ export function ConfigPage() {
port,
public: launcherForm.publicAccess,
allowed_cidrs: allowedCIDRs,
auth_enabled: launcherForm.authEnabled,
})
const parsedLauncher: LauncherForm = {
port: String(savedLauncherConfig.port),
@ -260,6 +262,7 @@ export function ConfigPage() {
allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join(
"\n",
),
authEnabled: savedLauncherConfig.auth_enabled ?? false,
}
setLauncherForm(parsedLauncher)
setLauncherBaseline(parsedLauncher)

View file

@ -423,6 +423,15 @@ export function LauncherSection({
onCheckedChange={(checked) => onFieldChange("publicAccess", checked)}
/>
<SwitchCardField
label={t("pages.config.auth_enabled")}
hint={t("pages.config.auth_enabled_hint")}
layout="setting-row"
checked={launcherForm.authEnabled}
disabled={disabled}
onCheckedChange={(checked) => onFieldChange("authEnabled", checked)}
/>
<Field
label={t("pages.config.server_port")}
hint={t("pages.config.server_port_hint")}

View file

@ -29,6 +29,7 @@ export interface LauncherForm {
port: string
publicAccess: boolean
allowedCIDRsText: string
authEnabled: boolean
}
export const DM_SCOPE_OPTIONS = [
@ -91,6 +92,7 @@ export const EMPTY_LAUNCHER_FORM: LauncherForm = {
port: "18800",
publicAccess: false,
allowedCIDRsText: "",
authEnabled: false,
}
function asRecord(value: unknown): JsonRecord {

View file

@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View file

@ -0,0 +1,101 @@
import { useAtom } from "jotai"
import { useCallback, useEffect } from "react"
import { authStatusAtom, isAuthenticatedAtom, needsLoginAtom, needsSetupAtom } from "./store"
import type { AuthStatus, ChangePasswordRequest, LoginRequest, SetupRequest } from "./types"
const API_BASE = "/api/auth"
async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
})
return response.json()
}
export function useAuth() {
const [status, setStatus] = useAtom(authStatusAtom)
const isAuthenticated = useAtom(isAuthenticatedAtom)[0]
const needsLogin = useAtom(needsLoginAtom)[0]
const needsSetup = useAtom(needsSetupAtom)[0]
const checkStatus = useCallback(async () => {
try {
const result = await fetchJSON<AuthStatus>(`${API_BASE}/status`)
setStatus(result)
return result
} catch {
return status
}
}, [setStatus, status])
const login = useCallback(
async (username: string, password: string) => {
const result = await fetchJSON<{ success: boolean; message?: string }>(`${API_BASE}/login`, {
method: "POST",
body: JSON.stringify({ username, password } as LoginRequest),
})
if (result.success) {
await checkStatus()
}
return result
},
[checkStatus]
)
const logout = useCallback(async () => {
await fetchJSON<{ success: boolean }>(`${API_BASE}/logout`, {
method: "POST",
})
await checkStatus()
}, [checkStatus])
const setup = useCallback(
async (username: string, password: string) => {
const result = await fetchJSON<{ success: boolean; message?: string }>(`${API_BASE}/setup`, {
method: "POST",
body: JSON.stringify({ username, password } as SetupRequest),
})
if (result.success) {
await checkStatus()
}
return result
},
[checkStatus]
)
const changePassword = useCallback(async (currentPassword: string, newPassword: string) => {
const result = await fetchJSON<{ success: boolean; error?: string }>(`${API_BASE}/change-password`, {
method: "POST",
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword,
} as ChangePasswordRequest),
})
return result
}, [])
useEffect(() => {
checkStatus()
}, [checkStatus])
return {
status,
isAuthenticated,
needsLogin,
needsSetup,
checkStatus,
login,
logout,
setup,
changePassword,
}
}

View file

@ -0,0 +1,3 @@
export { authStatusAtom, isAuthenticatedAtom, needsLoginAtom, needsSetupAtom } from "./store"
export type { AuthStatus, LoginRequest, LoginResponse, SetupRequest, SetupResponse, ChangePasswordRequest } from "./types"
export { useAuth } from "./hooks"

View file

@ -0,0 +1,25 @@
import { atom } from "jotai"
import type { AuthStatus } from "./types"
export const authStatusAtom = atom<AuthStatus>({
enabled: false,
configured: false,
logged_in: false,
})
export const isAuthenticatedAtom = atom((get) => {
const status = get(authStatusAtom)
if (!status.enabled) return true
return status.logged_in
})
export const needsSetupAtom = atom((get) => {
const status = get(authStatusAtom)
return status.enabled && !status.configured
})
export const needsLoginAtom = atom((get) => {
const status = get(authStatusAtom)
return status.enabled && status.configured && !status.logged_in
})

View file

@ -0,0 +1,30 @@
export interface AuthStatus {
enabled: boolean
configured: boolean
logged_in: boolean
}
export interface LoginRequest {
username: string
password: string
}
export interface LoginResponse {
success: boolean
message?: string
}
export interface SetupRequest {
username: string
password: string
}
export interface SetupResponse {
success: boolean
message?: string
}
export interface ChangePasswordRequest {
current_password: string
new_password: string
}

View file

@ -472,6 +472,8 @@
"allowed_cidrs": "Allowed Network CIDRs",
"allowed_cidrs_hint": "Only clients from these CIDR ranges can access the service. One per line or comma-separated. Leave empty to allow all.",
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
"auth_enabled": "Enable Authentication",
"auth_enabled_hint": "Require login to access the web console. After enabling, you will need to create an admin account on first access.",
"sections": {
"agent": "Agent",
"runtime": "Runtime",
@ -499,5 +501,37 @@
"clear": "Clear logs",
"empty": "Waiting for logs..."
}
},
"auth": {
"username": "Username",
"password": "Password",
"confirmPassword": "Confirm Password",
"login": "Login",
"logout": "Logout",
"loggingIn": "Logging in...",
"loginFailed": "Invalid username or password",
"loginDescription": "Enter your credentials to access the console",
"setup": "Create Account",
"settingUp": "Setting up...",
"setupFailed": "Setup failed",
"setupDescription": "Create an admin account to secure your instance",
"passwordMismatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 6 characters",
"usernameTooShort": "Username must be at least 2 characters",
"networkError": "Network error",
"changePassword": "Change Password",
"currentPassword": "Current Password",
"newPassword": "New Password",
"passwordChanged": "Password changed successfully",
"passwordChangeFailed": "Failed to change password",
"usernamePlaceholder": "Enter your username",
"passwordPlaceholder": "Enter your password",
"confirmPasswordPlaceholder": "Confirm your password",
"usernameRequirement": "At least 2 characters",
"passwordRequirement": "At least 6 characters",
"passwordValid": "Password is valid",
"passwordsMatch": "Passwords match",
"passwordsDoNotMatch": "Passwords do not match",
"insecureConnection": "Warning: Connection is not secure. Passwords will be sent in plain text."
}
}

View file

@ -472,6 +472,8 @@
"allowed_cidrs": "允许访问网段",
"allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源。",
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
"auth_enabled": "启用登录认证",
"auth_enabled_hint": "要求登录后才能访问 Web 控制台。启用后,首次访问时需要创建管理员账户。",
"sections": {
"agent": "智能体",
"runtime": "运行时",
@ -499,5 +501,37 @@
"clear": "清空日志",
"empty": "等待日志中..."
}
},
"auth": {
"username": "用户名",
"password": "密码",
"confirmPassword": "确认密码",
"login": "登录",
"logout": "退出登录",
"loggingIn": "登录中...",
"loginFailed": "用户名或密码错误",
"loginDescription": "请输入凭据以访问控制台",
"setup": "创建账户",
"settingUp": "设置中...",
"setupFailed": "设置失败",
"setupDescription": "创建管理员账户以保护您的实例",
"passwordMismatch": "两次密码不一致",
"passwordTooShort": "密码至少需要6个字符",
"usernameTooShort": "用户名至少需要2个字符",
"networkError": "网络错误",
"changePassword": "修改密码",
"currentPassword": "当前密码",
"newPassword": "新密码",
"passwordChanged": "密码修改成功",
"passwordChangeFailed": "密码修改失败",
"usernamePlaceholder": "请输入用户名",
"passwordPlaceholder": "请输入密码",
"confirmPasswordPlaceholder": "请再次输入密码",
"usernameRequirement": "至少2个字符",
"passwordRequirement": "至少6个字符",
"passwordValid": "密码有效",
"passwordsMatch": "密码匹配",
"passwordsDoNotMatch": "密码不匹配",
"insecureConnection": "警告:连接不安全。密码将以明文形式传输。"
}
}

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 LoginRouteImport } from './routes/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 LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/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
'/login': typeof LoginRoute
'/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
'/login': typeof LoginRoute
'/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
'/login': typeof LoginRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/agent/skills': typeof AgentSkillsRoute
@ -125,6 +134,7 @@ export interface FileRouteTypes {
| '/agent'
| '/config'
| '/credentials'
| '/login'
| '/logs'
| '/models'
| '/agent/skills'
@ -138,6 +148,7 @@ export interface FileRouteTypes {
| '/agent'
| '/config'
| '/credentials'
| '/login'
| '/logs'
| '/models'
| '/agent/skills'
@ -151,6 +162,7 @@ export interface FileRouteTypes {
| '/agent'
| '/config'
| '/credentials'
| '/login'
| '/logs'
| '/models'
| '/agent/skills'
@ -165,6 +177,7 @@ export interface RootRouteChildren {
AgentRoute: typeof AgentRouteWithChildren
ConfigRoute: typeof ConfigRouteWithChildren
CredentialsRoute: typeof CredentialsRoute
LoginRoute: typeof LoginRoute
LogsRoute: typeof LogsRoute
ModelsRoute: typeof ModelsRoute
}
@ -185,6 +198,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LogsRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/credentials': {
id: '/credentials'
path: '/credentials'
@ -292,6 +312,7 @@ const rootRouteChildren: RootRouteChildren = {
AgentRoute: AgentRouteWithChildren,
ConfigRoute: ConfigRouteWithChildren,
CredentialsRoute: CredentialsRoute,
LoginRoute: LoginRoute,
LogsRoute: LogsRoute,
ModelsRoute: ModelsRoute,
}

View file

@ -1,15 +1,30 @@
import { Outlet, createRootRoute } from "@tanstack/react-router"
import { Outlet, createRootRoute, useNavigate, 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 { useAuth } from "@/features/auth"
const RootLayout = () => {
const navigate = useNavigate()
const pathname = useRouterState({ select: (s) => s.location.pathname })
const { needsSetup, needsLogin, isAuthenticated } = useAuth()
useEffect(() => {
initializeChatStore()
}, [])
useEffect(() => {
if ((needsSetup || needsLogin) && pathname !== "/login") {
navigate({ to: "/login" })
}
}, [needsSetup, needsLogin, pathname, navigate])
if (!isAuthenticated && pathname !== "/login") {
return null
}
return (
<AppLayout>
<Outlet />

View file

@ -0,0 +1,49 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { useTranslation } from "react-i18next"
import { useEffect } from "react"
import { useAuth } from "@/features/auth"
import { LoginForm, SetupForm } from "@/components/auth"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
function LoginPage() {
const { t } = useTranslation()
const { needsSetup, needsLogin, isAuthenticated } = useAuth()
const navigate = useNavigate()
useEffect(() => {
if (isAuthenticated) {
navigate({ to: "/" })
}
}, [isAuthenticated, navigate])
useEffect(() => {
if (!needsSetup && !needsLogin) {
navigate({ to: "/" })
}
}, [needsSetup, needsLogin, navigate])
if (isAuthenticated || (!needsSetup && !needsLogin)) {
return null
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">PicoClaw</CardTitle>
<CardDescription>
{needsSetup
? t("auth.setupDescription", "Create an admin account to secure your instance")
: t("auth.loginDescription", "Enter your credentials to access the console")}
</CardDescription>
</CardHeader>
<CardContent>{needsSetup ? <SetupForm /> : <LoginForm />}</CardContent>
</Card>
</div>
)
}
export const Route = createFileRoute("/login")({
component: LoginPage,
})