feat(ui): add AI_WORKSPACE_AUTH_TOKEN reset feature with double confirmation
This commit is contained in:
parent
9fed1c1cf9
commit
c4db1282ea
65
api/auth.go
65
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
|
||||
}
|
||||
|
||||
@ -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})
|
||||
}
|
||||
|
||||
BIN
api/xworkspace-api
Executable file
BIN
api/xworkspace-api
Executable file
Binary file not shown.
@ -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() {
|
||||
<WorkspaceHome labels={labels} services={currentServices} metrics={metrics} portalServices={portalServicesConfig} onOpenService={openTab} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
{showResetModal && (
|
||||
<ResetAuthModal
|
||||
labels={labels}
|
||||
onClose={() => 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();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
141
dashboard/src/components/ResetAuthModal.tsx
Normal file
141
dashboard/src/components/ResetAuthModal.tsx
Normal file
@ -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 (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0, left: 0, right: 0, bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10000,
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'var(--card-bg, #fff)',
|
||||
color: 'var(--text, #333)',
|
||||
borderRadius: '8px',
|
||||
padding: '24px',
|
||||
width: '400px',
|
||||
maxWidth: '90%',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
|
||||
}}>
|
||||
<h2 style={{ marginTop: 0, display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Icon name="terminal" /> Reset Auth Token
|
||||
</h2>
|
||||
<p style={{ color: '#d32f2f', fontSize: '0.9rem', marginBottom: '16px' }}>
|
||||
<strong>Warning:</strong> 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.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleReset}>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: 'bold' }}>
|
||||
Confirm Current Token
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentToken}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div style={{ color: '#d32f2f', marginBottom: '16px', fontSize: '0.9rem' }}>{error}</div>}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--border, #ccc)',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text, #333)'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
background: '#d32f2f',
|
||||
color: '#fff',
|
||||
cursor: loading ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{loading ? 'Resetting...' : 'Reset Token'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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({
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-tools">
|
||||
<button
|
||||
className="sidebar-tool-button"
|
||||
type="button"
|
||||
aria-label="Reset Auth Token"
|
||||
title="Reset Auth Token"
|
||||
onClick={onResetAuthClick}
|
||||
style={{ color: '#d32f2f' }}
|
||||
>
|
||||
<Icon name="terminal" />
|
||||
<strong>{!collapsed ? 'Reset Token' : ''}</strong>
|
||||
</button>
|
||||
<button className="sidebar-tool-button" type="button" aria-label={collapsed ? labels.expand : labels.collapse} onClick={onToggle}>
|
||||
<Icon name={collapsed ? 'panel-expand' : 'panel-collapse'} />
|
||||
</button>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user