Squashed commit of the following:

commit 70cb0b9de2bdbb6b564a7e6fb3a926a104e1e17c
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Tue Jun 17 14:16:45 2025 -0400

    Update API

commit 36a5161b96b8f034daa91d08d648be77fbdcb30b
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Tue Jun 17 14:14:33 2025 -0400

    Further auth removal

commit cba24ffd462a3de178658f26231011ed4d28a78b
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Tue Jun 17 14:03:13 2025 -0400

    Fully remove users

commit cfb1aec9b984c3374e5cc0ffe182de2647caf85d
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Tue Jun 17 11:51:20 2025 -0400

    Start removing users
This commit is contained in:
Louis Knight-Webb
2025-06-17 14:17:31 -04:00
parent ac2f227cf0
commit a709951fdc
33 changed files with 148 additions and 1981 deletions

View File

@@ -1,43 +1,19 @@
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom'
import { LoginForm } from '@/components/auth/login-form'
import { Navbar } from '@/components/layout/navbar'
import { HomePage } from '@/pages/home'
import { Projects } from '@/pages/projects'
import { ProjectTasks } from '@/pages/project-tasks'
import { TaskDetailsPage } from '@/pages/task-details'
import { TaskAttemptComparePage } from '@/pages/task-attempt-compare'
import { Users } from '@/pages/users'
import { AuthProvider, useAuth } from '@/contexts/auth-context'
function AppContent() {
const location = useLocation()
const { isAuthenticated, isLoading, logout } = useAuth()
const showNavbar = location.pathname !== '/' || isAuthenticated
const handleLogin = () => {
// The actual login logic is handled by the LoginForm component
// which will call the login method from useAuth()
}
// Show loading while checking auth status
if (isLoading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto mb-4"></div>
<p className="text-muted-foreground">Checking authentication...</p>
</div>
</div>
)
}
if (!isAuthenticated) {
return <LoginForm onSuccess={handleLogin} />
}
const showNavbar = true
return (
<div className="min-h-screen bg-background">
{showNavbar && <Navbar onLogout={logout} />}
{showNavbar && <Navbar />}
<div className={showNavbar && location.pathname !== '/' ? "max-w-7xl mx-auto p-6 sm:p-8" : ""}>
<Routes>
<Route path="/" element={<HomePage />} />
@@ -46,7 +22,7 @@ function AppContent() {
<Route path="/projects/:projectId/tasks" element={<ProjectTasks />} />
<Route path="/projects/:projectId/tasks/:taskId" element={<TaskDetailsPage />} />
<Route path="/projects/:projectId/tasks/:taskId/attempts/:attemptId/compare" element={<TaskAttemptComparePage />} />
<Route path="/users" element={<Users />} />
</Routes>
</div>
</div>
@@ -56,9 +32,7 @@ function AppContent() {
function App() {
return (
<BrowserRouter>
<AuthProvider>
<AppContent />
</AuthProvider>
<AppContent />
</BrowserRouter>
)
}

View File

@@ -1,118 +0,0 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { LoginRequest, LoginResponse, ApiResponse } from 'shared/types'
import { useAuth } from '@/contexts/auth-context'
import { LogIn, AlertCircle } from 'lucide-react'
interface LoginFormProps {
onSuccess?: () => void
}
export function LoginForm({ onSuccess }: LoginFormProps) {
const { login } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const loginData: LoginRequest = { email, password }
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(loginData),
})
if (!response.ok) {
if (response.status === 401) {
throw new Error('Invalid email or password')
}
throw new Error('Login failed')
}
const data: ApiResponse<LoginResponse> = await response.json()
if (data.success && data.data) {
login(data.data.user, data.data.token)
onSuccess?.()
} else {
throw new Error('Login failed')
}
} catch (error) {
setError(error instanceof Error ? error.message : 'An error occurred')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/20">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<LogIn className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-2xl">Welcome back</CardTitle>
<CardDescription>
Sign in to your account to continue
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
/>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{error}
</AlertDescription>
</Alert>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Signing in...' : 'Sign in'}
</Button>
</form>
<div className="mt-6 text-center text-sm text-muted-foreground">
<p>Default admin credentials:</p>
<p>Email: admin@example.com</p>
<p>Password: Check your ADMIN_PASSWORD env var</p>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,21 +1,11 @@
import { Link, useLocation } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { authStorage } from '@/lib/auth'
import { ArrowLeft, FolderOpen, Users, LogOut } from 'lucide-react'
import { ArrowLeft, FolderOpen, Users } from 'lucide-react'
interface NavbarProps {
onLogout: () => void
}
export function Navbar({ onLogout }: NavbarProps) {
export function Navbar() {
const location = useLocation()
const currentUser = authStorage.getUser()
const isHome = location.pathname === '/'
const handleLogout = () => {
onLogout()
}
return (
<div className="border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@@ -33,24 +23,19 @@ export function Navbar({ onLogout }: NavbarProps) {
Projects
</Link>
</Button>
{currentUser?.is_admin && (
<Button
asChild
variant={location.pathname === '/users' ? 'default' : 'ghost'}
size="sm"
>
<Link to="/users">
<Users className="mr-2 h-4 w-4" />
Users
</Link>
</Button>
)}
<Button
asChild
variant={location.pathname === '/users' ? 'default' : 'ghost'}
size="sm"
>
<Link to="/users">
<Users className="mr-2 h-4 w-4" />
Users
</Link>
</Button>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="text-sm text-muted-foreground">
Welcome, {currentUser?.email}
</div>
{!isHome && (
<Button asChild variant="ghost">
<Link to="/">
@@ -59,10 +44,6 @@ export function Navbar({ onLogout }: NavbarProps) {
</Link>
</Button>
)}
<Button variant="ghost" onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
Logout
</Button>
</div>
</div>
</div>

View File

@@ -6,8 +6,8 @@ import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Project, ApiResponse } from 'shared/types'
import { ProjectForm } from './project-form'
import { makeAuthenticatedRequest } from '@/lib/auth'
import { ArrowLeft, Edit, Trash2, Calendar, Clock, User, AlertCircle, Loader2, CheckSquare } from 'lucide-react'
import { makeRequest } from '@/lib/api'
import { ArrowLeft, Edit, Trash2, Calendar, Clock, AlertCircle, Loader2, CheckSquare } from 'lucide-react'
interface ProjectDetailProps {
projectId: string
@@ -25,7 +25,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
setLoading(true)
setError('')
try {
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}`)
const response = await makeRequest(`/api/projects/${projectId}`)
const data: ApiResponse<Project> = await response.json()
if (data.success && data.data) {
setProject(data.data)
@@ -45,7 +45,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
if (!confirm(`Are you sure you want to delete "${project.name}"? This action cannot be undone.`)) return
try {
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}`, {
const response = await makeRequest(`/api/projects/${projectId}`, {
method: 'DELETE',
})
if (response.ok) {
@@ -166,13 +166,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
<span className="text-muted-foreground">Last Updated:</span>
<span className="ml-2">{new Date(project.updated_at).toLocaleDateString()}</span>
</div>
<div className="flex items-center text-sm">
<User className="mr-2 h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Owner ID:</span>
<code className="ml-2 text-xs bg-muted px-1 py-0.5 rounded">
{project.owner_id.substring(0, 8)}...
</code>
</div>
</div>
</CardContent>
</Card>

View File

@@ -14,7 +14,7 @@ import {
import { FolderPicker } from "@/components/ui/folder-picker";
import { Project, CreateProject, UpdateProject } from "shared/types";
import { AlertCircle, Folder } from "lucide-react";
import { makeAuthenticatedRequest } from "@/lib/auth";
import { makeRequest } from "@/lib/api";
interface ProjectFormProps {
open: boolean;
@@ -76,7 +76,7 @@ export function ProjectForm({
name,
git_repo_path: finalGitRepoPath,
};
const response = await makeAuthenticatedRequest(
const response = await makeRequest(
`/api/projects/${project.id}`,
{
method: "PUT",
@@ -98,7 +98,7 @@ export function ProjectForm({
git_repo_path: finalGitRepoPath,
use_existing_repo: repoMode === "existing",
};
const response = await makeAuthenticatedRequest("/api/projects", {
const response = await makeRequest("/api/projects", {
method: "POST",
body: JSON.stringify(createData),
});

View File

@@ -6,7 +6,7 @@ import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Project, ApiResponse } from 'shared/types'
import { ProjectForm } from './project-form'
import { makeAuthenticatedRequest } from '@/lib/auth'
import { makeRequest } from '@/lib/api'
import { Plus, Edit, Trash2, Calendar, AlertCircle, Loader2, MoreHorizontal, ExternalLink } from 'lucide-react'
import {
DropdownMenu,
@@ -27,7 +27,7 @@ export function ProjectList() {
setLoading(true)
setError('')
try {
const response = await makeAuthenticatedRequest('/api/projects')
const response = await makeRequest('/api/projects')
const data: ApiResponse<Project[]> = await response.json()
if (data.success && data.data) {
setProjects(data.data)
@@ -46,7 +46,7 @@ export function ProjectList() {
if (!confirm(`Are you sure you want to delete "${name}"? This action cannot be undone.`)) return
try {
const response = await makeAuthenticatedRequest(`/api/projects/${id}`, {
const response = await makeRequest(`/api/projects/${id}`, {
method: 'DELETE',
})
if (response.ok) {

View File

@@ -18,7 +18,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { makeAuthenticatedRequest } from "@/lib/auth";
import { makeRequest } from "@/lib/api";
import type {
TaskStatus,
TaskAttempt,
@@ -107,7 +107,7 @@ export function TaskDetailsDialog({
const fetchTaskAttempts = async (taskId: string) => {
try {
setTaskAttemptsLoading(true);
const response = await makeAuthenticatedRequest(
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts`
);
@@ -141,7 +141,7 @@ export function TaskDetailsDialog({
try {
setActivitiesLoading(true);
const response = await makeAuthenticatedRequest(
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/activities`
);
@@ -171,7 +171,7 @@ export function TaskDetailsDialog({
try {
setSavingTask(true);
const response = await makeAuthenticatedRequest(
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}`,
{
method: "PUT",
@@ -216,7 +216,7 @@ export function TaskDetailsDialog({
setCreatingAttempt(true);
const worktreePath = `/tmp/task-${task.id}-attempt-${Date.now()}`;
const response = await makeAuthenticatedRequest(
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts`,
{
method: "POST",
@@ -251,7 +251,7 @@ export function TaskDetailsDialog({
try {
setStoppingAttempt(true);
const response = await makeAuthenticatedRequest(
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/stop`,
{
method: "POST",

View File

@@ -4,7 +4,7 @@ import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Folder, FolderOpen, File, AlertCircle, Home, ChevronUp } from 'lucide-react'
import { makeAuthenticatedRequest } from '@/lib/auth'
import { makeRequest } from '@/lib/api'
import { DirectoryEntry } from 'shared/types'
interface FolderPickerProps {
@@ -43,7 +43,7 @@ export function FolderPicker({
try {
const queryParam = path ? `?path=${encodeURIComponent(path)}` : ''
const response = await makeAuthenticatedRequest(`/api/filesystem/list${queryParam}`)
const response = await makeRequest(`/api/filesystem/list${queryParam}`)
if (!response.ok) {
throw new Error('Failed to load directory')

View File

@@ -1,185 +0,0 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { User, CreateUser, UpdateUser } from 'shared/types'
import { makeAuthenticatedRequest, authStorage } from '@/lib/auth'
import { AlertCircle } from 'lucide-react'
interface UserFormProps {
open: boolean
onClose: () => void
onSuccess: () => void
user?: User | null
}
export function UserForm({ open, onClose, onSuccess, user }: UserFormProps) {
const [email, setEmail] = useState(user?.email || '')
const [password, setPassword] = useState('')
const [isAdmin, setIsAdmin] = useState(user?.is_admin || false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const currentUser = authStorage.getUser()
const isEditing = !!user
const canEditAdminStatus = currentUser?.is_admin && currentUser.id !== user?.id
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
if (isEditing) {
const updateData: UpdateUser = {
email: email !== user.email ? email : null,
password: password ? password : null,
is_admin: canEditAdminStatus && isAdmin !== user.is_admin ? isAdmin : null
}
// Remove null values
Object.keys(updateData).forEach(key => {
if (updateData[key as keyof UpdateUser] === null) {
delete updateData[key as keyof UpdateUser]
}
})
const response = await makeAuthenticatedRequest(`/api/users/${user.id}`, {
method: 'PUT',
body: JSON.stringify(updateData),
})
if (!response.ok) {
throw new Error('Failed to update user')
}
} else {
if (!password) {
throw new Error('Password is required for new users')
}
const createData: CreateUser = {
email,
password,
is_admin: currentUser?.is_admin ? isAdmin : false
}
const response = await makeAuthenticatedRequest('/api/users', {
method: 'POST',
body: JSON.stringify(createData),
})
if (!response.ok) {
if (response.status === 409) {
throw new Error('A user with this email already exists')
}
throw new Error('Failed to create user')
}
}
onSuccess()
resetForm()
} catch (error) {
setError(error instanceof Error ? error.message : 'An error occurred')
} finally {
setLoading(false)
}
}
const resetForm = () => {
setEmail(user?.email || '')
setPassword('')
setIsAdmin(user?.is_admin || false)
setError('')
}
const handleClose = () => {
resetForm()
onClose()
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>
{isEditing ? 'Edit User' : 'Create New User'}
</DialogTitle>
<DialogDescription>
{isEditing
? 'Make changes to the user account here. Click save when you\'re done.'
: 'Add a new user to the system. They will be able to log in with these credentials.'
}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter email address"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">
{isEditing ? 'New Password (leave blank to keep current)' : 'Password'}
</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={isEditing ? "Enter new password" : "Enter password"}
required={!isEditing}
/>
</div>
{canEditAdminStatus && (
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="isAdmin"
checked={isAdmin}
onChange={(e) => setIsAdmin(e.target.checked)}
className="rounded border-gray-300"
/>
<Label htmlFor="isAdmin" className="text-sm font-medium">
Administrator privileges
</Label>
</div>
)}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{error}
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading || !email.trim()}>
{loading ? 'Saving...' : isEditing ? 'Save Changes' : 'Create User'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,188 +0,0 @@
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { User, ApiResponse } from 'shared/types'
import { UserForm } from './user-form'
import { makeAuthenticatedRequest, authStorage } from '@/lib/auth'
import { Plus, Edit, Trash2, Calendar, AlertCircle, Loader2, Shield, User as UserIcon } from 'lucide-react'
export function UserList() {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(false)
const [showForm, setShowForm] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [error, setError] = useState('')
const currentUser = authStorage.getUser()
const fetchUsers = async () => {
setLoading(true)
setError('')
try {
const response = await makeAuthenticatedRequest('/api/users')
const data: ApiResponse<User[]> = await response.json()
if (data.success && data.data) {
setUsers(data.data)
} else {
setError('Failed to load users')
}
} catch (error) {
console.error('Failed to fetch users:', error)
setError('Failed to connect to server')
} finally {
setLoading(false)
}
}
const handleDelete = async (id: string, email: string) => {
if (!confirm(`Are you sure you want to delete user "${email}"? This action cannot be undone.`)) return
try {
const response = await makeAuthenticatedRequest(`/api/users/${id}`, {
method: 'DELETE',
})
if (response.ok) {
fetchUsers()
} else if (response.status === 403) {
setError('You cannot delete this user')
} else {
setError('Failed to delete user')
}
} catch (error) {
console.error('Failed to delete user:', error)
setError('Failed to delete user')
}
}
const handleEdit = (user: User) => {
setEditingUser(user)
setShowForm(true)
}
const handleFormSuccess = () => {
setShowForm(false)
setEditingUser(null)
fetchUsers()
}
useEffect(() => {
fetchUsers()
}, [])
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Users</h1>
<p className="text-muted-foreground">
Manage user accounts and permissions
</p>
</div>
{currentUser?.is_admin && (
<Button onClick={() => setShowForm(true)}>
<Plus className="mr-2 h-4 w-4" />
Add User
</Button>
)}
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{error}
</AlertDescription>
</Alert>
)}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading users...
</div>
) : users.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-lg bg-muted">
<UserIcon className="h-6 w-6" />
</div>
<h3 className="mt-4 text-lg font-semibold">No users found</h3>
<p className="mt-2 text-sm text-muted-foreground">
Get started by creating the first user account.
</p>
{currentUser?.is_admin && (
<Button
className="mt-4"
onClick={() => setShowForm(true)}
>
<Plus className="mr-2 h-4 w-4" />
Add your first user
</Button>
)}
</CardContent>
</Card>
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{users.map((user) => (
<Card key={user.id} className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<CardTitle className="text-lg flex items-center">
{user.is_admin ? (
<Shield className="mr-2 h-4 w-4 text-orange-500" />
) : (
<UserIcon className="mr-2 h-4 w-4 text-blue-500" />
)}
{user.email}
</CardTitle>
<Badge variant={user.is_admin ? "default" : "secondary"}>
{user.is_admin ? "Admin" : "User"}
</Badge>
</div>
<CardDescription className="flex items-center">
<Calendar className="mr-1 h-3 w-3" />
Joined {user.created_at ? new Date(user.created_at).toLocaleDateString() : 'Unknown'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(user)}
className="h-8"
>
<Edit className="mr-1 h-3 w-3" />
Edit
</Button>
{currentUser?.is_admin && currentUser.id !== user.id && (
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(user.id, user.email)}
className="h-8 text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="mr-1 h-3 w-3" />
Delete
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
<UserForm
open={showForm}
onClose={() => {
setShowForm(false)
setEditingUser(null)
}}
onSuccess={handleFormSuccess}
user={editingUser}
/>
</div>
)
}

View File

@@ -1,5 +0,0 @@
import { UserList } from './user-list'
export function UsersPage() {
return <UserList />
}

View File

@@ -1,122 +0,0 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import { isAuthenticated, authStorage, makeAuthenticatedRequest } from '@/lib/auth'
import { User } from 'shared/types'
interface AuthContextType {
user: User | null
isAuthenticated: boolean
isLoading: boolean
login: (user: User, token: string) => void
logout: () => void
refreshAuthStatus: () => Promise<void>
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
interface AuthProviderProps {
children: ReactNode
}
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null)
const [isAuthenticatedState, setIsAuthenticated] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const checkAuthStatus = async (): Promise<boolean> => {
const token = authStorage.getToken()
if (!token) {
return false
}
try {
const response = await makeAuthenticatedRequest('/api/auth/status')
if (response.ok) {
const data = await response.json()
if (data.success && data.data?.authenticated) {
// Update user data from server response
if (data.data.user_id && data.data.email) {
const userData: User = {
id: data.data.user_id,
email: data.data.email,
is_admin: data.data.is_admin || false,
created_at: new Date(),
updated_at: new Date()
}
authStorage.setUser(userData)
setUser(userData)
}
return true
}
}
// If we get here, the token is invalid
return false
} catch (error) {
console.error('Auth status check failed:', error)
return false
}
}
const refreshAuthStatus = async () => {
setIsLoading(true)
if (isAuthenticated()) {
const isValid = await checkAuthStatus()
if (isValid) {
setIsAuthenticated(true)
} else {
// Clear invalid auth state
authStorage.clear()
setUser(null)
setIsAuthenticated(false)
}
} else {
setUser(null)
setIsAuthenticated(false)
}
setIsLoading(false)
}
useEffect(() => {
refreshAuthStatus()
}, [])
const login = (userData: User, token: string) => {
authStorage.setToken(token)
authStorage.setUser(userData)
setUser(userData)
setIsAuthenticated(true)
}
const logout = () => {
authStorage.clear()
setUser(null)
setIsAuthenticated(false)
window.location.href = '/'
}
const value: AuthContextType = {
user,
isAuthenticated: isAuthenticatedState,
isLoading,
login,
logout,
refreshAuthStatus
}
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}

11
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,11 @@
export const makeRequest = async (url: string, options: RequestInit = {}) => {
const headers = {
'Content-Type': 'application/json',
...(options.headers || {})
}
return fetch(url, {
...options,
headers
})
}

View File

@@ -1,58 +0,0 @@
import { User } from 'shared/types'
const TOKEN_KEY = 'auth_token'
const USER_KEY = 'auth_user'
export const authStorage = {
getToken: (): string | null => {
return localStorage.getItem(TOKEN_KEY)
},
setToken: (token: string): void => {
localStorage.setItem(TOKEN_KEY, token)
},
removeToken: (): void => {
localStorage.removeItem(TOKEN_KEY)
},
getUser: (): User | null => {
const user = localStorage.getItem(USER_KEY)
return user ? JSON.parse(user) : null
},
setUser: (user: User): void => {
localStorage.setItem(USER_KEY, JSON.stringify(user))
},
removeUser: (): void => {
localStorage.removeItem(USER_KEY)
},
clear: (): void => {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
}
}
export const getAuthHeaders = (): Record<string, string> => {
const token = authStorage.getToken()
return token ? { Authorization: `Bearer ${token}` } : {}
}
export const makeAuthenticatedRequest = async (url: string, options: RequestInit = {}) => {
const headers = {
'Content-Type': 'application/json',
...getAuthHeaders(),
...(options.headers || {})
}
return fetch(url, {
...options,
headers
})
}
export const isAuthenticated = (): boolean => {
return !!authStorage.getToken()
}

View File

@@ -11,7 +11,7 @@ import {
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { ApiResponse } from "shared/types";
import { authStorage, makeAuthenticatedRequest } from "@/lib/auth";
import { makeRequest } from "@/lib/api";
import {
Heart,
Activity,
@@ -20,7 +20,6 @@ import {
CheckCircle,
AlertCircle,
Zap,
Shield,
} from "lucide-react";
export function HomePage() {
@@ -30,12 +29,12 @@ export function HomePage() {
);
const [loading, setLoading] = useState(false);
const currentUser = authStorage.getUser();
// Single user app, no need for user data
const checkHealth = async () => {
setLoading(true);
try {
const response = await makeAuthenticatedRequest("/api/health");
const response = await makeRequest("/api/health");
const data: ApiResponse<string> = await response.json();
setMessage(data.message || "Health check completed");
setMessageType("success");
@@ -134,43 +133,37 @@ export function HomePage() {
</CardContent>
</Card>
{currentUser?.is_admin && (
<Card className="group hover:shadow-lg transition-all duration-200 border-muted/50 hover:border-muted">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="rounded-lg bg-amber-500/10 p-2 mr-3 group-hover:bg-amber-500/20 transition-colors">
<Users className="h-5 w-5 text-amber-600" />
</div>
<div>
<CardTitle className="text-lg flex items-center gap-2">
Users
<Badge variant="outline" className="text-xs">
<Shield className="mr-1 h-3 w-3" />
Admin Only
</Badge>
</CardTitle>
<CardDescription className="mt-1">
Manage user accounts and permissions
</CardDescription>
</div>
<Card className="group hover:shadow-lg transition-all duration-200 border-muted/50 hover:border-muted">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="rounded-lg bg-amber-500/10 p-2 mr-3 group-hover:bg-amber-500/20 transition-colors">
<Users className="h-5 w-5 text-amber-600" />
</div>
<div>
<CardTitle className="text-lg flex items-center gap-2">
Users
</CardTitle>
<CardDescription className="mt-1">
Manage user accounts and permissions
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<Button
asChild
className="group-hover:shadow-sm transition-shadow"
size="sm"
>
<Link to="/users">
<Users className="mr-2 h-4 w-4" />
Manage Users
</Link>
</Button>
</CardContent>
</Card>
)}
</div>
</CardHeader>
<CardContent>
<Button
asChild
className="group-hover:shadow-sm transition-shadow"
size="sm"
>
<Link to="/users">
<Users className="mr-2 h-4 w-4" />
Manage Users
</Link>
</Button>
</CardContent>
</Card>
</div>
{/* Status Alert */}

View File

@@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { ArrowLeft, Plus } from 'lucide-react'
import { makeAuthenticatedRequest } from '@/lib/auth'
import { makeRequest } from '@/lib/api'
import { TaskCreateDialog } from '@/components/tasks/TaskCreateDialog'
import { TaskEditDialog } from '@/components/tasks/TaskEditDialog'
@@ -53,7 +53,7 @@ export function ProjectTasks() {
const fetchProject = async () => {
try {
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}`)
const response = await makeRequest(`/api/projects/${projectId}`)
if (response.ok) {
const result: ApiResponse<Project> = await response.json()
@@ -72,7 +72,7 @@ export function ProjectTasks() {
const fetchTasks = async () => {
try {
setLoading(true)
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks`)
const response = await makeRequest(`/api/projects/${projectId}/tasks`)
if (response.ok) {
const result: ApiResponse<Task[]> = await response.json()
@@ -91,7 +91,7 @@ export function ProjectTasks() {
const handleCreateTask = async (title: string, description: string) => {
try {
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks`, {
const response = await makeRequest(`/api/projects/${projectId}/tasks`, {
method: 'POST',
body: JSON.stringify({
project_id: projectId,
@@ -114,7 +114,7 @@ export function ProjectTasks() {
if (!editingTask) return
try {
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks/${editingTask.id}`, {
const response = await makeRequest(`/api/projects/${projectId}/tasks/${editingTask.id}`, {
method: 'PUT',
body: JSON.stringify({
title,
@@ -138,7 +138,7 @@ export function ProjectTasks() {
if (!confirm('Are you sure you want to delete this task?')) return
try {
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks/${taskId}`, {
const response = await makeRequest(`/api/projects/${projectId}/tasks/${taskId}`, {
method: 'DELETE',
})
@@ -179,7 +179,7 @@ export function ProjectTasks() {
))
try {
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks/${taskId}`, {
const response = await makeRequest(`/api/projects/${projectId}/tasks/${taskId}`, {
method: 'PUT',
body: JSON.stringify({
title: task.title,

View File

@@ -3,7 +3,7 @@ import { useParams, useNavigate } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ArrowLeft, FileText } from "lucide-react";
import { makeAuthenticatedRequest } from "@/lib/auth";
import { makeRequest } from "@/lib/api";
import type { WorktreeDiff, DiffChunkType } from "shared/types";
interface ApiResponse<T> {
@@ -37,7 +37,7 @@ export function TaskAttemptComparePage() {
try {
setLoading(true);
const response = await makeAuthenticatedRequest(
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/diff`
);
@@ -67,7 +67,7 @@ export function TaskAttemptComparePage() {
try {
setMerging(true);
const response = await makeAuthenticatedRequest(
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/merge`,
{
method: 'POST',

View File

@@ -14,7 +14,7 @@ import {
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { ArrowLeft, FileText } from "lucide-react";
import { makeAuthenticatedRequest } from "@/lib/auth";
import { makeRequest } from "@/lib/api";
import type {
TaskStatus,
TaskAttempt,
@@ -119,7 +119,7 @@ export function TaskDetailsPage() {
try {
setTaskLoading(true);
const response = await makeAuthenticatedRequest(
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}`
);
@@ -152,7 +152,7 @@ export function TaskDetailsPage() {
setTaskAttemptsLoading(true);
}
const response = await makeAuthenticatedRequest(
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts`
);
@@ -205,7 +205,7 @@ export function TaskDetailsPage() {
setActivitiesLoading(true);
}
const response = await makeAuthenticatedRequest(
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/activities`
);
@@ -237,7 +237,7 @@ export function TaskDetailsPage() {
try {
setSavingTask(true);
const response = await makeAuthenticatedRequest(
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}`,
{
method: "PUT",
@@ -287,7 +287,7 @@ export function TaskDetailsPage() {
setCreatingAttempt(true);
const worktreePath = `/tmp/task-${task.id}-attempt-${Date.now()}`;
const response = await makeAuthenticatedRequest(
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts`,
{
method: "POST",
@@ -322,7 +322,7 @@ export function TaskDetailsPage() {
try {
setStoppingAttempt(true);
const response = await makeAuthenticatedRequest(
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/stop`,
{
method: "POST",

View File

@@ -1,188 +0,0 @@
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { User, ApiResponse } from 'shared/types'
import { UserForm } from '@/components/users/user-form'
import { makeAuthenticatedRequest, authStorage } from '@/lib/auth'
import { Plus, Edit, Trash2, Calendar, AlertCircle, Loader2, Shield, User as UserIcon } from 'lucide-react'
export function Users() {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(false)
const [showForm, setShowForm] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [error, setError] = useState('')
const currentUser = authStorage.getUser()
const fetchUsers = async () => {
setLoading(true)
setError('')
try {
const response = await makeAuthenticatedRequest('/api/users')
const data: ApiResponse<User[]> = await response.json()
if (data.success && data.data) {
setUsers(data.data)
} else {
setError('Failed to load users')
}
} catch (error) {
console.error('Failed to fetch users:', error)
setError('Failed to connect to server')
} finally {
setLoading(false)
}
}
const handleDelete = async (id: string, email: string) => {
if (!confirm(`Are you sure you want to delete user "${email}"? This action cannot be undone.`)) return
try {
const response = await makeAuthenticatedRequest(`/api/users/${id}`, {
method: 'DELETE',
})
if (response.ok) {
fetchUsers()
} else if (response.status === 403) {
setError('You cannot delete this user')
} else {
setError('Failed to delete user')
}
} catch (error) {
console.error('Failed to delete user:', error)
setError('Failed to delete user')
}
}
const handleEdit = (user: User) => {
setEditingUser(user)
setShowForm(true)
}
const handleFormSuccess = () => {
setShowForm(false)
setEditingUser(null)
fetchUsers()
}
useEffect(() => {
fetchUsers()
}, [])
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Users</h1>
<p className="text-muted-foreground">
Manage user accounts and permissions
</p>
</div>
{currentUser?.is_admin && (
<Button onClick={() => setShowForm(true)}>
<Plus className="mr-2 h-4 w-4" />
Add User
</Button>
)}
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{error}
</AlertDescription>
</Alert>
)}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading users...
</div>
) : users.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-lg bg-muted">
<UserIcon className="h-6 w-6" />
</div>
<h3 className="mt-4 text-lg font-semibold">No users found</h3>
<p className="mt-2 text-sm text-muted-foreground">
Get started by creating the first user account.
</p>
{currentUser?.is_admin && (
<Button
className="mt-4"
onClick={() => setShowForm(true)}
>
<Plus className="mr-2 h-4 w-4" />
Add your first user
</Button>
)}
</CardContent>
</Card>
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{users.map((user) => (
<Card key={user.id} className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<CardTitle className="text-lg flex items-center">
{user.is_admin ? (
<Shield className="mr-2 h-4 w-4 text-orange-500" />
) : (
<UserIcon className="mr-2 h-4 w-4 text-blue-500" />
)}
{user.email}
</CardTitle>
<Badge variant={user.is_admin ? "default" : "secondary"}>
{user.is_admin ? "Admin" : "User"}
</Badge>
</div>
<CardDescription className="flex items-center">
<Calendar className="mr-1 h-3 w-3" />
Joined {user.created_at ? new Date(user.created_at).toLocaleDateString() : 'Unknown'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(user)}
className="h-8"
>
<Edit className="mr-1 h-3 w-3" />
Edit
</Button>
{currentUser?.is_admin && currentUser.id !== user.id && (
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(user.id, user.email)}
className="h-8 text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="mr-1 h-3 w-3" />
Delete
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
<UserForm
open={showForm}
onClose={() => {
setShowForm(false)
setEditingUser(null)
}}
onSuccess={handleFormSuccess}
user={editingUser}
/>
</div>
)
}

View File

@@ -15,7 +15,6 @@ export default defineConfig({
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},