phase 3 bug fixing

This commit is contained in:
2026-05-07 00:55:46 +02:00
parent ce2aa2c776
commit 200c4f43ea
29 changed files with 1337 additions and 62 deletions
+37 -32
View File
@@ -1,6 +1,7 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AppShell } from './components/layout/AppShell'
import { AuthProvider } from './contexts/AuthContext'
import { RepoListSkeleton } from './ui/Skeleton'
import { Suspense, lazy, useEffect } from 'react'
import { bootstrapCSRF } from './api/client'
@@ -10,33 +11,30 @@ const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
retry: false,
placeholderData: (prev: unknown) => prev,
},
},
})
// Pages — code-split per route
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
const ReposPage = lazy(() => import('./pages/ReposPage'))
const RepoPage = lazy(() => import('./pages/RepoPage'))
const RepoPRsPage = lazy(() => import('./pages/RepoPRsPage'))
const PRDetailPage = lazy(() => import('./pages/PRDetailPage'))
const PRsPage = lazy(() => import('./pages/PRsPage'))
const PipelinesPage = lazy(() => import('./pages/PipelinesPage'))
const ProfilePage = lazy(() => import('./pages/ProfilePage'))
const ExplorePage = lazy(() => import('./pages/ExplorePage'))
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
function PageLoader() {
return <div className="p-6"><RepoListSkeleton /></div>
}
const LoginPage = lazy(() => import('./pages/LoginPage'))
const RegisterPage = lazy(() => import('./pages/RegisterPage'))
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
const ReposPage = lazy(() => import('./pages/ReposPage'))
const RepoPage = lazy(() => import('./pages/RepoPage'))
const RepoPRsPage = lazy(() => import('./pages/RepoPRsPage'))
const PRDetailPage = lazy(() => import('./pages/PRDetailPage'))
const PRsPage = lazy(() => import('./pages/PRsPage'))
const PipelinesPage = lazy(() => import('./pages/PipelinesPage'))
const ProfilePage = lazy(() => import('./pages/ProfilePage'))
const ExplorePage = lazy(() => import('./pages/ExplorePage'))
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
function S({ children }: { children: React.ReactNode }) {
return <Suspense fallback={<PageLoader />}>{children}</Suspense>
return <Suspense fallback={<div className="p-6"><RepoListSkeleton /></div>}>{children}</Suspense>
}
// Primes the CSRF cookie once when the SPA mounts
function CSRFBootstrap() {
useEffect(() => { bootstrapCSRF() }, [])
return null
@@ -47,21 +45,28 @@ export default function App() {
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<CSRFBootstrap />
<Routes>
<Route element={<AppShell />}>
<Route index element={<S><DashboardPage /></S>} />
<Route path="repos" element={<S><ReposPage /></S>} />
<Route path="repos/:owner/:repo" element={<S><RepoPage /></S>} />
<Route path="repos/:owner/:repo/pulls" element={<S><RepoPRsPage /></S>} />
<Route path="repos/:owner/:repo/pulls/:prId" element={<S><PRDetailPage /></S>} />
<Route path="pulls" element={<S><PRsPage /></S>} />
<Route path="pipelines" element={<S><PipelinesPage /></S>} />
<Route path="explore" element={<S><ExplorePage /></S>} />
<Route path="profile" element={<S><ProfilePage /></S>} />
<Route path="settings" element={<S><SettingsPage /></S>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
<AuthProvider>
<Routes>
{/* Public — no shell */}
<Route path="login" element={<S><LoginPage /></S>} />
<Route path="register" element={<S><RegisterPage /></S>} />
{/* App shell wraps all authenticated pages */}
<Route element={<AppShell />}>
<Route index element={<S><DashboardPage /></S>} />
<Route path="repos" element={<S><ReposPage /></S>} />
<Route path="repos/:owner/:repo" element={<S><RepoPage /></S>} />
<Route path="repos/:owner/:repo/pulls" element={<S><RepoPRsPage /></S>} />
<Route path="repos/:owner/:repo/pulls/:prId" element={<S><PRDetailPage /></S>} />
<Route path="pulls" element={<S><PRsPage /></S>} />
<Route path="pipelines" element={<S><PipelinesPage /></S>} />
<Route path="explore" element={<S><ExplorePage /></S>} />
<Route path="profile" element={<S><ProfilePage /></S>} />
<Route path="settings" element={<S><SettingsPage /></S>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
)
+34 -3
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { NavLink } from 'react-router-dom'
import { NavLink, Link } from 'react-router-dom'
import { cn } from '../../lib/utils'
import { useAuth } from '../../contexts/AuthContext'
type SidebarState = 'expanded' | 'collapsed' | 'hidden'
@@ -64,6 +65,7 @@ interface SidebarProps {
export function Sidebar({ className }: SidebarProps) {
const [state, setState] = useState<SidebarState>('expanded')
const { user, isAuthenticated } = useAuth()
const isCollapsed = state === 'collapsed'
const width = isCollapsed ? 'w-14' : 'w-80'
@@ -133,8 +135,37 @@ export function Sidebar({ className }: SidebarProps) {
</ul>
</nav>
{/* Bottom: settings */}
<div className="py-2 px-2 border-t border-white/10 shrink-0">
{/* Bottom: user + settings */}
<div className="py-2 px-2 border-t border-white/10 shrink-0 flex flex-col gap-0.5">
{isAuthenticated ? (
<NavLink
to="/settings"
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded px-2 min-h-[44px] transition-colors',
isActive ? 'bg-white/20 text-white' : 'text-white/70 hover:bg-white/10 hover:text-white',
)
}
title={isCollapsed ? user?.username : undefined}
>
<div className="w-5 h-5 rounded-full bg-[#0052CC] flex items-center justify-center text-[10px] font-bold shrink-0">
{user?.username?.[0]?.toUpperCase()}
</div>
{!isCollapsed && <span className="text-sm font-medium truncate">{user?.username}</span>}
</NavLink>
) : (
<Link
to="/login"
className="flex items-center gap-3 rounded px-2 min-h-[44px] text-white/70 hover:bg-white/10 hover:text-white transition-colors"
title={isCollapsed ? 'Sign in' : undefined}
>
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15M12 9l-3 3m0 0 3 3m-3-3h12.75" />
</svg>
{!isCollapsed && <span className="text-sm font-medium">Sign in</span>}
</Link>
)}
<NavLink
to="/settings"
className={({ isActive }) =>
+64
View File
@@ -0,0 +1,64 @@
import { createContext, useContext } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '../api/client'
import { z } from 'zod'
import type { User } from '../types/api'
const userSchema = z.object({
id: z.number(),
username: z.string(),
email: z.string(),
avatarUrl: z.string(),
isAdmin: z.boolean(),
createdAt: z.string(),
updatedAt: z.string(),
})
interface AuthContextValue {
user: User | null
isLoading: boolean
isAuthenticated: boolean
logout: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue>({
user: null,
isLoading: true,
isAuthenticated: false,
logout: async () => {},
})
export function AuthProvider({ children }: { children: React.ReactNode }) {
const queryClient = useQueryClient()
const { data: user, isLoading } = useQuery({
queryKey: ['me'],
queryFn: () => api.get<User>('/api/v1/me', userSchema),
retry: false,
// Don't treat 401 as a fatal error — it just means not logged in
throwOnError: false,
})
const logout = async () => {
try {
await fetch('/api/v1/auth/logout', {
method: 'POST',
credentials: 'include',
headers: { 'X-CSRF-Token': document.cookie.match(/fb_csrf=([^;]+)/)?.[1] ?? '' },
})
} finally {
queryClient.clear()
window.location.href = '/login'
}
}
return (
<AuthContext.Provider value={{ user: user ?? null, isLoading, isAuthenticated: !!user, logout }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
return useContext(AuthContext)
}
+13 -3
View File
@@ -1,8 +1,18 @@
export default function ExplorePage() {
return (
<div className="p-6">
<h1 className="text-2xl font-semibold text-[#172B4D] mb-4">Explore</h1>
<p className="text-[#5E6C84]">Coming soon Phase 2 implementation.</p>
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6 space-y-6">
<h1 className="text-xl font-semibold text-[#172B4D]">Explore</h1>
<div className="flex flex-col items-center justify-center py-16 border border-dashed border-[#DFE1E6] rounded text-center gap-3">
<svg width="40" height="40" fill="none" stroke="#97A0AF" strokeWidth="1" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
<div>
<p className="text-sm font-medium text-[#172B4D]">Explore public repositories</p>
<p className="text-xs text-[#5E6C84] mt-1">
Federated discovery across ForgeBucket instances coming soon.
</p>
</div>
</div>
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { bootstrapCSRF, api, ApiError } from '../api/client'
import { z } from 'zod'
import { useQueryClient } from '@tanstack/react-query'
const userSchema = z.object({
id: z.number(), username: z.string(), email: z.string(),
avatarUrl: z.string(), isAdmin: z.boolean(), createdAt: z.string(), updatedAt: z.string(),
})
export default function LoginPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await bootstrapCSRF()
await api.post('/api/v1/auth/login', userSchema, { username, password })
queryClient.invalidateQueries({ queryKey: ['me'] })
navigate('/')
} catch (err) {
setError(err instanceof ApiError && err.status === 401 ? 'Invalid username or password.' : 'Login failed. Please try again.')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-[#F4F5F7] flex items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-[#172B4D]">ForgeBucket</h1>
<p className="text-sm text-[#5E6C84] mt-1">Sign in to your account</p>
</div>
<div className="bg-white border border-[#DFE1E6] rounded-lg p-6 shadow-sm">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<label className="block text-xs font-semibold text-[#172B4D] mb-1">Username</label>
<input
value={username} onChange={e => setUsername(e.target.value)}
required autoFocus autoComplete="username"
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF] focus:ring-1 focus:ring-[#4C9AFF]"
/>
</div>
<div>
<label className="block text-xs font-semibold text-[#172B4D] mb-1">Password</label>
<input
type="password" value={password} onChange={e => setPassword(e.target.value)}
required autoComplete="current-password"
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF] focus:ring-1 focus:ring-[#4C9AFF]"
/>
</div>
{error && <p className="text-xs text-[#DE350B] bg-[#FFEBE6] rounded px-3 py-2">{error}</p>}
<button
type="submit" disabled={loading}
className="w-full py-2.5 rounded bg-[#0052CC] text-white text-sm font-semibold hover:bg-[#0065FF] disabled:opacity-50 min-h-[44px]"
>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
<p className="text-center text-xs text-[#5E6C84] mt-4">
No account?{' '}
<Link to="/register" className="text-[#0052CC] hover:underline font-medium">Create one</Link>
</p>
</div>
</div>
)
}
+12 -16
View File
@@ -1,23 +1,19 @@
import { PipelineWaterfall } from '../components/ci/PipelineWaterfall'
import type { Pipeline } from '../types/api'
const DEMO_PIPELINE: Pipeline = {
id: 1,
repoId: 1,
ref: 'main',
status: 'running',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
export default function PipelinesPage() {
return (
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6 space-y-6">
<h1 className="text-xl font-semibold text-[#172B4D]">Pipelines</h1>
<p className="text-sm text-[#5E6C84]">CI/CD pipeline integration preview below.</p>
<PipelineWaterfall pipeline={DEMO_PIPELINE} />
<PipelineWaterfall pipeline={{ ...DEMO_PIPELINE, id: 2, status: 'success' }} />
<PipelineWaterfall pipeline={{ ...DEMO_PIPELINE, id: 3, status: 'failure' }} />
<div className="flex flex-col items-center justify-center py-16 border border-dashed border-[#DFE1E6] rounded text-center gap-3">
<svg width="40" height="40" fill="none" stroke="#97A0AF" strokeWidth="1" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
</svg>
<div>
<p className="text-sm font-medium text-[#172B4D]">No pipelines yet</p>
<p className="text-xs text-[#5E6C84] mt-1 max-w-xs">
Pipelines run automatically when you push to a repository.<br />
Add a <code className="font-mono bg-[#F4F5F7] px-1 rounded">.forgebucket.yml</code> file to get started.
</p>
</div>
</div>
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { bootstrapCSRF, api, ApiError } from '../api/client'
import { z } from 'zod'
import { useQueryClient } from '@tanstack/react-query'
const userSchema = z.object({
id: z.number(), username: z.string(), email: z.string(),
avatarUrl: z.string(), isAdmin: z.boolean(), createdAt: z.string(), updatedAt: z.string(),
})
export default function RegisterPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await bootstrapCSRF()
await api.post('/api/v1/auth/register', userSchema, { username, email, password })
// Auto-login after register
await api.post('/api/v1/auth/login', userSchema, { username, password })
queryClient.invalidateQueries({ queryKey: ['me'] })
navigate('/')
} catch (err) {
setError(err instanceof ApiError && err.status === 409 ? 'Username or email already taken.' : 'Registration failed. Please try again.')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-[#F4F5F7] flex items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-[#172B4D]">ForgeBucket</h1>
<p className="text-sm text-[#5E6C84] mt-1">Create your account</p>
</div>
<div className="bg-white border border-[#DFE1E6] rounded-lg p-6 shadow-sm">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<label className="block text-xs font-semibold text-[#172B4D] mb-1">Username</label>
<input value={username} onChange={e => setUsername(e.target.value)} required autoFocus
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF] focus:ring-1 focus:ring-[#4C9AFF]" />
</div>
<div>
<label className="block text-xs font-semibold text-[#172B4D] mb-1">Email</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF] focus:ring-1 focus:ring-[#4C9AFF]" />
</div>
<div>
<label className="block text-xs font-semibold text-[#172B4D] mb-1">Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF] focus:ring-1 focus:ring-[#4C9AFF]" />
</div>
{error && <p className="text-xs text-[#DE350B] bg-[#FFEBE6] rounded px-3 py-2">{error}</p>}
<button type="submit" disabled={loading}
className="w-full py-2.5 rounded bg-[#0052CC] text-white text-sm font-semibold hover:bg-[#0065FF] disabled:opacity-50 min-h-[44px]">
{loading ? 'Creating account…' : 'Create account'}
</button>
</form>
</div>
<p className="text-center text-xs text-[#5E6C84] mt-4">
Already have an account?{' '}
<Link to="/login" className="text-[#0052CC] hover:underline font-medium">Sign in</Link>
</p>
</div>
</div>
)
}
+23 -2
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useRepos, useCreateRepo } from '../api/queries/repos'
import { RepoCard } from '../components/repos/RepoCard'
import { RepoListSkeleton } from '../ui/Skeleton'
import { Link } from 'react-router-dom'
export default function ReposPage() {
const { data: repos, isLoading, isError } = useRepos()
@@ -32,9 +33,12 @@ export default function ReposPage() {
{isLoading ? (
<RepoListSkeleton />
) : isError ? (
<p className="text-sm text-[#DE350B]">Failed to load repositories.</p>
<NotSignedIn />
) : !repos?.length ? (
<p className="text-sm text-[#5E6C84] py-8 text-center">No repositories yet.</p>
<div className="py-12 text-center text-sm text-[#5E6C84]">
No repositories yet.{' '}
<button onClick={() => setShowCreate(true)} className="text-[#0052CC] hover:underline">Create your first one.</button>
</div>
) : (
<div className="flex flex-col gap-2">
{repos.map(r => <RepoCard key={r.id} repo={r} />)}
@@ -44,6 +48,23 @@ export default function ReposPage() {
)
}
function NotSignedIn() {
return (
<div className="flex flex-col items-center justify-center py-12 border border-dashed border-[#DFE1E6] rounded text-center gap-3">
<svg width="36" height="36" fill="none" stroke="#97A0AF" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
<div>
<p className="text-sm font-medium text-[#172B4D]">Sign in to see your repositories</p>
<p className="text-xs text-[#5E6C84] mt-1">You need to be signed in to view and create repositories.</p>
</div>
<Link to="/login" className="px-4 py-2 rounded bg-[#0052CC] text-white text-sm font-medium hover:bg-[#0065FF] min-h-[44px] flex items-center">
Sign in
</Link>
</div>
)
}
function CreateRepoForm({ onClose }: { onClose: () => void }) {
const createRepo = useCreateRepo()
const [name, setName] = useState('')
+40 -3
View File
@@ -1,8 +1,45 @@
import { useAuth } from '../contexts/AuthContext'
export default function SettingsPage() {
const { user, logout } = useAuth()
return (
<div className="p-6">
<h1 className="text-2xl font-semibold text-[#172B4D] mb-4">Settings</h1>
<p className="text-[#5E6C84]">Coming soon Phase 2 implementation.</p>
<div className="max-w-2xl mx-auto px-4 md:px-6 py-6 space-y-8">
<h1 className="text-xl font-semibold text-[#172B4D]">Settings</h1>
<section className="border border-[#DFE1E6] rounded-lg overflow-hidden">
<div className="px-5 py-4 border-b border-[#DFE1E6] bg-[#FAFBFC]">
<h2 className="text-sm font-semibold text-[#172B4D]">Account</h2>
</div>
<div className="px-5 py-4 flex flex-col gap-3">
<div>
<p className="text-xs text-[#5E6C84]">Username</p>
<p className="text-sm font-medium text-[#172B4D]">{user?.username}</p>
</div>
<div>
<p className="text-xs text-[#5E6C84]">Email</p>
<p className="text-sm text-[#172B4D]">{user?.email}</p>
</div>
<div>
<p className="text-xs text-[#5E6C84]">Role</p>
<p className="text-sm text-[#172B4D]">{user?.isAdmin ? 'Administrator' : 'Member'}</p>
</div>
</div>
</section>
<section className="border border-[#FFEBE6] rounded-lg overflow-hidden">
<div className="px-5 py-4 border-b border-[#FFEBE6] bg-[#FFEBE6]/50">
<h2 className="text-sm font-semibold text-[#BF2600]">Danger zone</h2>
</div>
<div className="px-5 py-4">
<button
onClick={logout}
className="px-4 py-2 rounded border border-[#DE350B] text-[#DE350B] text-sm font-medium hover:bg-[#FFEBE6] min-h-[44px]"
>
Sign out
</button>
</div>
</section>
</div>
)
}