diff --git a/api/auth.go b/api/auth.go index b0b4215..f313e61 100644 --- a/api/auth.go +++ b/api/auth.go @@ -1,11 +1,16 @@ package main import ( + "crypto/rand" "crypto/subtle" + "encoding/base64" + "fmt" "net/http" "os" + "os/exec" "path/filepath" "strings" + "time" ) type AuthConfig struct { @@ -96,3 +101,63 @@ func constantTimeTokenEqual(actual string, expected string) bool { } return subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1 } + +type ResetAuthRequest struct { + CurrentToken string `json:"currentToken"` +} + +func generateNewToken() string { + b := make([]byte, 32) + rand.Read(b) + return base64.StdEncoding.EncodeToString(b) +} + +func (a *AuthConfig) ResetAuthToken(currentToken string) (string, error) { + if !a.Required() { + return "", fmt.Errorf("auth is not required") + } + + valid := false + for _, expected := range a.tokens { + if constantTimeTokenEqual(currentToken, expected) { + valid = true + break + } + } + if !valid { + return "", fmt.Errorf("invalid current token") + } + + newToken := generateNewToken() + home, err := os.UserHomeDir() + if err == nil { + path1 := filepath.Join(home, ".ai_workspace_auth_token") + path2 := filepath.Join(home, ".config", "xworkspace", "auth-token") + os.MkdirAll(filepath.Dir(path2), 0700) + os.WriteFile(path1, []byte(newToken), 0600) + os.WriteFile(path2, []byte(newToken), 0600) + } + + a.tokens = append([]string{newToken}, a.tokens...) + + // Schedule a delayed restart of related system services so the response can be sent first. + go func() { + time.Sleep(1 * time.Second) + services := []string{ + "plus.svc.xworkspace.litellm", + "plus.svc.xworkspace.openclaw", + "plus.svc.xworkspace.hermes", + "plus.svc.xworkspace.vault", + "plus.svc.xworkspace.bridge", + "plus.svc.xworkspace.qmd", + "plus.svc.xworkspace.api", + "plus.svc.xworkspace.console", + } + uid := fmt.Sprintf("gui/%d", os.Getuid()) + for _, svc := range services { + _ = exec.Command("launchctl", "kickstart", "-k", fmt.Sprintf("%s/%s", uid, svc)).Run() + } + }() + + return newToken, nil +} diff --git a/api/server.go b/api/server.go index b01f7bf..04500e3 100644 --- a/api/server.go +++ b/api/server.go @@ -31,6 +31,7 @@ func (s *Server) ListenAndServe(addr string) error { func (s *Server) routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/auth/status", s.authStatusHandler) + mux.HandleFunc("/auth/reset", s.resetAuthHandler) mux.HandleFunc("/health", s.healthHandler) mux.HandleFunc("/services", s.servicesHandler) mux.HandleFunc("/portal/services", s.portalServicesHandler) @@ -125,3 +126,23 @@ func writeJSON(w http.ResponseWriter, v any) { http.Error(w, err.Error(), http.StatusInternalServerError) } } + +func (s *Server) resetAuthHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + var req ResetAuthRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + newToken, err := s.auth.ResetAuthToken(req.CurrentToken) + if err != nil { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + writeJSON(w, map[string]string{"token": newToken}) +} diff --git a/api/xworkspace-api b/api/xworkspace-api new file mode 100755 index 0000000..2534ace Binary files /dev/null and b/api/xworkspace-api differ diff --git a/dashboard/src/components/AppShell.tsx b/dashboard/src/components/AppShell.tsx index d35746f..b166e14 100644 --- a/dashboard/src/components/AppShell.tsx +++ b/dashboard/src/components/AppShell.tsx @@ -20,6 +20,7 @@ import { Topbar } from './Topbar'; import { WorkspaceTabs } from './WorkspaceTabs'; import { WorkspaceHome } from './WorkspaceHome'; import { ServicePanel } from './ServicePanel'; +import { ResetAuthModal } from './ResetAuthModal'; export function AppShell() { const [selectedTab, setSelectedTab] = useState('workspace'); @@ -38,6 +39,7 @@ export function AppShell() { const [tokenLoaded, setTokenLoaded] = useState(false); const [authError, setAuthError] = useState(false); const [authChecking, setAuthChecking] = useState(false); + const [showResetModal, setShowResetModal] = useState(false); useEffect(() => { let active = true; @@ -193,6 +195,7 @@ export function AppShell() { onOpen={openTab} onToggleLanguage={() => setLanguage((value) => (value === 'en' ? 'zh' : 'en'))} onToggleTheme={() => setTheme((value) => (value === 'light' ? 'dark' : 'light'))} + onResetAuthClick={() => setShowResetModal(true)} theme={theme} remoteMode={remoteMode} onToggleRemoteMode={toggleRemoteMode} @@ -221,6 +224,21 @@ export function AppShell() { )} + + {showResetModal && ( + setShowResetModal(false)} + onResetSuccess={(newToken) => { + window.localStorage.setItem('xworkspace-bridge-token', newToken); + setAuthToken(newToken); + setTokenInput(newToken); + setShowResetModal(false); + alert(`Token reset successfully! New token has been saved to your browser session.\n\nPlease note: System services are restarting and may be briefly unavailable.`); + window.location.reload(); + }} + /> + )} ); } diff --git a/dashboard/src/components/ResetAuthModal.tsx b/dashboard/src/components/ResetAuthModal.tsx new file mode 100644 index 0000000..20ae1b9 --- /dev/null +++ b/dashboard/src/components/ResetAuthModal.tsx @@ -0,0 +1,141 @@ +import { useState } from 'react'; +import type { Labels } from '@/lib/data'; +import { Icon } from './Icon'; + +export function ResetAuthModal({ + labels, + onClose, + onResetSuccess +}: { + labels: Labels; + onClose: () => void; + onResetSuccess: (newToken: string) => void; +}) { + const [currentToken, setCurrentToken] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleReset = async (e: React.FormEvent) => { + e.preventDefault(); + if (!currentToken.trim()) { + setError('Please enter the current token'); + return; + } + setLoading(true); + setError(''); + + try { + const response = await fetch('/auth/reset', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${window.localStorage.getItem('xworkspace-bridge-token') || ''}`, + }, + body: JSON.stringify({ currentToken }), + }); + + if (!response.ok) { + const errData = await response.json(); + throw new Error(errData.error || 'Failed to reset token'); + } + + const data = await response.json(); + if (data.token) { + onResetSuccess(data.token); + } else { + throw new Error('No token returned from server'); + } + } catch (err: any) { + setError(err.message || 'An error occurred while resetting'); + } finally { + setLoading(false); + } + }; + + return ( + + + + Reset Auth Token + + + Warning: This will invalidate the current token. All connected services (LiteLLM, OpenClaw, Vault, Hermes) will automatically restart and drop existing connections. You will need the new token to log in. + + + + + + Confirm Current Token + + setCurrentToken(e.target.value)} + placeholder="Paste current token here to confirm..." + style={{ + width: '100%', + padding: '8px', + borderRadius: '4px', + border: '1px solid var(--border, #ccc)', + backgroundColor: 'var(--input-bg, #fff)', + color: 'var(--text, #333)', + boxSizing: 'border-box' + }} + autoFocus + /> + + + {error && {error}} + + + + Cancel + + + {loading ? 'Resetting...' : 'Reset Token'} + + + + + + ); +} diff --git a/dashboard/src/components/Sidebar.tsx b/dashboard/src/components/Sidebar.tsx index 2b56e03..82f9468 100644 --- a/dashboard/src/components/Sidebar.tsx +++ b/dashboard/src/components/Sidebar.tsx @@ -13,6 +13,7 @@ export function Sidebar({ onOpen, onToggleLanguage, onToggleTheme, + onResetAuthClick, theme, remoteMode, onToggleRemoteMode, @@ -25,6 +26,7 @@ export function Sidebar({ onOpen: (item: NavItem) => void; onToggleLanguage: () => void; onToggleTheme: () => void; + onResetAuthClick: () => void; theme: 'light' | 'dark'; remoteMode: boolean; onToggleRemoteMode: () => void; @@ -73,6 +75,17 @@ export function Sidebar({ + + + {!collapsed ? 'Reset Token' : ''} +
+ Warning: This will invalidate the current token. All connected services (LiteLLM, OpenClaw, Vault, Hermes) will automatically restart and drop existing connections. You will need the new token to log in. +