feat(FN-005): implement frontend foundation with auth and routing
- Add OIDC authentication with PKCE flow - Create dashboard shell with sidebar and header - Implement project management UI (list, create, detail) - Add tool spawn page with tool/project selection - Create tool instance detail page with status and controls - Set up React Router with route guards - Add Zustand auth store and API client with types
This commit is contained in:
+5
-22
@@ -1,29 +1,12 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { AuthProvider } from './auth/AuthProvider'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>Headquarter</h1>
|
||||
<p className="tagline">Hosted workspace and tool-orchestration platform</p>
|
||||
</header>
|
||||
<main className="app-main">
|
||||
<section className="status-card">
|
||||
<h2>Platform Status</h2>
|
||||
<div className="status-row">
|
||||
<span className="status-label">API</span>
|
||||
<span className="status-value" data-testid="api-status">Checking…</span>
|
||||
</div>
|
||||
<div className="status-row">
|
||||
<span className="status-label">Version</span>
|
||||
<span className="status-value">0.0.1</span>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<footer className="app-footer">
|
||||
<p>Scaffolded by FN-002</p>
|
||||
</footer>
|
||||
</div>
|
||||
<AuthProvider>
|
||||
<Outlet />
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { render } from '@testing-library/react'
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
|
||||
import App from '../App'
|
||||
|
||||
describe('App', () => {
|
||||
it('renders the platform name', () => {
|
||||
render(<App />)
|
||||
expect(screen.getByText('Headquarter')).toBeInTheDocument()
|
||||
})
|
||||
it('renders without crashing', () => {
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <App />,
|
||||
children: [
|
||||
{ path: '/', element: <div>Test Page</div> },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
it('renders the tagline', () => {
|
||||
render(<App />)
|
||||
expect(screen.getByText('Hosted workspace and tool-orchestration platform')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the platform status section', () => {
|
||||
render(<App />)
|
||||
expect(screen.getByText('Platform Status')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('api-status')).toHaveTextContent('Checking…')
|
||||
})
|
||||
|
||||
it('renders the scaffold footer', () => {
|
||||
render(<App />)
|
||||
expect(screen.getByText('Scaffolded by FN-002')).toBeInTheDocument()
|
||||
render(<RouterProvider router={router} />)
|
||||
expect(document.body).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1 +1,26 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
// Mock localStorage for tests
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
})
|
||||
|
||||
// Mock sessionStorage for tests
|
||||
const sessionStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'sessionStorage', {
|
||||
value: sessionStorageMock,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { Project, ProjectCreate, ProjectUpdate, ToolDefinition, ToolInstance, User } from '../types/api.ts'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public statusText: string,
|
||||
public data?: unknown
|
||||
) {
|
||||
super(`API Error ${status}: ${statusText}`)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
function getAuthToken(): string | null {
|
||||
return localStorage.getItem('access_token')
|
||||
}
|
||||
|
||||
async function fetchWithAuth(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
const url = `${API_URL}/api/v1${endpoint}`
|
||||
const token = getAuthToken()
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...((options.headers as Record<string, string>) || {}),
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`[API] ${options.method || 'GET'} ${url}`)
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`[API] ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => undefined)
|
||||
throw new ApiError(response.status, response.statusText, data)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Auth
|
||||
getCurrentUser: async (): Promise<User> => {
|
||||
const response = await fetchWithAuth('/users/me')
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// Projects
|
||||
getProjects: async (): Promise<Project[]> => {
|
||||
const response = await fetchWithAuth('/projects')
|
||||
return response.json()
|
||||
},
|
||||
|
||||
getProject: async (id: string): Promise<Project> => {
|
||||
const response = await fetchWithAuth(`/projects/${id}`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
createProject: async (data: ProjectCreate): Promise<Project> => {
|
||||
const response = await fetchWithAuth('/projects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
updateProject: async (id: string, data: ProjectUpdate): Promise<Project> => {
|
||||
const response = await fetchWithAuth(`/projects/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
deleteProject: async (id: string): Promise<void> => {
|
||||
await fetchWithAuth(`/projects/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
},
|
||||
|
||||
getToolInstances: async (projectId: string): Promise<ToolInstance[]> => {
|
||||
const response = await fetchWithAuth(`/projects/${projectId}/tool-instances`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
getToolInstance: async (projectId: string, instanceId: string): Promise<ToolInstance> => {
|
||||
const response = await fetchWithAuth(`/projects/${projectId}/tool-instances/${instanceId}`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
createToolInstance: async (projectId: string, data: { tool_definition_id: string; name: string }): Promise<ToolInstance> => {
|
||||
const response = await fetchWithAuth(`/projects/${projectId}/tool-instances`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
deleteToolInstance: async (projectId: string, instanceId: string): Promise<void> => {
|
||||
await fetchWithAuth(`/projects/${projectId}/tool-instances/${instanceId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
},
|
||||
|
||||
stopToolInstance: async (projectId: string, instanceId: string): Promise<ToolInstance> => {
|
||||
const response = await fetchWithAuth(`/projects/${projectId}/tool-instances/${instanceId}/stop`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
startToolInstance: async (projectId: string, instanceId: string): Promise<ToolInstance> => {
|
||||
const response = await fetchWithAuth(`/projects/${projectId}/tool-instances/${instanceId}/start`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
getToolInstanceStatus: async (projectId: string, instanceId: string): Promise<{ status: string; subdomain: string; container_id: string }> => {
|
||||
const response = await fetchWithAuth(`/projects/${projectId}/tool-instances/${instanceId}/status`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
getToolRegistry: async (): Promise<ToolDefinition[]> => {
|
||||
const response = await fetchWithAuth('/tools')
|
||||
return response.json()
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { api } from '../api/client'
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const { setUser, setError, setState } = useAuthStore()
|
||||
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (!token) {
|
||||
setState('unauthenticated')
|
||||
return
|
||||
}
|
||||
|
||||
const user = await api.getCurrentUser()
|
||||
setUser(user)
|
||||
} catch (error) {
|
||||
console.error('Auth initialization failed:', error)
|
||||
setError(error instanceof Error ? error : new Error('Auth failed'))
|
||||
localStorage.removeItem('access_token')
|
||||
}
|
||||
}
|
||||
|
||||
initAuth()
|
||||
}, [setUser, setError, setState])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// PKCE utilities for OIDC flow
|
||||
function generateRandomString(length: number): string {
|
||||
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let text = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
text += possible.charAt(Math.floor(Math.random() * possible.length))
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(verifier)
|
||||
const digest = await crypto.subtle.digest('SHA-256', data)
|
||||
const base64 = btoa(String.fromCharCode(...new Uint8Array(digest)))
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
|
||||
}
|
||||
|
||||
export async function createPKCE(): Promise<{ verifier: string; challenge: string }> {
|
||||
const verifier = generateRandomString(128)
|
||||
const challenge = await generateCodeChallenge(verifier)
|
||||
return { verifier, challenge }
|
||||
}
|
||||
|
||||
export function storePKCE(verifier: string): void {
|
||||
sessionStorage.setItem('pkce_verifier', verifier)
|
||||
}
|
||||
|
||||
export function getPKCE(): string | null {
|
||||
return sessionStorage.getItem('pkce_verifier')
|
||||
}
|
||||
|
||||
export function clearPKCE(): void {
|
||||
sessionStorage.removeItem('pkce_verifier')
|
||||
}
|
||||
|
||||
export function storeToken(token: string): void {
|
||||
localStorage.setItem('access_token', token)
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem('access_token')
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
localStorage.removeItem('access_token')
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import Header from './Header'
|
||||
import Sidebar from './Sidebar'
|
||||
|
||||
export default function DashboardLayout() {
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<Header />
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import {
|
||||
UserCircleIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
Cog6ToothIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
|
||||
export default function Header() {
|
||||
const { user, logout } = useAuthStore()
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
localStorage.removeItem('access_token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="h-16 border-b border-border bg-surface flex items-center justify-between px-6">
|
||||
<div className="flex items-center">
|
||||
<span className="text-lg font-semibold">Headquarter</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-surface transition-colors"
|
||||
>
|
||||
<UserCircleIcon className="w-6 h-6" />
|
||||
<span className="text-sm">{user?.display_name || user?.email || 'User'}</span>
|
||||
</button>
|
||||
|
||||
{showDropdown && (
|
||||
<div className="absolute right-0 mt-2 w-48 rounded-lg border border-border bg-surface shadow-lg z-50">
|
||||
<div className="p-2">
|
||||
<Link
|
||||
to="/settings"
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-surface text-sm"
|
||||
onClick={() => setShowDropdown(false)}
|
||||
>
|
||||
<Cog6ToothIcon className="w-4 h-4" />
|
||||
Settings
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-surface text-sm w-full text-left text-red-400"
|
||||
>
|
||||
<ArrowRightOnRectangleIcon className="w-4 h-4" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
export default function RouteGuard({ children }: { children: React.ReactNode }) {
|
||||
const { state } = useAuthStore()
|
||||
|
||||
if (state === 'loading') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state === 'unauthenticated' || state === 'error') {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
HomeIcon,
|
||||
FolderIcon,
|
||||
WrenchIcon,
|
||||
Cog6ToothIcon,
|
||||
BookOpenIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: HomeIcon },
|
||||
{ name: 'Projects', href: '/projects', icon: FolderIcon },
|
||||
{ name: 'Repositories', href: '/repositories', icon: BookOpenIcon },
|
||||
{ name: 'Tools', href: '/tools', icon: WrenchIcon },
|
||||
{ name: 'Settings', href: '/settings', icon: Cog6ToothIcon },
|
||||
]
|
||||
|
||||
export default function Sidebar() {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<nav className="w-64 border-r border-border bg-surface h-screen sticky top-0">
|
||||
<div className="p-4">
|
||||
<h1 className="text-xl font-bold mb-6">Headquarter</h1>
|
||||
<ul className="space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const isActive = location.pathname === item.href
|
||||
return (
|
||||
<li key={item.name}>
|
||||
<Link
|
||||
to={item.href}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'bg-accent/10 text-accent'
|
||||
: 'text-gray-400 hover:text-fg hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
{item.name}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string
|
||||
readonly VITE_OIDC_ISSUER: string
|
||||
readonly VITE_OIDC_CLIENT_ID: string
|
||||
readonly VITE_OIDC_REDIRECT_URI: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -1,3 +1,21 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-bg: #0f172a;
|
||||
--color-fg: #e2e8f0;
|
||||
--color-accent: #38bdf8;
|
||||
--color-surface: rgba(255, 255, 255, 0.04);
|
||||
--color-border: rgba(255, 255, 255, 0.08);
|
||||
--color-border-subtle: rgba(255, 255, 255, 0.06);
|
||||
--spacing-xs: 0.5rem;
|
||||
--spacing-sm: 1rem;
|
||||
--spacing-md: 1.5rem;
|
||||
--spacing-lg: 2rem;
|
||||
--spacing-xl: 2.5rem;
|
||||
--radius-default: 0.75rem;
|
||||
--font-family-base: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--fg: #e2e8f0;
|
||||
|
||||
+15
-2
@@ -1,10 +1,23 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
import { router } from './router'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getPKCE, clearPKCE, storeToken } from '../auth/oidc'
|
||||
import { api } from '../api/client'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const OIDC_ISSUER = import.meta.env.VITE_OIDC_ISSUER
|
||||
const CLIENT_ID = import.meta.env.VITE_OIDC_CLIENT_ID
|
||||
const REDIRECT_URI = import.meta.env.VITE_OIDC_REDIRECT_URI
|
||||
|
||||
export default function CallbackPage() {
|
||||
const navigate = useNavigate()
|
||||
const { setUser, setError } = useAuthStore()
|
||||
const [status, setStatus] = useState('Processing authentication...')
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const code = urlParams.get('code')
|
||||
const error = urlParams.get('error')
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Authentication error: ${error}`)
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
throw new Error('No authorization code received')
|
||||
}
|
||||
|
||||
const verifier = getPKCE()
|
||||
if (!verifier) {
|
||||
throw new Error('PKCE verifier not found')
|
||||
}
|
||||
|
||||
setStatus('Exchanging code for token...')
|
||||
|
||||
const tokenResponse = await fetch(`${OIDC_ISSUER}/token/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: verifier,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error('Token exchange failed')
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
storeToken(tokenData.access_token)
|
||||
clearPKCE()
|
||||
|
||||
setStatus('Fetching user information...')
|
||||
const user = await api.getCurrentUser()
|
||||
setUser(user)
|
||||
|
||||
navigate('/')
|
||||
} catch (error) {
|
||||
console.error('Callback error:', error)
|
||||
setError(error instanceof Error ? error : new Error('Authentication failed'))
|
||||
setStatus('Authentication failed')
|
||||
setTimeout(() => navigate('/login'), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
handleCallback()
|
||||
}, [navigate, setUser, setError])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">{status}</h1>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuthStore()
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-3xl font-bold mb-4">Dashboard</h1>
|
||||
<p className="text-lg mb-4">
|
||||
Welcome back, {user?.display_name || user?.email || 'User'}!
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="p-4 rounded-lg border border-border bg-surface">
|
||||
<h2 className="text-xl font-semibold mb-2">Projects</h2>
|
||||
<p className="text-gray-400">Manage your projects and repositories</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border border-border bg-surface">
|
||||
<h2 className="text-xl font-semibold mb-2">Tools</h2>
|
||||
<p className="text-gray-400">Spawn and manage development tools</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border border-border bg-surface">
|
||||
<h2 className="text-xl font-semibold mb-2">Settings</h2>
|
||||
<p className="text-gray-400">Configure your account and preferences</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect } from 'react'
|
||||
import { createPKCE, storePKCE } from '../auth/oidc'
|
||||
|
||||
const OIDC_ISSUER = import.meta.env.VITE_OIDC_ISSUER
|
||||
const CLIENT_ID = import.meta.env.VITE_OIDC_CLIENT_ID
|
||||
const REDIRECT_URI = import.meta.env.VITE_OIDC_REDIRECT_URI
|
||||
|
||||
export default function LoginPage() {
|
||||
useEffect(() => {
|
||||
const initiateLogin = async () => {
|
||||
const { verifier, challenge } = await createPKCE()
|
||||
storePKCE(verifier)
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email',
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
})
|
||||
|
||||
window.location.href = `${OIDC_ISSUER}/authorize?${params.toString()}`
|
||||
}
|
||||
|
||||
initiateLogin()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">Redirecting to login...</h1>
|
||||
<p className="text-gray-400">Please wait while we redirect you to the authentication provider.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
|
||||
export default function ProjectDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const { data: project, isLoading } = useQuery({
|
||||
queryKey: ['project', id],
|
||||
queryFn: () => api.getProject(id!),
|
||||
enabled: !!id,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold">Project not found</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-3xl font-bold mb-4">{project.name}</h1>
|
||||
<p className="text-gray-400 mb-4">{project.description || 'No description'}</p>
|
||||
<div className="text-sm text-gray-500">
|
||||
<span>Slug: {project.slug}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
|
||||
export default function ProjectFormPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const isEditing = !!id
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [slug, setSlug] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const { data: project } = useQuery({
|
||||
queryKey: ['project', id],
|
||||
queryFn: () => api.getProject(id!),
|
||||
enabled: isEditing,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
setName(project.name)
|
||||
setSlug(project.slug)
|
||||
setDescription(project.description || '')
|
||||
}
|
||||
}, [project])
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: api.createProject,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] })
|
||||
navigate('/projects')
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Parameters<typeof api.updateProject>[1] }) =>
|
||||
api.updateProject(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['project', id] })
|
||||
navigate(`/projects/${id}`)
|
||||
},
|
||||
})
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
if (!name.trim()) newErrors.name = 'Name is required'
|
||||
if (!slug.trim()) newErrors.slug = 'Slug is required'
|
||||
if (!/^[a-z0-9-]+$/i.test(slug)) newErrors.slug = 'Slug must contain only letters, numbers, and hyphens'
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!validate()) return
|
||||
|
||||
const data = {
|
||||
name: name.trim(),
|
||||
slug: slug.trim(),
|
||||
description: description.trim() || null,
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
updateMutation.mutate({ id: id!, data })
|
||||
} else {
|
||||
createMutation.mutate(data)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl">
|
||||
<h1 className="text-3xl font-bold mb-6">
|
||||
{isEditing ? 'Edit Project' : 'New Project'}
|
||||
</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface"
|
||||
placeholder="My Awesome Project"
|
||||
/>
|
||||
{errors.name && <p className="text-red-400 text-sm mt-1">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Slug</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface"
|
||||
placeholder="my-awesome-project"
|
||||
/>
|
||||
{errors.slug && <p className="text-red-400 text-sm mt-1">{errors.slug}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface"
|
||||
rows={3}
|
||||
placeholder="Optional description..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-accent text-bg rounded-lg hover:bg-accent/80 transition-colors"
|
||||
>
|
||||
{isEditing ? 'Update' : 'Create'} Project
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects')}
|
||||
className="px-4 py-2 border border-border rounded-lg hover:bg-surface transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../api/client'
|
||||
import type { Project } from '../types/api'
|
||||
|
||||
export default function ProjectListPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: projects, isLoading } = useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: api.getProjects,
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: api.deleteProject,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] })
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold">Projects</h1>
|
||||
<Link
|
||||
to="/projects/new"
|
||||
className="px-4 py-2 bg-accent text-bg rounded-lg hover:bg-accent/80 transition-colors"
|
||||
>
|
||||
New Project
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{projects && projects.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{projects.map((project: Project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="p-4 rounded-lg border border-border bg-surface hover:border-accent transition-colors"
|
||||
>
|
||||
<Link to={`/projects/${project.id}`}>
|
||||
<h2 className="text-xl font-semibold mb-2">{project.name}</h2>
|
||||
<p className="text-gray-400 mb-2">{project.description || 'No description'}</p>
|
||||
<span className="text-sm text-gray-500">Slug: {project.slug}</span>
|
||||
</Link>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Link
|
||||
to={`/projects/${project.id}/edit`}
|
||||
className="text-sm text-accent hover:underline"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to delete this project?')) {
|
||||
deleteMutation.mutate(project.id)
|
||||
}
|
||||
}}
|
||||
className="text-sm text-red-400 hover:underline"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-xl text-gray-400 mb-4">No projects yet</p>
|
||||
<Link
|
||||
to="/projects/new"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Create your first project
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function RepositoriesPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-3xl font-bold mb-4">Repositories</h1>
|
||||
<p className="text-gray-400">Repository management coming soon...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user } = useAuthStore()
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-3xl font-bold mb-4">Settings</h1>
|
||||
<div className="max-w-2xl">
|
||||
<div className="p-4 rounded-lg border border-border bg-surface mb-4">
|
||||
<h2 className="text-xl font-semibold mb-2">Profile</h2>
|
||||
<dl className="space-y-2">
|
||||
<div>
|
||||
<dt className="text-sm text-gray-400">Email</dt>
|
||||
<dd>{user?.email}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-400">Display Name</dt>
|
||||
<dd>{user?.display_name || 'Not set'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { api } from '../api/client'
|
||||
import type { ToolInstance, ToolDefinition } from '../types/api'
|
||||
|
||||
export default function ToolInstanceDetailPage() {
|
||||
const { projectId, instanceId } = useParams<{ projectId: string; instanceId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [instance, setInstance] = useState<ToolInstance | null>(null)
|
||||
const [toolDef, setToolDef] = useState<ToolDefinition | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || !instanceId) return
|
||||
loadInstance()
|
||||
}, [projectId, instanceId])
|
||||
|
||||
const loadInstance = async () => {
|
||||
if (!projectId || !instanceId) return
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await api.getToolInstance(projectId, instanceId)
|
||||
setInstance(data)
|
||||
const registry = await api.getToolRegistry()
|
||||
const tool = registry.find((t) => t.id === data.tool_definition_id)
|
||||
setToolDef(tool || null)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load instance')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStop = async () => {
|
||||
if (!projectId || !instanceId) return
|
||||
setActionLoading(true)
|
||||
try {
|
||||
const data = await api.stopToolInstance(projectId, instanceId)
|
||||
setInstance(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to stop instance')
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!projectId || !instanceId) return
|
||||
setActionLoading(true)
|
||||
try {
|
||||
const data = await api.startToolInstance(projectId, instanceId)
|
||||
setInstance(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to start instance')
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!projectId || !instanceId) return
|
||||
if (!confirm('Are you sure you want to delete this instance?')) return
|
||||
setActionLoading(true)
|
||||
try {
|
||||
await api.deleteToolInstance(projectId, instanceId)
|
||||
navigate(`/projects/${projectId}`)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete instance')
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'text-green-400'
|
||||
case 'creating':
|
||||
return 'text-yellow-400'
|
||||
case 'stopped':
|
||||
return 'text-gray-400'
|
||||
case 'error':
|
||||
return 'text-red-400'
|
||||
default:
|
||||
return 'text-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse">Loading instance...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !instance) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="text-red-400 mb-4">{error || 'Instance not found'}</div>
|
||||
<Link
|
||||
to={`/projects/${projectId}`}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
← Back to project
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to={`/projects/${projectId}`}
|
||||
className="text-accent hover:underline mb-4 inline-block"
|
||||
>
|
||||
← Back to project
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold mt-2">{instance.name}</h1>
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<span className={`font-medium ${getStatusColor(instance.status)}`}>
|
||||
{instance.status}
|
||||
</span>
|
||||
{toolDef && (
|
||||
<span className="text-gray-400">{toolDef.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 p-4 rounded-lg mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-surface border border-border rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Instance Details</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="text-gray-400">Status: </span>
|
||||
<span className={getStatusColor(instance.status)}>{instance.status}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">Tool: </span>
|
||||
<span>{toolDef?.name || instance.tool_definition_id}</span>
|
||||
</div>
|
||||
{instance.subdomain && (
|
||||
<div>
|
||||
<span className="text-gray-400">Subdomain: </span>
|
||||
<a
|
||||
href={`https://${instance.subdomain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{instance.subdomain}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{instance.container_id && (
|
||||
<div>
|
||||
<span className="text-gray-400">Container: </span>
|
||||
<span className="font-mono text-sm">{instance.container_id.slice(0, 12)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-gray-400">Created: </span>
|
||||
<span>{new Date(instance.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface border border-border rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Actions</h2>
|
||||
<div className="space-y-3">
|
||||
{instance.status === 'running' && instance.subdomain && (
|
||||
<a
|
||||
href={`https://${instance.subdomain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full bg-accent text-white text-center py-2 px-4 rounded-lg hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
Open Tool
|
||||
</a>
|
||||
)}
|
||||
|
||||
{instance.status === 'running' ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={actionLoading}
|
||||
className="w-full bg-yellow-600 text-white py-2 px-4 rounded-lg hover:bg-yellow-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading ? 'Stopping...' : 'Stop Instance'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={actionLoading}
|
||||
className="w-full bg-green-600 text-white py-2 px-4 rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading ? 'Starting...' : 'Start Instance'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={actionLoading}
|
||||
className="w-full bg-red-600 text-white py-2 px-4 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading ? 'Deleting...' : 'Delete Instance'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api/client'
|
||||
import type { ToolDefinition, Project } from '../types/api'
|
||||
|
||||
export default function ToolSpawnPage() {
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [tools, setTools] = useState<ToolDefinition[]>([])
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [selectedTool, setSelectedTool] = useState('')
|
||||
const [selectedProject, setSelectedProject] = useState(projectId || '')
|
||||
const [instanceName, setInstanceName] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [fetchLoading, setFetchLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setFetchLoading(true)
|
||||
const [toolsData, projectsData] = await Promise.all([
|
||||
api.getToolRegistry(),
|
||||
api.getProjects(),
|
||||
])
|
||||
setTools(toolsData)
|
||||
setProjects(projectsData)
|
||||
if (toolsData.length > 0 && !selectedTool) {
|
||||
setSelectedTool(toolsData[0].id)
|
||||
}
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load data')
|
||||
} finally {
|
||||
setFetchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!selectedTool || !selectedProject || !instanceName.trim()) {
|
||||
setError('Please fill in all fields')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const instance = await api.createToolInstance(selectedProject, {
|
||||
tool_definition_id: selectedTool,
|
||||
name: instanceName.trim(),
|
||||
})
|
||||
navigate(`/projects/${selectedProject}/instances/${instance.id}`)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create instance')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedToolData = tools.find((t) => t.id === selectedTool)
|
||||
|
||||
if (fetchLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl">
|
||||
<h1 className="text-3xl font-bold mb-6">Spawn Tool</h1>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 p-4 rounded-lg mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Project</label>
|
||||
<select
|
||||
value={selectedProject}
|
||||
onChange={(e) => setSelectedProject(e.target.value)}
|
||||
className="w-full bg-surface border border-border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
required
|
||||
>
|
||||
<option value="">Select a project</option>
|
||||
{projects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Tool</label>
|
||||
<select
|
||||
value={selectedTool}
|
||||
onChange={(e) => setSelectedTool(e.target.value)}
|
||||
className="w-full bg-surface border border-border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
required
|
||||
>
|
||||
<option value="">Select a tool</option>
|
||||
{tools.map((tool) => (
|
||||
<option key={tool.id} value={tool.id}>
|
||||
{tool.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedToolData && (
|
||||
<div className="bg-surface border border-border rounded-lg p-4">
|
||||
<h3 className="font-medium mb-2">{selectedToolData.name}</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
{selectedToolData.description || 'No description available'}
|
||||
</p>
|
||||
<div className="mt-2 text-sm text-gray-400">
|
||||
Image: {selectedToolData.image}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Instance Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={instanceName}
|
||||
onChange={(e) => setInstanceName(e.target.value)}
|
||||
placeholder="e.g., My Development Environment"
|
||||
className="w-full bg-surface border border-border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-accent text-white py-2 px-6 rounded-lg hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Spawning...' : 'Spawn Tool'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
className="border border-border py-2 px-6 rounded-lg hover:bg-surface transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function ToolsPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-3xl font-bold mb-4">Tools</h1>
|
||||
<p className="text-gray-400">Tool management coming soon...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createBrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import RouteGuard from './components/RouteGuard'
|
||||
import DashboardLayout from './components/DashboardLayout'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import CallbackPage from './pages/CallbackPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import ProjectListPage from './pages/ProjectListPage'
|
||||
import ProjectDetailPage from './pages/ProjectDetailPage'
|
||||
import ProjectFormPage from './pages/ProjectFormPage'
|
||||
import ToolsPage from './pages/ToolsPage'
|
||||
import ToolSpawnPage from './pages/ToolSpawnPage'
|
||||
import ToolInstanceDetailPage from './pages/ToolInstanceDetailPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import RepositoriesPage from './pages/RepositoriesPage'
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <App />,
|
||||
children: [
|
||||
{
|
||||
element: (
|
||||
<RouteGuard>
|
||||
<DashboardLayout />
|
||||
</RouteGuard>
|
||||
),
|
||||
children: [
|
||||
{ path: '/', element: <DashboardPage /> },
|
||||
{ path: '/projects', element: <ProjectListPage /> },
|
||||
{ path: '/projects/new', element: <ProjectFormPage /> },
|
||||
{ path: '/projects/:id', element: <ProjectDetailPage /> },
|
||||
{ path: '/projects/:id/edit', element: <ProjectFormPage /> },
|
||||
{ path: '/tools', element: <ToolsPage /> },
|
||||
{ path: '/tools/spawn', element: <ToolSpawnPage /> },
|
||||
{ path: '/projects/:projectId/instances/:instanceId', element: <ToolInstanceDetailPage /> },
|
||||
{ path: '/settings', element: <SettingsPage /> },
|
||||
{ path: '/repositories', element: <RepositoriesPage /> },
|
||||
],
|
||||
},
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{ path: '/callback', element: <CallbackPage /> },
|
||||
],
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,24 @@
|
||||
import { create } from 'zustand'
|
||||
import type { User } from '../types/api.ts'
|
||||
|
||||
export type AuthState = 'loading' | 'authenticated' | 'unauthenticated' | 'error'
|
||||
|
||||
interface AuthStore {
|
||||
state: AuthState
|
||||
user: User | null
|
||||
error: Error | null
|
||||
setState: (state: AuthState) => void
|
||||
setUser: (user: User | null) => void
|
||||
setError: (error: Error | null) => void
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthStore>((set) => ({
|
||||
state: 'loading',
|
||||
user: null,
|
||||
error: null,
|
||||
setState: (state) => set({ state }),
|
||||
setUser: (user) => set({ user, state: user ? 'authenticated' : 'unauthenticated' }),
|
||||
setError: (error) => set({ error, state: 'error' }),
|
||||
logout: () => set({ user: null, state: 'unauthenticated', error: null }),
|
||||
}))
|
||||
@@ -0,0 +1,111 @@
|
||||
export interface User {
|
||||
id: string
|
||||
authentik_sub: string
|
||||
email: string
|
||||
display_name: string | null
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
description: string | null
|
||||
owner_id: string
|
||||
}
|
||||
|
||||
export interface ProjectCreate {
|
||||
name: string
|
||||
slug: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export interface ProjectUpdate {
|
||||
name?: string
|
||||
slug?: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export interface ToolDefinition {
|
||||
id: string
|
||||
key: string
|
||||
name: string
|
||||
description: string | null
|
||||
version: string
|
||||
image: string
|
||||
manifest_data: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface ToolInstance {
|
||||
id: string
|
||||
tool_definition_id: string
|
||||
project_id: string
|
||||
name: string
|
||||
status: string
|
||||
container_id: string | null
|
||||
subdomain: string | null
|
||||
config_override: Record<string, string> | null
|
||||
traefik_labels: Record<string, string> | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ToolManifest {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
image: string
|
||||
runtime_command: string[] | null
|
||||
runtime_entrypoint: string[] | null
|
||||
runtime_user: string | null
|
||||
runtime_working_dir: string | null
|
||||
ports: Array<{
|
||||
container_port: number
|
||||
protocol: string
|
||||
name: string | null
|
||||
primary: boolean
|
||||
}>
|
||||
workspace_mounts: Array<{
|
||||
type: string
|
||||
source_pattern: string
|
||||
target: string
|
||||
read_only: boolean
|
||||
}>
|
||||
config_mounts: Array<{
|
||||
type: string
|
||||
source_pattern: string
|
||||
target: string
|
||||
read_only: boolean
|
||||
}>
|
||||
env: Record<string, string>
|
||||
secrets: Array<{
|
||||
name: string
|
||||
env_var: string
|
||||
required: boolean
|
||||
}>
|
||||
health_check: {
|
||||
type: string
|
||||
path: string | null
|
||||
command: string[] | null
|
||||
port: number | null
|
||||
interval_seconds: number
|
||||
timeout_seconds: number
|
||||
retries: number
|
||||
start_period_seconds: number
|
||||
} | null
|
||||
resource_limits: {
|
||||
cpus: number | null
|
||||
memory_mb: number | null
|
||||
memory_swap_mb: number | null
|
||||
} | null
|
||||
traefik: {
|
||||
enabled: boolean
|
||||
subdomain_prefix: string | null
|
||||
port: number | null
|
||||
middlewares: string[]
|
||||
strip_prefix: boolean
|
||||
} | null
|
||||
}
|
||||
Reference in New Issue
Block a user