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:
2026-05-14 17:28:15 +02:00
parent 6aea953734
commit 6f18dd24a5
27 changed files with 1469 additions and 43 deletions
+84
View File
@@ -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>
)
}
+28
View File
@@ -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>
)
}
+37
View File
@@ -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>
)
}
+38
View File
@@ -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>
)
}
+135
View File
@@ -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>
)
}
+86
View File
@@ -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>
)
}
+8
View File
@@ -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>
)
}
+26
View File
@@ -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>
)
}
+163
View File
@@ -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>
)
}
+8
View File
@@ -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>
)
}