diff --git a/.env.example b/.env.example index df35bb3..90d0dfd 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,7 @@ VITE_GATEWAY_WS_URL=ws://localhost:18789 # Optional: UI locale (default: en). Supported: en, fr VITE_LOCALE=en + +# Optional: client ID sent in the WebSocket connect frame (default: webchat) +# Set to "openclaw-control-ui" to use OpenClaw's dangerouslyDisableDeviceAuth bypass +VITE_CLIENT_ID=webchat diff --git a/src/components/LoginScreen.tsx b/src/components/LoginScreen.tsx index b4d012b..e1dbcc5 100644 --- a/src/components/LoginScreen.tsx +++ b/src/components/LoginScreen.tsx @@ -1,10 +1,10 @@ import { useState } from 'react'; -import { Sparkles, Eye, EyeOff, Loader2, Key, Lock } from 'lucide-react'; +import { Sparkles, Eye, EyeOff, Loader2, Key, Lock, ChevronDown } from 'lucide-react'; import { useT } from '../hooks/useLocale'; import { getStoredCredentials, type AuthMode } from '../lib/credentials'; interface Props { - onConnect: (url: string, secret: string, authMode: AuthMode) => void; + onConnect: (url: string, secret: string, authMode: AuthMode, clientId?: string) => void; error?: string | null; isConnecting?: boolean; } @@ -36,12 +36,19 @@ function getInitialAuthMode(): AuthMode { return stored?.authMode ?? 'token'; } +function getInitialClientId(): string { + const stored = getStoredCredentials(); + return stored?.clientId ?? ''; +} + export function LoginScreen({ onConnect, error, isConnecting }: Props) { const t = useT(); const [url, setUrl] = useState(getInitialUrl); const [token, setToken] = useState(getInitialToken); const [showToken, setShowToken] = useState(false); const [authMode, setAuthMode] = useState(getInitialAuthMode); + const [clientId, setClientId] = useState(getInitialClientId); + const [showAdvanced, setShowAdvanced] = useState(() => getInitialClientId() !== ''); const urlTrimmed = url.trim(); const isValidWsUrl = /^wss?:\/\/.+/.test(urlTrimmed); @@ -50,7 +57,7 @@ export function LoginScreen({ onConnect, error, isConnecting }: Props) { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!urlTrimmed || !token.trim() || !isValidWsUrl) return; - onConnect(urlTrimmed, token.trim(), authMode); + onConnect(urlTrimmed, token.trim(), authMode, clientId.trim() || undefined); }; return ( @@ -146,6 +153,37 @@ export function LoginScreen({ onConnect, error, isConnecting }: Props) { + {/* Advanced settings */} +
+ + {showAdvanced && ( +
+ + setClientId(e.target.value)} + placeholder="webchat" + className="w-full rounded-xl border border-pc-border bg-pc-elevated/50 px-4 py-3 text-sm text-pc-text placeholder:text-pc-text-faint outline-none focus:border-[var(--pc-accent-dim)] focus:ring-1 focus:ring-[var(--pc-accent-glow)] transition-all" + disabled={isConnecting} + /> +

+ {t('login.clientIdHint')} +

+
+ )} +
+ {error && (
{error} diff --git a/src/hooks/useGateway.ts b/src/hooks/useGateway.ts index c049369..b44a5f2 100644 --- a/src/hooks/useGateway.ts +++ b/src/hooks/useGateway.ts @@ -272,13 +272,13 @@ export function useGateway() { } }, []); - const setupClient = useCallback(async (wsUrl: string, token: string, authMode: AuthMode = 'token') => { + const setupClient = useCallback(async (wsUrl: string, token: string, authMode: AuthMode = 'token', clientId?: string) => { // Tear down existing client if (clientRef.current) { clientRef.current.disconnect(); } - const client = new GatewayClient(wsUrl, token, authMode); + const client = new GatewayClient(wsUrl, token, authMode, clientId); clientRef.current = client; // Load device identity for signed connect handshake @@ -296,7 +296,7 @@ export function useGateway() { setConnectError(null); setIsConnecting(false); isConnectingRef.current = false; - storeCredentials(wsUrl, token, authMode); + storeCredentials(wsUrl, token, authMode, clientId); loadSessions(); loadAgentIdentity(); loadHistory(activeSessionRef.current); @@ -445,7 +445,7 @@ export function useGateway() { const stored = getStoredCredentials(); if (stored) { // Init on mount — setupClient sets state as part of establishing the connection - setupClient(stored.url, stored.token, stored.authMode || 'token'); + setupClient(stored.url, stored.token, stored.authMode || 'token', stored.clientId); } else { setAuthenticated(false); } @@ -503,8 +503,8 @@ export function useGateway() { loadHistory(key); }, [loadHistory]); - const login = useCallback((url: string, token: string, authMode: AuthMode = 'token') => { - setupClient(url, token, authMode); + const login = useCallback((url: string, token: string, authMode: AuthMode = 'token', clientId?: string) => { + setupClient(url, token, authMode, clientId); }, [setupClient]); const deleteSession = useCallback(async (key: string) => { diff --git a/src/lib/credentials.ts b/src/lib/credentials.ts index 33c9b35..ec03a77 100644 --- a/src/lib/credentials.ts +++ b/src/lib/credentials.ts @@ -7,6 +7,8 @@ export interface StoredCredentials { token: string; /** Auth mode — defaults to 'token' for backward compatibility */ authMode?: AuthMode; + /** Custom client ID sent in the WebSocket connect frame (default: 'webchat') */ + clientId?: string; } export function getStoredCredentials(): StoredCredentials | null { @@ -21,8 +23,8 @@ export function getStoredCredentials(): StoredCredentials | null { return null; } -export function storeCredentials(url: string, token: string, authMode: AuthMode = 'token') { - localStorage.setItem(STORAGE_KEY, JSON.stringify({ url, token, authMode })); +export function storeCredentials(url: string, token: string, authMode: AuthMode = 'token', clientId?: string) { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ url, token, authMode, ...(clientId ? { clientId } : {}) })); } export function clearCredentials() { diff --git a/src/lib/gateway.ts b/src/lib/gateway.ts index 77febf3..2c1a871 100644 --- a/src/lib/gateway.ts +++ b/src/lib/gateway.ts @@ -44,11 +44,13 @@ export class GatewayClient { private authToken: string; private authMode: AuthMode = 'token'; private deviceIdentity: DeviceIdentity | null = null; + private clientId: string; - constructor(wsUrl?: string, authToken?: string, authMode?: AuthMode) { + constructor(wsUrl?: string, authToken?: string, authMode?: AuthMode, clientId?: string) { this.wsUrl = wsUrl || `ws://${window.location.hostname}:18789`; this.authToken = authToken || ''; this.authMode = authMode || 'token'; + this.clientId = clientId || import.meta.env.VITE_CLIENT_ID || 'webchat'; } /** Update credentials (e.g. after login). Does not reconnect automatically. */ @@ -130,7 +132,7 @@ export class GatewayClient { if (this.deviceIdentity) { const payload = buildDeviceAuthPayload({ deviceId: this.deviceIdentity.id, - clientId: 'webchat', + clientId: this.clientId, clientMode: 'webchat', role, scopes, @@ -152,7 +154,7 @@ export class GatewayClient { const res = await this.request(id, 'connect', { minProtocol: 3, maxProtocol: 3, - client: { id: 'webchat', version: __APP_VERSION__, platform: 'web', mode: 'webchat' }, + client: { id: this.clientId, version: __APP_VERSION__, platform: 'web', mode: 'webchat' }, role, scopes, caps: [], diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index c53fbf3..8a92bb3 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -24,6 +24,9 @@ const en = { 'login.hideToken': 'Hide token', 'login.storedLocally': 'Credentials are stored locally in your browser', 'login.wsHint': 'URL must start with ws:// or wss://', + 'login.advanced': 'Advanced', + 'login.clientId': 'Client ID', + 'login.clientIdHint': 'Sent in the WebSocket connect frame. Default: webchat', // Header 'header.title': 'PinchChat', @@ -205,6 +208,9 @@ const fr: Record = { 'login.hideToken': 'Masquer le token', 'login.storedLocally': 'Les identifiants sont stockés localement dans votre navigateur', 'login.wsHint': 'L\'URL doit commencer par ws:// ou wss://', + 'login.advanced': 'Avancé', + 'login.clientId': 'ID client', + 'login.clientIdHint': 'Envoyé dans la trame de connexion WebSocket. Par défaut : webchat', 'header.title': 'PinchChat', 'header.connected': 'Connecté', @@ -371,6 +377,9 @@ const es: Record = { 'login.hideToken': 'Ocultar token', 'login.storedLocally': 'Las credenciales se guardan localmente en tu navegador', 'login.wsHint': 'La URL debe empezar con ws:// o wss://', + 'login.advanced': 'Avanzado', + 'login.clientId': 'ID de cliente', + 'login.clientIdHint': 'Enviado en la trama de conexión WebSocket. Por defecto: webchat', 'header.title': 'PinchChat', 'header.connected': 'Conectado', @@ -539,6 +548,9 @@ const de: Record = { 'login.hideToken': 'Token verbergen', 'login.storedLocally': 'Zugangsdaten werden lokal in deinem Browser gespeichert', 'login.wsHint': 'URL muss mit ws:// oder wss:// beginnen', + 'login.advanced': 'Erweitert', + 'login.clientId': 'Client-ID', + 'login.clientIdHint': 'Wird im WebSocket-Connect-Frame gesendet. Standard: webchat', 'header.title': 'PinchChat', 'header.connected': 'Verbunden', @@ -705,6 +717,9 @@ const ja: Record = { 'login.hideToken': 'トークンを非表示', 'login.storedLocally': '認証情報はブラウザにローカル保存されます', 'login.wsHint': 'URLはws://またはwss://で始まる必要があります', + 'login.advanced': '詳細設定', + 'login.clientId': 'クライアントID', + 'login.clientIdHint': 'WebSocket接続フレームで送信されます。デフォルト: webchat', 'header.title': 'PinchChat', 'header.connected': '接続済み', @@ -871,6 +886,9 @@ const pt: Record = { 'login.hideToken': 'Ocultar token', 'login.storedLocally': 'As credenciais são armazenadas localmente no navegador', 'login.wsHint': 'A URL deve começar com ws:// ou wss://', + 'login.advanced': 'Avançado', + 'login.clientId': 'ID do cliente', + 'login.clientIdHint': 'Enviado no frame de conexão WebSocket. Padrão: webchat', 'header.title': 'PinchChat', 'header.connected': 'Conectado', @@ -1037,6 +1055,9 @@ const zh: Record = { 'login.hideToken': '隐藏令牌', 'login.storedLocally': '凭据仅存储在浏览器本地', 'login.wsHint': '地址必须以 ws:// 或 wss:// 开头', + 'login.advanced': '高级', + 'login.clientId': '客户端 ID', + 'login.clientIdHint': '在 WebSocket 连接帧中发送。默认值:webchat', 'header.title': 'PinchChat', 'header.connected': '已连接', @@ -1203,6 +1224,9 @@ const it: Record = { 'login.hideToken': 'Nascondi token', 'login.storedLocally': 'Le credenziali vengono salvate localmente nel browser', 'login.wsHint': 'L\'URL deve iniziare con ws:// o wss://', + 'login.advanced': 'Avanzate', + 'login.clientId': 'ID client', + 'login.clientIdHint': 'Inviato nel frame di connessione WebSocket. Predefinito: webchat', 'header.title': 'PinchChat', 'header.connected': 'Connesso',