Merge branch 'main' into release/v1.3.0
This commit is contained in:
commit
42753482ed
5 files changed
+152
-4
No files matched your search
@@ -88,6 +88,9 @@ func main() {
|
||||
case "doctor":
|
||||
runDoctor(os.Args[2:])
|
||||
return
|
||||
case "web":
|
||||
runWeb(os.Args[2:])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'));
|
||||
|
||||
Reference in new issue
Block a user