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)
This commit is contained in:
2026-07-14 13:21:34 +00:00
parent f5a030fd44
commit 6f85d25362
2 changed files with 162 additions and 11 deletions

View File

@@ -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<string, string> = { '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 (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (state === 'authenticated') {
return <>{children}</>;
}
// Login screen
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<Card className="w-full max-w-sm mx-4">
<CardContent className="p-6 space-y-4">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-primary/10 rounded-lg">
<ShieldCheck className="h-6 w-6 text-primary" />
</div>
<div>
<h1 className="text-xl font-semibold">Wild Central</h1>
<p className="text-sm text-muted-foreground">Enter your API token to continue</p>
</div>
</div>
{error && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<form onSubmit={handleLogin} className="space-y-3">
<div>
<Label htmlFor="token">API Token</Label>
<Input
id="token"
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Paste your bearer token"
className="mt-1 font-mono"
autoFocus
/>
</div>
<Button type="submit" className="w-full gap-2" disabled={submitting || !token}>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <LogIn className="h-4 w-4" />}
Sign In
</Button>
</form>
<p className="text-xs text-muted-foreground text-center">
Find your token in <code className="bg-muted px-1 rounded">secrets.yaml</code> under <code className="bg-muted px-1 rounded">api.bearerToken</code>
</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Outlet } from 'react-router'; import { Outlet } from 'react-router';
import { AppSidebar } from '../components/AppSidebar'; import { AppSidebar } from '../components/AppSidebar';
import { AuthGate } from '../components/AuthGate';
import { SidebarProvider, SidebarInset, SidebarTrigger } from '../components/ui/sidebar'; import { SidebarProvider, SidebarInset, SidebarTrigger } from '../components/ui/sidebar';
import { HelpProvider, useHelp } from '../contexts/HelpContext'; import { HelpProvider, useHelp } from '../contexts/HelpContext';
import { HelpPanel } from '../components/HelpPanel'; import { HelpPanel } from '../components/HelpPanel';
@@ -46,16 +47,18 @@ export function CentralLayout() {
); );
return ( return (
<HelpProvider> <AuthGate>
<SidebarProvider <HelpProvider>
open={sidebarOpen} <SidebarProvider
onOpenChange={(open) => { open={sidebarOpen}
setSidebarOpen(open); onOpenChange={(open) => {
localStorage.setItem('sidebar_state', String(open)); setSidebarOpen(open);
}} localStorage.setItem('sidebar_state', String(open));
> }}
<CentralLayoutContent /> >
</SidebarProvider> <CentralLayoutContent />
</HelpProvider> </SidebarProvider>
</HelpProvider>
</AuthGate>
); );
} }