feat: implement docker infrastructure (US-001)

- Add docker-compose.yml with postgres, redis, api, and web services
- Add multi-stage Dockerfile for API (Python 3.11)
- Add multi-stage Dockerfile for web (Node.js 20 + nginx)
- Add Makefile with common development commands
- Add .env.example with all required environment variables
- Add placeholder pyproject.toml and package.json for builds
- Configure health checks for all services
- Setup persistent volumes for postgres, redis, and repos
- Run services as non-root users
This commit is contained in:
2026-05-16 17:44:39 +00:00
parent 212d072417
commit e7819bfc82
246 changed files with 3625 additions and 17311 deletions
-70
View File
@@ -1,70 +0,0 @@
.app {
display: flex;
flex-direction: column;
min-height: 100dvh;
}
.app-header {
padding: var(--space-lg);
text-align: center;
border-bottom: var(--border-width) solid var(--border);
}
.app-header h1 {
margin: 0;
font-size: var(--font-xl);
color: var(--accent);
}
.tagline {
margin: var(--space-xs) 0 0;
opacity: var(--opacity-high);
}
.app-main {
flex: 1;
padding: var(--space-lg);
display: flex;
justify-content: center;
align-items: flex-start;
}
.status-card {
background: var(--surface);
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
padding: var(--space-md) var(--space-lg);
min-width: var(--width-card);
}
.status-card h2 {
margin: 0 0 var(--space-sm);
font-size: var(--font-lg);
}
.status-row {
display: flex;
justify-content: space-between;
padding: var(--space-xs) 0;
border-bottom: var(--border-width) solid var(--border-subtle);
}
.status-row:last-child {
border-bottom: none;
}
.status-label {
opacity: var(--opacity-medium);
}
.status-value {
font-weight: 500;
}
.app-footer {
padding: var(--space-sm) var(--space-lg);
text-align: center;
opacity: var(--opacity-dim);
font-size: var(--font-sm);
border-top: var(--border-width) solid var(--border);
}
-13
View File
@@ -1,13 +0,0 @@
import { Outlet } from 'react-router-dom'
import { AuthProvider } from './auth/AuthProvider'
import './App.css'
function App() {
return (
<AuthProvider>
<Outlet />
</AuthProvider>
)
}
export default App
-21
View File
@@ -1,21 +0,0 @@
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import App from '../App'
describe('App', () => {
it('renders without crashing', () => {
const router = createBrowserRouter([
{
path: '/',
element: <App />,
children: [
{ path: '/', element: <div>Test Page</div> },
],
},
])
render(<RouterProvider router={router} />)
expect(document.body).toBeTruthy()
})
})
-26
View File
@@ -1,26 +0,0 @@
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,
})
-306
View File
@@ -1,306 +0,0 @@
import type { Config, ConfigCreate, Project, ProjectCreate, ProjectUpdate, Repository, RepositoryConnection, RepositoryConnectionCreate, RepositoryCreate, Secret, SecretCreate, ToolDefinition, ToolInstance, User } from '../types/api.ts'
import { getToken, clearToken } from '../auth/oidc'
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'
}
}
let isRefreshing = false
let refreshPromise: Promise<string | null> | null = null
async function refreshToken(): Promise<string | null> {
if (isRefreshing && refreshPromise) {
return refreshPromise
}
isRefreshing = true
refreshPromise = (async () => {
try {
const token = getToken()
if (!token) return null
const response = await fetch(`${API_URL}/api/v1/auth/refresh`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
clearToken()
return null
}
const data = await response.json()
if (data.access_token) {
localStorage.setItem('access_token', data.access_token)
return data.access_token
}
return null
} catch {
clearToken()
return null
} finally {
isRefreshing = false
refreshPromise = null
}
})()
return refreshPromise
}
async function fetchWithAuth(
endpoint: string,
options: RequestInit = {}
): Promise<Response> {
const url = `${API_URL}/api/v1${endpoint}`
const token = getToken()
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.status === 401 && token) {
const newToken = await refreshToken()
if (newToken) {
headers['Authorization'] = `Bearer ${newToken}`
const retryResponse = await fetch(url, {
...options,
headers,
})
if (!retryResponse.ok) {
const data = await retryResponse.json().catch(() => undefined)
throw new ApiError(retryResponse.status, retryResponse.statusText, data)
}
return retryResponse
}
}
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()
},
getConfigs: async (projectId: string): Promise<Config[]> => {
const response = await fetchWithAuth(`/configs?scope_type=project&scope_id=${projectId}`)
return response.json()
},
createConfig: async (data: ConfigCreate): Promise<Config> => {
const response = await fetchWithAuth('/configs', {
method: 'POST',
body: JSON.stringify(data),
})
return response.json()
},
updateConfig: async (id: string, data: { value: unknown }): Promise<Config> => {
const response = await fetchWithAuth(`/configs/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
return response.json()
},
deleteConfig: async (id: string): Promise<void> => {
await fetchWithAuth(`/configs/${id}`, {
method: 'DELETE',
})
},
getSecrets: async (projectId: string): Promise<Secret[]> => {
const response = await fetchWithAuth(`/secrets?scope_type=project&scope_id=${projectId}`)
return response.json()
},
createSecret: async (data: SecretCreate): Promise<Secret> => {
const response = await fetchWithAuth('/secrets', {
method: 'POST',
body: JSON.stringify(data),
})
return response.json()
},
updateSecret: async (id: string, data: { value: string }): Promise<Secret> => {
const response = await fetchWithAuth(`/secrets/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
return response.json()
},
deleteSecret: async (id: string): Promise<void> => {
await fetchWithAuth(`/secrets/${id}`, {
method: 'DELETE',
})
},
getRepository: async (projectId: string, repoId: string): Promise<Repository> => {
const response = await fetchWithAuth(`/projects/${projectId}/repositories/${repoId}`)
return response.json()
},
getRepositories: async (projectId: string): Promise<Repository[]> => {
const response = await fetchWithAuth(`/projects/${projectId}/repositories`)
return response.json()
},
createRepository: async (projectId: string, data: RepositoryCreate): Promise<Repository> => {
const response = await fetchWithAuth(`/projects/${projectId}/repositories`, {
method: 'POST',
body: JSON.stringify(data),
})
return response.json()
},
deleteRepository: async (projectId: string, repoId: string): Promise<void> => {
await fetchWithAuth(`/projects/${projectId}/repositories/${repoId}`, {
method: 'DELETE',
})
},
getRepositoryConnections: async (projectId: string): Promise<RepositoryConnection[]> => {
const response = await fetchWithAuth(`/projects/${projectId}/repository-connections`)
return response.json()
},
createRepositoryConnection: async (projectId: string, data: RepositoryConnectionCreate): Promise<RepositoryConnection> => {
const response = await fetchWithAuth(`/projects/${projectId}/repository-connections`, {
method: 'POST',
body: JSON.stringify(data),
})
return response.json()
},
deleteRepositoryConnection: async (projectId: string, connectionId: string): Promise<void> => {
await fetchWithAuth(`/projects/${projectId}/repository-connections/${connectionId}`, {
method: 'DELETE',
})
},
generateSshKey: async (projectId: string, connectionId: string): Promise<{ public_key: string }> => {
const response = await fetchWithAuth(`/projects/${projectId}/repository-connections/${connectionId}/ssh-key`, {
method: 'POST',
})
return response.json()
},
}
-50
View File
@@ -1,50 +0,0 @@
import { useEffect, useRef } from 'react'
import { useAuthStore } from '../stores/auth'
import { api } from '../api/client'
import { getToken, clearToken } from './oidc'
export function AuthProvider({ children }: { children: React.ReactNode }) {
const { setUser, setError, setState } = useAuthStore()
const isMounted = useRef(true)
useEffect(() => {
return () => {
isMounted.current = false
}
}, [])
useEffect(() => {
const initAuth = async () => {
try {
const token = getToken()
if (!token) {
if (isMounted.current) {
setState('unauthenticated')
}
return
}
const user = await api.getCurrentUser()
if (isMounted.current) {
setUser(user)
}
} catch (error) {
console.error('Auth initialization failed:', error)
if (error instanceof Error && error.message.includes('401')) {
clearToken()
}
if (isMounted.current) {
setError(error instanceof Error ? error : new Error('Auth failed'))
clearToken()
}
}
}
initAuth()
}, [setUser, setError, setState])
return <>{children}</>
}
-58
View File
@@ -1,58 +0,0 @@
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 storeState(state: string): void {
sessionStorage.setItem('oidc_state', state)
}
export function getState(): string | null {
return sessionStorage.getItem('oidc_state')
}
export function clearState(): void {
sessionStorage.removeItem('oidc_state')
}
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')
}
@@ -1,17 +0,0 @@
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>
)
}
-59
View File
@@ -1,59 +0,0 @@
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>
)
}
-20
View File
@@ -1,20 +0,0 @@
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}</>
}
-48
View File
@@ -1,48 +0,0 @@
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>
)
}
-12
View File
@@ -1,12 +0,0 @@
/// <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
}
-56
View File
@@ -1,56 +0,0 @@
@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;
--accent: #38bdf8;
--surface: rgba(255, 255, 255, 0.04);
--border: rgba(255, 255, 255, 0.08);
--border-subtle: rgba(255, 255, 255, 0.06);
--space-xs: 0.5rem;
--space-sm: 1rem;
--space-md: 1.5rem;
--space-lg: 2rem;
--space-xl: 2.5rem;
--radius: 0.75rem;
--border-width: 1px;
--width-card: 17.5rem;
--font-base: 1rem;
--font-sm: 0.875rem;
--font-lg: 1.25rem;
--font-xl: 2.5rem;
--opacity-dim: 0.5;
--opacity-medium: 0.7;
--opacity-high: 0.8;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif;
line-height: 1.5;
color-scheme: dark;
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
}
#root {
min-height: 100dvh;
display: flex;
flex-direction: column;
}
-23
View File
@@ -1,23 +0,0 @@
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 { router } from './router'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
},
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
)
-111
View File
@@ -1,111 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { getPKCE, clearPKCE, storeToken, getState, clearState } from '../auth/oidc'
import { api } from '../api/client'
import { useAuthStore } from '../stores/auth'
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...')
const isMounted = useRef(true)
useEffect(() => {
return () => {
isMounted.current = false
}
}, [])
useEffect(() => {
const handleCallback = async () => {
try {
const urlParams = new URLSearchParams(window.location.search)
const code = urlParams.get('code')
const error = urlParams.get('error')
const state = urlParams.get('state')
const storedState = getState()
if (error) {
throw new Error(`Authentication error: ${error}`)
}
if (!code) {
throw new Error('No authorization code received')
}
if (!state || state !== storedState) {
throw new Error('Invalid or missing state parameter')
}
const verifier = getPKCE()
if (!verifier) {
throw new Error('PKCE verifier not found')
}
if (isMounted.current) {
setStatus('Exchanging code for token...')
}
const tokenEndpoint = 'https://auth.commumedia.org/application/o/token/'
const tokenResponse = await fetch(tokenEndpoint, {
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) {
const errorData = await tokenResponse.json().catch(() => ({}))
throw new Error(errorData.error_description || errorData.error || 'Token exchange failed')
}
const tokenData = await tokenResponse.json()
storeToken(tokenData.access_token)
clearPKCE()
clearState()
if (isMounted.current) {
setStatus('Fetching user information...')
}
const user = await api.getCurrentUser()
if (isMounted.current) {
setUser(user)
window.location.href = '/'
}
} catch (error) {
console.error('Callback error:', error)
clearPKCE()
clearState()
if (isMounted.current) {
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>
)
}
-183
View File
@@ -1,183 +0,0 @@
import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import { api } from '../api/client'
import type { Config } from '../types/api'
export default function ConfigListPage() {
const { id: projectId } = useParams<{ id: string }>()
const [configs, setConfigs] = useState<Config[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [editingConfig, setEditingConfig] = useState<Config | null>(null)
const [formData, setFormData] = useState({
key: '',
value: '',
scope_type: 'project',
})
useEffect(() => {
if (!projectId) return
loadConfigs()
}, [projectId])
const loadConfigs = async () => {
try {
setLoading(true)
const data = await api.getConfigs(projectId!)
setConfigs(data)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load configs')
} finally {
setLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!projectId) return
try {
const value = JSON.parse(formData.value)
if (editingConfig) {
await api.updateConfig(editingConfig.id, { value })
} else {
await api.createConfig({
key: formData.key,
value,
scope_type: formData.scope_type,
scope_id: projectId,
})
}
setShowForm(false)
setEditingConfig(null)
setFormData({ key: '', value: '', scope_type: 'project' })
loadConfigs()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save config')
}
}
const handleDelete = async (configId: string) => {
if (!confirm('Are you sure you want to delete this config?')) return
try {
await api.deleteConfig(configId)
loadConfigs()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete config')
}
}
const startEdit = (config: Config) => {
setEditingConfig(config)
setFormData({
key: config.key,
value: JSON.stringify(config.value, null, 2),
scope_type: config.scope_type,
})
setShowForm(true)
}
if (loading) return <div className="p-4">Loading configs...</div>
if (error) return <div className="p-4 text-red-600">Error: {error}</div>
return (
<div className="p-4">
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold">Configuration</h1>
<button
onClick={() => {
setShowForm(!showForm)
setEditingConfig(null)
setFormData({ key: '', value: '', scope_type: 'project' })
}}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
{showForm ? 'Cancel' : 'Add Config'}
</button>
</div>
{showForm && (
<form onSubmit={handleSubmit} className="mb-6 p-4 border rounded bg-gray-50">
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Key</label>
<input
type="text"
value={formData.key}
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
className="w-full px-3 py-2 border rounded"
required
disabled={!!editingConfig}
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Value (JSON)</label>
<textarea
value={formData.value}
onChange={(e) => setFormData({ ...formData, value: e.target.value })}
className="w-full px-3 py-2 border rounded font-mono text-sm"
rows={6}
required
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Scope</label>
<select
value={formData.scope_type}
onChange={(e) => setFormData({ ...formData, scope_type: e.target.value })}
className="w-full px-3 py-2 border rounded"
disabled={!!editingConfig}
>
<option value="global">Global</option>
<option value="project">Project</option>
</select>
</div>
<button type="submit" className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
{editingConfig ? 'Update' : 'Create'}
</button>
</form>
)}
<div className="space-y-2">
{configs.length === 0 ? (
<p className="text-gray-500">No configs found.</p>
) : (
configs.map((config) => (
<div key={config.id} className="p-4 border rounded flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold">{config.key}</span>
<span className="text-xs px-2 py-1 bg-gray-100 rounded">{config.scope_type}</span>
</div>
<pre className="text-sm bg-gray-50 p-2 rounded overflow-auto">
{JSON.stringify(config.value, null, 2)}
</pre>
</div>
<div className="flex gap-2 ml-4">
<button
onClick={() => startEdit(config)}
className="px-3 py-1 text-sm bg-gray-100 rounded hover:bg-gray-200"
>
Edit
</button>
<button
onClick={() => handleDelete(config.id)}
className="px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200"
>
Delete
</button>
</div>
</div>
))
)}
</div>
<div className="mt-4">
<Link to={`/projects/${projectId}`} className="text-blue-600 hover:underline">
Back to Project
</Link>
</div>
</div>
)
}
-28
View File
@@ -1,28 +0,0 @@
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>
)
}
-47
View File
@@ -1,47 +0,0 @@
import { useEffect } from 'react'
import { createPKCE, storePKCE, storeState } from '../auth/oidc'
const CLIENT_ID = import.meta.env.VITE_OIDC_CLIENT_ID
const REDIRECT_URI = import.meta.env.VITE_OIDC_REDIRECT_URI
function generateState(): string {
const array = new Uint8Array(32)
crypto.getRandomValues(array)
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('')
}
export default function LoginPage() {
useEffect(() => {
const initiateLogin = async () => {
const { verifier, challenge } = await createPKCE()
storePKCE(verifier)
const state = generateState()
storeState(state)
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',
state,
})
const authorizationEndpoint = 'https://auth.commumedia.org/application/o/authorize/'
window.location.href = `${authorizationEndpoint}?${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
@@ -1,38 +0,0 @@
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
@@ -1,135 +0,0 @@
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
@@ -1,86 +0,0 @@
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
@@ -1,8 +0,0 @@
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>
)
}
-193
View File
@@ -1,193 +0,0 @@
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '../api/client.ts'
export default function RepositoryDetailPage() {
const { projectId, repoId } = useParams<{ projectId: string; repoId: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [providerKind, setProviderKind] = useState('github')
const [credentialPayload, setCredentialPayload] = useState('')
const [showSshKey, setShowSshKey] = useState(false)
const { data: repository } = useQuery({
queryKey: ['repository', projectId, repoId],
queryFn: () => api.getRepository(projectId!, repoId!),
enabled: !!projectId && !!repoId,
})
const { data: connections } = useQuery({
queryKey: ['repository-connections', projectId],
queryFn: () => api.getRepositoryConnections(projectId!),
enabled: !!projectId,
})
const createConnectionMutation = useMutation({
mutationFn: (data: {
repository_id: string
provider_kind: string
credential_kind: string
credential_payload: string
}) => api.createRepositoryConnection(projectId!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repository-connections', projectId] })
setCredentialPayload('')
},
})
const deleteConnectionMutation = useMutation({
mutationFn: (connectionId: string) =>
api.deleteRepositoryConnection(projectId!, connectionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repository-connections', projectId] })
},
})
const generateSshKeyMutation = useMutation({
mutationFn: (connectionId: string) =>
api.generateSshKey(projectId!, connectionId),
onSuccess: () => {
setShowSshKey(true)
},
})
const handleCreateConnection = (e: React.FormEvent) => {
e.preventDefault()
createConnectionMutation.mutate({
repository_id: repoId!,
provider_kind: providerKind,
credential_kind: 'access_token',
credential_payload: credentialPayload,
})
}
const repoConnections = connections?.filter(
(conn) => conn.repository_id === repoId
)
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{repository?.name || 'Repository'}</h1>
<button
onClick={() => navigate(`/projects/${projectId}/repositories`)}
className="text-indigo-600 hover:text-indigo-900"
>
Back to Repositories
</button>
</div>
{repository && (
<div className="bg-white p-6 rounded-lg shadow">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Git URL</label>
<p className="mt-1 text-sm text-gray-900">{repository.git_url}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Provider</label>
<p className="mt-1 text-sm text-gray-900">{repository.provider_type}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Default Branch</label>
<p className="mt-1 text-sm text-gray-900">{repository.default_branch}</p>
</div>
</div>
</div>
)}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold mb-4">Create Connection</h2>
<form onSubmit={handleCreateConnection} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Provider</label>
<select
value={providerKind}
onChange={(e) => setProviderKind(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
>
<option value="github">GitHub</option>
<option value="gitlab">GitLab</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Access Token</label>
<input
type="password"
value={credentialPayload}
onChange={(e) => setCredentialPayload(e.target.value)}
placeholder="ghp_xxxxxxxxxxxx"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
required
/>
</div>
<button
type="submit"
disabled={createConnectionMutation.isPending}
className="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
>
{createConnectionMutation.isPending ? 'Creating...' : 'Create Connection'}
</button>
</form>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold mb-4">Connections</h2>
{repoConnections?.length === 0 ? (
<p className="text-gray-500">No connections yet.</p>
) : (
<div className="space-y-4">
{repoConnections?.map((conn) => (
<div
key={conn.id}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div>
<p className="font-medium">{conn.provider_kind}</p>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
conn.connection_status === 'connected'
? 'bg-green-100 text-green-800'
: conn.connection_status === 'error'
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
}`}
>
{conn.connection_status}
</span>
</div>
<div className="flex space-x-2">
<button
onClick={() => generateSshKeyMutation.mutate(conn.id)}
className="text-indigo-600 hover:text-indigo-900"
>
Generate SSH Key
</button>
<button
onClick={() => deleteConnectionMutation.mutate(conn.id)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
{showSshKey && generateSshKeyMutation.data && (
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold mb-4">SSH Public Key</h2>
<pre className="bg-gray-100 p-4 rounded text-sm overflow-x-auto">
{generateSshKeyMutation.data.public_key}
</pre>
<p className="text-sm text-gray-600 mt-2">
Add this key to your repository's deploy keys.
</p>
</div>
)}
</div>
)
}
-129
View File
@@ -1,129 +0,0 @@
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '../api/client.ts'
export default function RepositoryListPage() {
const { projectId } = useParams<{ projectId: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [gitUrl, setGitUrl] = useState('')
const [providerType, setProviderType] = useState('github')
const { data: repositories, isLoading } = useQuery({
queryKey: ['repositories', projectId],
queryFn: () => api.getRepositories(projectId!),
enabled: !!projectId,
})
const createMutation = useMutation({
mutationFn: (data: { name: string; git_url: string; provider_type: string }) =>
api.createRepository(projectId!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repositories', projectId] })
setName('')
setGitUrl('')
},
})
const deleteMutation = useMutation({
mutationFn: (repoId: string) => api.deleteRepository(projectId!, repoId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repositories', projectId] })
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
createMutation.mutate({ name, git_url: gitUrl, provider_type: providerType })
}
if (isLoading) return <div>Loading repositories...</div>
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Repositories</h1>
<form onSubmit={handleSubmit} className="space-y-4 bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold">Add Repository</h2>
<div>
<label className="block text-sm font-medium text-gray-700">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Git URL</label>
<input
type="url"
value={gitUrl}
onChange={(e) => setGitUrl(e.target.value)}
placeholder="https://github.com/user/repo.git"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Provider</label>
<select
value={providerType}
onChange={(e) => setProviderType(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
>
<option value="github">GitHub</option>
<option value="gitlab">GitLab</option>
<option value="generic">Generic</option>
</select>
</div>
<button
type="submit"
disabled={createMutation.isPending}
className="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
>
{createMutation.isPending ? 'Adding...' : 'Add Repository'}
</button>
</form>
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h2 className="text-lg font-semibold mb-4">Repository List</h2>
{repositories?.length === 0 ? (
<p className="text-gray-500">No repositories yet.</p>
) : (
<div className="space-y-4">
{repositories?.map((repo) => (
<div
key={repo.id}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50 cursor-pointer"
onClick={() => navigate(`/projects/${projectId}/repositories/${repo.id}`)}
>
<div>
<h3 className="font-medium">{repo.name}</h3>
<p className="text-sm text-gray-500">{repo.git_url}</p>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{repo.provider_type}
</span>
</div>
<button
onClick={(e) => {
e.stopPropagation()
deleteMutation.mutate(repo.id)
}}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}
-181
View File
@@ -1,181 +0,0 @@
import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import { api } from '../api/client'
import type { Secret } from '../types/api'
export default function SecretListPage() {
const { id: projectId } = useParams<{ id: string }>()
const [secrets, setSecrets] = useState<Secret[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [editingSecret, setEditingSecret] = useState<Secret | null>(null)
const [formData, setFormData] = useState({
key: '',
value: '',
scope_type: 'project',
})
useEffect(() => {
if (!projectId) return
loadSecrets()
}, [projectId])
const loadSecrets = async () => {
try {
setLoading(true)
const data = await api.getSecrets(projectId!)
setSecrets(data)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load secrets')
} finally {
setLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!projectId) return
try {
if (editingSecret) {
await api.updateSecret(editingSecret.id, { value: formData.value })
} else {
await api.createSecret({
key: formData.key,
value: formData.value,
scope_type: formData.scope_type,
scope_id: projectId,
})
}
setShowForm(false)
setEditingSecret(null)
setFormData({ key: '', value: '', scope_type: 'project' })
loadSecrets()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save secret')
}
}
const handleDelete = async (secretId: string) => {
if (!confirm('Are you sure you want to delete this secret?')) return
try {
await api.deleteSecret(secretId)
loadSecrets()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete secret')
}
}
const startEdit = (secret: Secret) => {
setEditingSecret(secret)
setFormData({
key: secret.key,
value: '',
scope_type: secret.scope_type,
})
setShowForm(true)
}
if (loading) return <div className="p-4">Loading secrets...</div>
if (error) return <div className="p-4 text-red-600">Error: {error}</div>
return (
<div className="p-4">
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold">Secrets</h1>
<button
onClick={() => {
setShowForm(!showForm)
setEditingSecret(null)
setFormData({ key: '', value: '', scope_type: 'project' })
}}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
{showForm ? 'Cancel' : 'Add Secret'}
</button>
</div>
{showForm && (
<form onSubmit={handleSubmit} className="mb-6 p-4 border rounded bg-gray-50">
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Key</label>
<input
type="text"
value={formData.key}
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
className="w-full px-3 py-2 border rounded"
required
disabled={!!editingSecret}
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Value</label>
<input
type="password"
value={formData.value}
onChange={(e) => setFormData({ ...formData, value: e.target.value })}
className="w-full px-3 py-2 border rounded"
required
placeholder={editingSecret ? 'Enter new value' : ''}
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Scope</label>
<select
value={formData.scope_type}
onChange={(e) => setFormData({ ...formData, scope_type: e.target.value })}
className="w-full px-3 py-2 border rounded"
disabled={!!editingSecret}
>
<option value="global">Global</option>
<option value="project">Project</option>
</select>
</div>
<button type="submit" className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
{editingSecret ? 'Update' : 'Create'}
</button>
</form>
)}
<div className="space-y-2">
{secrets.length === 0 ? (
<p className="text-gray-500">No secrets found.</p>
) : (
secrets.map((secret) => (
<div key={secret.id} className="p-4 border rounded flex justify-between items-center">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold">{secret.key}</span>
<span className="text-xs px-2 py-1 bg-gray-100 rounded">{secret.scope_type}</span>
</div>
<div className="text-sm text-gray-500 mt-1">{secret.value}</div>
</div>
<div className="flex gap-2 ml-4">
<button
onClick={() => startEdit(secret)}
className="px-3 py-1 text-sm bg-gray-100 rounded hover:bg-gray-200"
>
Edit
</button>
<button
onClick={() => handleDelete(secret.id)}
className="px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200"
>
Delete
</button>
</div>
</div>
))
)}
</div>
<div className="mt-4">
<Link to={`/projects/${projectId}`} className="text-blue-600 hover:underline">
Back to Project
</Link>
</div>
</div>
)
}
-26
View File
@@ -1,26 +0,0 @@
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>
)
}
@@ -1,222 +0,0 @@
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
@@ -1,163 +0,0 @@
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
@@ -1,8 +0,0 @@
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>
)
}
-51
View File
@@ -1,51 +0,0 @@
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 RepositoryListPage from './pages/RepositoryListPage'
import RepositoryDetailPage from './pages/RepositoryDetailPage'
import ConfigListPage from './pages/ConfigListPage'
import SecretListPage from './pages/SecretListPage'
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: <RepositoryListPage /> },
{ path: '/projects/:projectId/repositories/:repoId', element: <RepositoryDetailPage /> },
{ path: '/projects/:id/configs', element: <ConfigListPage /> },
{ path: '/projects/:id/secrets', element: <SecretListPage /> },
],
},
{ path: '/login', element: <LoginPage /> },
{ path: '/callback', element: <CallbackPage /> },
],
},
])
-27
View File
@@ -1,27 +0,0 @@
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: () => {
localStorage.removeItem('access_token')
set({ user: null, state: 'unauthenticated', error: null })
},
}))
-120
View File
@@ -1,120 +0,0 @@
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 Config {
id: string
key: string
value: unknown
scope_type: string
scope_id: string
created_at: string
updated_at: string
}
export interface ConfigCreate {
key: string
value: unknown
scope_type: string
scope_id: string
}
export interface Secret {
id: string
key: string
value: string
scope_type: string
scope_id: string
created_at: string
updated_at: string
}
export interface SecretCreate {
key: string
value: string
scope_type: string
scope_id: string
}
export interface Repository {
id: string
name: string
git_url: string
provider_type: string
default_branch: string
project_id: string
}
export interface RepositoryCreate {
name: string
git_url: string
provider_type?: string
default_branch?: string
}
export interface RepositoryConnection {
id: string
project_id: string
repository_id: string | null
provider_kind: string
credential_id: string | null
connection_status: string
default_branch: string | null
}
export interface RepositoryConnectionCreate {
repository_id: string
provider_kind: string
credential_kind: string
credential_payload: string
}