From 6f85d2536292ae7c45b20b9033b89e1438d2fc24 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Tue, 14 Jul 2026 13:21:34 +0000 Subject: [PATCH] Add login screen for API token authentication AuthGate component wraps the app layout: - On load, checks if saved token is valid by calling a protected endpoint - If auth not required (dev mode), passes through immediately - If auth required and no valid token, shows a clean login screen - Token input with password field, stored in localStorage on success - Shows guidance: "Find your token in secrets.yaml under api.bearerToken" - Handles connection errors gracefully (shows app anyway) --- web/src/components/AuthGate.tsx | 148 +++++++++++++++++++++++++++++++ web/src/router/CentralLayout.tsx | 25 +++--- 2 files changed, 162 insertions(+), 11 deletions(-) create mode 100644 web/src/components/AuthGate.tsx diff --git a/web/src/components/AuthGate.tsx b/web/src/components/AuthGate.tsx new file mode 100644 index 0000000..9d75ccf --- /dev/null +++ b/web/src/components/AuthGate.tsx @@ -0,0 +1,148 @@ +import { useState, useEffect } from 'react'; +import { Card, CardContent } from './ui/card'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; +import { Label } from './ui/label'; +import { Alert, AlertDescription } from './ui/alert'; +import { ShieldCheck, Loader2, AlertCircle, LogIn } from 'lucide-react'; +import { apiClient } from '../services/api/client'; + +interface AuthGateProps { + children: React.ReactNode; +} + +export function AuthGate({ children }: AuthGateProps) { + const [state, setState] = useState<'checking' | 'authenticated' | 'login'>('checking'); + const [token, setToken] = useState(''); + const [error, setError] = useState(''); + const [submitting, setSubmitting] = useState(false); + + // On mount, check if saved token is still valid + useEffect(() => { + checkAuth(); + }, []); + + async function checkAuth() { + // If no token saved, might be dev mode (no auth required) + try { + const resp = await fetch(`${apiClient.getBaseURL()}/api/v1/health`); + if (!resp.ok) { + setState('login'); + return; + } + + // Health is public, but try a protected endpoint to see if auth is needed + const headers: Record = { 'Content-Type': 'application/json' }; + if (apiClient.hasToken()) { + headers['Authorization'] = `Bearer ${localStorage.getItem('wild-central:token')}`; + } + const statusResp = await fetch(`${apiClient.getBaseURL()}/api/v1/operator`, { headers }); + + if (statusResp.ok) { + setState('authenticated'); + } else if (statusResp.status === 401) { + // Auth required but token is missing or invalid + if (apiClient.hasToken()) { + apiClient.clearToken(); + } + setState('login'); + } else { + // Other error — maybe server is starting up, treat as authenticated + setState('authenticated'); + } + } catch { + // Can't reach server — show app anyway, it'll show connection errors + setState('authenticated'); + } + } + + async function handleLogin(e: React.FormEvent) { + e.preventDefault(); + setError(''); + setSubmitting(true); + + try { + // Test the token against a protected endpoint + const resp = await fetch(`${apiClient.getBaseURL()}/api/v1/operator`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + }); + + if (resp.ok) { + apiClient.setToken(token); + setState('authenticated'); + } else if (resp.status === 401) { + setError('Invalid token'); + } else { + setError(`Server error: ${resp.status}`); + } + } catch { + setError('Cannot reach Wild Central'); + } finally { + setSubmitting(false); + } + } + + if (state === 'checking') { + return ( +
+ +
+ ); + } + + if (state === 'authenticated') { + return <>{children}; + } + + // Login screen + return ( +
+ + +
+
+ +
+
+

Wild Central

+

Enter your API token to continue

+
+
+ + {error && ( + + + {error} + + )} + +
+
+ + setToken(e.target.value)} + placeholder="Paste your bearer token" + className="mt-1 font-mono" + autoFocus + /> +
+ +
+ +

+ Find your token in secrets.yaml under api.bearerToken +

+
+
+
+ ); +} diff --git a/web/src/router/CentralLayout.tsx b/web/src/router/CentralLayout.tsx index b256b01..d66287e 100644 --- a/web/src/router/CentralLayout.tsx +++ b/web/src/router/CentralLayout.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { Outlet } from 'react-router'; import { AppSidebar } from '../components/AppSidebar'; +import { AuthGate } from '../components/AuthGate'; import { SidebarProvider, SidebarInset, SidebarTrigger } from '../components/ui/sidebar'; import { HelpProvider, useHelp } from '../contexts/HelpContext'; import { HelpPanel } from '../components/HelpPanel'; @@ -46,16 +47,18 @@ export function CentralLayout() { ); return ( - - { - setSidebarOpen(open); - localStorage.setItem('sidebar_state', String(open)); - }} - > - - - + + + { + setSidebarOpen(open); + localStorage.setItem('sidebar_state', String(open)); + }} + > + + + + ); }