From ef83d29b6267d86bd13df405773875e783cf8d57 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 07:19:32 +0800 Subject: [PATCH] feat: add `cc-connect web` CLI command with auto-login and 401 handling - Add `cc-connect web` CLI subcommand: reads config, auto-enables web admin if needed, opens browser with token pre-filled for instant login - Support --no-browser flag to just print URL and token - Browser open supports macOS, Linux, Windows, and WSL - Frontend Login page reads ?token= query param and auto-authenticates - Add global 401 interceptor in API client: automatically logs out and redirects to login page when token is invalid or changed Made-with: Cursor --- cmd/cc-connect/main.go | 3 ++ cmd/cc-connect/web.go | 100 ++++++++++++++++++++++++++++++++++++++++ web/src/api/client.ts | 15 ++++++ web/src/main.tsx | 5 ++ web/src/pages/Login.tsx | 33 +++++++++++-- 5 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 cmd/cc-connect/web.go diff --git a/cmd/cc-connect/main.go b/cmd/cc-connect/main.go index 76824c8..a30cb3d 100644 --- a/cmd/cc-connect/main.go +++ b/cmd/cc-connect/main.go @@ -88,6 +88,9 @@ func main() { case "doctor": runDoctor(os.Args[2:]) return + case "web": + runWeb(os.Args[2:]) + return } } diff --git a/cmd/cc-connect/web.go b/cmd/cc-connect/web.go new file mode 100644 index 0000000..48aad91 --- /dev/null +++ b/cmd/cc-connect/web.go @@ -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") +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 42fe6ca..7970da8 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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(); } diff --git a/web/src/main.tsx b/web/src/main.tsx index af514e9..de570ed 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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( diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index db82f63..17c0187 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -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'));