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:
Nicolas Varrot
2026-02-23 14:41:15 +00:00
parent 6ed6a4eadf
commit e8fe3329f3
6 changed files with 84 additions and 14 deletions

View File

@@ -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

View File

@@ -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}

View File

@@ -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) => {

View File

@@ -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() {

View File

@@ -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: [],

View File

@@ -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',