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:
16
backend/migrations/010_remove_users_table.sql
Normal file
16
backend/migrations/010_remove_users_table.sql
Normal 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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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)?;
|
||||
|
||||
@@ -2,4 +2,3 @@ pub mod filesystem;
|
||||
pub mod health;
|
||||
pub mod projects;
|
||||
pub mod tasks;
|
||||
pub mod users;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { UserList } from './user-list'
|
||||
|
||||
export function UsersPage() {
|
||||
return <UserList />
|
||||
}
|
||||
@@ -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
11
frontend/src/lib/api.ts
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,6 @@ export default defineConfig({
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user