fc-server: add user management REST API endpoints

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I25545df3273967086f8473d3f92c30736a6a6964
This commit is contained in:
raf 2026-02-02 22:38:49 +03:00
commit b6287a2030
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
3 changed files with 552 additions and 51 deletions

View file

@ -1026,61 +1026,124 @@ async fn login_page() -> Html<String> {
#[derive(serde::Deserialize)]
struct LoginForm {
api_key: String,
username: Option<String>,
api_key: Option<String>,
password: Option<String>,
}
async fn login_action(
State(state): State<AppState>,
Form(form): Form<LoginForm>,
) -> 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<AppState>,
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()

View file

@ -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())

View file

@ -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<String>,
pub password: String,
pub role: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct UserResponse {
pub id: Uuid,
pub username: String,
pub email: String,
pub full_name: Option<String>,
pub user_type: String,
pub role: String,
pub enabled: bool,
pub email_verified: bool,
pub public_dashboard: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub last_login_at: Option<chrono::DateTime<chrono::Utc>>,
}
impl From<User> 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<String>,
pub full_name: Option<String>,
pub password: Option<String>,
pub role: Option<String>,
pub enabled: Option<bool>,
pub public_dashboard: Option<bool>,
}
#[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<Uuid>,
pub job_name: String,
pub created_at: chrono::DateTime<chrono::Utc>,
}
// --- Handlers ---
async fn list_users(
_auth: RequireAdmin,
State(state): State<AppState>,
Query(params): Query<PaginationParams>,
) -> Result<Json<Vec<UserResponse>>, 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<AppState>,
Json(req): Json<CreateUserRequest>,
) -> Result<Json<UserResponse>, 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<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<UserResponse>, 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<AppState>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateUserRequest>,
) -> Result<Json<UserResponse>, 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<AppState>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
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<AppState>,
extensions: axum::http::Extensions,
) -> Result<Json<UserResponse>, ApiError> {
// Try to get user from extensions first
if let Some(user) = extensions.get::<User>() {
return Ok(Json(UserResponse::from(user.clone())));
}
// Fall back to API key
let api_key = extensions
.get::<fc_common::models::ApiKey>()
.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<AppState>,
extensions: axum::http::Extensions,
Json(req): Json<UpdateUserRequest>,
) -> Result<Json<UserResponse>, ApiError> {
let user = extensions.get::<User>().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<AppState>,
extensions: axum::http::Extensions,
Json(req): Json<ChangePasswordRequest>,
) -> Result<StatusCode, ApiError> {
let user = extensions.get::<User>().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<AppState>,
extensions: axum::http::Extensions,
Query(params): Query<PaginationParams>,
) -> Result<Json<Vec<StarredJobResponse>>, ApiError> {
let user = extensions.get::<User>().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<AppState>,
extensions: axum::http::Extensions,
Json(req): Json<CreateStarredJobRequest>,
) -> Result<Json<StarredJobResponse>, ApiError> {
let user = extensions.get::<User>().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<Uuid>,
pub job_name: String,
}
async fn delete_starred_job(
State(state): State<AppState>,
extensions: axum::http::Extensions,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
let _user = extensions.get::<User>().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<AppState> {
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))
}