feat: add configurable client ID for WebSocket connect frame
Add a clientId option that can be set via: - VITE_CLIENT_ID env var at build time - Advanced section in the login screen at runtime - Stored in localStorage with other credentials Defaults to 'webchat' for backward compatibility. Users can set it to 'openclaw-control-ui' to use OpenClaw's dangerouslyDisableDeviceAuth bypass without post-install patching. Closes #11
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<AuthMode>(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) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced settings */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex items-center gap-1.5 text-xs text-pc-text-muted hover:text-pc-text transition-colors"
|
||||
>
|
||||
<ChevronDown size={14} className={`transition-transform ${showAdvanced ? 'rotate-0' : '-rotate-90'}`} />
|
||||
{t('login.advanced')}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<label htmlFor="client-id" className="block text-xs font-medium text-pc-text-secondary uppercase tracking-wider">
|
||||
{t('login.clientId')}
|
||||
</label>
|
||||
<input
|
||||
id="client-id"
|
||||
type="text"
|
||||
value={clientId}
|
||||
onChange={e => 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}
|
||||
/>
|
||||
<p className="text-xs text-pc-text-faint pl-1">
|
||||
{t('login.clientIdHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-500/5 px-4 py-3 text-sm text-red-300">
|
||||
{error}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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<keyof typeof en, string> = {
|
||||
'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<keyof typeof en, string> = {
|
||||
'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<keyof typeof en, string> = {
|
||||
'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<keyof typeof en, string> = {
|
||||
'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<keyof typeof en, string> = {
|
||||
'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<keyof typeof en, string> = {
|
||||
'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<keyof typeof en, string> = {
|
||||
'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',
|
||||
|
||||
Reference in New Issue
Block a user