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
|
# Optional: UI locale (default: en). Supported: en, fr
|
||||||
VITE_LOCALE=en
|
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 { 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 { useT } from '../hooks/useLocale';
|
||||||
import { getStoredCredentials, type AuthMode } from '../lib/credentials';
|
import { getStoredCredentials, type AuthMode } from '../lib/credentials';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onConnect: (url: string, secret: string, authMode: AuthMode) => void;
|
onConnect: (url: string, secret: string, authMode: AuthMode, clientId?: string) => void;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
isConnecting?: boolean;
|
isConnecting?: boolean;
|
||||||
}
|
}
|
||||||
@@ -36,12 +36,19 @@ function getInitialAuthMode(): AuthMode {
|
|||||||
return stored?.authMode ?? 'token';
|
return stored?.authMode ?? 'token';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getInitialClientId(): string {
|
||||||
|
const stored = getStoredCredentials();
|
||||||
|
return stored?.clientId ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
export function LoginScreen({ onConnect, error, isConnecting }: Props) {
|
export function LoginScreen({ onConnect, error, isConnecting }: Props) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [url, setUrl] = useState(getInitialUrl);
|
const [url, setUrl] = useState(getInitialUrl);
|
||||||
const [token, setToken] = useState(getInitialToken);
|
const [token, setToken] = useState(getInitialToken);
|
||||||
const [showToken, setShowToken] = useState(false);
|
const [showToken, setShowToken] = useState(false);
|
||||||
const [authMode, setAuthMode] = useState<AuthMode>(getInitialAuthMode);
|
const [authMode, setAuthMode] = useState<AuthMode>(getInitialAuthMode);
|
||||||
|
const [clientId, setClientId] = useState(getInitialClientId);
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(() => getInitialClientId() !== '');
|
||||||
|
|
||||||
const urlTrimmed = url.trim();
|
const urlTrimmed = url.trim();
|
||||||
const isValidWsUrl = /^wss?:\/\/.+/.test(urlTrimmed);
|
const isValidWsUrl = /^wss?:\/\/.+/.test(urlTrimmed);
|
||||||
@@ -50,7 +57,7 @@ export function LoginScreen({ onConnect, error, isConnecting }: Props) {
|
|||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!urlTrimmed || !token.trim() || !isValidWsUrl) return;
|
if (!urlTrimmed || !token.trim() || !isValidWsUrl) return;
|
||||||
onConnect(urlTrimmed, token.trim(), authMode);
|
onConnect(urlTrimmed, token.trim(), authMode, clientId.trim() || undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -146,6 +153,37 @@ export function LoginScreen({ onConnect, error, isConnecting }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{error && (
|
||||||
<div className="rounded-xl border border-red-500/20 bg-red-500/5 px-4 py-3 text-sm text-red-300">
|
<div className="rounded-xl border border-red-500/20 bg-red-500/5 px-4 py-3 text-sm text-red-300">
|
||||||
{error}
|
{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
|
// Tear down existing client
|
||||||
if (clientRef.current) {
|
if (clientRef.current) {
|
||||||
clientRef.current.disconnect();
|
clientRef.current.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new GatewayClient(wsUrl, token, authMode);
|
const client = new GatewayClient(wsUrl, token, authMode, clientId);
|
||||||
clientRef.current = client;
|
clientRef.current = client;
|
||||||
|
|
||||||
// Load device identity for signed connect handshake
|
// Load device identity for signed connect handshake
|
||||||
@@ -296,7 +296,7 @@ export function useGateway() {
|
|||||||
setConnectError(null);
|
setConnectError(null);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
isConnectingRef.current = false;
|
isConnectingRef.current = false;
|
||||||
storeCredentials(wsUrl, token, authMode);
|
storeCredentials(wsUrl, token, authMode, clientId);
|
||||||
loadSessions();
|
loadSessions();
|
||||||
loadAgentIdentity();
|
loadAgentIdentity();
|
||||||
loadHistory(activeSessionRef.current);
|
loadHistory(activeSessionRef.current);
|
||||||
@@ -445,7 +445,7 @@ export function useGateway() {
|
|||||||
const stored = getStoredCredentials();
|
const stored = getStoredCredentials();
|
||||||
if (stored) {
|
if (stored) {
|
||||||
// Init on mount — setupClient sets state as part of establishing the connection
|
// 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 {
|
} else {
|
||||||
setAuthenticated(false);
|
setAuthenticated(false);
|
||||||
}
|
}
|
||||||
@@ -503,8 +503,8 @@ export function useGateway() {
|
|||||||
loadHistory(key);
|
loadHistory(key);
|
||||||
}, [loadHistory]);
|
}, [loadHistory]);
|
||||||
|
|
||||||
const login = useCallback((url: string, token: string, authMode: AuthMode = 'token') => {
|
const login = useCallback((url: string, token: string, authMode: AuthMode = 'token', clientId?: string) => {
|
||||||
setupClient(url, token, authMode);
|
setupClient(url, token, authMode, clientId);
|
||||||
}, [setupClient]);
|
}, [setupClient]);
|
||||||
|
|
||||||
const deleteSession = useCallback(async (key: string) => {
|
const deleteSession = useCallback(async (key: string) => {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export interface StoredCredentials {
|
|||||||
token: string;
|
token: string;
|
||||||
/** Auth mode — defaults to 'token' for backward compatibility */
|
/** Auth mode — defaults to 'token' for backward compatibility */
|
||||||
authMode?: AuthMode;
|
authMode?: AuthMode;
|
||||||
|
/** Custom client ID sent in the WebSocket connect frame (default: 'webchat') */
|
||||||
|
clientId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStoredCredentials(): StoredCredentials | null {
|
export function getStoredCredentials(): StoredCredentials | null {
|
||||||
@@ -21,8 +23,8 @@ export function getStoredCredentials(): StoredCredentials | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function storeCredentials(url: string, token: string, authMode: AuthMode = 'token') {
|
export function storeCredentials(url: string, token: string, authMode: AuthMode = 'token', clientId?: string) {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ url, token, authMode }));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({ url, token, authMode, ...(clientId ? { clientId } : {}) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearCredentials() {
|
export function clearCredentials() {
|
||||||
|
|||||||
@@ -44,11 +44,13 @@ export class GatewayClient {
|
|||||||
private authToken: string;
|
private authToken: string;
|
||||||
private authMode: AuthMode = 'token';
|
private authMode: AuthMode = 'token';
|
||||||
private deviceIdentity: DeviceIdentity | null = null;
|
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.wsUrl = wsUrl || `ws://${window.location.hostname}:18789`;
|
||||||
this.authToken = authToken || '';
|
this.authToken = authToken || '';
|
||||||
this.authMode = authMode || 'token';
|
this.authMode = authMode || 'token';
|
||||||
|
this.clientId = clientId || import.meta.env.VITE_CLIENT_ID || 'webchat';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update credentials (e.g. after login). Does not reconnect automatically. */
|
/** Update credentials (e.g. after login). Does not reconnect automatically. */
|
||||||
@@ -130,7 +132,7 @@ export class GatewayClient {
|
|||||||
if (this.deviceIdentity) {
|
if (this.deviceIdentity) {
|
||||||
const payload = buildDeviceAuthPayload({
|
const payload = buildDeviceAuthPayload({
|
||||||
deviceId: this.deviceIdentity.id,
|
deviceId: this.deviceIdentity.id,
|
||||||
clientId: 'webchat',
|
clientId: this.clientId,
|
||||||
clientMode: 'webchat',
|
clientMode: 'webchat',
|
||||||
role,
|
role,
|
||||||
scopes,
|
scopes,
|
||||||
@@ -152,7 +154,7 @@ export class GatewayClient {
|
|||||||
const res = await this.request(id, 'connect', {
|
const res = await this.request(id, 'connect', {
|
||||||
minProtocol: 3,
|
minProtocol: 3,
|
||||||
maxProtocol: 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,
|
role,
|
||||||
scopes,
|
scopes,
|
||||||
caps: [],
|
caps: [],
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ const en = {
|
|||||||
'login.hideToken': 'Hide token',
|
'login.hideToken': 'Hide token',
|
||||||
'login.storedLocally': 'Credentials are stored locally in your browser',
|
'login.storedLocally': 'Credentials are stored locally in your browser',
|
||||||
'login.wsHint': 'URL must start with ws:// or wss://',
|
'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
|
||||||
'header.title': 'PinchChat',
|
'header.title': 'PinchChat',
|
||||||
@@ -205,6 +208,9 @@ const fr: Record<keyof typeof en, string> = {
|
|||||||
'login.hideToken': 'Masquer le token',
|
'login.hideToken': 'Masquer le token',
|
||||||
'login.storedLocally': 'Les identifiants sont stockés localement dans votre navigateur',
|
'login.storedLocally': 'Les identifiants sont stockés localement dans votre navigateur',
|
||||||
'login.wsHint': 'L\'URL doit commencer par ws:// ou wss://',
|
'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.title': 'PinchChat',
|
||||||
'header.connected': 'Connecté',
|
'header.connected': 'Connecté',
|
||||||
@@ -371,6 +377,9 @@ const es: Record<keyof typeof en, string> = {
|
|||||||
'login.hideToken': 'Ocultar token',
|
'login.hideToken': 'Ocultar token',
|
||||||
'login.storedLocally': 'Las credenciales se guardan localmente en tu navegador',
|
'login.storedLocally': 'Las credenciales se guardan localmente en tu navegador',
|
||||||
'login.wsHint': 'La URL debe empezar con ws:// o wss://',
|
'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.title': 'PinchChat',
|
||||||
'header.connected': 'Conectado',
|
'header.connected': 'Conectado',
|
||||||
@@ -539,6 +548,9 @@ const de: Record<keyof typeof en, string> = {
|
|||||||
'login.hideToken': 'Token verbergen',
|
'login.hideToken': 'Token verbergen',
|
||||||
'login.storedLocally': 'Zugangsdaten werden lokal in deinem Browser gespeichert',
|
'login.storedLocally': 'Zugangsdaten werden lokal in deinem Browser gespeichert',
|
||||||
'login.wsHint': 'URL muss mit ws:// oder wss:// beginnen',
|
'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.title': 'PinchChat',
|
||||||
'header.connected': 'Verbunden',
|
'header.connected': 'Verbunden',
|
||||||
@@ -705,6 +717,9 @@ const ja: Record<keyof typeof en, string> = {
|
|||||||
'login.hideToken': 'トークンを非表示',
|
'login.hideToken': 'トークンを非表示',
|
||||||
'login.storedLocally': '認証情報はブラウザにローカル保存されます',
|
'login.storedLocally': '認証情報はブラウザにローカル保存されます',
|
||||||
'login.wsHint': 'URLはws://またはwss://で始まる必要があります',
|
'login.wsHint': 'URLはws://またはwss://で始まる必要があります',
|
||||||
|
'login.advanced': '詳細設定',
|
||||||
|
'login.clientId': 'クライアントID',
|
||||||
|
'login.clientIdHint': 'WebSocket接続フレームで送信されます。デフォルト: webchat',
|
||||||
|
|
||||||
'header.title': 'PinchChat',
|
'header.title': 'PinchChat',
|
||||||
'header.connected': '接続済み',
|
'header.connected': '接続済み',
|
||||||
@@ -871,6 +886,9 @@ const pt: Record<keyof typeof en, string> = {
|
|||||||
'login.hideToken': 'Ocultar token',
|
'login.hideToken': 'Ocultar token',
|
||||||
'login.storedLocally': 'As credenciais são armazenadas localmente no navegador',
|
'login.storedLocally': 'As credenciais são armazenadas localmente no navegador',
|
||||||
'login.wsHint': 'A URL deve começar com ws:// ou wss://',
|
'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.title': 'PinchChat',
|
||||||
'header.connected': 'Conectado',
|
'header.connected': 'Conectado',
|
||||||
@@ -1037,6 +1055,9 @@ const zh: Record<keyof typeof en, string> = {
|
|||||||
'login.hideToken': '隐藏令牌',
|
'login.hideToken': '隐藏令牌',
|
||||||
'login.storedLocally': '凭据仅存储在浏览器本地',
|
'login.storedLocally': '凭据仅存储在浏览器本地',
|
||||||
'login.wsHint': '地址必须以 ws:// 或 wss:// 开头',
|
'login.wsHint': '地址必须以 ws:// 或 wss:// 开头',
|
||||||
|
'login.advanced': '高级',
|
||||||
|
'login.clientId': '客户端 ID',
|
||||||
|
'login.clientIdHint': '在 WebSocket 连接帧中发送。默认值:webchat',
|
||||||
|
|
||||||
'header.title': 'PinchChat',
|
'header.title': 'PinchChat',
|
||||||
'header.connected': '已连接',
|
'header.connected': '已连接',
|
||||||
@@ -1203,6 +1224,9 @@ const it: Record<keyof typeof en, string> = {
|
|||||||
'login.hideToken': 'Nascondi token',
|
'login.hideToken': 'Nascondi token',
|
||||||
'login.storedLocally': 'Le credenziali vengono salvate localmente nel browser',
|
'login.storedLocally': 'Le credenziali vengono salvate localmente nel browser',
|
||||||
'login.wsHint': 'L\'URL deve iniziare con ws:// o wss://',
|
'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.title': 'PinchChat',
|
||||||
'header.connected': 'Connesso',
|
'header.connected': 'Connesso',
|
||||||
|
|||||||
Reference in New Issue
Block a user