Implement authentication flow with API key support and loading states

This commit is contained in:
2025-07-31 18:59:12 +02:00
parent 89f90697ef
commit c038cabaf6
7 changed files with 414 additions and 17 deletions

View File

@@ -2,11 +2,14 @@ import { useState } from "react";
import Header from "@/components/Header"; import Header from "@/components/Header";
import InstanceList from "@/components/InstanceList"; import InstanceList from "@/components/InstanceList";
import InstanceModal from "@/components/InstanceModal"; import InstanceModal from "@/components/InstanceModal";
import LoginDialog from "@/components/LoginDialog";
import SystemInfoModal from "./components/SystemInfoModal";
import { type CreateInstanceOptions, type Instance } from "@/types/instance"; import { type CreateInstanceOptions, type Instance } from "@/types/instance";
import { useInstances } from "@/contexts/InstancesContext"; import { useInstances } from "@/contexts/InstancesContext";
import SystemInfoModal from "./components/SystemInfoModal"; import { useAuth } from "@/contexts/AuthContext";
function App() { function App() {
const { isAuthenticated, isLoading: authLoading } = useAuth();
const [isInstanceModalOpen, setIsInstanceModalOpen] = useState(false); const [isInstanceModalOpen, setIsInstanceModalOpen] = useState(false);
const [isSystemInfoModalOpen, setIsSystemInfoModalOpen] = useState(false); const [isSystemInfoModalOpen, setIsSystemInfoModalOpen] = useState(false);
const [editingInstance, setEditingInstance] = useState<Instance | undefined>( const [editingInstance, setEditingInstance] = useState<Instance | undefined>(
@@ -36,6 +39,28 @@ function App() {
setIsSystemInfoModalOpen(true); setIsSystemInfoModalOpen(true);
}; };
// Show loading spinner while checking auth
if (authLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Loading...</p>
</div>
</div>
);
}
// Show login dialog if not authenticated
if (!isAuthenticated) {
return (
<div className="min-h-screen bg-gray-50">
<LoginDialog open={true} />
</div>
);
}
// Show main app if authenticated
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<Header onCreateInstance={handleCreateInstance} onShowSystemInfo={handleShowSystemInfo} /> <Header onCreateInstance={handleCreateInstance} onShowSystemInfo={handleShowSystemInfo} />

View File

@@ -1,5 +1,6 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { HelpCircle } from "lucide-react"; import { HelpCircle, LogOut } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
interface HeaderProps { interface HeaderProps {
onCreateInstance: () => void; onCreateInstance: () => void;
@@ -7,6 +8,14 @@ interface HeaderProps {
} }
function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) { function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) {
const { logout } = useAuth();
const handleLogout = () => {
if (confirm("Are you sure you want to logout?")) {
logout();
}
};
return ( return (
<header className="bg-white border-b border-gray-200"> <header className="bg-white border-b border-gray-200">
<div className="container mx-auto max-w-4xl px-4 py-4"> <div className="container mx-auto max-w-4xl px-4 py-4">
@@ -16,7 +25,9 @@ function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) {
</h1> </h1>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button onClick={onCreateInstance} data-testid="create-instance-button">Create Instance</Button> <Button onClick={onCreateInstance} data-testid="create-instance-button">
Create Instance
</Button>
<Button <Button
variant="outline" variant="outline"
@@ -27,6 +38,16 @@ function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) {
> >
<HelpCircle className="h-4 w-4" /> <HelpCircle className="h-4 w-4" />
</Button> </Button>
<Button
variant="outline"
size="icon"
onClick={handleLogout}
data-testid="logout-button"
title="Logout"
>
<LogOut className="h-4 w-4" />
</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,157 @@
import React, { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { AlertCircle, Key, Loader2 } from 'lucide-react'
import { useAuth } from '@/contexts/AuthContext'
interface LoginDialogProps {
open: boolean
onOpenChange?: (open: boolean) => void
}
const LoginDialog: React.FC<LoginDialogProps> = ({
open,
onOpenChange,
}) => {
const { login, isLoading, error, clearError } = useAuth()
const [apiKey, setApiKey] = useState('')
const [localLoading, setLocalLoading] = useState(false)
// Clear form and errors when dialog opens/closes
useEffect(() => {
if (open) {
setApiKey('')
clearError()
}
}, [open, clearError])
// Clear error when user starts typing
useEffect(() => {
if (error && apiKey) {
clearError()
}
}, [apiKey, error, clearError])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!apiKey.trim()) {
return
}
setLocalLoading(true)
try {
await login(apiKey.trim())
// Login successful - dialog will close automatically when auth state changes
setApiKey('')
} catch (err) {
// Error is handled by the AuthContext
console.error('Login failed:', err)
} finally {
setLocalLoading(false)
}
}
const handleCancel = () => {
setApiKey('')
clearError()
onOpenChange?.(false)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !isSubmitDisabled) {
// Create a synthetic FormEvent to satisfy handleSubmit's type
const syntheticEvent = {
preventDefault: () => {},
} as React.FormEvent<HTMLFormElement>;
void handleSubmit(syntheticEvent)
}
}
const isSubmitDisabled = !apiKey.trim() || isLoading || localLoading
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-md"
showCloseButton={false} // Prevent closing without auth
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Key className="h-5 w-5" />
Authentication Required
</DialogTitle>
<DialogDescription>
Please enter your management API key to access the Llamactl dashboard.
</DialogDescription>
</DialogHeader>
<form onSubmit={(e) => { void handleSubmit(e) }}>
<div className="grid gap-4 py-4">
{/* Error Display */}
{error && (
<div className="flex items-center gap-2 p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0" />
<span className="text-sm text-destructive">{error}</span>
</div>
)}
{/* API Key Input */}
<div className="grid gap-2">
<Label htmlFor="apiKey">
Management API Key <span className="text-red-500">*</span>
</Label>
<Input
id="apiKey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="sk-management-..."
disabled={isLoading || localLoading}
className={error ? "border-red-500" : ""}
autoFocus
autoComplete="off"
/>
<p className="text-sm text-muted-foreground">
Your management API key is required to access instance management features.
</p>
</div>
</div>
<DialogFooter className="flex gap-2">
<Button
type="submit"
disabled={isSubmitDisabled}
data-testid="login-submit-button"
>
{(isLoading || localLoading) ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Authenticating...
</>
) : (
<>
<Key className="h-4 w-4" />
Login
</>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export default LoginDialog

View File

@@ -0,0 +1,162 @@
import { type ReactNode, createContext, useContext, useState, useEffect, useCallback } from 'react'
interface AuthContextState {
isAuthenticated: boolean
isLoading: boolean
apiKey: string | null
error: string | null
}
interface AuthContextActions {
login: (apiKey: string) => Promise<void>
logout: () => void
clearError: () => void
validateAuth: () => Promise<boolean>
}
type AuthContextType = AuthContextState & AuthContextActions
const AuthContext = createContext<AuthContextType | undefined>(undefined)
interface AuthProviderProps {
children: ReactNode
}
const AUTH_STORAGE_KEY = 'llamactl_management_key'
export const AuthProvider = ({ children }: AuthProviderProps) => {
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [apiKey, setApiKey] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
// Load auth state from sessionStorage on mount
useEffect(() => {
const loadStoredAuth = async () => {
try {
const storedKey = sessionStorage.getItem(AUTH_STORAGE_KEY)
if (storedKey) {
setApiKey(storedKey)
// Validate the stored key
const isValid = await validateApiKey(storedKey)
if (isValid) {
setIsAuthenticated(true)
} else {
// Invalid key, remove it
sessionStorage.removeItem(AUTH_STORAGE_KEY)
setApiKey(null)
}
}
} catch (err) {
console.error('Error loading stored auth:', err)
// Clear potentially corrupted storage
sessionStorage.removeItem(AUTH_STORAGE_KEY)
} finally {
setIsLoading(false)
}
}
void loadStoredAuth()
}, [])
// Validate API key by making a test request
const validateApiKey = async (key: string): Promise<boolean> => {
try {
const response = await fetch('/api/v1/instances', {
headers: {
'Authorization': `Bearer ${key}`,
'Content-Type': 'application/json'
}
})
return response.ok
} catch (err) {
console.error('Auth validation error:', err)
return false
}
}
const login = useCallback(async (key: string) => {
setIsLoading(true)
setError(null)
try {
// Validate the provided API key
const isValid = await validateApiKey(key)
if (!isValid) {
throw new Error('Invalid API key')
}
// Store the key and update state
sessionStorage.setItem(AUTH_STORAGE_KEY, key)
setApiKey(key)
setIsAuthenticated(true)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Authentication failed'
setError(errorMessage)
throw new Error(errorMessage)
} finally {
setIsLoading(false)
}
}, [])
const logout = useCallback(() => {
sessionStorage.removeItem(AUTH_STORAGE_KEY)
setApiKey(null)
setIsAuthenticated(false)
setError(null)
}, [])
const clearError = useCallback(() => {
setError(null)
}, [])
const validateAuth = useCallback(async (): Promise<boolean> => {
if (!apiKey) return false
const isValid = await validateApiKey(apiKey)
if (!isValid) {
logout()
}
return isValid
}, [apiKey, logout])
const value: AuthContextType = {
isAuthenticated,
isLoading,
apiKey,
error,
login,
logout,
clearError,
validateAuth,
}
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
// Helper hook for getting auth headers
export const useAuthHeaders = (): HeadersInit => {
const { apiKey, isAuthenticated } = useAuth()
if (!isAuthenticated || !apiKey) {
return {}
}
return {
'Authorization': `Bearer ${apiKey}`
}
}

View File

@@ -1,7 +1,7 @@
import type { ReactNode } from 'react'; import { type ReactNode, createContext, useContext, useState, useEffect, useCallback } from 'react'
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
import type { CreateInstanceOptions, Instance } from '@/types/instance' import type { CreateInstanceOptions, Instance } from '@/types/instance'
import { instancesApi } from '@/lib/api' import { instancesApi } from '@/lib/api'
import { useAuth } from '@/contexts/AuthContext'
interface InstancesContextState { interface InstancesContextState {
instances: Instance[] instances: Instance[]
@@ -29,6 +29,7 @@ interface InstancesProviderProps {
} }
export const InstancesProvider = ({ children }: InstancesProviderProps) => { export const InstancesProvider = ({ children }: InstancesProviderProps) => {
const { isAuthenticated, isLoading: authLoading } = useAuth()
const [instancesMap, setInstancesMap] = useState<Map<string, Instance>>(new Map()) const [instancesMap, setInstancesMap] = useState<Map<string, Instance>>(new Map())
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -41,6 +42,11 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
}, []) }, [])
const fetchInstances = useCallback(async () => { const fetchInstances = useCallback(async () => {
if (!isAuthenticated) {
setLoading(false)
return
}
try { try {
setLoading(true) setLoading(true)
setError(null) setError(null)
@@ -57,7 +63,7 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, []) }, [isAuthenticated])
const updateInstanceInMap = useCallback((name: string, updates: Partial<Instance>) => { const updateInstanceInMap = useCallback((name: string, updates: Partial<Instance>) => {
setInstancesMap(prev => { setInstancesMap(prev => {
@@ -154,9 +160,19 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
} }
}, []) }, [])
// Only fetch instances when auth is ready and user is authenticated
useEffect(() => { useEffect(() => {
fetchInstances() if (!authLoading) {
}, [fetchInstances]) if (isAuthenticated) {
void fetchInstances()
} else {
// Clear instances when not authenticated
setInstancesMap(new Map())
setLoading(false)
setError(null)
}
}
}, [authLoading, isAuthenticated, fetchInstances])
const value: InstancesContextType = { const value: InstancesContextType = {
instances, instances,

View File

@@ -10,18 +10,31 @@ async function apiCall<T>(
): Promise<T> { ): Promise<T> {
const url = `${API_BASE}${endpoint}`; const url = `${API_BASE}${endpoint}`;
// Prepare headers // Get auth token from sessionStorage (same as AuthContext)
const headers: HeadersInit = { const storedKey = sessionStorage.getItem('llamactl_management_key');
// Prepare headers with auth
const headers: Record<string, string> = {
"Content-Type": "application/json", "Content-Type": "application/json",
...options.headers, ...(options.headers as Record<string, string>),
}; };
// Add auth header if available
if (storedKey) {
headers['Authorization'] = `Bearer ${storedKey}`;
}
try { try {
const response = await fetch(url, { const response = await fetch(url, {
...options, ...options,
headers, headers,
}); });
// Handle authentication errors
if (response.status === 401) {
throw new Error('Authentication required');
}
if (!response.ok) { if (!response.ok) {
// Try to get error message from response // Try to get error message from response
let errorMessage = `HTTP ${response.status}`; let errorMessage = `HTTP ${response.status}`;
@@ -47,7 +60,7 @@ async function apiCall<T>(
const text = await response.text(); const text = await response.text();
return text as T; return text as T;
} else { } else {
const data = await response.json(); const data = await response.json() as T;
return data; return data;
} }
} catch (error) { } catch (error) {

View File

@@ -3,11 +3,14 @@ import ReactDOM from 'react-dom/client'
import App from './App' import App from './App'
import { InstancesProvider } from './contexts/InstancesContext' import { InstancesProvider } from './contexts/InstancesContext'
import './index.css' import './index.css'
import { AuthProvider } from './contexts/AuthContext'
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<AuthProvider>
<InstancesProvider> <InstancesProvider>
<App /> <App />
</InstancesProvider> </InstancesProvider>
</AuthProvider>
</React.StrictMode>, </React.StrictMode>,
) )