Files
PinchChat/src/components/LoginScreen.tsx
Nicolas Varrot 36f948027b 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
2026-02-11 12:48:58 +00:00

139 lines
5.5 KiB
TypeScript

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>
);
}