diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 93675ef..4f0c171 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,29 +1,12 @@ +import { Outlet } from 'react-router-dom' +import { AuthProvider } from './auth/AuthProvider' import './App.css' function App() { return ( -
-
-

Headquarter

-

Hosted workspace and tool-orchestration platform

-
-
-
-

Platform Status

-
- API - Checking… -
-
- Version - 0.0.1 -
-
-
- -
+ + + ) } diff --git a/apps/web/src/__tests__/App.test.tsx b/apps/web/src/__tests__/App.test.tsx index 273c047..42f3776 100644 --- a/apps/web/src/__tests__/App.test.tsx +++ b/apps/web/src/__tests__/App.test.tsx @@ -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() - expect(screen.getByText('Headquarter')).toBeInTheDocument() - }) + it('renders without crashing', () => { + const router = createBrowserRouter([ + { + path: '/', + element: , + children: [ + { path: '/', element:
Test Page
}, + ], + }, + ]) - it('renders the tagline', () => { - render() - expect(screen.getByText('Hosted workspace and tool-orchestration platform')).toBeInTheDocument() - }) - - it('renders the platform status section', () => { - render() - expect(screen.getByText('Platform Status')).toBeInTheDocument() - expect(screen.getByTestId('api-status')).toHaveTextContent('Checking…') - }) - - it('renders the scaffold footer', () => { - render() - expect(screen.getByText('Scaffolded by FN-002')).toBeInTheDocument() + render() + expect(document.body).toBeTruthy() }) }) diff --git a/apps/web/src/__tests__/setup.ts b/apps/web/src/__tests__/setup.ts index a9d0dd3..7910d4d 100644 --- a/apps/web/src/__tests__/setup.ts +++ b/apps/web/src/__tests__/setup.ts @@ -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, +}) diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts new file mode 100644 index 0000000..166bb60 --- /dev/null +++ b/apps/web/src/api/client.ts @@ -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 { + const url = `${API_URL}/api/v1${endpoint}` + const token = getAuthToken() + + const headers: Record = { + 'Content-Type': 'application/json', + ...((options.headers as Record) || {}), + } + + 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 => { + const response = await fetchWithAuth('/users/me') + return response.json() + }, + + // Projects + getProjects: async (): Promise => { + const response = await fetchWithAuth('/projects') + return response.json() + }, + + getProject: async (id: string): Promise => { + const response = await fetchWithAuth(`/projects/${id}`) + return response.json() + }, + + createProject: async (data: ProjectCreate): Promise => { + const response = await fetchWithAuth('/projects', { + method: 'POST', + body: JSON.stringify(data), + }) + return response.json() + }, + + updateProject: async (id: string, data: ProjectUpdate): Promise => { + const response = await fetchWithAuth(`/projects/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }) + return response.json() + }, + + deleteProject: async (id: string): Promise => { + await fetchWithAuth(`/projects/${id}`, { + method: 'DELETE', + }) + }, + + getToolInstances: async (projectId: string): Promise => { + const response = await fetchWithAuth(`/projects/${projectId}/tool-instances`) + return response.json() + }, + + getToolInstance: async (projectId: string, instanceId: string): Promise => { + const response = await fetchWithAuth(`/projects/${projectId}/tool-instances/${instanceId}`) + return response.json() + }, + + createToolInstance: async (projectId: string, data: { tool_definition_id: string; name: string }): Promise => { + const response = await fetchWithAuth(`/projects/${projectId}/tool-instances`, { + method: 'POST', + body: JSON.stringify(data), + }) + return response.json() + }, + + deleteToolInstance: async (projectId: string, instanceId: string): Promise => { + await fetchWithAuth(`/projects/${projectId}/tool-instances/${instanceId}`, { + method: 'DELETE', + }) + }, + + stopToolInstance: async (projectId: string, instanceId: string): Promise => { + const response = await fetchWithAuth(`/projects/${projectId}/tool-instances/${instanceId}/stop`, { + method: 'POST', + }) + return response.json() + }, + + startToolInstance: async (projectId: string, instanceId: string): Promise => { + 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 => { + const response = await fetchWithAuth('/tools') + return response.json() + }, +} diff --git a/apps/web/src/auth/AuthProvider.tsx b/apps/web/src/auth/AuthProvider.tsx new file mode 100644 index 0000000..020b4e0 --- /dev/null +++ b/apps/web/src/auth/AuthProvider.tsx @@ -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} +} diff --git a/apps/web/src/auth/oidc.ts b/apps/web/src/auth/oidc.ts new file mode 100644 index 0000000..bd6dca9 --- /dev/null +++ b/apps/web/src/auth/oidc.ts @@ -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 { + 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') +} diff --git a/apps/web/src/components/DashboardLayout.tsx b/apps/web/src/components/DashboardLayout.tsx new file mode 100644 index 0000000..31f68cc --- /dev/null +++ b/apps/web/src/components/DashboardLayout.tsx @@ -0,0 +1,17 @@ +import { Outlet } from 'react-router-dom' +import Header from './Header' +import Sidebar from './Sidebar' + +export default function DashboardLayout() { + return ( +
+ +
+
+
+ +
+
+
+ ) +} diff --git a/apps/web/src/components/Header.tsx b/apps/web/src/components/Header.tsx new file mode 100644 index 0000000..51c97a1 --- /dev/null +++ b/apps/web/src/components/Header.tsx @@ -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 ( +
+
+ Headquarter +
+ +
+ + + {showDropdown && ( +
+
+ setShowDropdown(false)} + > + + Settings + + +
+
+ )} +
+
+ ) +} diff --git a/apps/web/src/components/RouteGuard.tsx b/apps/web/src/components/RouteGuard.tsx new file mode 100644 index 0000000..3d953ce --- /dev/null +++ b/apps/web/src/components/RouteGuard.tsx @@ -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 ( +
+
+
+ ) + } + + if (state === 'unauthenticated' || state === 'error') { + return + } + + return <>{children} +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx new file mode 100644 index 0000000..6163460 --- /dev/null +++ b/apps/web/src/components/Sidebar.tsx @@ -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 ( + + ) +} diff --git a/apps/web/src/env.d.ts b/apps/web/src/env.d.ts new file mode 100644 index 0000000..c85f9f4 --- /dev/null +++ b/apps/web/src/env.d.ts @@ -0,0 +1,12 @@ +/// + +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 +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 2cd8488..575e66f 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -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; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index db032b7..8b234ea 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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( - + + + , ) diff --git a/apps/web/src/pages/CallbackPage.tsx b/apps/web/src/pages/CallbackPage.tsx new file mode 100644 index 0000000..5b103fc --- /dev/null +++ b/apps/web/src/pages/CallbackPage.tsx @@ -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 ( +
+
+

