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

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