From ca231bd6be5237a62534385f8c236c2e23928901 Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Sat, 14 Jun 2025 16:26:48 -0400 Subject: [PATCH] User management --- .gitignore | 3 +- AGENT.md | 4 + backend/Cargo.toml | 2 + backend/migrations/002_update_users_auth.sql | 23 ++ backend/src/auth.rs | 90 ++++++ backend/src/main.rs | 99 ++++-- backend/src/models/user.rs | 39 ++- backend/src/routes/mod.rs | 2 + backend/src/routes/projects.rs | 171 ++++++++++ backend/src/routes/users.rs | 301 ++++++++++++++++++ frontend/package-lock.json | 75 ++++- frontend/package.json | 4 +- frontend/src/App.tsx | 285 +++++++++++++++-- frontend/src/components/auth/login-form.tsx | 118 +++++++ .../components/projects/project-detail.tsx | 211 ++++++++++++ .../src/components/projects/project-form.tsx | 127 ++++++++ .../src/components/projects/project-list.tsx | 169 ++++++++++ .../src/components/projects/projects-page.tsx | 18 ++ frontend/src/components/ui/alert.tsx | 59 ++++ frontend/src/components/ui/badge.tsx | 36 +++ frontend/src/components/ui/dialog.tsx | 113 +++++++ frontend/src/components/ui/input.tsx | 25 ++ frontend/src/components/ui/label.tsx | 24 ++ frontend/src/components/ui/separator.tsx | 31 ++ frontend/src/components/ui/table.tsx | 117 +++++++ frontend/src/components/users/user-form.tsx | 185 +++++++++++ frontend/src/components/users/user-list.tsx | 188 +++++++++++ frontend/src/components/users/users-page.tsx | 5 + frontend/src/lib/auth.ts | 63 ++++ frontend/tsconfig.json | 3 +- shared/types.ts | 47 +++ 31 files changed, 2581 insertions(+), 56 deletions(-) create mode 100644 backend/migrations/002_update_users_auth.sql create mode 100644 backend/src/auth.rs create mode 100644 backend/src/routes/projects.rs create mode 100644 backend/src/routes/users.rs create mode 100644 frontend/src/components/auth/login-form.tsx create mode 100644 frontend/src/components/projects/project-detail.tsx create mode 100644 frontend/src/components/projects/project-form.tsx create mode 100644 frontend/src/components/projects/project-list.tsx create mode 100644 frontend/src/components/projects/projects-page.tsx create mode 100644 frontend/src/components/ui/alert.tsx create mode 100644 frontend/src/components/ui/badge.tsx create mode 100644 frontend/src/components/ui/dialog.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/label.tsx create mode 100644 frontend/src/components/ui/separator.tsx create mode 100644 frontend/src/components/ui/table.tsx create mode 100644 frontend/src/components/users/user-form.tsx create mode 100644 frontend/src/components/users/user-list.tsx create mode 100644 frontend/src/components/users/users-page.tsx create mode 100644 frontend/src/lib/auth.ts diff --git a/.gitignore b/.gitignore index 041eadc2..a25f7a58 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,5 @@ coverage/ .out .storybook-out -.env \ No newline at end of file +.env +frontend/dist \ No newline at end of file diff --git a/AGENT.md b/AGENT.md index 87773c99..7e88151d 100644 --- a/AGENT.md +++ b/AGENT.md @@ -54,3 +54,7 @@ bloop/ ├── pnpm-workspace.yaml # pnpm workspace └── package.json # Root scripts ``` + +# Managing Shared Types Between Rust and TypeScript + +ts-rs allows you to derive TypeScript types from Rust structs/enums. By annotating your Rust types with #[derive(TS)] and related macros, ts-rs will generate .ts declaration files for those types. diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 6a737247..23cff90c 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -17,3 +17,5 @@ sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "chron chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1.0", features = ["v4", "serde"] } dotenvy = "0.15" +bcrypt = "0.15" +jsonwebtoken = "9.2" diff --git a/backend/migrations/002_update_users_auth.sql b/backend/migrations/002_update_users_auth.sql new file mode 100644 index 00000000..cf41e051 --- /dev/null +++ b/backend/migrations/002_update_users_auth.sql @@ -0,0 +1,23 @@ +-- Update users table for authentication system +-- Add new columns and update existing ones + +-- First, add the new columns +ALTER TABLE users +ADD COLUMN password_hash VARCHAR(255), +ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE; + +-- Update existing users to have a placeholder password hash +-- (This is safe since there shouldn't be any real users yet) +UPDATE users SET password_hash = '$2b$10$placeholder' WHERE password_hash IS NULL; + +-- Make password_hash required +ALTER TABLE users ALTER COLUMN password_hash SET NOT NULL; + +-- Remove the old password column if it exists +ALTER TABLE users DROP COLUMN IF EXISTS password; + +-- Create index on email for faster lookups +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + +-- Create index on is_admin for admin queries +CREATE INDEX IF NOT EXISTS idx_users_is_admin ON users(is_admin); diff --git a/backend/src/auth.rs b/backend/src/auth.rs new file mode 100644 index 00000000..c47b08ad --- /dev/null +++ b/backend/src/auth.rs @@ -0,0 +1,90 @@ +use axum::{ + async_trait, + extract::FromRequestParts, + http::{request::Parts, StatusCode, HeaderMap}, + RequestPartsExt, +}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub user_id: Uuid, + pub email: String, + pub is_admin: bool, + pub exp: usize, +} + +pub struct AuthUser { + pub user_id: Uuid, + pub email: String, + pub is_admin: bool, +} + +#[async_trait] +impl FromRequestParts for AuthUser +where + S: Send + Sync, +{ + type Rejection = StatusCode; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let headers = &parts.headers; + + let auth_header = headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let token = auth_header + .strip_prefix("Bearer ") + .ok_or(StatusCode::UNAUTHORIZED)?; + + let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "your-secret-key".to_string()); + + let claims = decode::( + token, + &DecodingKey::from_secret(jwt_secret.as_ref()), + &Validation::default(), + ) + .map_err(|_| StatusCode::UNAUTHORIZED)? + .claims; + + Ok(AuthUser { + user_id: claims.user_id, + email: claims.email, + is_admin: claims.is_admin, + }) + } +} + +pub fn create_token(user_id: Uuid, email: String, is_admin: bool) -> Result { + let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "your-secret-key".to_string()); + + let expiration = chrono::Utc::now() + .checked_add_signed(chrono::Duration::hours(24)) + .expect("valid timestamp") + .timestamp() as usize; + + let claims = Claims { + user_id, + email, + is_admin, + exp: expiration, + }; + + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(jwt_secret.as_ref()), + ) +} + +pub fn hash_password(password: &str) -> Result { + bcrypt::hash(password, bcrypt::DEFAULT_COST) +} + +pub fn verify_password(password: &str, hash: &str) -> Result { + bcrypt::verify(password, hash) +} diff --git a/backend/src/main.rs b/backend/src/main.rs index b8652574..e3a745cd 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,22 +1,22 @@ use axum::{ - routing::{get, post}, - Router, - Json, + extract::{Extension, Query}, response::Json as ResponseJson, - extract::{Query, Extension}, + routing::{get, post}, + Json, Router, }; -use tower_http::cors::CorsLayer; use serde::{Deserialize, Serialize}; -use tracing_subscriber; -use sqlx::{PgPool, postgres::PgPoolOptions}; +use sqlx::{postgres::PgPoolOptions, PgPool}; use std::env; +use tower_http::cors::CorsLayer; +use tracing_subscriber; - -mod routes; +mod auth; mod models; +mod routes; -use routes::health; +use auth::hash_password; use models::ApiResponse; +use routes::{health, projects, users}; #[derive(Debug, Deserialize)] struct HelloQuery { @@ -35,7 +35,9 @@ async fn hello_handler(Query(params): Query) -> ResponseJson) -> ResponseJson> { +async fn echo_handler( + Json(payload): Json, +) -> ResponseJson> { ResponseJson(ApiResponse { success: true, data: Some(payload), @@ -47,31 +49,92 @@ async fn echo_handler(Json(payload): Json) -> ResponseJson anyhow::Result<()> { // Load environment variables from .env file dotenvy::dotenv().ok(); - + tracing_subscriber::fmt::init(); // Database connection - let database_url = env::var("DATABASE_URL") - .expect("DATABASE_URL must be set in environment or .env file"); - + let database_url = + env::var("DATABASE_URL").expect("DATABASE_URL must be set in environment or .env file"); + let pool = PgPoolOptions::new() .max_connections(10) .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); + } + let app = Router::new() .route("/", get(|| async { "Bloop API" })) .route("/health", get(health::health_check)) .route("/hello", get(hello_handler)) .route("/echo", post(echo_handler)) + .merge(projects::projects_router()) + .merge(users::users_router()) .layer(Extension(pool)) .layer(CorsLayer::permissive()); let listener = tokio::net::TcpListener::bind("0.0.0.0:3001").await?; - + tracing::info!("Server running on http://0.0.0.0:3001"); - + axum::serve(listener, app).await?; - + + Ok(()) +} + +async fn create_admin_account(pool: &sqlx::PgPool) -> anyhow::Result<()> { + use chrono::Utc; + use uuid::Uuid; + + let admin_email = "admin@example.com"; + let admin_password = env::var("ADMIN_PASSWORD") + .unwrap_or_else(|_| "admin123".to_string()); + + // Check if admin already exists + let existing_admin = sqlx::query!( + "SELECT id, password_hash FROM users WHERE email = $1", + admin_email + ) + .fetch_optional(pool) + .await?; + + let password_hash = hash_password(&admin_password)?; + + 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(); + let now = Utc::now(); + sqlx::query!( + "INSERT INTO users (id, email, password_hash, is_admin, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)", + id, + admin_email, + password_hash, + true, + now, + now + ) + .execute(pool) + .await?; + + tracing::info!("Created admin account: {}", admin_email); + } + Ok(()) } diff --git a/backend/src/models/user.rs b/backend/src/models/user.rs index 95086ab1..a5117729 100644 --- a/backend/src/models/user.rs +++ b/backend/src/models/user.rs @@ -7,7 +7,9 @@ use uuid::Uuid; pub struct User { pub id: Uuid, pub email: String, - pub password: String, // This should be hashed + #[serde(skip_serializing)] + pub password_hash: String, // Hashed password + pub is_admin: bool, pub created_at: DateTime, pub updated_at: DateTime, } @@ -16,10 +18,45 @@ pub struct User { pub struct CreateUser { pub email: String, pub password: String, + pub is_admin: Option, } #[derive(Debug, Deserialize)] pub struct UpdateUser { pub email: Option, pub password: Option, + pub is_admin: Option, +} + +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub email: String, + pub password: String, +} + +#[derive(Debug, Serialize)] +pub struct LoginResponse { + pub user: UserResponse, + pub token: String, +} + +#[derive(Debug, Serialize)] +pub struct UserResponse { + pub id: Uuid, + pub email: String, + pub is_admin: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl From 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, + } + } } diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 43a7c768..bba193c4 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -1 +1,3 @@ pub mod health; +pub mod projects; +pub mod users; diff --git a/backend/src/routes/projects.rs b/backend/src/routes/projects.rs new file mode 100644 index 00000000..c199ab61 --- /dev/null +++ b/backend/src/routes/projects.rs @@ -0,0 +1,171 @@ +use axum::{ + routing::{get, post, put, delete}, + Router, + Json, + response::Json as ResponseJson, + extract::{Path, Extension}, + http::StatusCode, +}; +use sqlx::PgPool; +use uuid::Uuid; +use chrono::Utc; + +use crate::models::{ApiResponse, project::{Project, CreateProject, UpdateProject}}; + +pub async fn get_projects(Extension(pool): Extension) -> Result>>, StatusCode> { + match sqlx::query_as!( + Project, + "SELECT id, name, owner_id, created_at, updated_at FROM projects ORDER BY created_at DESC" + ) + .fetch_all(&pool) + .await + { + Ok(projects) => Ok(ResponseJson(ApiResponse { + success: true, + data: Some(projects), + message: None, + })), + Err(e) => { + tracing::error!("Failed to fetch projects: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +pub async fn get_project( + Path(id): Path, + Extension(pool): Extension +) -> Result>, StatusCode> { + match sqlx::query_as!( + Project, + "SELECT id, name, owner_id, created_at, updated_at FROM projects WHERE id = $1", + id + ) + .fetch_optional(&pool) + .await + { + Ok(Some(project)) => Ok(ResponseJson(ApiResponse { + success: true, + data: Some(project), + message: None, + })), + Ok(None) => Err(StatusCode::NOT_FOUND), + Err(e) => { + tracing::error!("Failed to fetch project: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +pub async fn create_project( + Extension(pool): Extension, + Json(payload): Json +) -> Result>, StatusCode> { + let id = Uuid::new_v4(); + let now = Utc::now(); + + match sqlx::query_as!( + Project, + "INSERT INTO projects (id, name, owner_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5) RETURNING id, name, owner_id, created_at, updated_at", + id, + payload.name, + payload.owner_id, + now, + now + ) + .fetch_one(&pool) + .await + { + Ok(project) => Ok(ResponseJson(ApiResponse { + success: true, + data: Some(project), + message: Some("Project created successfully".to_string()), + })), + Err(e) => { + tracing::error!("Failed to create project: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +pub async fn update_project( + Path(id): Path, + Extension(pool): Extension, + Json(payload): Json +) -> Result>, StatusCode> { + let now = Utc::now(); + + // Check if project exists first + let existing_project = sqlx::query_as!( + Project, + "SELECT id, name, owner_id, created_at, updated_at FROM projects WHERE id = $1", + id + ) + .fetch_optional(&pool) + .await; + + let existing_project = match existing_project { + Ok(Some(project)) => project, + Ok(None) => return Err(StatusCode::NOT_FOUND), + Err(e) => { + tracing::error!("Failed to check project existence: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + // Use existing name if not provided in update + let name = payload.name.unwrap_or(existing_project.name); + + match sqlx::query_as!( + Project, + "UPDATE projects SET name = $2, updated_at = $3 WHERE id = $1 RETURNING id, name, owner_id, created_at, updated_at", + id, + name, + now + ) + .fetch_one(&pool) + .await + { + Ok(project) => Ok(ResponseJson(ApiResponse { + success: true, + data: Some(project), + message: Some("Project updated successfully".to_string()), + })), + Err(e) => { + tracing::error!("Failed to update project: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +pub async fn delete_project( + Path(id): Path, + Extension(pool): Extension +) -> Result>, StatusCode> { + match sqlx::query!("DELETE FROM projects WHERE id = $1", id) + .execute(&pool) + .await + { + Ok(result) => { + if result.rows_affected() == 0 { + Err(StatusCode::NOT_FOUND) + } else { + Ok(ResponseJson(ApiResponse { + success: true, + data: None, + message: Some("Project deleted successfully".to_string()), + })) + } + } + Err(e) => { + tracing::error!("Failed to delete project: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +pub fn projects_router() -> Router { + Router::new() + .route("/projects", get(get_projects).post(create_project)) + .route("/projects/:id", get(get_project).put(update_project).delete(delete_project)) +} diff --git a/backend/src/routes/users.rs b/backend/src/routes/users.rs new file mode 100644 index 00000000..c964bd6e --- /dev/null +++ b/backend/src/routes/users.rs @@ -0,0 +1,301 @@ +use axum::{ + routing::{get, post, put, delete}, + Router, + Json, + response::Json as ResponseJson, + extract::{Path, Extension}, + http::StatusCode, +}; +use sqlx::PgPool; +use uuid::Uuid; +use chrono::Utc; + +use crate::models::{ApiResponse, user::{User, CreateUser, UpdateUser, LoginRequest, LoginResponse, UserResponse}}; +use crate::auth::{AuthUser, create_token, hash_password, verify_password}; + +pub async fn login( + Extension(pool): Extension, + Json(payload): Json +) -> Result>, StatusCode> { + match sqlx::query_as!( + User, + "SELECT id, email, password_hash, is_admin, created_at, updated_at FROM users WHERE email = $1", + payload.email + ) + .fetch_optional(&pool) + .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 +) -> Result>>, StatusCode> { + match 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 + { + Ok(users) => { + let user_responses: Vec = 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, + Extension(pool): Extension +) -> Result>, 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 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 + { + 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, + Json(payload): Json +) -> Result>, StatusCode> { + // Only admins can create users + if !auth.is_admin { + return Err(StatusCode::FORBIDDEN); + } + + let id = Uuid::new_v4(); + let now = Utc::now(); + let is_admin = payload.is_admin.unwrap_or(false); + + let password_hash = match hash_password(&payload.password) { + Ok(hash) => hash, + Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), + }; + + match 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, + payload.email, + password_hash, + is_admin, + now, + now + ) + .fetch_one(&pool) + .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, + Extension(pool): Extension, + Json(payload): Json +) -> Result>, StatusCode> { + // Users can only update their own profile unless they're admin + if auth.user_id != id && !auth.is_admin { + return Err(StatusCode::FORBIDDEN); + } + + let now = Utc::now(); + + // Get existing user + let existing_user = match 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 + { + 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 sqlx::query_as!( + User, + "UPDATE users SET email = $2, password_hash = $3, is_admin = $4, updated_at = $5 WHERE id = $1 RETURNING id, email, password_hash, is_admin, created_at, updated_at", + id, + email, + password_hash, + is_admin, + now + ) + .fetch_one(&pool) + .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, + Extension(pool): Extension +) -> Result>, 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 sqlx::query!("DELETE FROM users WHERE id = $1", id) + .execute(&pool) + .await + { + Ok(result) => { + if result.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 +) -> Result>, StatusCode> { + match sqlx::query_as!( + User, + "SELECT id, email, password_hash, is_admin, created_at, updated_at FROM users WHERE id = $1", + auth.user_id + ) + .fetch_optional(&pool) + .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 fn users_router() -> Router { + Router::new() + .route("/auth/login", post(login)) + .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)) +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 76ca09a8..b9dc81d6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,9 @@ "name": "bloop-frontend", "version": "0.1.0", "dependencies": { - "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "lucide-react": "^0.303.0", @@ -1060,6 +1062,75 @@ } } }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -1455,7 +1526,7 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" diff --git a/frontend/package.json b/frontend/package.json index e67dea72..323a0587 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,9 @@ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { - "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "lucide-react": "^0.303.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 537d746b..ac43588c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,25 +1,48 @@ import { useState, useEffect } from 'react' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' - -interface ApiResponse { - success: boolean - data?: T - message?: string -} +import { Alert, AlertDescription } from '@/components/ui/alert' +import { ProjectsPage } from '@/components/projects/projects-page' +import { UsersPage } from '@/components/users/users-page' +import { LoginForm } from '@/components/auth/login-form' +import { ApiResponse } from 'shared/types' +import { authStorage, isAuthenticated, logout, makeAuthenticatedRequest } from '@/lib/auth' +import { ArrowLeft, Heart, Activity, FolderOpen, Users, CheckCircle, AlertCircle, LogOut } from 'lucide-react' function App() { + const [currentPage, setCurrentPage] = useState<'home' | 'projects' | 'users'>('home') const [message, setMessage] = useState('') + const [messageType, setMessageType] = useState<'success' | 'error'>('success') const [loading, setLoading] = useState(false) + const [authenticated, setAuthenticated] = useState(false) + + const currentUser = authStorage.getUser() + + useEffect(() => { + setAuthenticated(isAuthenticated()) + }, []) + + const handleLogin = () => { + setAuthenticated(true) + setCurrentPage('home') + } + + const handleLogout = () => { + logout() + setAuthenticated(false) + setCurrentPage('home') + } const fetchHello = async () => { setLoading(true) try { - const response = await fetch('/api/hello?name=Bloop') + const response = await makeAuthenticatedRequest('/api/hello?name=Bloop') const data = await response.json() setMessage(data.message) + setMessageType('success') } catch (error) { setMessage('Error connecting to backend') + setMessageType('error') } finally { setLoading(false) } @@ -28,42 +51,240 @@ function App() { const checkHealth = async () => { setLoading(true) try { - const response = await fetch('/api/health') + const response = await makeAuthenticatedRequest('/api/health') const data: ApiResponse = await response.json() setMessage(data.message || 'Health check completed') + setMessageType('success') } catch (error) { setMessage('Backend health check failed') + setMessageType('error') } finally { setLoading(false) } } - return ( -
-
- - - Welcome to Bloop - - A full-stack monorepo with Rust backend and React frontend - - - -
- - -
- {message && ( -
-

{message}

+ if (!authenticated) { + return + } + + if (currentPage === 'projects' || currentPage === 'users') { + return ( +
+
+
+
+
+

Bloop

+
+ + {currentUser?.is_admin && ( + + )} +
+
+
+ Welcome, {currentUser?.email} +
+ + +
+
+
+
+
+ {currentPage === 'projects' ? : } +
+
+ ) + } + + return ( +
+
+
+
+
+
+ +
+
+

+ Welcome to Bloop +

+

+ A modern full-stack monorepo built with Rust backend and React frontend. + Get started by exploring our features below. +

+
+ +
+ + +
+
+ +
+ API Test +
+ + Test the connection between frontend and backend + +
+ + + +
+ + + +
+
+ +
+ Health Check +
+ + Monitor the health status of your backend services + +
+ + + +
+ + + +
+
+ +
+ Projects +
+ + Manage your projects with full CRUD operations + +
+ + + +
+ + {currentUser?.is_admin && ( + + +
+
+ +
+ Users +
+ + Manage user accounts and permissions + +
+ + + +
)} - - + + + +
+
+ +
+ Account +
+ + Logged in as {currentUser?.email} + +
+ + + +
+
+ + {message && ( + + {messageType === 'error' ? ( + + ) : ( + + )} + + {message} + + + )} + +
+

+ Built with ❤️ using Rust, React, TypeScript, and Tailwind CSS +

+
+
) diff --git a/frontend/src/components/auth/login-form.tsx b/frontend/src/components/auth/login-form.tsx new file mode 100644 index 00000000..093d2f35 --- /dev/null +++ b/frontend/src/components/auth/login-form.tsx @@ -0,0 +1,118 @@ +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 { authStorage } from '@/lib/auth' +import { LogIn, AlertCircle } from 'lucide-react' + +interface LoginFormProps { + onSuccess: () => void +} + +export function LoginForm({ onSuccess }: LoginFormProps) { + 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 = await response.json() + + if (data.success && data.data) { + authStorage.setToken(data.data.token) + authStorage.setUser(data.data.user) + onSuccess() + } else { + throw new Error('Login failed') + } + } catch (error) { + setError(error instanceof Error ? error.message : 'An error occurred') + } finally { + setLoading(false) + } + } + + return ( +
+ + +
+ +
+ Welcome back + + Sign in to your account to continue + +
+ +
+
+ + setEmail(e.target.value)} + placeholder="Enter your email" + required + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Enter your password" + required + /> +
+ + {error && ( + + + + {error} + + + )} + + +
+ +
+

Default admin credentials:

+

Email: admin@example.com

+

Password: Check your ADMIN_PASSWORD env var

+
+
+
+
+ ) +} diff --git a/frontend/src/components/projects/project-detail.tsx b/frontend/src/components/projects/project-detail.tsx new file mode 100644 index 00000000..7e865093 --- /dev/null +++ b/frontend/src/components/projects/project-detail.tsx @@ -0,0 +1,211 @@ +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 { Project, ApiResponse } from 'shared/types' +import { ProjectForm } from './project-form' +import { ArrowLeft, Edit, Trash2, Calendar, Clock, User, AlertCircle, Loader2 } from 'lucide-react' + +interface ProjectDetailProps { + projectId: string + onBack: () => void +} + +export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) { + const [project, setProject] = useState(null) + const [loading, setLoading] = useState(false) + const [showEditForm, setShowEditForm] = useState(false) + const [error, setError] = useState('') + + const fetchProject = async () => { + setLoading(true) + setError('') + try { + const response = await fetch(`/api/projects/${projectId}`) + const data: ApiResponse = await response.json() + if (data.success && data.data) { + setProject(data.data) + } else { + setError('Project not found') + } + } catch (error) { + console.error('Failed to fetch project:', error) + setError('Failed to load project') + } finally { + setLoading(false) + } + } + + const handleDelete = async () => { + if (!project) return + if (!confirm(`Are you sure you want to delete "${project.name}"? This action cannot be undone.`)) return + + try { + const response = await fetch(`/api/projects/${projectId}`, { + method: 'DELETE', + }) + if (response.ok) { + onBack() + } + } catch (error) { + console.error('Failed to delete project:', error) + setError('Failed to delete project') + } + } + + const handleEditSuccess = () => { + setShowEditForm(false) + fetchProject() + } + + useEffect(() => { + fetchProject() + }, [projectId]) + + if (loading) { + return ( +
+ + Loading project... +
+ ) + } + + if (error || !project) { + return ( +
+ + + +
+ +
+

Project not found

+

+ {error || 'The project you\'re looking for doesn\'t exist or has been deleted.'} +

+ +
+
+
+ ) + } + + return ( +
+
+
+ +
+

{project.name}

+

Project details and settings

+
+
+
+ + +
+
+ + {error && ( + + + + {error} + + + )} + +
+ + + + + Project Information + + + +
+ Status + Active +
+
+
+ + Created: + {new Date(project.created_at).toLocaleDateString()} +
+
+ + Last Updated: + {new Date(project.updated_at).toLocaleDateString()} +
+
+ + Owner ID: + + {project.owner_id.substring(0, 8)}... + +
+
+
+
+ + + + Project Details + + Technical information about this project + + + +
+

Project ID

+ + {project.id} + +
+
+

Created At

+

+ {new Date(project.created_at).toLocaleString()} +

+
+
+

Last Modified

+

+ {new Date(project.updated_at).toLocaleString()} +

+
+
+
+
+ + setShowEditForm(false)} + onSuccess={handleEditSuccess} + project={project} + /> +
+ ) +} diff --git a/frontend/src/components/projects/project-form.tsx b/frontend/src/components/projects/project-form.tsx new file mode 100644 index 00000000..d90d8a4b --- /dev/null +++ b/frontend/src/components/projects/project-form.tsx @@ -0,0 +1,127 @@ +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 { Project, CreateProject, UpdateProject } from 'shared/types' +import { AlertCircle } from 'lucide-react' + +interface ProjectFormProps { + open: boolean + onClose: () => void + onSuccess: () => void + project?: Project | null +} + +export function ProjectForm({ open, onClose, onSuccess, project }: ProjectFormProps) { + const [name, setName] = useState(project?.name || '') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + const isEditing = !!project + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + setLoading(true) + + try { + if (isEditing) { + const updateData: UpdateProject = { name } + const response = await fetch(`/api/projects/${project.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updateData), + }) + + if (!response.ok) { + throw new Error('Failed to update project') + } + } else { + // For now, using a placeholder owner_id - this should come from auth + const createData: CreateProject = { + name, + owner_id: '00000000-0000-0000-0000-000000000000' + } + const response = await fetch('/api/projects', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(createData), + }) + + if (!response.ok) { + throw new Error('Failed to create project') + } + } + + onSuccess() + setName('') + } catch (error) { + setError(error instanceof Error ? error.message : 'An error occurred') + } finally { + setLoading(false) + } + } + + const handleClose = () => { + setName(project?.name || '') + setError('') + onClose() + } + + return ( + + + + + {isEditing ? 'Edit Project' : 'Create New Project'} + + + {isEditing + ? 'Make changes to your project here. Click save when you\'re done.' + : 'Add a new project to your workspace. You can always edit it later.' + } + + + +
+
+ + setName(e.target.value)} + placeholder="Enter project name" + required + /> +
+ + {error && ( + + + + {error} + + + )} + + + + + +
+
+
+ ) +} diff --git a/frontend/src/components/projects/project-list.tsx b/frontend/src/components/projects/project-list.tsx new file mode 100644 index 00000000..04c9213c --- /dev/null +++ b/frontend/src/components/projects/project-list.tsx @@ -0,0 +1,169 @@ +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 { Project, ApiResponse } from 'shared/types' +import { ProjectForm } from './project-form' +import { Plus, Edit, Trash2, Calendar, AlertCircle, Loader2 } from 'lucide-react' + +export function ProjectList() { + const [projects, setProjects] = useState([]) + const [loading, setLoading] = useState(false) + const [showForm, setShowForm] = useState(false) + const [editingProject, setEditingProject] = useState(null) + const [error, setError] = useState('') + + const fetchProjects = async () => { + setLoading(true) + setError('') + try { + const response = await fetch('/api/projects') + const data: ApiResponse = await response.json() + if (data.success && data.data) { + setProjects(data.data) + } else { + setError('Failed to load projects') + } + } catch (error) { + console.error('Failed to fetch projects:', error) + setError('Failed to connect to server') + } finally { + setLoading(false) + } + } + + const handleDelete = async (id: string, name: string) => { + if (!confirm(`Are you sure you want to delete "${name}"? This action cannot be undone.`)) return + + try { + const response = await fetch(`/api/projects/${id}`, { + method: 'DELETE', + }) + if (response.ok) { + fetchProjects() + } + } catch (error) { + console.error('Failed to delete project:', error) + setError('Failed to delete project') + } + } + + const handleEdit = (project: Project) => { + setEditingProject(project) + setShowForm(true) + } + + const handleFormSuccess = () => { + setShowForm(false) + setEditingProject(null) + fetchProjects() + } + + useEffect(() => { + fetchProjects() + }, []) + + return ( +
+
+
+

Projects

+

+ Manage your projects and track their progress +

+
+ +
+ + {error && ( + + + + {error} + + + )} + + {loading ? ( +
+ + Loading projects... +
+ ) : projects.length === 0 ? ( + + +
+ +
+

No projects yet

+

+ Get started by creating your first project. +

+ +
+
+ ) : ( +
+ {projects.map((project) => ( + + +
+ {project.name} + + Active + +
+ + + Created {new Date(project.created_at).toLocaleDateString()} + +
+ +
+ + +
+
+
+ ))} +
+ )} + + { + setShowForm(false) + setEditingProject(null) + }} + onSuccess={handleFormSuccess} + project={editingProject} + /> +
+ ) +} diff --git a/frontend/src/components/projects/projects-page.tsx b/frontend/src/components/projects/projects-page.tsx new file mode 100644 index 00000000..98130c69 --- /dev/null +++ b/frontend/src/components/projects/projects-page.tsx @@ -0,0 +1,18 @@ +import { useState } from 'react' +import { ProjectList } from './project-list' +import { ProjectDetail } from './project-detail' + +export function ProjectsPage() { + const [selectedProjectId, setSelectedProjectId] = useState(null) + + if (selectedProjectId) { + return ( + setSelectedProjectId(null)} + /> + ) + } + + return +} diff --git a/frontend/src/components/ui/alert.tsx b/frontend/src/components/ui/alert.tsx new file mode 100644 index 00000000..41fa7e05 --- /dev/null +++ b/frontend/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx new file mode 100644 index 00000000..f000e3ef --- /dev/null +++ b/frontend/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx new file mode 100644 index 00000000..aab0623b --- /dev/null +++ b/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,113 @@ +import * as React from "react" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & { + open?: boolean + onOpenChange?: (open: boolean) => void + } +>(({ className, open, onOpenChange, children, ...props }, ref) => { + if (!open) return null + + return ( +
+
onOpenChange?.(false)} + /> +
+ + {children} +
+
+ ) +}) +Dialog.displayName = "Dialog" + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +DialogTitle.displayName = "DialogTitle" + +const DialogDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +DialogDescription.displayName = "DialogDescription" + +const DialogContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +DialogContent.displayName = "DialogContent" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +export { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 00000000..677d05fd --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx new file mode 100644 index 00000000..683faa79 --- /dev/null +++ b/frontend/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/frontend/src/components/ui/separator.tsx b/frontend/src/components/ui/separator.tsx new file mode 100644 index 00000000..12d81c4a --- /dev/null +++ b/frontend/src/components/ui/separator.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/frontend/src/components/ui/table.tsx b/frontend/src/components/ui/table.tsx new file mode 100644 index 00000000..7f3502f8 --- /dev/null +++ b/frontend/src/components/ui/table.tsx @@ -0,0 +1,117 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className + )} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/frontend/src/components/users/user-form.tsx b/frontend/src/components/users/user-form.tsx new file mode 100644 index 00000000..bf5d6364 --- /dev/null +++ b/frontend/src/components/users/user-form.tsx @@ -0,0 +1,185 @@ +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 : undefined, + password: password ? password : undefined, + is_admin: canEditAdminStatus && isAdmin !== user.is_admin ? isAdmin : undefined + } + + // Remove undefined values + Object.keys(updateData).forEach(key => { + if (updateData[key as keyof UpdateUser] === undefined) { + 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 ( + + + + + {isEditing ? 'Edit User' : 'Create New User'} + + + {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.' + } + + + +
+
+ + setEmail(e.target.value)} + placeholder="Enter email address" + required + /> +
+ +
+ + setPassword(e.target.value)} + placeholder={isEditing ? "Enter new password" : "Enter password"} + required={!isEditing} + /> +
+ + {canEditAdminStatus && ( +
+ setIsAdmin(e.target.checked)} + className="rounded border-gray-300" + /> + +
+ )} + + {error && ( + + + + {error} + + + )} + + + + + +
+
+
+ ) +} diff --git a/frontend/src/components/users/user-list.tsx b/frontend/src/components/users/user-list.tsx new file mode 100644 index 00000000..f8c52c6f --- /dev/null +++ b/frontend/src/components/users/user-list.tsx @@ -0,0 +1,188 @@ +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([]) + const [loading, setLoading] = useState(false) + const [showForm, setShowForm] = useState(false) + const [editingUser, setEditingUser] = useState(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 = 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 ( +
+
+
+

Users

+

+ Manage user accounts and permissions +

+
+ {currentUser?.is_admin && ( + + )} +
+ + {error && ( + + + + {error} + + + )} + + {loading ? ( +
+ + Loading users... +
+ ) : users.length === 0 ? ( + + +
+ +
+

No users found

+

+ Get started by creating the first user account. +

+ {currentUser?.is_admin && ( + + )} +
+
+ ) : ( +
+ {users.map((user) => ( + + +
+ + {user.is_admin ? ( + + ) : ( + + )} + {user.email} + + + {user.is_admin ? "Admin" : "User"} + +
+ + + Joined {new Date(user.created_at).toLocaleDateString()} + +
+ +
+ + {currentUser?.is_admin && currentUser.id !== user.id && ( + + )} +
+
+
+ ))} +
+ )} + + { + setShowForm(false) + setEditingUser(null) + }} + onSuccess={handleFormSuccess} + user={editingUser} + /> +
+ ) +} diff --git a/frontend/src/components/users/users-page.tsx b/frontend/src/components/users/users-page.tsx new file mode 100644 index 00000000..92392027 --- /dev/null +++ b/frontend/src/components/users/users-page.tsx @@ -0,0 +1,5 @@ +import { UserList } from './user-list' + +export function UsersPage() { + return +} diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts new file mode 100644 index 00000000..8fb71e5b --- /dev/null +++ b/frontend/src/lib/auth.ts @@ -0,0 +1,63 @@ +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 => { + 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() +} + +export const logout = (): void => { + authStorage.clear() + window.location.href = '/' +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index c20738e2..9e7764ea 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -17,7 +17,8 @@ "noFallthroughCasesInSwitch": true, "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "shared/*": ["../shared/*"] } }, "include": ["src"], diff --git a/shared/types.ts b/shared/types.ts index f4c3bf02..8c4bb261 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -12,3 +12,50 @@ export interface HelloResponse { export interface HelloQuery { name?: string } + +export interface Project { + id: string + name: string + owner_id: string + created_at: string + updated_at: string +} + +export interface CreateProject { + name: string + owner_id: string +} + +export interface UpdateProject { + name?: string +} + +export interface User { + id: string + email: string + is_admin: boolean + created_at: string + updated_at: string +} + +export interface CreateUser { + email: string + password: string + is_admin?: boolean +} + +export interface UpdateUser { + email?: string + password?: string + is_admin?: boolean +} + +export interface LoginRequest { + email: string + password: string +} + +export interface LoginResponse { + user: User + token: string +}