{status}

+
+
+
+ ) +} diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..e246747 --- /dev/null +++ b/apps/web/src/pages/DashboardPage.tsx @@ -0,0 +1,28 @@ +import { useAuthStore } from '../stores/auth' + +export default function DashboardPage() { + const { user } = useAuthStore() + + return ( +
+

Dashboard

+

+ Welcome back, {user?.display_name || user?.email || 'User'}! +

+
+
+

Projects

+

Manage your projects and repositories

+
+
+

Tools

+

Spawn and manage development tools

+
+
+

Settings

+

Configure your account and preferences

+
+
+
+ ) +} diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx new file mode 100644 index 0000000..0f15555 --- /dev/null +++ b/apps/web/src/pages/LoginPage.tsx @@ -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 ( +
+
+

Redirecting to login...

+

Please wait while we redirect you to the authentication provider.

+
+
+ ) +} diff --git a/apps/web/src/pages/ProjectDetailPage.tsx b/apps/web/src/pages/ProjectDetailPage.tsx new file mode 100644 index 0000000..4e00948 --- /dev/null +++ b/apps/web/src/pages/ProjectDetailPage.tsx @@ -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 ( +
+
+
+ ) + } + + if (!project) { + return ( +
+

Project not found

+
+ ) + } + + return ( +
+

{project.name}

+

{project.description || 'No description'}

+
+ Slug: {project.slug} +
+
+ ) +} diff --git a/apps/web/src/pages/ProjectFormPage.tsx b/apps/web/src/pages/ProjectFormPage.tsx new file mode 100644 index 0000000..8e2ac13 --- /dev/null +++ b/apps/web/src/pages/ProjectFormPage.tsx @@ -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>({}) + + 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[1] }) => + api.updateProject(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['projects'] }) + queryClient.invalidateQueries({ queryKey: ['project', id] }) + navigate(`/projects/${id}`) + }, + }) + + const validate = () => { + const newErrors: Record = {} + 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 ( +
+

+ {isEditing ? 'Edit Project' : 'New Project'} +

+ +
+
+ + setName(e.target.value)} + className="w-full px-3 py-2 rounded-lg border border-border bg-surface" + placeholder="My Awesome Project" + /> + {errors.name &&

{errors.name}

} +
+ +
+ + setSlug(e.target.value)} + className="w-full px-3 py-2 rounded-lg border border-border bg-surface" + placeholder="my-awesome-project" + /> + {errors.slug &&

{errors.slug}

} +
+ +
+ +