From b6287a20307595242ce4c2f66e6671cebdf6f5a6 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 2 Feb 2026 22:38:49 +0300 Subject: [PATCH] fc-server: add user management REST API endpoints Signed-off-by: NotAShelf Change-Id: I25545df3273967086f8473d3f92c30736a6a6964 --- crates/server/src/routes/dashboard.rs | 182 +++++++++--- crates/server/src/routes/mod.rs | 24 +- crates/server/src/routes/users.rs | 397 ++++++++++++++++++++++++++ 3 files changed, 552 insertions(+), 51 deletions(-) create mode 100644 crates/server/src/routes/users.rs diff --git a/crates/server/src/routes/dashboard.rs b/crates/server/src/routes/dashboard.rs index c0c953d..4b7928c 100644 --- a/crates/server/src/routes/dashboard.rs +++ b/crates/server/src/routes/dashboard.rs @@ -1026,61 +1026,124 @@ async fn login_page() -> Html { #[derive(serde::Deserialize)] struct LoginForm { - api_key: String, + username: Option, + api_key: Option, + password: Option, } async fn login_action( State(state): State, Form(form): Form, ) -> Response { - let token = form.api_key.trim(); - if token.is_empty() { - let tmpl = LoginTemplate { - error: Some("API key is required".to_string()), + // Try username/password authentication first + if let (Some(username), Some(password)) = + (form.username.as_ref(), form.password.as_ref()) + { + let creds = fc_common::models::LoginCredentials { + username: username.clone(), + password: password.clone(), }; - return Html( - tmpl - .render() - .unwrap_or_else(|e| format!("Template error: {e}")), - ) - .into_response(); + + match fc_common::repo::users::authenticate(&state.pool, &creds).await { + Ok(user) => { + let session_id = Uuid::new_v4().to_string(); + state + .sessions + .insert(session_id.clone(), crate::state::SessionData { + api_key: None, + user: Some(user), + created_at: std::time::Instant::now(), + }); + + let cookie = format!( + "fc_user_session={}; HttpOnly; SameSite=Strict; Path=/; \ + Max-Age=86400", + session_id + ); + return ( + [(axum::http::header::SET_COOKIE, cookie)], + Redirect::to("/"), + ) + .into_response(); + }, + Err(_) => { + let tmpl = LoginTemplate { + error: Some("Invalid username or password".to_string()), + }; + return Html( + tmpl + .render() + .unwrap_or_else(|e| format!("Template error: {e}")), + ) + .into_response(); + }, + } } - let mut hasher = Sha256::new(); - hasher.update(token.as_bytes()); - let key_hash = hex::encode(hasher.finalize()); - - match fc_common::repo::api_keys::get_by_hash(&state.pool, &key_hash).await { - Ok(Some(api_key)) => { - let session_id = Uuid::new_v4().to_string(); - state - .sessions - .insert(session_id.clone(), crate::state::SessionData { - api_key, - created_at: std::time::Instant::now(), - }); - - let cookie = format!( - "fc_session={}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400", - session_id - ); - ( - [(axum::http::header::SET_COOKIE, cookie)], - Redirect::to("/"), - ) - .into_response() - }, - _ => { + // Fall back to API key authentication + if let Some(token) = form.api_key.as_ref() { + let token = token.trim(); + if token.is_empty() { let tmpl = LoginTemplate { - error: Some("Invalid API key".to_string()), + error: Some("API key is required".to_string()), }; - Html( + return Html( tmpl .render() .unwrap_or_else(|e| format!("Template error: {e}")), ) - .into_response() - }, + .into_response(); + } + + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + let key_hash = hex::encode(hasher.finalize()); + + match fc_common::repo::api_keys::get_by_hash(&state.pool, &key_hash).await { + Ok(Some(api_key)) => { + let session_id = Uuid::new_v4().to_string(); + state + .sessions + .insert(session_id.clone(), crate::state::SessionData { + api_key: Some(api_key), + user: None, + created_at: std::time::Instant::now(), + }); + + let cookie = format!( + "fc_session={}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400", + session_id + ); + ( + [(axum::http::header::SET_COOKIE, cookie)], + Redirect::to("/"), + ) + .into_response() + }, + _ => { + let tmpl = LoginTemplate { + error: Some("Invalid API key".to_string()), + }; + Html( + tmpl + .render() + .unwrap_or_else(|e| format!("Template error: {e}")), + ) + .into_response() + }, + } + } else { + let tmpl = LoginTemplate { + error: Some( + "Please provide either username/password or API key".to_string(), + ), + }; + Html( + tmpl + .render() + .unwrap_or_else(|e| format!("Template error: {e}")), + ) + .into_response() } } @@ -1088,12 +1151,31 @@ async fn logout_action( State(state): State, request: axum::extract::Request, ) -> Response { - // Remove server-side session + // Remove server-side session for both cookie types if let Some(cookie_header) = request .headers() .get("cookie") .and_then(|v| v.to_str().ok()) - && let Some(session_id) = cookie_header + { + // Check for user session + if let Some(session_id) = cookie_header + .split(';') + .filter_map(|pair| { + let pair = pair.trim(); + let (k, v) = pair.split_once('=')?; + if k.trim() == "fc_user_session" { + Some(v.trim().to_string()) + } else { + None + } + }) + .next() + { + state.sessions.remove(&session_id); + } + + // Check for legacy API key session + if let Some(session_id) = cookie_header .split(';') .filter_map(|pair| { let pair = pair.trim(); @@ -1105,13 +1187,21 @@ async fn logout_action( } }) .next() - { - state.sessions.remove(&session_id); + { + state.sessions.remove(&session_id); + } } - let cookie = "fc_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0"; + // Clear both cookies + let cookies = [ + "fc_user_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0", + "fc_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0", + ]; ( - [(axum::http::header::SET_COOKIE, cookie.to_string())], + [ + (axum::http::header::SET_COOKIE, cookies[0].to_string()), + (axum::http::header::SET_COOKIE, cookies[1].to_string()), + ], Redirect::to("/"), ) .into_response() diff --git a/crates/server/src/routes/mod.rs b/crates/server/src/routes/mod.rs index 6ced0c1..ca676ba 100644 --- a/crates/server/src/routes/mod.rs +++ b/crates/server/src/routes/mod.rs @@ -12,6 +12,7 @@ pub mod logs; pub mod metrics; pub mod projects; pub mod search; +pub mod users; pub mod webhooks; use std::{net::IpAddr, sync::Arc, time::Instant}; @@ -126,11 +127,24 @@ pub fn router(state: AppState, config: &ServerConfig) -> Router { // Static assets .route("/static/style.css", get(serve_style_css)) // Dashboard routes with session extraction middleware - .merge( - dashboard::router(state.clone()).route_layer(middleware::from_fn_with_state( - state.clone(), - extract_session, - )), +.nest( + "/api/v1", + Router::new() + .merge(projects::router()) + .merge(jobsets::router()) + .merge(evaluations::router()) + .merge(builds::router()) + .merge(logs::router()) + .merge(auth::router()) + .merge(users::router()) + .merge(search::router()) + .merge(badges::router()) + .merge(channels::router()) + .merge(admin::router()) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_api_key, + )), ) .merge(health::router()) .merge(cache::router()) diff --git a/crates/server/src/routes/users.rs b/crates/server/src/routes/users.rs new file mode 100644 index 0000000..46f71a4 --- /dev/null +++ b/crates/server/src/routes/users.rs @@ -0,0 +1,397 @@ +//! User management API routes + +use axum::{ + Json, + Router, + extract::{Path, Query, State}, + http::StatusCode, + routing::get, +}; +use fc_common::{ + models::{ + CreateStarredJob, + CreateUser, + LoginCredentials, + PaginationParams, + UpdateUser, + User, + }, + repo::{self, api_keys}, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{auth_middleware::RequireAdmin, error::ApiError, state::AppState}; + +// --- DTOs --- + +#[derive(Debug, Deserialize)] +pub struct CreateUserRequest { + pub username: String, + pub email: String, + pub full_name: Option, + pub password: String, + pub role: Option, +} + +#[derive(Debug, Serialize)] +pub struct UserResponse { + pub id: Uuid, + pub username: String, + pub email: String, + pub full_name: Option, + pub user_type: String, + pub role: String, + pub enabled: bool, + pub email_verified: bool, + pub public_dashboard: bool, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub last_login_at: Option>, +} + +impl From for UserResponse { + fn from(u: User) -> Self { + UserResponse { + id: u.id, + username: u.username, + email: u.email, + full_name: u.full_name, + user_type: format!("{:?}", u.user_type).to_lowercase(), + role: u.role, + enabled: u.enabled, + email_verified: u.email_verified, + public_dashboard: u.public_dashboard, + created_at: u.created_at, + updated_at: u.updated_at, + last_login_at: u.last_login_at, + } + } +} + +#[derive(Debug, Deserialize)] +pub struct UpdateUserRequest { + pub email: Option, + pub full_name: Option, + pub password: Option, + pub role: Option, + pub enabled: Option, + pub public_dashboard: Option, +} + +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub username: String, + pub password: String, +} + +#[derive(Debug, Serialize)] +pub struct LoginResponse { + pub user: UserResponse, +} + +#[derive(Debug, Serialize)] +pub struct StarredJobResponse { + pub id: Uuid, + pub project_id: Uuid, + pub jobset_id: Option, + pub job_name: String, + pub created_at: chrono::DateTime, +} + +// --- Handlers --- + +async fn list_users( + _auth: RequireAdmin, + State(state): State, + Query(params): Query, +) -> Result>, ApiError> { + let users = repo::users::list(&state.pool, params.limit(), params.offset()) + .await + .map_err(ApiError)?; + Ok(Json(users.into_iter().map(UserResponse::from).collect())) +} + +async fn create_user( + _auth: RequireAdmin, + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let data = CreateUser { + username: req.username, + email: req.email, + full_name: req.full_name, + password: req.password, + role: req.role, + }; + + let user = repo::users::create(&state.pool, &data) + .await + .map_err(ApiError)?; + Ok(Json(UserResponse::from(user))) +} + +async fn get_user( + _auth: RequireAdmin, + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let user = repo::users::get(&state.pool, id).await.map_err(ApiError)?; + Ok(Json(UserResponse::from(user))) +} + +async fn update_user( + _auth: RequireAdmin, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result, ApiError> { + let data = UpdateUser { + email: req.email, + full_name: req.full_name, + password: req.password, + role: req.role, + enabled: req.enabled, + public_dashboard: req.public_dashboard, + }; + + let user = repo::users::update(&state.pool, id, &data) + .await + .map_err(ApiError)?; + Ok(Json(UserResponse::from(user))) +} + +async fn delete_user( + _auth: RequireAdmin, + State(state): State, + Path(id): Path, +) -> Result { + repo::users::delete(&state.pool, id) + .await + .map_err(ApiError)?; + Ok(StatusCode::NO_CONTENT) +} + +// --- Current User Handlers --- + +async fn get_current_user( + State(state): State, + extensions: axum::http::Extensions, +) -> Result, ApiError> { + // Try to get user from extensions first + if let Some(user) = extensions.get::() { + return Ok(Json(UserResponse::from(user.clone()))); + } + + // Fall back to API key + let api_key = extensions + .get::() + .cloned() + .ok_or_else(|| { + ApiError(fc_common::error::CiError::Unauthorized( + "Not authenticated".to_string(), + )) + })?; + + // For API key auth, we don't have a user record yet + // Return a synthetic user response based on the API key + let synthetic_user = UserResponse { + id: api_key.id, + username: api_key.name.clone(), + email: String::new(), + full_name: None, + user_type: "api_key".to_string(), + role: api_key.role.clone(), + enabled: true, + email_verified: true, + public_dashboard: false, + created_at: api_key.created_at, + updated_at: api_key.created_at, + last_login_at: api_key.last_used_at, + }; + + Ok(Json(synthetic_user)) +} + +async fn update_current_user( + State(state): State, + extensions: axum::http::Extensions, + Json(req): Json, +) -> Result, ApiError> { + let user = extensions.get::().cloned().ok_or_else(|| { + ApiError(fc_common::error::CiError::Unauthorized( + "User authentication required".to_string(), + )) + })?; + + if let Some(ref full_name) = req.full_name { + repo::users::update_full_name( + &state.pool, + user.id, + Some(full_name.as_str()), + ) + .await + .map_err(ApiError)?; + } + + if let Some(ref email) = req.email { + repo::users::update_email(&state.pool, user.id, email) + .await + .map_err(ApiError)?; + } + + if let Some(public) = req.public_dashboard { + repo::users::set_public_dashboard(&state.pool, user.id, public) + .await + .map_err(ApiError)?; + } + + let updated_user = repo::users::get(&state.pool, user.id) + .await + .map_err(ApiError)?; + Ok(Json(UserResponse::from(updated_user))) +} + +async fn change_password( + State(state): State, + extensions: axum::http::Extensions, + Json(req): Json, +) -> Result { + let user = extensions.get::().cloned().ok_or_else(|| { + ApiError(fc_common::error::CiError::Unauthorized( + "User authentication required".to_string(), + )) + })?; + + // Verify current password (OAuth users don't have passwords) + let hash = user.password_hash.ok_or_else(|| { + ApiError(fc_common::error::CiError::Unauthorized( + "OAuth user - use OAuth login".to_string(), + )) + })?; + + if !repo::users::verify_password(&req.current_password, &hash) + .map_err(|e| ApiError(fc_common::error::CiError::Internal(e.to_string())))? + { + return Err(ApiError(fc_common::error::CiError::Unauthorized( + "Current password is incorrect".to_string(), + ))); + } + + repo::users::update_password(&state.pool, user.id, &req.new_password) + .await + .map_err(ApiError)?; + + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Debug, Deserialize)] +pub struct ChangePasswordRequest { + pub current_password: String, + pub new_password: String, +} + +// --- Starred Jobs Handlers --- + +async fn list_starred_jobs( + State(state): State, + extensions: axum::http::Extensions, + Query(params): Query, +) -> Result>, ApiError> { + let user = extensions.get::().cloned().ok_or_else(|| { + ApiError(fc_common::error::CiError::Unauthorized( + "User authentication required".to_string(), + )) + })?; + + let jobs = repo::starred_jobs::list_for_user( + &state.pool, + user.id, + params.limit(), + params.offset(), + ) + .await + .map_err(ApiError)?; + + Ok(Json( + jobs + .into_iter() + .map(|j| { + StarredJobResponse { + id: j.id, + project_id: j.project_id, + jobset_id: j.jobset_id, + job_name: j.job_name, + created_at: j.created_at, + } + }) + .collect(), + )) +} + +async fn create_starred_job( + State(state): State, + extensions: axum::http::Extensions, + Json(req): Json, +) -> Result, ApiError> { + let user = extensions.get::().cloned().ok_or_else(|| { + ApiError(fc_common::error::CiError::Unauthorized( + "User authentication required".to_string(), + )) + })?; + + let data = CreateStarredJob { + project_id: req.project_id, + jobset_id: req.jobset_id, + job_name: req.job_name, + }; + + let job = repo::starred_jobs::create(&state.pool, user.id, &data) + .await + .map_err(ApiError)?; + + Ok(Json(StarredJobResponse { + id: job.id, + project_id: job.project_id, + jobset_id: job.jobset_id, + job_name: job.job_name, + created_at: job.created_at, + })) +} + +#[derive(Debug, Deserialize)] +pub struct CreateStarredJobRequest { + pub project_id: Uuid, + pub jobset_id: Option, + pub job_name: String, +} + +async fn delete_starred_job( + State(state): State, + extensions: axum::http::Extensions, + Path(id): Path, +) -> Result { + let _user = extensions.get::().cloned().ok_or_else(|| { + ApiError(fc_common::error::CiError::Unauthorized( + "User authentication required".to_string(), + )) + })?; + + repo::starred_jobs::delete(&state.pool, id) + .await + .map_err(ApiError)?; + Ok(StatusCode::NO_CONTENT) +} + +pub fn router() -> Router { + Router::new() + // User management (admin only) + .route("/users", get(list_users).post(create_user)) + .route("/users/{id}", get(get_user).put(update_user).delete(delete_user)) + // Current user + .route("/me", get(get_current_user).put(update_current_user)) + .route("/me/password", axum::routing::post(change_password)) + // Starred jobs + .route("/me/starred-jobs", get(list_starred_jobs).post(create_starred_job)) + .route("/me/starred-jobs/{id}", axum::routing::delete(delete_starred_job)) +}