From 865dd39a07b76b2606041cd965fc433522114206 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 8 Feb 2026 02:13:48 +0300 Subject: [PATCH] fc-server: polish user management; add starred jobs UI Signed-off-by: NotAShelf Change-Id: Ie3034d4a66a55cb71c23ba25b40d678f6a6a6964 --- crates/server/src/routes/dashboard.rs | 338 ++++++++++++++++++++------ crates/server/src/routes/projects.rs | 3 + crates/server/templates/base.html | 2 + crates/server/templates/jobset.html | 17 +- crates/server/templates/starred.html | 82 +++++++ crates/server/templates/users.html | 168 +++++++++++++ 6 files changed, 538 insertions(+), 72 deletions(-) create mode 100644 crates/server/templates/starred.html create mode 100644 crates/server/templates/users.html diff --git a/crates/server/src/routes/dashboard.rs b/crates/server/src/routes/dashboard.rs index 63817c2..4f10df6 100644 --- a/crates/server/src/routes/dashboard.rs +++ b/crates/server/src/routes/dashboard.rs @@ -7,7 +7,7 @@ use axum::{ response::{Html, IntoResponse, Redirect, Response}, routing::get, }; -use fc_common::models::*; +use fc_common::models::{Build, Evaluation, BuildStatus, EvaluationStatus, ApiKey, Project, Jobset, BuildStep, BuildProduct, Channel, SystemStatus, RemoteBuilder}; use sha2::{Digest, Sha256}; use uuid::Uuid; @@ -74,6 +74,28 @@ struct ApiKeyView { last_used_at: String, } +struct UserView { + id: Uuid, + username: String, + email: String, + role: String, + user_type: String, + enabled: bool, + last_login_at: String, +} + +struct StarredJobView { + id: Uuid, + project_id: Uuid, + project_name: String, + jobset_id: Option, + jobset_name: String, + job_name: String, + status_text: String, + status_class: String, + latest_build_id: Option, +} + fn format_duration( started: Option<&chrono::DateTime>, completed: Option<&chrono::DateTime>, @@ -180,8 +202,7 @@ fn eval_badge(s: &EvaluationStatus) -> (String, String) { fn is_admin(extensions: &Extensions) -> bool { extensions .get::() - .map(|k| k.role == "admin") - .unwrap_or(false) + .is_some_and(|k| k.role == "admin") } fn auth_name(extensions: &Extensions) -> String { @@ -338,6 +359,31 @@ struct LoginTemplate { error: Option, } +#[derive(Template)] +#[template(path = "users.html")] +struct UsersTemplate { + users: Vec, + limit: i64, + has_prev: bool, + has_next: bool, + prev_offset: i64, + next_offset: i64, + page: i64, + total_pages: i64, + is_admin: bool, + auth_name: String, +} + +#[derive(Template)] +#[template(path = "starred.html")] +struct StarredTemplate { + starred_jobs: Vec, + is_logged_in: bool, + #[allow(dead_code)] + is_admin: bool, + auth_name: String, +} + // --- Handlers --- async fn home( @@ -626,9 +672,7 @@ async fn evaluations_page( Ok(js) => { let pname = fc_common::repo::projects::get(&state.pool, js.project_id) - .await - .map(|p| p.name) - .unwrap_or_else(|_| "-".to_string()); + .await.map_or_else(|_| "-".to_string(), |p| p.name); (js.name, pname) }, Err(_) => ("-".to_string(), "-".to_string()), @@ -978,9 +1022,7 @@ async fn admin_page( role: k.role, created_at: k.created_at.format("%Y-%m-%d %H:%M").to_string(), last_used_at: k - .last_used_at - .map(|t| t.format("%Y-%m-%d %H:%M").to_string()) - .unwrap_or_else(|| "Never".to_string()), + .last_used_at.map_or_else(|| "Never".to_string(), |t| t.format("%Y-%m-%d %H:%M").to_string()), } }) .collect(); @@ -1044,39 +1086,35 @@ async fn login_action( password: password.clone(), }; - 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(), - }); + if let Ok(user) = fc_common::repo::users::authenticate(&state.pool, &creds).await { + 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}")), - ) + let cookie = format!( + "fc_user_session={session_id}; HttpOnly; SameSite=Strict; Path=/; \ + Max-Age=86400" + ); + return ( + [(axum::http::header::SET_COOKIE, cookie)], + Redirect::to("/"), + ) .into_response(); - }, + } else { + 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(); } } @@ -1099,38 +1137,34 @@ async fn login_action( 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(), - }); + if let Ok(Some(api_key)) = fc_common::repo::api_keys::get_by_hash(&state.pool, &key_hash).await { + 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}")), - ) + let cookie = format!( + "fc_session={session_id}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400" + ); + ( + [(axum::http::header::SET_COOKIE, cookie)], + Redirect::to("/"), + ) .into_response() - }, + } else { + 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 { @@ -1207,6 +1241,168 @@ async fn logout_action( .into_response() } +async fn users_page( + State(state): State, + Query(params): Query, + extensions: Extensions, +) -> Result, axum::response::Response> { + // Only admins can view user list (contains PII like emails) + if !is_admin(&extensions) { + return Err(axum::response::Redirect::to("/").into_response()); + } + + let limit = params.limit.unwrap_or(50).clamp(1, 200); + let offset = params.offset.unwrap_or(0).max(0); + + let users_list = fc_common::repo::users::list(&state.pool, limit, offset) + .await + .unwrap_or_default(); + let total = fc_common::repo::users::count(&state.pool) + .await + .unwrap_or(0); + + let users: Vec = users_list + .into_iter() + .map(|u| { + let user_type = match u.user_type { + fc_common::models::UserType::Local => "Local", + fc_common::models::UserType::Github => "GitHub", + fc_common::models::UserType::Google => "Google", + }; + UserView { + id: u.id, + username: u.username, + email: u.email, + role: u.role, + user_type: user_type.to_string(), + enabled: u.enabled, + last_login_at: u + .last_login_at.map_or_else(|| "Never".to_string(), |t| t.format("%Y-%m-%d %H:%M").to_string()), + } + }) + .collect(); + + let total_pages = (total + limit - 1) / limit.max(1); + let page = offset / limit.max(1) + 1; + + let tmpl = UsersTemplate { + users, + limit, + has_prev: offset > 0, + has_next: offset + limit < total, + prev_offset: (offset - limit).max(0), + next_offset: offset + limit, + page, + total_pages, + is_admin: true, // Already checked above + auth_name: auth_name(&extensions), + }; + Ok(Html( + tmpl + .render() + .unwrap_or_else(|e| format!("Template error: {e}")), + )) +} + +async fn starred_page( + State(state): State, + extensions: Extensions, +) -> Html { + // Check if user is logged in via session + let user = extensions.get::().cloned(); + let is_logged_in = user.is_some(); + + let starred_jobs = if let Some(ref u) = user { + let starred = + fc_common::repo::starred_jobs::list_for_user(&state.pool, u.id, 100, 0) + .await + .unwrap_or_default(); + + let mut views = Vec::new(); + for s in starred { + // Get project name + let project_name = + fc_common::repo::projects::get(&state.pool, s.project_id) + .await.map_or_else(|_| "-".to_string(), |p| p.name); + + // Get jobset name + let jobset_name = if let Some(js_id) = s.jobset_id { + fc_common::repo::jobsets::get(&state.pool, js_id) + .await.map_or_else(|_| "-".to_string(), |j| j.name) + } else { + "-".to_string() + }; + + // Get latest build for this job, filtered by jobset context + let (status_text, status_class, latest_build_id) = + if let Some(js_id) = s.jobset_id { + // Get latest evaluation for this jobset to find relevant builds + let evals = fc_common::repo::evaluations::list_filtered( + &state.pool, + Some(js_id), + None, + 1, + 0, + ) + .await + .unwrap_or_default(); + + let builds = if let Some(eval) = evals.first() { + fc_common::repo::builds::list_filtered( + &state.pool, + Some(eval.id), + None, + None, + Some(&s.job_name), + 1, + 0, + ) + .await + .unwrap_or_default() + } else { + Vec::new() + }; + + if let Some(build) = builds.first() { + let (text, class) = status_badge(&build.status); + (text, class, Some(build.id)) + } else { + ("No builds".to_string(), "pending".to_string(), None) + } + } else { + ("No builds".to_string(), "pending".to_string(), None) + }; + + views.push(StarredJobView { + id: s.id, + project_id: s.project_id, + project_name, + jobset_id: s.jobset_id, + jobset_name, + job_name: s.job_name, + status_text, + status_class, + latest_build_id, + }); + } + views + } else { + Vec::new() + }; + + let tmpl = StarredTemplate { + starred_jobs, + is_logged_in, + is_admin: is_admin(&extensions), + auth_name: auth_name(&extensions), + }; + Html( + tmpl + .render() + .unwrap_or_else(|e| format!("Template error: {e}")), + ) +} + pub fn router() -> Router { Router::new() .route("/login", get(login_page).post(login_action)) @@ -1223,4 +1419,6 @@ pub fn router() -> Router { .route("/queue", get(queue_page)) .route("/channels", get(channels_page)) .route("/admin", get(admin_page)) + .route("/users", get(users_page)) + .route("/starred", get(starred_page)) } diff --git a/crates/server/src/routes/projects.rs b/crates/server/src/routes/projects.rs index c80efa0..189ad9a 100644 --- a/crates/server/src/routes/projects.rs +++ b/crates/server/src/routes/projects.rs @@ -133,6 +133,7 @@ struct CreateJobsetBody { enabled: Option, flake_mode: Option, check_interval: Option, + state: Option, } async fn create_project_jobset( @@ -157,6 +158,7 @@ async fn create_project_jobset( check_interval: body.check_interval, branch: None, scheduling_shares: None, + state: body.state, }; input .validate() @@ -243,6 +245,7 @@ async fn setup_project( check_interval: None, branch: None, scheduling_shares: None, + state: None, }; input .validate() diff --git a/crates/server/templates/base.html b/crates/server/templates/base.html index 12d7bbd..f120e85 100644 --- a/crates/server/templates/base.html +++ b/crates/server/templates/base.html @@ -15,6 +15,8 @@ Builds Queue Channels + Starred + Users Admin