From 71335a61935971c7621fefcef973cc2b42e313fd Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 25 Dec 2025 14:37:03 -0500 Subject: [PATCH] feat(ui): add auth tokens settings tab - Add AuthSection component with API key and management secret inputs - Show/hide toggle, copy to clipboard, regenerate secret button - Reset to defaults action with disabled state when using defaults - Add Auth tab to settings navigation with KeyRound icon - Update SettingsTab type to include 'auth' --- .../settings/components/tab-navigation.tsx | 6 +- .../pages/settings/hooks/use-settings-tab.ts | 8 +- ui/src/pages/settings/index.tsx | 2 + .../pages/settings/sections/auth-section.tsx | 426 ++++++++++++++++++ ui/src/pages/settings/types.ts | 2 +- 5 files changed, 441 insertions(+), 3 deletions(-) create mode 100644 ui/src/pages/settings/sections/auth-section.tsx diff --git a/ui/src/pages/settings/components/tab-navigation.tsx b/ui/src/pages/settings/components/tab-navigation.tsx index 0755e437..a318d81d 100644 --- a/ui/src/pages/settings/components/tab-navigation.tsx +++ b/ui/src/pages/settings/components/tab-navigation.tsx @@ -4,7 +4,7 @@ */ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Globe, Settings2, Server } from 'lucide-react'; +import { Globe, Settings2, Server, KeyRound } from 'lucide-react'; import type { SettingsTab } from '../types'; interface TabNavigationProps { @@ -28,6 +28,10 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) { Proxy + + + Auth + ); diff --git a/ui/src/pages/settings/hooks/use-settings-tab.ts b/ui/src/pages/settings/hooks/use-settings-tab.ts index 444b13e3..9ec10395 100644 --- a/ui/src/pages/settings/hooks/use-settings-tab.ts +++ b/ui/src/pages/settings/hooks/use-settings-tab.ts @@ -10,7 +10,13 @@ export function useSettingsTab() { const [searchParams, setSearchParams] = useSearchParams(); const tabParam = searchParams.get('tab'); const activeTab: SettingsTab = - tabParam === 'globalenv' ? 'globalenv' : tabParam === 'proxy' ? 'proxy' : 'websearch'; + tabParam === 'globalenv' + ? 'globalenv' + : tabParam === 'proxy' + ? 'proxy' + : tabParam === 'auth' + ? 'auth' + : 'websearch'; const setActiveTab = useCallback( (tab: SettingsTab) => { diff --git a/ui/src/pages/settings/index.tsx b/ui/src/pages/settings/index.tsx index d658bc51..ce86a63c 100644 --- a/ui/src/pages/settings/index.tsx +++ b/ui/src/pages/settings/index.tsx @@ -18,6 +18,7 @@ import type { SettingsTab } from './types'; const WebSearchSection = lazy(() => import('./sections/websearch')); const GlobalEnvSection = lazy(() => import('./sections/globalenv-section')); const ProxySection = lazy(() => import('./sections/proxy')); +const AuthSection = lazy(() => import('./sections/auth-section')); // Inner component that uses context function SettingsPageInner() { @@ -57,6 +58,7 @@ function SettingsPageInner() { {activeTab === 'websearch' && } {activeTab === 'globalenv' && } {activeTab === 'proxy' && } + {activeTab === 'auth' && } diff --git a/ui/src/pages/settings/sections/auth-section.tsx b/ui/src/pages/settings/sections/auth-section.tsx new file mode 100644 index 00000000..07e070a7 --- /dev/null +++ b/ui/src/pages/settings/sections/auth-section.tsx @@ -0,0 +1,426 @@ +/** + * Auth Section + * Settings section for CLIProxy auth tokens (API key and management secret) + */ + +import { useEffect, useState, useCallback } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { + RefreshCw, + CheckCircle2, + AlertCircle, + Eye, + EyeOff, + RotateCcw, + Sparkles, + Copy, + Check, + KeyRound, + ShieldCheck, +} from 'lucide-react'; +import { useRawConfig } from '../hooks'; + +interface TokenInfo { + value: string; + isCustom: boolean; +} + +interface AuthTokens { + apiKey: TokenInfo; + managementSecret: TokenInfo; +} + +export default function AuthSection() { + const { fetchRawConfig } = useRawConfig(); + + // State + const [tokens, setTokens] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + // Edit state + const [showApiKey, setShowApiKey] = useState(false); + const [showSecret, setShowSecret] = useState(false); + const [editedApiKey, setEditedApiKey] = useState(null); + const [editedSecret, setEditedSecret] = useState(null); + const [copiedApiKey, setCopiedApiKey] = useState(false); + const [copiedSecret, setCopiedSecret] = useState(false); + + // Fetch tokens + const fetchTokens = useCallback(async () => { + try { + setLoading(true); + setError(null); + // Use /raw to get unmasked values for editing + const response = await fetch('/api/settings/auth/tokens/raw'); + if (!response.ok) { + throw new Error('Failed to fetch auth tokens'); + } + const data = await response.json(); + setTokens(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }, []); + + // Load on mount + useEffect(() => { + fetchTokens(); + fetchRawConfig(); + }, [fetchTokens, fetchRawConfig]); + + // Clear success after timeout + useEffect(() => { + if (success) { + const timer = setTimeout(() => setSuccess(null), 3000); + return () => clearTimeout(timer); + } + }, [success]); + + // Clear error after timeout + useEffect(() => { + if (error) { + const timer = setTimeout(() => setError(null), 5000); + return () => clearTimeout(timer); + } + }, [error]); + + // Save API key + const saveApiKey = async () => { + if (editedApiKey === null) return; + + try { + setSaving(true); + setError(null); + const response = await fetch('/api/settings/auth/tokens', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ apiKey: editedApiKey }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to save API key'); + } + + setSuccess('API key updated. Restart CLIProxy to apply.'); + setEditedApiKey(null); + await fetchTokens(); + await fetchRawConfig(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setSaving(false); + } + }; + + // Save management secret + const saveSecret = async () => { + if (editedSecret === null) return; + + try { + setSaving(true); + setError(null); + const response = await fetch('/api/settings/auth/tokens', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ managementSecret: editedSecret }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to save management secret'); + } + + setSuccess('Management secret updated. Restart CLIProxy to apply.'); + setEditedSecret(null); + await fetchTokens(); + await fetchRawConfig(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setSaving(false); + } + }; + + // Regenerate management secret + const regenerateSecret = async () => { + try { + setSaving(true); + setError(null); + const response = await fetch('/api/settings/auth/tokens/regenerate-secret', { + method: 'POST', + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to regenerate secret'); + } + + setSuccess('New management secret generated. Restart CLIProxy to apply.'); + await fetchTokens(); + await fetchRawConfig(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setSaving(false); + } + }; + + // Reset to defaults + const resetToDefaults = async () => { + try { + setSaving(true); + setError(null); + const response = await fetch('/api/settings/auth/tokens/reset', { + method: 'POST', + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to reset tokens'); + } + + setSuccess('Tokens reset to defaults. Restart CLIProxy to apply.'); + setEditedApiKey(null); + setEditedSecret(null); + await fetchTokens(); + await fetchRawConfig(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setSaving(false); + } + }; + + // Copy to clipboard helpers + const copyApiKey = async () => { + if (!tokens) return; + await navigator.clipboard.writeText(tokens.apiKey.value); + setCopiedApiKey(true); + setTimeout(() => setCopiedApiKey(false), 2000); + }; + + const copySecret = async () => { + if (!tokens) return; + await navigator.clipboard.writeText(tokens.managementSecret.value); + setCopiedSecret(true); + setTimeout(() => setCopiedSecret(false), 2000); + }; + + if (loading || !tokens) { + return ( +
+
+ + Loading... +
+
+ ); + } + + // Display values + const displayApiKey = editedApiKey ?? tokens.apiKey.value; + const displaySecret = editedSecret ?? tokens.managementSecret.value; + + return ( + <> + {/* Toast-style alerts */} +
+ {error && ( + + + {error} + + )} + {success && ( +
+ + {success} +
+ )} +
+ + {/* Scrollable Content */} + +
+

+ Configure CLIProxy authentication tokens. Changes require CLIProxy restart. +

+ + {/* API Key Section */} +
+
+ +

API Key

+ {tokens.apiKey.isCustom && ( + + Custom + + )} +
+

+ Used by Claude Code to authenticate with CLIProxy +

+
+
+ setEditedApiKey(e.target.value)} + onBlur={() => { + if (editedApiKey !== null && editedApiKey !== tokens.apiKey.value) { + saveApiKey(); + } else { + setEditedApiKey(null); + } + }} + placeholder="API key" + disabled={saving} + className="pr-20 font-mono text-sm" + /> +
+ + +
+
+
+
+ + {/* Management Secret Section */} +
+
+ +

Management Secret

+ {tokens.managementSecret.isCustom && ( + + Custom + + )} +
+

+ Used by CCS dashboard to access CLIProxy management APIs +

+
+
+ setEditedSecret(e.target.value)} + onBlur={() => { + if (editedSecret !== null && editedSecret !== tokens.managementSecret.value) { + saveSecret(); + } else { + setEditedSecret(null); + } + }} + placeholder="Management secret" + disabled={saving} + className="pr-20 font-mono text-sm" + /> +
+ + +
+
+ +
+
+ + {/* Actions */} +
+ +

+ Resets both API key and management secret to their default values. +

+
+
+
+ + {/* Footer */} +
+ +
+ + ); +} diff --git a/ui/src/pages/settings/types.ts b/ui/src/pages/settings/types.ts index 55ea8cfa..9fdd73de 100644 --- a/ui/src/pages/settings/types.ts +++ b/ui/src/pages/settings/types.ts @@ -49,7 +49,7 @@ export interface GlobalEnvConfig { // === Tab Types === -export type SettingsTab = 'websearch' | 'globalenv' | 'proxy'; +export type SettingsTab = 'websearch' | 'globalenv' | 'proxy' | 'auth'; // === Re-exports from api-client ===