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:
148
web/src/components/AuthGate.tsx
Normal file
148
web/src/components/AuthGate.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,6 +47,7 @@ export function CentralLayout() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<AuthGate>
|
||||||
<HelpProvider>
|
<HelpProvider>
|
||||||
<SidebarProvider
|
<SidebarProvider
|
||||||
open={sidebarOpen}
|
open={sidebarOpen}
|
||||||
@@ -57,5 +59,6 @@ export function CentralLayout() {
|
|||||||
<CentralLayoutContent />
|
<CentralLayoutContent />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</HelpProvider>
|
</HelpProvider>
|
||||||
|
</AuthGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user