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

@@ -0,0 +1,16 @@
-- Remove users table and all references to it
-- Drop the trigger on users table
DROP TRIGGER IF EXISTS update_users_updated_at ON users;
-- Drop indexes related to users
DROP INDEX IF EXISTS idx_users_email;
DROP INDEX IF EXISTS idx_users_is_admin;
-- Drop the foreign key constraint and column from projects table
ALTER TABLE projects DROP CONSTRAINT projects_owner_id_fkey;
DROP INDEX IF EXISTS idx_projects_owner_id;
ALTER TABLE projects DROP COLUMN owner_id;
-- Drop the users table
DROP TABLE IF EXISTS users;

View File

@@ -109,15 +109,7 @@ pub async fn auth_middleware(
.get::<PgPool>()
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
// Verify user exists in database
let user_exists = sqlx::query!("SELECT id FROM users WHERE id = $1", claims.user_id)
.fetch_optional(pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if user_exists.is_none() {
return Err(StatusCode::UNAUTHORIZED);
}
// Note: User table removed, skipping database verification
// Add user info to request extensions for handlers to access
request.extensions_mut().insert(AuthUser {

View File

@@ -71,16 +71,6 @@ export {}
export {}
export {}
export {}
export {}
export {}
export {}
export {}"#,
bloop_backend::models::ApiResponse::<()>::decl(),
bloop_backend::executor::ExecutorConfig::decl(),
@@ -98,11 +88,6 @@ export {}"#,
bloop_backend::models::task_attempt::UpdateTaskAttempt::decl(),
bloop_backend::models::task_attempt_activity::TaskAttemptActivity::decl(),
bloop_backend::models::task_attempt_activity::CreateTaskAttemptActivity::decl(),
bloop_backend::models::user::CreateUser::decl(),
bloop_backend::models::user::LoginRequest::decl(),
bloop_backend::models::user::LoginResponse::decl(),
bloop_backend::models::user::UpdateUser::decl(),
bloop_backend::models::user::UserResponse::decl(),
bloop_backend::routes::filesystem::DirectoryEntry::decl(),
bloop_backend::models::task_attempt::DiffChunkType::decl(),
bloop_backend::models::task_attempt::DiffChunk::decl(),

View File

@@ -2,7 +2,6 @@ use axum::{
body::Body,
extract::Extension,
http::{header, HeaderValue, StatusCode},
middleware,
response::{IntoResponse, Json as ResponseJson, Response},
routing::{get, post},
Json, Router,
@@ -20,10 +19,9 @@ mod executors;
mod models;
mod routes;
use auth::{auth_middleware, hash_password};
use execution_monitor::{execution_monitor, AppState};
use models::{user::User, ApiResponse};
use routes::{filesystem, health, projects, tasks, users};
use models::ApiResponse;
use routes::{filesystem, health, projects, tasks};
#[derive(RustEmbed)]
#[folder = "../frontend/dist"]
@@ -105,11 +103,6 @@ async fn main() -> anyhow::Result<()> {
.connect(&database_url)
.await?;
// Create default admin account if it doesn't exist
if let Err(e) = create_admin_account(&pool).await {
tracing::warn!("Failed to create admin account: {}", e);
}
// Create app state
let app_state = AppState {
running_executions: Arc::new(Mutex::new(HashMap::new())),
@@ -125,21 +118,22 @@ async fn main() -> anyhow::Result<()> {
// Public routes (no auth required)
let public_routes = Router::new()
.route("/api/health", get(health::health_check))
.route("/api/echo", post(echo_handler))
.merge(users::public_users_router());
.route("/api/echo", post(echo_handler));
// Protected routes (auth required)
let protected_routes = Router::new()
// All routes (no auth required)
let app_routes = Router::new()
.nest(
"/api",
Router::new()
.merge(projects::projects_router())
.merge(tasks::tasks_router())
.merge(users::protected_users_router())
.merge(filesystem::filesystem_router())
.layer(Extension(pool.clone()))
.layer(middleware::from_fn(auth_middleware));
.merge(filesystem::filesystem_router()),
)
.layer(Extension(pool.clone()));
let app = Router::new()
.merge(public_routes)
.merge(protected_routes)
.merge(app_routes)
// Static file serving routes
.route("/", get(index_handler))
.route("/*path", get(static_handler))
@@ -155,14 +149,3 @@ async fn main() -> anyhow::Result<()> {
Ok(())
}
async fn create_admin_account(pool: &sqlx::PgPool) -> anyhow::Result<()> {
let admin_email = "admin@example.com";
let admin_password = env::var("ADMIN_PASSWORD").unwrap_or_else(|_| "admin123".to_string());
let password_hash = hash_password(&admin_password)?;
User::create_or_update_admin(pool, admin_email, &password_hash).await?;
Ok(())
}

View File

@@ -3,6 +3,5 @@ pub mod project;
pub mod task;
pub mod task_attempt;
pub mod task_attempt_activity;
pub mod user;
pub use api_response::ApiResponse;

View File

@@ -10,7 +10,7 @@ pub struct Project {
pub id: Uuid,
pub name: String,
pub git_repo_path: String,
pub owner_id: Uuid, // Foreign key to User
#[ts(type = "Date")]
pub created_at: DateTime<Utc>,
#[ts(type = "Date")]
@@ -36,7 +36,7 @@ impl Project {
pub async fn find_all(pool: &PgPool) -> Result<Vec<Self>, sqlx::Error> {
sqlx::query_as!(
Project,
"SELECT id, name, git_repo_path, owner_id, created_at, updated_at FROM projects ORDER BY created_at DESC"
"SELECT id, name, git_repo_path, created_at, updated_at FROM projects ORDER BY created_at DESC"
)
.fetch_all(pool)
.await
@@ -45,7 +45,7 @@ impl Project {
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as!(
Project,
"SELECT id, name, git_repo_path, owner_id, created_at, updated_at FROM projects WHERE id = $1",
"SELECT id, name, git_repo_path, created_at, updated_at FROM projects WHERE id = $1",
id
)
.fetch_optional(pool)
@@ -58,7 +58,7 @@ impl Project {
) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as!(
Project,
"SELECT id, name, git_repo_path, owner_id, created_at, updated_at FROM projects WHERE git_repo_path = $1",
"SELECT id, name, git_repo_path, created_at, updated_at FROM projects WHERE git_repo_path = $1",
git_repo_path
)
.fetch_optional(pool)
@@ -72,7 +72,7 @@ impl Project {
) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as!(
Project,
"SELECT id, name, git_repo_path, owner_id, created_at, updated_at FROM projects WHERE git_repo_path = $1 AND id != $2",
"SELECT id, name, git_repo_path, created_at, updated_at FROM projects WHERE git_repo_path = $1 AND id != $2",
git_repo_path,
exclude_id
)
@@ -83,16 +83,14 @@ impl Project {
pub async fn create(
pool: &PgPool,
data: &CreateProject,
owner_id: Uuid,
project_id: Uuid,
) -> Result<Self, sqlx::Error> {
sqlx::query_as!(
Project,
"INSERT INTO projects (id, name, git_repo_path, owner_id) VALUES ($1, $2, $3, $4) RETURNING id, name, git_repo_path, owner_id, created_at, updated_at",
"INSERT INTO projects (id, name, git_repo_path) VALUES ($1, $2, $3) RETURNING id, name, git_repo_path, created_at, updated_at",
project_id,
data.name,
data.git_repo_path,
owner_id
data.git_repo_path
)
.fetch_one(pool)
.await
@@ -106,7 +104,7 @@ impl Project {
) -> Result<Self, sqlx::Error> {
sqlx::query_as!(
Project,
"UPDATE projects SET name = $2, git_repo_path = $3 WHERE id = $1 RETURNING id, name, git_repo_path, owner_id, created_at, updated_at",
"UPDATE projects SET name = $2, git_repo_path = $3 WHERE id = $1 RETURNING id, name, git_repo_path, created_at, updated_at",
id,
name,
git_repo_path

View File

@@ -1,196 +0,0 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, PgPool};
use ts_rs::TS;
use uuid::Uuid;
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct User {
pub id: Uuid,
pub email: String,
#[serde(skip_serializing)]
pub password_hash: String, // Hashed password
pub is_admin: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize, TS)]
#[ts(export)]
pub struct CreateUser {
pub email: String,
pub password: String,
pub is_admin: Option<bool>,
}
#[derive(Debug, Deserialize, TS)]
#[ts(export)]
pub struct UpdateUser {
pub email: Option<String>,
pub password: Option<String>,
pub is_admin: Option<bool>,
}
#[derive(Debug, Deserialize, TS)]
#[ts(export)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Debug, Serialize, TS)]
#[ts(export)]
pub struct LoginResponse {
pub user: UserResponse,
pub token: String,
}
#[derive(Debug, Serialize, TS)]
#[ts(export)]
#[ts(rename = "User")]
pub struct UserResponse {
pub id: Uuid,
pub email: String,
pub is_admin: bool,
#[ts(type = "Date")]
pub created_at: DateTime<Utc>,
#[ts(type = "Date")]
pub updated_at: DateTime<Utc>,
}
impl From<User> for UserResponse {
fn from(user: User) -> Self {
Self {
id: user.id,
email: user.email,
is_admin: user.is_admin,
created_at: user.created_at,
updated_at: user.updated_at,
}
}
}
impl User {
pub async fn find_by_email(pool: &PgPool, email: &str) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as!(
User,
"SELECT id, email, password_hash, is_admin, created_at, updated_at FROM users WHERE email = $1",
email
)
.fetch_optional(pool)
.await
}
pub async fn find_all(pool: &PgPool) -> Result<Vec<Self>, sqlx::Error> {
sqlx::query_as!(
User,
"SELECT id, email, password_hash, is_admin, created_at, updated_at FROM users ORDER BY created_at DESC"
)
.fetch_all(pool)
.await
}
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as!(
User,
"SELECT id, email, password_hash, is_admin, created_at, updated_at FROM users WHERE id = $1",
id
)
.fetch_optional(pool)
.await
}
pub async fn create(
pool: &PgPool,
data: &CreateUser,
password_hash: String,
user_id: Uuid,
) -> Result<Self, sqlx::Error> {
let is_admin = data.is_admin.unwrap_or(false);
sqlx::query_as!(
User,
"INSERT INTO users (id, email, password_hash, is_admin) VALUES ($1, $2, $3, $4) RETURNING id, email, password_hash, is_admin, created_at, updated_at",
user_id,
data.email,
password_hash,
is_admin
)
.fetch_one(pool)
.await
}
pub async fn update(
pool: &PgPool,
id: Uuid,
email: String,
password_hash: String,
is_admin: bool,
) -> Result<Self, sqlx::Error> {
sqlx::query_as!(
User,
"UPDATE users SET email = $2, password_hash = $3, is_admin = $4 WHERE id = $1 RETURNING id, email, password_hash, is_admin, created_at, updated_at",
id,
email,
password_hash,
is_admin
)
.fetch_one(pool)
.await
}
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<u64, sqlx::Error> {
let result = sqlx::query!("DELETE FROM users WHERE id = $1", id)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
pub async fn create_or_update_admin(
pool: &PgPool,
email: &str,
password_hash: &str,
) -> Result<(), sqlx::Error> {
use chrono::Utc;
// Check if admin already exists
let existing_admin = sqlx::query!(
"SELECT id, password_hash FROM users WHERE email = $1",
email
)
.fetch_optional(pool)
.await?;
if let Some(admin) = existing_admin {
// Update existing admin password
let now = Utc::now();
sqlx::query!(
"UPDATE users SET password_hash = $2, is_admin = $3, updated_at = $4 WHERE id = $1",
admin.id,
password_hash,
true,
now
)
.execute(pool)
.await?;
tracing::info!("Updated admin account");
} else {
// Create new admin account
let id = Uuid::new_v4();
sqlx::query!(
"INSERT INTO users (id, email, password_hash, is_admin) VALUES ($1, $2, $3, $4)",
id,
email,
password_hash,
true
)
.execute(pool)
.await?;
tracing::info!("Created admin account: {}", email);
}
Ok(())
}
}

View File

@@ -10,7 +10,6 @@ use std::fs;
use std::path::{Path, PathBuf};
use ts_rs::TS;
use crate::auth::AuthUser;
use crate::models::ApiResponse;
#[derive(Debug, Serialize, TS)]
@@ -28,7 +27,6 @@ pub struct ListDirectoryQuery {
}
pub async fn list_directory(
_auth: AuthUser,
Query(query): Query<ListDirectoryQuery>,
) -> Result<ResponseJson<ApiResponse<Vec<DirectoryEntry>>>, StatusCode> {
let path_str = query.path.unwrap_or_else(|| {
@@ -114,7 +112,6 @@ pub async fn list_directory(
}
pub async fn validate_git_path(
_auth: AuthUser,
Query(query): Query<ListDirectoryQuery>,
) -> Result<ResponseJson<ApiResponse<bool>>, StatusCode> {
let path_str = query.path.ok_or(StatusCode::BAD_REQUEST)?;
@@ -135,7 +132,6 @@ pub async fn validate_git_path(
}
pub async fn create_git_repo(
_auth: AuthUser,
Query(query): Query<ListDirectoryQuery>,
) -> Result<ResponseJson<ApiResponse<()>>, StatusCode> {
let path_str = query.path.ok_or(StatusCode::BAD_REQUEST)?;

View File

@@ -2,4 +2,3 @@ pub mod filesystem;
pub mod health;
pub mod projects;
pub mod tasks;
pub mod users;

View File

@@ -8,14 +8,12 @@ use axum::{
use sqlx::PgPool;
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::models::{
project::{CreateProject, Project, UpdateProject},
ApiResponse,
};
pub async fn get_projects(
_auth: AuthUser,
Extension(pool): Extension<PgPool>,
) -> Result<ResponseJson<ApiResponse<Vec<Project>>>, StatusCode> {
match Project::find_all(&pool).await {
@@ -32,7 +30,6 @@ pub async fn get_projects(
}
pub async fn get_project(
_auth: AuthUser,
Path(id): Path<Uuid>,
Extension(pool): Extension<PgPool>,
) -> Result<ResponseJson<ApiResponse<Project>>, StatusCode> {
@@ -51,17 +48,12 @@ pub async fn get_project(
}
pub async fn create_project(
auth: AuthUser,
Extension(pool): Extension<PgPool>,
Json(payload): Json<CreateProject>,
) -> Result<ResponseJson<ApiResponse<Project>>, StatusCode> {
let id = Uuid::new_v4();
tracing::debug!(
"Creating project '{}' for user {}",
payload.name,
auth.user_id
);
tracing::debug!("Creating project '{}'", payload.name);
// Check if git repo path is already used by another project
match Project::find_by_git_repo_path(&pool, &payload.git_repo_path).await {
@@ -154,7 +146,7 @@ pub async fn create_project(
}
}
match Project::create(&pool, &payload, auth.user_id, id).await {
match Project::create(&pool, &payload, id).await {
Ok(project) => Ok(ResponseJson(ApiResponse {
success: true,
data: Some(project),
@@ -261,10 +253,7 @@ pub fn projects_router() -> Router {
mod tests {
use super::*;
use crate::auth::{hash_password, AuthUser};
use crate::models::{
project::{CreateProject, UpdateProject},
user::User,
};
use crate::models::project::{CreateProject, UpdateProject};
use axum::extract::Extension;
use chrono::Utc;
use sqlx::PgPool;

View File

@@ -8,7 +8,6 @@ use axum::{
use sqlx::PgPool;
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::models::{
project::Project,
task::{CreateTask, Task, TaskWithAttemptStatus, UpdateTask},
@@ -18,7 +17,6 @@ use crate::models::{
};
pub async fn get_project_tasks(
_auth: AuthUser,
Path(project_id): Path<Uuid>,
Extension(pool): Extension<PgPool>,
) -> Result<ResponseJson<ApiResponse<Vec<TaskWithAttemptStatus>>>, StatusCode> {
@@ -36,7 +34,6 @@ pub async fn get_project_tasks(
}
pub async fn get_task(
_auth: AuthUser,
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
Extension(pool): Extension<PgPool>,
) -> Result<ResponseJson<ApiResponse<Task>>, StatusCode> {
@@ -61,7 +58,6 @@ pub async fn get_task(
pub async fn create_task(
Path(project_id): Path<Uuid>,
auth: AuthUser,
Extension(pool): Extension<PgPool>,
Json(mut payload): Json<CreateTask>,
) -> Result<ResponseJson<ApiResponse<Task>>, StatusCode> {
@@ -81,10 +77,9 @@ pub async fn create_task(
}
tracing::debug!(
"Creating task '{}' in project {} for user {}",
"Creating task '{}' in project {}",
payload.title,
project_id,
auth.user_id
project_id
);
match Task::create(&pool, &payload, id).await {
@@ -158,7 +153,6 @@ pub async fn delete_task(
// Task Attempts endpoints
pub async fn get_task_attempts(
_auth: AuthUser,
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
Extension(pool): Extension<PgPool>,
) -> Result<ResponseJson<ApiResponse<Vec<TaskAttempt>>>, StatusCode> {
@@ -186,7 +180,6 @@ pub async fn get_task_attempts(
}
pub async fn get_task_attempt_activities(
_auth: AuthUser,
Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>,
Extension(pool): Extension<PgPool>,
) -> Result<ResponseJson<ApiResponse<Vec<TaskAttemptActivity>>>, StatusCode> {
@@ -218,7 +211,6 @@ pub async fn get_task_attempt_activities(
}
pub async fn create_task_attempt(
_auth: AuthUser,
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
Extension(pool): Extension<PgPool>,
Json(mut payload): Json<CreateTaskAttempt>,
@@ -258,7 +250,6 @@ pub async fn create_task_attempt(
}
pub async fn create_task_attempt_activity(
_auth: AuthUser,
Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>,
Extension(pool): Extension<PgPool>,
Json(mut payload): Json<CreateTaskAttemptActivity>,
@@ -295,7 +286,6 @@ pub async fn create_task_attempt_activity(
}
pub async fn stop_task_attempt(
_auth: AuthUser,
Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>,
Extension(pool): Extension<PgPool>,
Extension(app_state): Extension<crate::execution_monitor::AppState>,
@@ -377,7 +367,6 @@ pub async fn stop_task_attempt(
}
pub async fn get_task_attempt_diff(
_auth: AuthUser,
Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>,
Extension(pool): Extension<PgPool>,
) -> Result<ResponseJson<ApiResponse<WorktreeDiff>>, StatusCode> {
@@ -459,7 +448,7 @@ pub fn tasks_router() -> Router {
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::{hash_password, AuthUser};
use crate::auth::hash_password;
use crate::models::{
project::Project,
task::{CreateTask, TaskStatus, UpdateTask},
@@ -560,13 +549,7 @@ mod tests {
)
.await;
let auth = AuthUser {
user_id: user.id,
email: user.email,
is_admin: false,
};
let result = get_project_tasks(auth, Path(project.id), Extension(pool)).await;
let result = get_project_tasks(Path(project.id), Extension(pool)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
@@ -580,13 +563,7 @@ mod tests {
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
let project = create_test_project(&pool, "Empty Project", user.id).await;
let auth = AuthUser {
user_id: user.id,
email: user.email,
is_admin: false,
};
let result = get_project_tasks(auth, Path(project.id), Extension(pool)).await;
let result = get_project_tasks(Path(project.id), Extension(pool)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
@@ -608,13 +585,7 @@ mod tests {
)
.await;
let auth = AuthUser {
user_id: user.id,
email: user.email,
is_admin: false,
};
let result = get_task(auth, Path((project.id, task.id)), Extension(pool)).await;
let result = get_task(Path((project.id, task.id)), Extension(pool)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
@@ -633,18 +604,7 @@ mod tests {
let project = create_test_project(&pool, "Test Project", user.id).await;
let nonexistent_task_id = Uuid::new_v4();
let auth = AuthUser {
user_id: user.id,
email: user.email,
is_admin: false,
};
let result = get_task(
auth,
Path((project.id, nonexistent_task_id)),
Extension(pool),
)
.await;
let result = get_task(Path((project.id, nonexistent_task_id)), Extension(pool)).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
}
@@ -656,14 +616,8 @@ mod tests {
let project2 = create_test_project(&pool, "Project 2", user.id).await;
let task = create_test_task(&pool, project1.id, "Test Task", None, TaskStatus::Todo).await;
let auth = AuthUser {
user_id: user.id,
email: user.email,
is_admin: false,
};
// Try to get task from wrong project
let result = get_task(auth, Path((project2.id, task.id)), Extension(pool)).await;
let result = get_task(Path((project2.id, task.id)), Extension(pool)).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
}
@@ -673,25 +627,13 @@ mod tests {
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
let project = create_test_project(&pool, "Test Project", user.id).await;
let auth = AuthUser {
user_id: user.id,
email: user.email,
is_admin: false,
};
let create_request = CreateTask {
project_id: project.id, // This will be overridden by the path parameter
title: "New Task".to_string(),
description: Some("Task description".to_string()),
};
let result = create_task(
Path(project.id),
auth,
Extension(pool),
Json(create_request),
)
.await;
let result = create_task(Path(project.id), Extension(pool), Json(create_request)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
@@ -712,12 +654,6 @@ mod tests {
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
let nonexistent_project_id = Uuid::new_v4();
let auth = AuthUser {
user_id: user.id,
email: user.email,
is_admin: false,
};
let create_request = CreateTask {
project_id: nonexistent_project_id,
title: "New Task".to_string(),
@@ -726,7 +662,6 @@ mod tests {
let result = create_task(
Path(nonexistent_project_id),
auth,
Extension(pool),
Json(create_request),
)

View File

@@ -1,607 +0,0 @@
use axum::{
extract::{Extension, Path},
http::StatusCode,
response::Json as ResponseJson,
routing::{get, post},
Json, Router,
};
use sqlx::PgPool;
use uuid::Uuid;
use crate::auth::{create_token, hash_password, verify_password, AuthUser};
use crate::models::{
user::{CreateUser, LoginRequest, LoginResponse, UpdateUser, User, UserResponse},
ApiResponse,
};
pub async fn login(
Extension(pool): Extension<PgPool>,
Json(payload): Json<LoginRequest>,
) -> Result<ResponseJson<ApiResponse<LoginResponse>>, StatusCode> {
match User::find_by_email(&pool, &payload.email).await {
Ok(Some(user)) => match verify_password(&payload.password, &user.password_hash) {
Ok(true) => match create_token(user.id, user.email.clone(), user.is_admin) {
Ok(token) => Ok(ResponseJson(ApiResponse {
success: true,
data: Some(LoginResponse {
user: user.into(),
token,
}),
message: Some("Login successful".to_string()),
})),
Err(e) => {
tracing::error!("Failed to create token: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
},
Ok(false) => Err(StatusCode::UNAUTHORIZED),
Err(e) => {
tracing::error!("Password verification error: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
},
Ok(None) => Err(StatusCode::UNAUTHORIZED),
Err(e) => {
tracing::error!("Failed to fetch user: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
pub async fn get_users(
_auth: AuthUser,
Extension(pool): Extension<PgPool>,
) -> Result<ResponseJson<ApiResponse<Vec<UserResponse>>>, StatusCode> {
match User::find_all(&pool).await {
Ok(users) => {
let user_responses: Vec<UserResponse> = users.into_iter().map(|u| u.into()).collect();
Ok(ResponseJson(ApiResponse {
success: true,
data: Some(user_responses),
message: None,
}))
}
Err(e) => {
tracing::error!("Failed to fetch users: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
pub async fn get_user(
auth: AuthUser,
Path(id): Path<Uuid>,
Extension(pool): Extension<PgPool>,
) -> Result<ResponseJson<ApiResponse<UserResponse>>, StatusCode> {
// Users can only view their own profile unless they're admin
if auth.user_id != id && !auth.is_admin {
return Err(StatusCode::FORBIDDEN);
}
match User::find_by_id(&pool, id).await {
Ok(Some(user)) => Ok(ResponseJson(ApiResponse {
success: true,
data: Some(user.into()),
message: None,
})),
Ok(None) => Err(StatusCode::NOT_FOUND),
Err(e) => {
tracing::error!("Failed to fetch user: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
pub async fn create_user(
auth: AuthUser,
Extension(pool): Extension<PgPool>,
Json(payload): Json<CreateUser>,
) -> Result<ResponseJson<ApiResponse<UserResponse>>, StatusCode> {
// Only admins can create users
if !auth.is_admin {
return Err(StatusCode::FORBIDDEN);
}
let id = Uuid::new_v4();
let password_hash = match hash_password(&payload.password) {
Ok(hash) => hash,
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
match User::create(&pool, &payload, password_hash, id).await {
Ok(user) => Ok(ResponseJson(ApiResponse {
success: true,
data: Some(user.into()),
message: Some("User created successfully".to_string()),
})),
Err(e) => {
tracing::error!("Failed to create user: {}", e);
if e.to_string().contains("users_email_key") {
Err(StatusCode::CONFLICT) // Email already exists
} else {
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
}
pub async fn update_user(
auth: AuthUser,
Path(id): Path<Uuid>,
Extension(pool): Extension<PgPool>,
Json(payload): Json<UpdateUser>,
) -> Result<ResponseJson<ApiResponse<UserResponse>>, StatusCode> {
// Users can only update their own profile unless they're admin
if auth.user_id != id && !auth.is_admin {
return Err(StatusCode::FORBIDDEN);
}
// Get existing user
let existing_user = match User::find_by_id(&pool, id).await {
Ok(Some(user)) => user,
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(e) => {
tracing::error!("Failed to check user existence: {}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
let email = payload.email.unwrap_or(existing_user.email);
let is_admin = if auth.is_admin {
payload.is_admin.unwrap_or(existing_user.is_admin)
} else {
existing_user.is_admin // Non-admins can't change admin status
};
let password_hash = if let Some(new_password) = payload.password {
match hash_password(&new_password) {
Ok(hash) => hash,
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
}
} else {
existing_user.password_hash
};
match User::update(&pool, id, email, password_hash, is_admin).await {
Ok(user) => Ok(ResponseJson(ApiResponse {
success: true,
data: Some(user.into()),
message: Some("User updated successfully".to_string()),
})),
Err(e) => {
tracing::error!("Failed to update user: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
pub async fn delete_user(
auth: AuthUser,
Path(id): Path<Uuid>,
Extension(pool): Extension<PgPool>,
) -> Result<ResponseJson<ApiResponse<()>>, StatusCode> {
// Only admins can delete users, and they can't delete themselves
if !auth.is_admin || auth.user_id == id {
return Err(StatusCode::FORBIDDEN);
}
match User::delete(&pool, id).await {
Ok(rows_affected) => {
if rows_affected == 0 {
Err(StatusCode::NOT_FOUND)
} else {
Ok(ResponseJson(ApiResponse {
success: true,
data: None,
message: Some("User deleted successfully".to_string()),
}))
}
}
Err(e) => {
tracing::error!("Failed to delete user: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
pub async fn get_current_user(
auth: AuthUser,
Extension(pool): Extension<PgPool>,
) -> Result<ResponseJson<ApiResponse<UserResponse>>, StatusCode> {
match User::find_by_id(&pool, auth.user_id).await {
Ok(Some(user)) => Ok(ResponseJson(ApiResponse {
success: true,
data: Some(user.into()),
message: None,
})),
Ok(None) => Err(StatusCode::NOT_FOUND),
Err(e) => {
tracing::error!("Failed to fetch current user: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
pub async fn check_auth_status(auth: AuthUser) -> ResponseJson<ApiResponse<serde_json::Value>> {
ResponseJson(ApiResponse {
success: true,
data: Some(serde_json::json!({
"authenticated": true,
"user_id": auth.user_id,
"email": auth.email,
"is_admin": auth.is_admin
})),
message: Some("User is authenticated".to_string()),
})
}
pub fn public_users_router() -> Router {
Router::new().route("/auth/login", post(login))
}
pub fn protected_users_router() -> Router {
Router::new()
.route("/auth/status", get(check_auth_status))
.route("/auth/me", get(get_current_user))
.route("/users", get(get_users).post(create_user))
.route(
"/users/:id",
get(get_user).put(update_user).delete(delete_user),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::{hash_password, AuthUser};
use crate::models::user::{CreateUser, LoginRequest, UpdateUser};
use axum::extract::Extension;
use chrono::Utc;
use sqlx::PgPool;
use uuid::Uuid;
async fn create_test_user(pool: &PgPool, email: &str, password: &str, is_admin: bool) -> User {
let id = Uuid::new_v4();
let now = Utc::now();
let password_hash = hash_password(password).unwrap();
sqlx::query_as!(
User,
"INSERT INTO users (id, email, password_hash, is_admin, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, email, password_hash, is_admin, created_at, updated_at",
id,
email,
password_hash,
is_admin,
now,
now
)
.fetch_one(pool)
.await
.unwrap()
}
#[sqlx::test]
async fn test_login_success(pool: PgPool) {
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
let login_request = LoginRequest {
email: "test@example.com".to_string(),
password: "password123".to_string(),
};
let result = login(Extension(pool), Json(login_request)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
assert_eq!(response.data.as_ref().unwrap().user.email, user.email);
}
#[sqlx::test]
async fn test_login_invalid_password(pool: PgPool) {
create_test_user(&pool, "test@example.com", "password123", false).await;
let login_request = LoginRequest {
email: "test@example.com".to_string(),
password: "wrongpassword".to_string(),
};
let result = login(Extension(pool), Json(login_request)).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test]
async fn test_login_user_not_found(pool: PgPool) {
let login_request = LoginRequest {
email: "nonexistent@example.com".to_string(),
password: "password123".to_string(),
};
let result = login(Extension(pool), Json(login_request)).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test]
async fn test_get_users_as_admin(pool: PgPool) {
let admin_user = create_test_user(&pool, "admin@example.com", "password123", true).await;
create_test_user(&pool, "user1@example.com", "password123", false).await;
create_test_user(&pool, "user2@example.com", "password123", false).await;
let auth = AuthUser {
user_id: admin_user.id,
email: admin_user.email,
is_admin: true,
};
let result = get_users(auth, Extension(pool)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
assert_eq!(response.data.unwrap().len(), 3);
}
#[sqlx::test]
async fn test_get_user_own_profile(pool: PgPool) {
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
let auth = AuthUser {
user_id: user.id,
email: user.email.clone(),
is_admin: false,
};
let result = get_user(auth, Path(user.id), Extension(pool)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
assert_eq!(response.data.unwrap().email, user.email);
}
#[sqlx::test]
async fn test_get_user_forbidden_non_admin(pool: PgPool) {
let user1 = create_test_user(&pool, "user1@example.com", "password123", false).await;
let user2 = create_test_user(&pool, "user2@example.com", "password123", false).await;
let auth = AuthUser {
user_id: user1.id,
email: user1.email,
is_admin: false,
};
let result = get_user(auth, Path(user2.id), Extension(pool)).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err(), StatusCode::FORBIDDEN);
}
#[sqlx::test]
async fn test_get_user_admin_can_view_any(pool: PgPool) {
let admin_user = create_test_user(&pool, "admin@example.com", "password123", true).await;
let regular_user = create_test_user(&pool, "user@example.com", "password123", false).await;
let auth = AuthUser {
user_id: admin_user.id,
email: admin_user.email,
is_admin: true,
};
let result = get_user(auth, Path(regular_user.id), Extension(pool)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
assert_eq!(response.data.unwrap().email, regular_user.email);
}
#[sqlx::test]
async fn test_create_user_as_admin(pool: PgPool) {
let admin_user = create_test_user(&pool, "admin@example.com", "password123", true).await;
let auth = AuthUser {
user_id: admin_user.id,
email: admin_user.email,
is_admin: true,
};
let create_request = CreateUser {
email: "newuser@example.com".to_string(),
password: "password123".to_string(),
is_admin: Some(false),
};
let result = create_user(auth, Extension(pool), Json(create_request)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
assert_eq!(response.data.unwrap().email, "newuser@example.com");
}
#[sqlx::test]
async fn test_create_user_forbidden_non_admin(pool: PgPool) {
let regular_user = create_test_user(&pool, "user@example.com", "password123", false).await;
let auth = AuthUser {
user_id: regular_user.id,
email: regular_user.email,
is_admin: false,
};
let create_request = CreateUser {
email: "newuser@example.com".to_string(),
password: "password123".to_string(),
is_admin: Some(false),
};
let result = create_user(auth, Extension(pool), Json(create_request)).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err(), StatusCode::FORBIDDEN);
}
#[sqlx::test]
async fn test_create_user_duplicate_email(pool: PgPool) {
let admin_user = create_test_user(&pool, "admin@example.com", "password123", true).await;
create_test_user(&pool, "existing@example.com", "password123", false).await;
let auth = AuthUser {
user_id: admin_user.id,
email: admin_user.email,
is_admin: true,
};
let create_request = CreateUser {
email: "existing@example.com".to_string(),
password: "password123".to_string(),
is_admin: Some(false),
};
let result = create_user(auth, Extension(pool), Json(create_request)).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err(), StatusCode::CONFLICT);
}
#[sqlx::test]
async fn test_update_user_own_profile(pool: PgPool) {
let user = create_test_user(&pool, "user@example.com", "password123", false).await;
let auth = AuthUser {
user_id: user.id,
email: user.email.clone(),
is_admin: false,
};
let update_request = UpdateUser {
email: Some("newemail@example.com".to_string()),
password: Some("newpassword123".to_string()),
is_admin: None,
};
let result = update_user(auth, Path(user.id), Extension(pool), Json(update_request)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
assert_eq!(response.data.unwrap().email, "newemail@example.com");
}
#[sqlx::test]
async fn test_update_user_forbidden_non_admin(pool: PgPool) {
let user1 = create_test_user(&pool, "user1@example.com", "password123", false).await;
let user2 = create_test_user(&pool, "user2@example.com", "password123", false).await;
let auth = AuthUser {
user_id: user1.id,
email: user1.email,
is_admin: false,
};
let update_request = UpdateUser {
email: Some("newemail@example.com".to_string()),
password: None,
is_admin: None,
};
let result = update_user(auth, Path(user2.id), Extension(pool), Json(update_request)).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err(), StatusCode::FORBIDDEN);
}
#[sqlx::test]
async fn test_delete_user_as_admin(pool: PgPool) {
let admin_user = create_test_user(&pool, "admin@example.com", "password123", true).await;
let user_to_delete =
create_test_user(&pool, "delete@example.com", "password123", false).await;
let auth = AuthUser {
user_id: admin_user.id,
email: admin_user.email,
is_admin: true,
};
let result = delete_user(auth, Path(user_to_delete.id), Extension(pool)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert_eq!(response.message.unwrap(), "User deleted successfully");
}
#[sqlx::test]
async fn test_delete_user_forbidden_non_admin(pool: PgPool) {
let user1 = create_test_user(&pool, "user1@example.com", "password123", false).await;
let user2 = create_test_user(&pool, "user2@example.com", "password123", false).await;
let auth = AuthUser {
user_id: user1.id,
email: user1.email,
is_admin: false,
};
let result = delete_user(auth, Path(user2.id), Extension(pool)).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err(), StatusCode::FORBIDDEN);
}
#[sqlx::test]
async fn test_delete_user_self_forbidden(pool: PgPool) {
let admin_user = create_test_user(&pool, "admin@example.com", "password123", true).await;
let auth = AuthUser {
user_id: admin_user.id,
email: admin_user.email.clone(),
is_admin: true,
};
let result = delete_user(auth, Path(admin_user.id), Extension(pool)).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err(), StatusCode::FORBIDDEN);
}
#[sqlx::test]
async fn test_get_current_user(pool: PgPool) {
let user = create_test_user(&pool, "user@example.com", "password123", false).await;
let auth = AuthUser {
user_id: user.id,
email: user.email.clone(),
is_admin: false,
};
let result = get_current_user(auth, Extension(pool)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
assert_eq!(response.data.unwrap().email, user.email);
}
#[tokio::test]
async fn test_check_auth_status() {
let auth = AuthUser {
user_id: Uuid::new_v4(),
email: "test@example.com".to_string(),
is_admin: true,
};
let response = check_auth_status(auth.clone()).await.0;
assert!(response.success);
assert!(response.data.is_some());
let data = response.data.unwrap();
assert_eq!(data["authenticated"], true);
assert_eq!(data["user_id"], auth.user_id.to_string());
assert_eq!(data["email"], auth.email);
assert_eq!(data["is_admin"], auth.is_admin);
}
}

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>
</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,7 +23,6 @@ export function Navbar({ onLogout }: NavbarProps) {
Projects
</Link>
</Button>
{currentUser?.is_admin && (
<Button
asChild
variant={location.pathname === '/users' ? 'default' : 'ghost'}
@@ -44,13 +33,9 @@ export function Navbar({ onLogout }: NavbarProps) {
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,7 +133,6 @@ 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">
@@ -145,10 +143,6 @@ export function HomePage() {
<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
@@ -170,7 +164,6 @@ export function HomePage() {
</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/, ''),
},
},
},

View File

@@ -7,7 +7,7 @@ export type ExecutorConfig = { "type": "echo" } | { "type": "claude" };
export type CreateProject = { name: string, git_repo_path: string, use_existing_repo: boolean, };
export type Project = { id: string, name: string, git_repo_path: string, owner_id: string, created_at: Date, updated_at: Date, };
export type Project = { id: string, name: string, git_repo_path: string, created_at: Date, updated_at: Date, };
export type UpdateProject = { name: string | null, git_repo_path: string | null, };
@@ -33,16 +33,6 @@ export type TaskAttemptActivity = { id: string, task_attempt_id: string, status:
export type CreateTaskAttemptActivity = { task_attempt_id: string, status: TaskAttemptStatus | null, note: string | null, };
export type CreateUser = { email: string, password: string, is_admin: boolean | null, };
export type LoginRequest = { email: string, password: string, };
export type LoginResponse = { user: User, token: string, };
export type UpdateUser = { email: string | null, password: string | null, is_admin: boolean | null, };
export type User = { id: string, email: string, is_admin: boolean, created_at: Date, updated_at: Date, };
export type DirectoryEntry = { name: string, path: string, is_directory: boolean, is_git_repo: boolean, };
export type DiffChunkType = "Equal" | "Insert" | "Delete";