fc-server: polish user management; add starred jobs UI

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ie3034d4a66a55cb71c23ba25b40d678f6a6a6964
This commit is contained in:
raf 2026-02-08 02:13:48 +03:00
commit 865dd39a07
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
6 changed files with 540 additions and 74 deletions

View file

@ -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<Uuid>,
jobset_name: String,
job_name: String,
status_text: String,
status_class: String,
latest_build_id: Option<Uuid>,
}
fn format_duration(
started: Option<&chrono::DateTime<chrono::Utc>>,
completed: Option<&chrono::DateTime<chrono::Utc>>,
@ -180,8 +202,7 @@ fn eval_badge(s: &EvaluationStatus) -> (String, String) {
fn is_admin(extensions: &Extensions) -> bool {
extensions
.get::<ApiKey>()
.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<String>,
}
#[derive(Template)]
#[template(path = "users.html")]
struct UsersTemplate {
users: Vec<UserView>,
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<StarredJobView>,
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<AppState>,
Query(params): Query<PageParams>,
extensions: Extensions,
) -> Result<Html<String>, 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<UserView> = 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<AppState>,
extensions: Extensions,
) -> Html<String> {
// Check if user is logged in via session
let user = extensions.get::<fc_common::models::User>().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<AppState> {
Router::new()
.route("/login", get(login_page).post(login_action))
@ -1223,4 +1419,6 @@ pub fn router() -> Router<AppState> {
.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))
}

View file

@ -133,6 +133,7 @@ struct CreateJobsetBody {
enabled: Option<bool>,
flake_mode: Option<bool>,
check_interval: Option<i32>,
state: Option<fc_common::models::JobsetState>,
}
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()