Merge branch 'main' into release/v1.3.0

This commit is contained in:
Claude committed 2026-04-19 07:19:32 +08:00
commit 42753482ed
5 files changed
+152 -4

No files matched your search

+3
View File
@@ -88,6 +88,9 @@ func main() {
case "doctor":
runDoctor(os.Args[2:])
return
case "web":
runWeb(os.Args[2:])
return
}
}
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"fmt"
"net/url"
"os"
"os/exec"
"runtime"
"strings"
"github.com/chenhg5/cc-connect/config"
"github.com/chenhg5/cc-connect/core"
)
func runWeb(args []string) {
configPath := resolveConfigPath("")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Config file not found: %s\nRun cc-connect first to create a default config.\n", configPath)
os.Exit(1)
}
cfg, err := config.Load(configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
config.ConfigPath = configPath
mgmtEnabled := cfg.Management.Enabled != nil && *cfg.Management.Enabled
port := cfg.Management.Port
if port == 0 {
port = 9820
}
token := cfg.Management.Token
if !mgmtEnabled {
fmt.Println("Web admin is not enabled. Configuring...")
mgmtToken := core.GenerateToken(16)
bridgeToken := core.GenerateToken(16)
result, err := config.EnableWebAdmin(mgmtToken, bridgeToken)
if err != nil {
fmt.Fprintf(os.Stderr, "Error enabling web admin: %v\n", err)
os.Exit(1)
}
port = result.ManagementPort
token = result.ManagementToken
fmt.Printf("Web admin configured on port %d.\n", port)
fmt.Println("Restart cc-connect for the changes to take effect.")
}
baseURL := fmt.Sprintf("http://localhost:%d", port)
noBrowser := false
for _, a := range args {
if a == "--no-browser" || a == "-n" {
noBrowser = true
}
}
if noBrowser {
fmt.Printf("URL: %s\n", baseURL)
fmt.Printf("Token: %s\n", token)
return
}
loginURL := fmt.Sprintf("%s/login?token=%s",
baseURL, url.QueryEscape(token))
fmt.Printf("Opening: %s\n", baseURL)
if err := openBrowser(loginURL); err != nil {
fmt.Printf("\nCould not open browser automatically.\n")
fmt.Printf("URL: %s\n", baseURL)
fmt.Printf("Token: %s\n", token)
}
}
func openBrowser(rawURL string) error {
switch runtime.GOOS {
case "darwin":
return exec.Command("open", rawURL).Start()
case "linux":
if isWSL() {
return exec.Command("cmd.exe", "/c", "start", rawURL).Start()
}
return exec.Command("xdg-open", rawURL).Start()
case "windows":
return exec.Command("cmd", "/c", "start", rawURL).Start()
default:
return fmt.Errorf("unsupported OS: %s", runtime.GOOS)
}
}
func isWSL() bool {
data, err := os.ReadFile("/proc/version")
if err != nil {
return false
}
return strings.Contains(strings.ToLower(string(data)), "microsoft")
}
+15
View File
@@ -1,7 +1,10 @@
const API_BASE = '/api/v1';
type UnauthorizedHandler = () => void;
class ApiClient {
private token: string = '';
private onUnauthorized?: UnauthorizedHandler;
setToken(token: string) {
this.token = token;
@@ -11,6 +14,10 @@ class ApiClient {
return this.token;
}
setOnUnauthorized(handler: UnauthorizedHandler) {
this.onUnauthorized = handler;
}
private headers(): HeadersInit {
const h: HeadersInit = { 'Content-Type': 'application/json' };
if (this.token) h['Authorization'] = `Bearer ${this.token}`;
@@ -28,6 +35,10 @@ class ApiClient {
headers: this.headers(),
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 401 && this.onUnauthorized) {
this.onUnauthorized();
throw new ApiError('Unauthorized', 401);
}
const json = await res.json();
if (!json.ok) {
throw new ApiError(json.error || 'Unknown error', res.status);
@@ -46,6 +57,10 @@ class ApiClient {
const h: HeadersInit = {};
if (this.token) h['Authorization'] = `Bearer ${this.token}`;
const res = await fetch(`${API_BASE}${path}`, { headers: h });
if (res.status === 401 && this.onUnauthorized) {
this.onUnauthorized();
throw new ApiError('Unauthorized', 401);
}
if (!res.ok) throw new ApiError(res.statusText, res.status);
return res.text();
}
+5
View File
@@ -6,10 +6,15 @@ import './index.css';
import './i18n';
import { useAuthStore } from './store/auth';
import { useThemeStore } from './store/theme';
import { api } from './api/client';
useAuthStore.getState().init();
useThemeStore.getState().init();
api.setOnUnauthorized(() => {
useAuthStore.getState().logout();
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
+29 -4
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useState, useEffect, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Zap, AlertCircle, Languages, Sun, Moon, Monitor } from 'lucide-react';
import { useAuthStore } from '@/store/auth';
@@ -18,12 +18,37 @@ const languages = [
export default function Login() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const login = useAuthStore((s) => s.login);
const [searchParams] = useSearchParams();
const loginStore = useAuthStore((s) => s.login);
const { theme, setTheme } = useThemeStore();
const [token, setToken] = useState('');
const [serverUrl, setServerUrl] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const autoLoginAttempted = useRef(false);
useEffect(() => {
if (autoLoginAttempted.current) return;
const qToken = searchParams.get('token');
if (!qToken) return;
autoLoginAttempted.current = true;
(async () => {
setLoading(true);
try {
api.setToken(qToken);
await getStatus();
loginStore(qToken);
navigate('/', { replace: true });
} catch {
setToken(qToken);
setError(t('login.invalidToken'));
api.setToken('');
} finally {
setLoading(false);
}
})();
}, [searchParams, loginStore, navigate, t]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -33,7 +58,7 @@ export default function Login() {
try {
api.setToken(token.trim());
await getStatus();
login(token.trim(), serverUrl.trim());
loginStore(token.trim(), serverUrl.trim());
navigate('/');
} catch {
setError(t('login.invalidToken'));