feat: runtime login screen — remove token from build

- Add LoginScreen component with Gateway URL + Token fields
- Store credentials in localStorage (not in bundle)
- Auto-reconnect with stored credentials on reload
- Add logout button (LogOut icon) in Header
- Remove VITE_GATEWAY_TOKEN from .env.example
- VITE_GATEWAY_WS_URL now only pre-fills the URL field
- Dark neon theme consistent with rest of app

Closes feedback item #4
This commit is contained in:
Nicolas Varrot
2026-02-11 12:48:58 +00:00
parent a01bae8c1c
commit 36f948027b
7 changed files with 274 additions and 31 deletions

View File

@@ -1,2 +1,3 @@
# Optional: pre-fill the Gateway URL field on the login screen
# If not set, defaults to ws://<current-hostname>:18789
VITE_GATEWAY_WS_URL=ws://localhost:18789
VITE_GATEWAY_TOKEN=your-gateway-token-here

View File

@@ -12,10 +12,26 @@
- **Priority:** high
- **Status:** done
- **Completed:** 2026-02-11 — commit `8834b2a`
- **Description:** Filtrer les messages "NO_REPLY" — ne pas afficher les messages dont le contenu est exactement "NO_REPLY" (ce sont des réponses internes de l'agent qui ne doivent pas être visibles dans le chat)
- **Description:** Filtrer les messages "NO_REPLY"
## Item #3
- **Date:** 2026-02-11
- **Priority:** medium
- **Status:** pending
- **Description:** Ajouter le support i18n (internationalisation) — le projet open-source est en anglais, mais le deploy perso de Nicolas doit rester en français. Soit via une config `.env` (ex: `VITE_LOCALE=fr`), soit via un système de traduction léger. Les strings UI (placeholder input, bouton envoyer, statut connexion, etc.) doivent être configurables.
## Item #4
- **Date:** 2026-02-11
- **Priority:** high
- **Status:** in-progress
- **Description:** Supprimer le token du build — implémenter un écran de login au runtime
- Au premier lancement (ou si pas de credentials en localStorage), afficher un écran de connexion avec :
- Champ "Gateway URL" (ex: `ws://192.168.1.14:18789`)
- Champ "Token" (password field)
- Bouton "Connect"
- Stocker les credentials en `localStorage` (pas dans le bundle JS)
- Supprimer `VITE_GATEWAY_TOKEN` du `.env.example` et du code
- Garder `VITE_GATEWAY_WS_URL` uniquement comme valeur par défaut optionnelle pour pré-remplir le champ URL
- Ajouter un bouton "Disconnect" / "Logout" dans le header qui clear le localStorage et revient à l'écran de login
- L'écran de login doit suivre le même thème dark neon que le reste de l'app
- ⚠️ Après ce changement, le deploy perso (`~/marlbot-chat/.env`) n'a plus besoin de `VITE_GATEWAY_TOKEN` — l'utilisateur entrera le token via l'UI

View File

@@ -3,11 +3,30 @@ import { useGateway } from './hooks/useGateway';
import { Header } from './components/Header';
import { Sidebar } from './components/Sidebar';
import { Chat } from './components/Chat';
import { LoginScreen } from './components/LoginScreen';
export default function App() {
const { status, messages, sessions, activeSession, isGenerating, sendMessage, abort, switchSession } = useGateway();
const {
status, messages, sessions, activeSession, isGenerating,
sendMessage, abort, switchSession,
authenticated, login, logout, connectError, isConnecting,
} = useGateway();
const [sidebarOpen, setSidebarOpen] = useState(false);
// Still checking stored credentials
if (authenticated === null) {
return (
<div className="h-dvh flex items-center justify-center bg-[#1e1e24] text-zinc-500">
<div className="animate-pulse text-sm">Connecting</div>
</div>
);
}
// Not authenticated — show login
if (!authenticated) {
return <LoginScreen onConnect={login} error={connectError} isConnecting={isConnecting} />;
}
return (
<div className="h-dvh flex bg-[#1e1e24] text-zinc-300 bg-[radial-gradient(ellipse_at_top,rgba(255,255,255,0.02),transparent_50%),radial_gradient(ellipse_at_bottom_right,rgba(99,102,241,0.04),transparent_50%)]" role="application" aria-label="PinchChat">
<Sidebar
@@ -18,7 +37,7 @@ export default function App() {
onClose={() => setSidebarOpen(false)}
/>
<div className="flex-1 flex flex-col min-w-0">
<Header status={status} sessionKey={activeSession} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} />
<Header status={status} sessionKey={activeSession} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} />
<Chat messages={messages} isGenerating={isGenerating} status={status} onSend={sendMessage} onAbort={abort} />
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { Menu, Bot, Sparkles } from 'lucide-react';
import { Menu, Bot, Sparkles, LogOut } from 'lucide-react';
import type { ConnectionStatus, Session } from '../types';
interface Props {
@@ -6,9 +6,10 @@ interface Props {
sessionKey: string;
onToggleSidebar: () => void;
activeSessionData?: Session;
onLogout?: () => void;
}
export function Header({ status, sessionKey, onToggleSidebar, activeSessionData }: Props) {
export function Header({ status, sessionKey, onToggleSidebar, activeSessionData, onLogout }: Props) {
const sessionLabel = sessionKey.split(':').pop() || sessionKey;
return (
@@ -46,6 +47,16 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData
<span className="text-xs text-zinc-300 hidden sm:inline">Disconnected</span>
</div>
)}
{onLogout && (
<button
onClick={onLogout}
aria-label="Disconnect and logout"
className="p-2 rounded-2xl hover:bg-white/5 text-zinc-500 hover:text-zinc-300 transition-colors"
title="Logout"
>
<LogOut size={16} />
</button>
)}
</div>
</header>
{(() => {

View File

@@ -0,0 +1,138 @@
import { useState, useEffect } from 'react';
import { Bot, Sparkles, Eye, EyeOff, Loader2 } from 'lucide-react';
interface Props {
onConnect: (url: string, token: string) => void;
error?: string | null;
isConnecting?: boolean;
}
const STORAGE_KEY = 'pinchchat_credentials';
export function getStoredCredentials(): { url: string; token: string } | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (parsed.url && parsed.token) return parsed;
} catch {}
return null;
}
export function storeCredentials(url: string, token: string) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ url, token }));
}
export function clearCredentials() {
localStorage.removeItem(STORAGE_KEY);
}
export function LoginScreen({ onConnect, error, isConnecting }: Props) {
const defaultUrl = import.meta.env.VITE_GATEWAY_WS_URL || `ws://${window.location.hostname}:18789`;
const [url, setUrl] = useState(defaultUrl);
const [token, setToken] = useState('');
const [showToken, setShowToken] = useState(false);
useEffect(() => {
const stored = getStoredCredentials();
if (stored) {
setUrl(stored.url);
setToken(stored.token);
}
}, []);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!url.trim() || !token.trim()) return;
onConnect(url.trim(), token.trim());
};
return (
<div className="h-dvh flex items-center justify-center bg-[#1e1e24] text-zinc-300 bg-[radial-gradient(ellipse_at_top,rgba(255,255,255,0.02),transparent_50%),radial-gradient(ellipse_at_bottom_right,rgba(99,102,241,0.04),transparent_50%)]">
<div className="w-full max-w-md mx-4">
{/* Logo */}
<div className="flex flex-col items-center gap-3 mb-8">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-white/10 bg-zinc-800/60 shadow-lg shadow-cyan-500/5">
<Bot className="h-7 w-7 text-cyan-200" />
</div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-zinc-200 tracking-wide">PinchChat</h1>
<Sparkles className="h-5 w-5 text-cyan-300/60" />
</div>
<p className="text-sm text-zinc-500">Connect to your OpenClaw gateway</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="rounded-2xl border border-white/8 bg-[#232329]/80 backdrop-blur-xl p-6 space-y-5 shadow-2xl shadow-black/30">
<div className="space-y-2">
<label htmlFor="gateway-url" className="block text-xs font-medium text-zinc-400 uppercase tracking-wider">
Gateway URL
</label>
<input
id="gateway-url"
type="text"
value={url}
onChange={e => setUrl(e.target.value)}
placeholder="ws://192.168.1.14:18789"
className="w-full rounded-xl border border-white/8 bg-zinc-800/50 px-4 py-3 text-sm text-zinc-200 placeholder:text-zinc-600 outline-none focus:border-cyan-400/40 focus:ring-1 focus:ring-cyan-400/20 transition-all"
autoComplete="url"
disabled={isConnecting}
/>
</div>
<div className="space-y-2">
<label htmlFor="gateway-token" className="block text-xs font-medium text-zinc-400 uppercase tracking-wider">
Token
</label>
<div className="relative">
<input
id="gateway-token"
type={showToken ? 'text' : 'password'}
value={token}
onChange={e => setToken(e.target.value)}
placeholder="Enter your gateway token"
className="w-full rounded-xl border border-white/8 bg-zinc-800/50 px-4 py-3 pr-12 text-sm text-zinc-200 placeholder:text-zinc-600 outline-none focus:border-cyan-400/40 focus:ring-1 focus:ring-cyan-400/20 transition-all"
autoComplete="current-password"
disabled={isConnecting}
/>
<button
type="button"
onClick={() => setShowToken(!showToken)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
tabIndex={-1}
aria-label={showToken ? 'Hide token' : 'Show token'}
>
{showToken ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</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}
</div>
)}
<button
type="submit"
disabled={!url.trim() || !token.trim() || isConnecting}
className="w-full rounded-xl bg-gradient-to-r from-cyan-500 to-violet-500 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-cyan-500/20 hover:shadow-cyan-500/30 hover:brightness-110 disabled:opacity-40 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
>
{isConnecting ? (
<>
<Loader2 size={16} className="animate-spin" />
Connecting
</>
) : (
'Connect'
)}
</button>
</form>
<p className="text-center text-xs text-zinc-600 mt-6">
Credentials are stored locally in your browser
</p>
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { GatewayClient } from '../lib/gateway';
import { genIdempotencyKey } from '../lib/utils';
import { getStoredCredentials, storeCredentials, clearCredentials } from '../components/LoginScreen';
import type { ChatMessage, MessageBlock, ConnectionStatus, Session } from '../types';
function extractText(message: any): string {
@@ -23,6 +24,10 @@ export function useGateway() {
const [sessions, setSessions] = useState<Session[]>([]);
const [activeSession, setActiveSession] = useState('agent:main:main');
const [isGenerating, setIsGenerating] = useState(false);
const [authenticated, setAuthenticated] = useState<boolean | null>(null); // null = checking
const [connectError, setConnectError] = useState<string | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
const isConnectingRef = useRef(false);
const messagesRef = useRef(messages);
messagesRef.current = messages;
const activeSessionRef = useRef(activeSession);
@@ -30,21 +35,38 @@ export function useGateway() {
const currentRunIdRef = useRef<string | null>(null);
const [activeSessions, setActiveSessions] = useState<Set<string>>(new Set());
useEffect(() => {
const client = new GatewayClient();
const setupClient = useCallback((wsUrl: string, token: string) => {
// Tear down existing client
if (clientRef.current) {
clientRef.current.disconnect();
}
const client = new GatewayClient(wsUrl, token);
clientRef.current = client;
client.onStatus((s) => {
setStatus(s);
if (s === 'connected') {
setAuthenticated(true);
setConnectError(null);
setIsConnecting(false);
isConnectingRef.current = false;
storeCredentials(wsUrl, token);
loadSessions();
loadHistory(activeSessionRef.current);
} else if (s === 'disconnected' && !client.isConnected) {
// If we never connected successfully, this is an auth/connection error
if (isConnectingRef.current) {
setConnectError('Connection failed — check URL and token');
setIsConnecting(false);
isConnectingRef.current = false;
setAuthenticated(false);
}
}
});
client.onEvent((event, payload) => {
if (event === 'agent') {
// Tool stream events
handleAgentEvent(payload);
return;
}
@@ -52,7 +74,6 @@ export function useGateway() {
const { state, runId, message, errorMessage, sessionKey: evtSession } = payload;
// Track active/inactive sessions globally
if (evtSession) {
if (state === 'delta') {
setActiveSessions(prev => {
@@ -80,14 +101,12 @@ export function useGateway() {
setMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'assistant' && last.isStreaming && last.runId === runId) {
// Update text block but preserve tool/thinking blocks
const updated = { ...last };
updated.content = text;
const nonTextBlocks = updated.blocks.filter(b => b.type !== 'text');
updated.blocks = [...nonTextBlocks, { type: 'text' as const, text }];
return [...prev.slice(0, -1), updated];
}
// Create new streaming message
const msg: ChatMessage = {
id: runId + '-' + Date.now(),
role: 'assistant',
@@ -102,7 +121,6 @@ export function useGateway() {
} else if (state === 'final') {
currentRunIdRef.current = null;
setIsGenerating(false);
// Reload full history to get the proper final messages with tool calls etc.
loadHistory(activeSessionRef.current);
} else if (state === 'error') {
currentRunIdRef.current = null;
@@ -133,12 +151,23 @@ export function useGateway() {
}
});
setIsConnecting(true);
isConnectingRef.current = true;
setConnectError(null);
client.connect();
return () => client.disconnect();
}, []);
// On mount: try stored credentials
useEffect(() => {
const stored = getStoredCredentials();
if (stored) {
setupClient(stored.url, stored.token);
} else {
setAuthenticated(false);
}
}, [setupClient]);
const handleAgentEvent = useCallback((payload: any) => {
// Handle tool stream events from agent stream
if (payload?.stream !== 'tool') return;
const data = payload.data ?? {};
const phase = data.phase;
@@ -202,10 +231,8 @@ export function useGateway() {
for (const block of m.content) {
if (block.type === 'text') blocks.push({ type: 'text', text: block.text });
else if (block.type === 'thinking') blocks.push({ type: 'thinking', text: block.thinking || block.text || '' });
// Anthropic format
else if (block.type === 'tool_use') blocks.push({ type: 'tool_use', name: block.name, input: block.input, id: block.id });
else if (block.type === 'tool_result') blocks.push({ type: 'tool_result', content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2), toolUseId: block.tool_use_id });
// OpenClaw gateway format (toolCall / toolResult)
else if (block.type === 'toolCall') blocks.push({ type: 'tool_use', name: block.name, input: block.arguments || block.input, id: block.id });
else if (block.type === 'toolResult') blocks.push({ type: 'tool_result', content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2), toolUseId: block.toolCallId || block.tool_use_id, name: block.name });
}
@@ -213,10 +240,8 @@ export function useGateway() {
blocks.push({ type: 'text', text: m.content });
}
}
// Map gateway roles to our simplified roles
const role: 'user' | 'assistant' = m.role === 'user' ? 'user' : 'assistant';
// toolResult role messages: convert text blocks to tool_result blocks, then merge into previous assistant
if (m.role === 'toolResult') {
const toolBlocks: MessageBlock[] = blocks.map(b => {
if (b.type === 'text') {
@@ -242,7 +267,6 @@ export function useGateway() {
blocks,
};
});
// Merge toolResult messages into their preceding assistant message
const merged: ChatMessage[] = [];
for (const msg of msgs) {
if ((msg as any).isToolResult && merged.length > 0 && merged[merged.length - 1].role === 'assistant') {
@@ -251,8 +275,7 @@ export function useGateway() {
blocks: [...merged[merged.length - 1].blocks, ...msg.blocks],
};
} else if ((msg as any).isToolResult) {
// Orphan toolResult — skip or show as assistant
// skip it
// skip orphan
} else {
merged.push(msg);
}
@@ -300,6 +323,23 @@ export function useGateway() {
loadHistory(key);
}, [loadHistory]);
const login = useCallback((url: string, token: string) => {
setupClient(url, token);
}, [setupClient]);
const logout = useCallback(() => {
if (clientRef.current) {
clientRef.current.disconnect();
clientRef.current = null;
}
clearCredentials();
setAuthenticated(false);
setMessages([]);
setSessions([]);
setStatus('disconnected');
setConnectError(null);
}, []);
// Periodic session refresh every 30s
useEffect(() => {
if (status !== 'connected') return;
@@ -307,11 +347,14 @@ export function useGateway() {
return () => clearInterval(interval);
}, [status, loadSessions]);
// Merge active state into sessions
const enrichedSessions = sessions.map(s => ({
...s,
isActive: activeSessions.has(s.key),
}));
return { status, messages, sessions: enrichedSessions, activeSession, isGenerating, sendMessage, abort, switchSession, loadSessions };
return {
status, messages, sessions: enrichedSessions, activeSession, isGenerating,
sendMessage, abort, switchSession, loadSessions,
authenticated, login, logout, connectError, isConnecting,
};
}

View File

@@ -3,9 +3,6 @@ import { genId } from './utils';
export type GatewayEventHandler = (event: string, payload: any) => void;
export type GatewayResponseHandler = (id: string, ok: boolean, payload: any) => void;
const WS_URL = import.meta.env.VITE_GATEWAY_WS_URL || `ws://${window.location.hostname}:18789`;
const AUTH_TOKEN = import.meta.env.VITE_GATEWAY_TOKEN || '';
export class GatewayClient {
private ws: WebSocket | null = null;
private pendingRequests = new Map<string, { resolve: (v: any) => void; reject: (e: any) => void }>();
@@ -13,6 +10,21 @@ export class GatewayClient {
private _onStatus: (s: 'disconnected' | 'connecting' | 'connected') => void = () => {};
private reconnectTimer: any = null;
private connected = false;
private autoReconnect = true;
private wsUrl: string;
private authToken: string;
constructor(wsUrl?: string, authToken?: string) {
this.wsUrl = wsUrl || `ws://${window.location.hostname}:18789`;
this.authToken = authToken || '';
}
/** Update credentials (e.g. after login). Does not reconnect automatically. */
setCredentials(wsUrl: string, authToken: string) {
this.wsUrl = wsUrl;
this.authToken = authToken;
}
onStatus(fn: (s: 'disconnected' | 'connecting' | 'connected') => void) {
this._onStatus = fn;
@@ -25,8 +37,9 @@ export class GatewayClient {
connect() {
if (this.ws) return;
this.autoReconnect = true;
this._onStatus('connecting');
this.ws = new WebSocket(WS_URL);
this.ws = new WebSocket(this.wsUrl);
this.ws.onopen = () => { console.log('[GW] WS open'); };
@@ -58,7 +71,7 @@ export class GatewayClient {
this._onStatus('disconnected');
this.pendingRequests.forEach(p => p.reject(new Error('disconnected')));
this.pendingRequests.clear();
this.scheduleReconnect();
if (this.autoReconnect) this.scheduleReconnect();
};
this.ws.onerror = (e) => { console.log('[GW] WS error', e); };
@@ -75,8 +88,8 @@ export class GatewayClient {
caps: [],
commands: [],
permissions: {},
auth: { token: AUTH_TOKEN },
locale: 'fr-FR',
auth: { token: this.authToken },
locale: navigator.language || 'en',
userAgent: 'pinchchat/1.0.0',
}).then((res) => {
console.log('[GW] connected!', res);
@@ -84,6 +97,7 @@ export class GatewayClient {
this._onStatus('connected');
}).catch((err) => {
console.log('[GW] connect failed:', err);
this.autoReconnect = false;
this.disconnect();
});
}
@@ -97,6 +111,7 @@ export class GatewayClient {
}
disconnect() {
this.autoReconnect = false;
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
if (this.ws) { this.ws.close(); this.ws = null; }
this.connected = false;