From 36f948027b174b3651ef066b93af3672b8edfb4e Mon Sep 17 00:00:00 2001 From: Nicolas Varrot Date: Wed, 11 Feb 2026 12:48:58 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20runtime=20login=20screen=20=E2=80=94=20?= =?UTF-8?q?remove=20token=20from=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 3 +- FEEDBACK.md | 18 ++++- src/App.tsx | 23 +++++- src/components/Header.tsx | 15 +++- src/components/LoginScreen.tsx | 138 +++++++++++++++++++++++++++++++++ src/hooks/useGateway.ts | 79 ++++++++++++++----- src/lib/gateway.ts | 29 +++++-- 7 files changed, 274 insertions(+), 31 deletions(-) create mode 100644 src/components/LoginScreen.tsx diff --git a/.env.example b/.env.example index cdc20b7..6bef0f9 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ +# Optional: pre-fill the Gateway URL field on the login screen +# If not set, defaults to ws://:18789 VITE_GATEWAY_WS_URL=ws://localhost:18789 -VITE_GATEWAY_TOKEN=your-gateway-token-here diff --git a/FEEDBACK.md b/FEEDBACK.md index 3b2dccf..045c778 100644 --- a/FEEDBACK.md +++ b/FEEDBACK.md @@ -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 diff --git a/src/App.tsx b/src/App.tsx index 0989bc3..3c382de 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( +
+
Connecting…
+
+ ); + } + + // Not authenticated — show login + if (!authenticated) { + return ; + } + return (
setSidebarOpen(false)} />
-
setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} /> +
setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} />
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 7ce47cb..ea7e152 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -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 Disconnected )} + {onLogout && ( + + )} {(() => { diff --git a/src/components/LoginScreen.tsx b/src/components/LoginScreen.tsx new file mode 100644 index 0000000..6c0d287 --- /dev/null +++ b/src/components/LoginScreen.tsx @@ -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 ( +
+
+ {/* Logo */} +
+
+ +
+
+

PinchChat

+ +
+

Connect to your OpenClaw gateway

+
+ + {/* Form */} +
+
+ + 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} + /> +
+ +
+ +
+ 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} + /> + +
+
+ + {error && ( +
+ {error} +
+ )} + + +
+ +

+ Credentials are stored locally in your browser +

+
+
+ ); +} diff --git a/src/hooks/useGateway.ts b/src/hooks/useGateway.ts index d1905d5..f9bb221 100644 --- a/src/hooks/useGateway.ts +++ b/src/hooks/useGateway.ts @@ -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([]); const [activeSession, setActiveSession] = useState('agent:main:main'); const [isGenerating, setIsGenerating] = useState(false); + const [authenticated, setAuthenticated] = useState(null); // null = checking + const [connectError, setConnectError] = useState(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(null); const [activeSessions, setActiveSessions] = useState>(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, + }; } diff --git a/src/lib/gateway.ts b/src/lib/gateway.ts index aaa8586..a152c27 100644 --- a/src/lib/gateway.ts +++ b/src/lib/gateway.ts @@ -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 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;