feat(web): add authentication for web
This commit is contained in:
parent
f2f6987f00
commit
53236ec118
26 changed files with 1423 additions and 2 deletions
260
web/backend/api/auth.go
Normal file
260
web/backend/api/auth.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ type launcherConfigPayload struct {
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
Public bool `json:"public"`
|
Public bool `json:"public"`
|
||||||
AllowedCIDRs []string `json:"allowed_cidrs"`
|
AllowedCIDRs []string `json:"allowed_cidrs"`
|
||||||
|
AuthEnabled bool `json:"auth_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) {
|
func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) {
|
||||||
|
|
@ -32,6 +33,7 @@ func (h *Handler) launcherFallbackConfig() launcherconfig.Config {
|
||||||
Port: port,
|
Port: port,
|
||||||
Public: h.serverPublic,
|
Public: h.serverPublic,
|
||||||
AllowedCIDRs: append([]string(nil), h.serverCIDRs...),
|
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,
|
Port: cfg.Port,
|
||||||
Public: cfg.Public,
|
Public: cfg.Public,
|
||||||
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
|
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,
|
Port: payload.Port,
|
||||||
Public: payload.Public,
|
Public: payload.Public,
|
||||||
AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...),
|
AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...),
|
||||||
|
AuthEnabled: payload.AuthEnabled,
|
||||||
}
|
}
|
||||||
if err := launcherconfig.Validate(cfg); err != nil {
|
if err := launcherconfig.Validate(cfg); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
|
@ -81,5 +85,6 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ
|
||||||
Port: cfg.Port,
|
Port: cfg.Port,
|
||||||
Public: cfg.Public,
|
Public: cfg.Public,
|
||||||
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
|
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
|
||||||
|
AuthEnabled: cfg.AuthEnabled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
54
web/backend/auth/auth.go
Normal file
54
web/backend/auth/auth.go
Normal 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)
|
||||||
|
}
|
||||||
103
web/backend/auth/middleware.go
Normal file
103
web/backend/auth/middleware.go
Normal 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
112
web/backend/auth/session.go
Normal 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
124
web/backend/auth/store.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ type Config struct {
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
Public bool `json:"public"`
|
Public bool `json:"public"`
|
||||||
AllowedCIDRs []string `json:"allowed_cidrs,omitempty"`
|
AllowedCIDRs []string `json:"allowed_cidrs,omitempty"`
|
||||||
|
AuthEnabled bool `json:"auth_enabled,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default returns default launcher settings.
|
// Default returns default launcher settings.
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/web/backend/api"
|
"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/launcherconfig"
|
||||||
"github.com/sipeed/picoclaw/web/backend/middleware"
|
"github.com/sipeed/picoclaw/web/backend/middleware"
|
||||||
"github.com/sipeed/picoclaw/web/backend/utils"
|
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||||
|
|
@ -167,6 +168,24 @@ func main() {
|
||||||
// Initialize Server components
|
// Initialize Server components
|
||||||
mux := http.NewServeMux()
|
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)
|
// API Routes (e.g. /api/status)
|
||||||
apiHandler = api.NewHandler(absPath)
|
apiHandler = api.NewHandler(absPath)
|
||||||
if _, err = apiHandler.EnsurePicoChannel(""); err != nil {
|
if _, err = apiHandler.EnsurePicoChannel(""); err != nil {
|
||||||
|
|
@ -175,6 +194,9 @@ func main() {
|
||||||
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
|
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
|
||||||
apiHandler.RegisterRoutes(mux)
|
apiHandler.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
// Auth API Routes
|
||||||
|
authHandler.RegisterRoutes(mux)
|
||||||
|
|
||||||
// Frontend Embedded Assets
|
// Frontend Embedded Assets
|
||||||
registerEmbedRoutes(mux)
|
registerEmbedRoutes(mux)
|
||||||
|
|
||||||
|
|
@ -183,10 +205,13 @@ func main() {
|
||||||
logger.Fatalf("Invalid allowed CIDR configuration: %v", err)
|
logger.Fatalf("Invalid allowed CIDR configuration: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply authentication middleware (before other middlewares)
|
||||||
|
authProtectedHandler := authMiddleware.RequireAuth(accessControlledMux)
|
||||||
|
|
||||||
// Apply middleware stack
|
// Apply middleware stack
|
||||||
handler := middleware.Recoverer(
|
handler := middleware.Recoverer(
|
||||||
middleware.Logger(
|
middleware.Logger(
|
||||||
middleware.JSONContentType(accessControlledMux),
|
middleware.JSONContentType(authProtectedHandler),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export interface LauncherConfig {
|
||||||
port: number
|
port: number
|
||||||
public: boolean
|
public: boolean
|
||||||
allowed_cidrs: string[]
|
allowed_cidrs: string[]
|
||||||
|
auth_enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import {
|
||||||
IconBook,
|
IconBook,
|
||||||
IconLanguage,
|
IconLanguage,
|
||||||
IconLoader2,
|
IconLoader2,
|
||||||
|
IconLogout,
|
||||||
IconMenu2,
|
IconMenu2,
|
||||||
IconMoon,
|
IconMoon,
|
||||||
IconPlayerPlay,
|
IconPlayerPlay,
|
||||||
|
|
@ -39,6 +40,7 @@ import {
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
import { useGateway } from "@/hooks/use-gateway.ts"
|
import { useGateway } from "@/hooks/use-gateway.ts"
|
||||||
import { useTheme } from "@/hooks/use-theme.ts"
|
import { useTheme } from "@/hooks/use-theme.ts"
|
||||||
|
import { useAuth } from "@/features/auth"
|
||||||
|
|
||||||
export function AppHeader() {
|
export function AppHeader() {
|
||||||
const { i18n, t } = useTranslation()
|
const { i18n, t } = useTranslation()
|
||||||
|
|
@ -52,6 +54,7 @@ export function AppHeader() {
|
||||||
restart,
|
restart,
|
||||||
stop,
|
stop,
|
||||||
} = useGateway()
|
} = useGateway()
|
||||||
|
const { status: authStatus, logout } = useAuth()
|
||||||
|
|
||||||
const isRunning = gwState === "running"
|
const isRunning = gwState === "running"
|
||||||
const isStarting = gwState === "starting"
|
const isStarting = gwState === "starting"
|
||||||
|
|
@ -245,6 +248,22 @@ export function AppHeader() {
|
||||||
<IconMoon className="size-4.5" />
|
<IconMoon className="size-4.5" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
2
web/frontend/src/components/auth/index.ts
Normal file
2
web/frontend/src/components/auth/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { LoginForm } from "./login-form"
|
||||||
|
export { SetupForm } from "./setup-form"
|
||||||
108
web/frontend/src/components/auth/login-form.tsx
Normal file
108
web/frontend/src/components/auth/login-form.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
222
web/frontend/src/components/auth/setup-form.tsx
Normal file
222
web/frontend/src/components/auth/setup-form.tsx
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -84,6 +84,7 @@ export function ConfigPage() {
|
||||||
port: String(launcherConfig.port),
|
port: String(launcherConfig.port),
|
||||||
publicAccess: launcherConfig.public,
|
publicAccess: launcherConfig.public,
|
||||||
allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"),
|
allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"),
|
||||||
|
authEnabled: launcherConfig.auth_enabled ?? false,
|
||||||
}
|
}
|
||||||
setLauncherForm(parsed)
|
setLauncherForm(parsed)
|
||||||
setLauncherBaseline(parsed)
|
setLauncherBaseline(parsed)
|
||||||
|
|
@ -253,6 +254,7 @@ export function ConfigPage() {
|
||||||
port,
|
port,
|
||||||
public: launcherForm.publicAccess,
|
public: launcherForm.publicAccess,
|
||||||
allowed_cidrs: allowedCIDRs,
|
allowed_cidrs: allowedCIDRs,
|
||||||
|
auth_enabled: launcherForm.authEnabled,
|
||||||
})
|
})
|
||||||
const parsedLauncher: LauncherForm = {
|
const parsedLauncher: LauncherForm = {
|
||||||
port: String(savedLauncherConfig.port),
|
port: String(savedLauncherConfig.port),
|
||||||
|
|
@ -260,6 +262,7 @@ export function ConfigPage() {
|
||||||
allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join(
|
allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join(
|
||||||
"\n",
|
"\n",
|
||||||
),
|
),
|
||||||
|
authEnabled: savedLauncherConfig.auth_enabled ?? false,
|
||||||
}
|
}
|
||||||
setLauncherForm(parsedLauncher)
|
setLauncherForm(parsedLauncher)
|
||||||
setLauncherBaseline(parsedLauncher)
|
setLauncherBaseline(parsedLauncher)
|
||||||
|
|
|
||||||
|
|
@ -423,6 +423,15 @@ export function LauncherSection({
|
||||||
onCheckedChange={(checked) => onFieldChange("publicAccess", checked)}
|
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
|
<Field
|
||||||
label={t("pages.config.server_port")}
|
label={t("pages.config.server_port")}
|
||||||
hint={t("pages.config.server_port_hint")}
|
hint={t("pages.config.server_port_hint")}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export interface LauncherForm {
|
||||||
port: string
|
port: string
|
||||||
publicAccess: boolean
|
publicAccess: boolean
|
||||||
allowedCIDRsText: string
|
allowedCIDRsText: string
|
||||||
|
authEnabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DM_SCOPE_OPTIONS = [
|
export const DM_SCOPE_OPTIONS = [
|
||||||
|
|
@ -91,6 +92,7 @@ export const EMPTY_LAUNCHER_FORM: LauncherForm = {
|
||||||
port: "18800",
|
port: "18800",
|
||||||
publicAccess: false,
|
publicAccess: false,
|
||||||
allowedCIDRsText: "",
|
allowedCIDRsText: "",
|
||||||
|
authEnabled: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
function asRecord(value: unknown): JsonRecord {
|
function asRecord(value: unknown): JsonRecord {
|
||||||
|
|
|
||||||
59
web/frontend/src/components/ui/alert.tsx
Normal file
59
web/frontend/src/components/ui/alert.tsx
Normal 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 }
|
||||||
101
web/frontend/src/features/auth/hooks.ts
Normal file
101
web/frontend/src/features/auth/hooks.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
3
web/frontend/src/features/auth/index.ts
Normal file
3
web/frontend/src/features/auth/index.ts
Normal 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"
|
||||||
25
web/frontend/src/features/auth/store.ts
Normal file
25
web/frontend/src/features/auth/store.ts
Normal 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
|
||||||
|
})
|
||||||
30
web/frontend/src/features/auth/types.ts
Normal file
30
web/frontend/src/features/auth/types.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -472,6 +472,8 @@
|
||||||
"allowed_cidrs": "Allowed Network CIDRs",
|
"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_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",
|
"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": {
|
"sections": {
|
||||||
"agent": "Agent",
|
"agent": "Agent",
|
||||||
"runtime": "Runtime",
|
"runtime": "Runtime",
|
||||||
|
|
@ -499,5 +501,37 @@
|
||||||
"clear": "Clear logs",
|
"clear": "Clear logs",
|
||||||
"empty": "Waiting for 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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -472,6 +472,8 @@
|
||||||
"allowed_cidrs": "允许访问网段",
|
"allowed_cidrs": "允许访问网段",
|
||||||
"allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源。",
|
"allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源。",
|
||||||
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
||||||
|
"auth_enabled": "启用登录认证",
|
||||||
|
"auth_enabled_hint": "要求登录后才能访问 Web 控制台。启用后,首次访问时需要创建管理员账户。",
|
||||||
"sections": {
|
"sections": {
|
||||||
"agent": "智能体",
|
"agent": "智能体",
|
||||||
"runtime": "运行时",
|
"runtime": "运行时",
|
||||||
|
|
@ -499,5 +501,37 @@
|
||||||
"clear": "清空日志",
|
"clear": "清空日志",
|
||||||
"empty": "等待日志中..."
|
"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": "警告:连接不安全。密码将以明文形式传输。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as ModelsRouteImport } from './routes/models'
|
import { Route as ModelsRouteImport } from './routes/models'
|
||||||
import { Route as LogsRouteImport } from './routes/logs'
|
import { Route as LogsRouteImport } from './routes/logs'
|
||||||
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as CredentialsRouteImport } from './routes/credentials'
|
import { Route as CredentialsRouteImport } from './routes/credentials'
|
||||||
import { Route as ConfigRouteImport } from './routes/config'
|
import { Route as ConfigRouteImport } from './routes/config'
|
||||||
import { Route as AgentRouteImport } from './routes/agent'
|
import { Route as AgentRouteImport } from './routes/agent'
|
||||||
|
|
@ -31,6 +32,11 @@ const LogsRoute = LogsRouteImport.update({
|
||||||
path: '/logs',
|
path: '/logs',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const LoginRoute = LoginRouteImport.update({
|
||||||
|
id: '/login',
|
||||||
|
path: '/login',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const CredentialsRoute = CredentialsRouteImport.update({
|
const CredentialsRoute = CredentialsRouteImport.update({
|
||||||
id: '/credentials',
|
id: '/credentials',
|
||||||
path: '/credentials',
|
path: '/credentials',
|
||||||
|
|
@ -83,6 +89,7 @@ export interface FileRoutesByFullPath {
|
||||||
'/agent': typeof AgentRouteWithChildren
|
'/agent': typeof AgentRouteWithChildren
|
||||||
'/config': typeof ConfigRouteWithChildren
|
'/config': typeof ConfigRouteWithChildren
|
||||||
'/credentials': typeof CredentialsRoute
|
'/credentials': typeof CredentialsRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
'/logs': typeof LogsRoute
|
'/logs': typeof LogsRoute
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
'/agent/skills': typeof AgentSkillsRoute
|
'/agent/skills': typeof AgentSkillsRoute
|
||||||
|
|
@ -96,6 +103,7 @@ export interface FileRoutesByTo {
|
||||||
'/agent': typeof AgentRouteWithChildren
|
'/agent': typeof AgentRouteWithChildren
|
||||||
'/config': typeof ConfigRouteWithChildren
|
'/config': typeof ConfigRouteWithChildren
|
||||||
'/credentials': typeof CredentialsRoute
|
'/credentials': typeof CredentialsRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
'/logs': typeof LogsRoute
|
'/logs': typeof LogsRoute
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
'/agent/skills': typeof AgentSkillsRoute
|
'/agent/skills': typeof AgentSkillsRoute
|
||||||
|
|
@ -110,6 +118,7 @@ export interface FileRoutesById {
|
||||||
'/agent': typeof AgentRouteWithChildren
|
'/agent': typeof AgentRouteWithChildren
|
||||||
'/config': typeof ConfigRouteWithChildren
|
'/config': typeof ConfigRouteWithChildren
|
||||||
'/credentials': typeof CredentialsRoute
|
'/credentials': typeof CredentialsRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
'/logs': typeof LogsRoute
|
'/logs': typeof LogsRoute
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
'/agent/skills': typeof AgentSkillsRoute
|
'/agent/skills': typeof AgentSkillsRoute
|
||||||
|
|
@ -125,6 +134,7 @@ export interface FileRouteTypes {
|
||||||
| '/agent'
|
| '/agent'
|
||||||
| '/config'
|
| '/config'
|
||||||
| '/credentials'
|
| '/credentials'
|
||||||
|
| '/login'
|
||||||
| '/logs'
|
| '/logs'
|
||||||
| '/models'
|
| '/models'
|
||||||
| '/agent/skills'
|
| '/agent/skills'
|
||||||
|
|
@ -138,6 +148,7 @@ export interface FileRouteTypes {
|
||||||
| '/agent'
|
| '/agent'
|
||||||
| '/config'
|
| '/config'
|
||||||
| '/credentials'
|
| '/credentials'
|
||||||
|
| '/login'
|
||||||
| '/logs'
|
| '/logs'
|
||||||
| '/models'
|
| '/models'
|
||||||
| '/agent/skills'
|
| '/agent/skills'
|
||||||
|
|
@ -151,6 +162,7 @@ export interface FileRouteTypes {
|
||||||
| '/agent'
|
| '/agent'
|
||||||
| '/config'
|
| '/config'
|
||||||
| '/credentials'
|
| '/credentials'
|
||||||
|
| '/login'
|
||||||
| '/logs'
|
| '/logs'
|
||||||
| '/models'
|
| '/models'
|
||||||
| '/agent/skills'
|
| '/agent/skills'
|
||||||
|
|
@ -165,6 +177,7 @@ export interface RootRouteChildren {
|
||||||
AgentRoute: typeof AgentRouteWithChildren
|
AgentRoute: typeof AgentRouteWithChildren
|
||||||
ConfigRoute: typeof ConfigRouteWithChildren
|
ConfigRoute: typeof ConfigRouteWithChildren
|
||||||
CredentialsRoute: typeof CredentialsRoute
|
CredentialsRoute: typeof CredentialsRoute
|
||||||
|
LoginRoute: typeof LoginRoute
|
||||||
LogsRoute: typeof LogsRoute
|
LogsRoute: typeof LogsRoute
|
||||||
ModelsRoute: typeof ModelsRoute
|
ModelsRoute: typeof ModelsRoute
|
||||||
}
|
}
|
||||||
|
|
@ -185,6 +198,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof LogsRouteImport
|
preLoaderRoute: typeof LogsRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/login': {
|
||||||
|
id: '/login'
|
||||||
|
path: '/login'
|
||||||
|
fullPath: '/login'
|
||||||
|
preLoaderRoute: typeof LoginRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/credentials': {
|
'/credentials': {
|
||||||
id: '/credentials'
|
id: '/credentials'
|
||||||
path: '/credentials'
|
path: '/credentials'
|
||||||
|
|
@ -292,6 +312,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||||
AgentRoute: AgentRouteWithChildren,
|
AgentRoute: AgentRouteWithChildren,
|
||||||
ConfigRoute: ConfigRouteWithChildren,
|
ConfigRoute: ConfigRouteWithChildren,
|
||||||
CredentialsRoute: CredentialsRoute,
|
CredentialsRoute: CredentialsRoute,
|
||||||
|
LoginRoute: LoginRoute,
|
||||||
LogsRoute: LogsRoute,
|
LogsRoute: LogsRoute,
|
||||||
ModelsRoute: ModelsRoute,
|
ModelsRoute: ModelsRoute,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
|
|
||||||
import { AppLayout } from "@/components/app-layout"
|
import { AppLayout } from "@/components/app-layout"
|
||||||
import { initializeChatStore } from "@/features/chat/controller"
|
import { initializeChatStore } from "@/features/chat/controller"
|
||||||
|
import { useAuth } from "@/features/auth"
|
||||||
|
|
||||||
const RootLayout = () => {
|
const RootLayout = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
const { needsSetup, needsLogin, isAuthenticated } = useAuth()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initializeChatStore()
|
initializeChatStore()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if ((needsSetup || needsLogin) && pathname !== "/login") {
|
||||||
|
navigate({ to: "/login" })
|
||||||
|
}
|
||||||
|
}, [needsSetup, needsLogin, pathname, navigate])
|
||||||
|
|
||||||
|
if (!isAuthenticated && pathname !== "/login") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|
|
||||||
49
web/frontend/src/routes/login.tsx
Normal file
49
web/frontend/src/routes/login.tsx
Normal 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,
|
||||||
|
})
|
||||||
Loading…
Add table
Reference in a new issue