- 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
139 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|