feat(ui): add AI_WORKSPACE_AUTH_TOKEN reset feature with double confirmation

This commit is contained in:
Haitao Pan 2026-06-16 23:27:38 +08:00
parent 9fed1c1cf9
commit c4db1282ea
6 changed files with 258 additions and 0 deletions

View File

@ -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
}

View File

@ -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

Binary file not shown.

View File

@ -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>
);
}

View 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>
);
}

View File

@ -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>