fc-server: polish user management; add starred jobs UI
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ie3034d4a66a55cb71c23ba25b40d678f6a6a6964
This commit is contained in:
parent
865b2f5f66
commit
865dd39a07
6 changed files with 540 additions and 74 deletions
|
|
@ -7,7 +7,7 @@ use axum::{
|
||||||
response::{Html, IntoResponse, Redirect, Response},
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
routing::get,
|
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 sha2::{Digest, Sha256};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
@ -74,6 +74,28 @@ struct ApiKeyView {
|
||||||
last_used_at: String,
|
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(
|
fn format_duration(
|
||||||
started: Option<&chrono::DateTime<chrono::Utc>>,
|
started: Option<&chrono::DateTime<chrono::Utc>>,
|
||||||
completed: 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 {
|
fn is_admin(extensions: &Extensions) -> bool {
|
||||||
extensions
|
extensions
|
||||||
.get::<ApiKey>()
|
.get::<ApiKey>()
|
||||||
.map(|k| k.role == "admin")
|
.is_some_and(|k| k.role == "admin")
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn auth_name(extensions: &Extensions) -> String {
|
fn auth_name(extensions: &Extensions) -> String {
|
||||||
|
|
@ -338,6 +359,31 @@ struct LoginTemplate {
|
||||||
error: Option<String>,
|
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 ---
|
// --- Handlers ---
|
||||||
|
|
||||||
async fn home(
|
async fn home(
|
||||||
|
|
@ -626,9 +672,7 @@ async fn evaluations_page(
|
||||||
Ok(js) => {
|
Ok(js) => {
|
||||||
let pname =
|
let pname =
|
||||||
fc_common::repo::projects::get(&state.pool, js.project_id)
|
fc_common::repo::projects::get(&state.pool, js.project_id)
|
||||||
.await
|
.await.map_or_else(|_| "-".to_string(), |p| p.name);
|
||||||
.map(|p| p.name)
|
|
||||||
.unwrap_or_else(|_| "-".to_string());
|
|
||||||
(js.name, pname)
|
(js.name, pname)
|
||||||
},
|
},
|
||||||
Err(_) => ("-".to_string(), "-".to_string()),
|
Err(_) => ("-".to_string(), "-".to_string()),
|
||||||
|
|
@ -978,9 +1022,7 @@ async fn admin_page(
|
||||||
role: k.role,
|
role: k.role,
|
||||||
created_at: k.created_at.format("%Y-%m-%d %H:%M").to_string(),
|
created_at: k.created_at.format("%Y-%m-%d %H:%M").to_string(),
|
||||||
last_used_at: k
|
last_used_at: k
|
||||||
.last_used_at
|
.last_used_at.map_or_else(|| "Never".to_string(), |t| t.format("%Y-%m-%d %H:%M").to_string()),
|
||||||
.map(|t| t.format("%Y-%m-%d %H:%M").to_string())
|
|
||||||
.unwrap_or_else(|| "Never".to_string()),
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -1044,39 +1086,35 @@ async fn login_action(
|
||||||
password: password.clone(),
|
password: password.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
match fc_common::repo::users::authenticate(&state.pool, &creds).await {
|
if let Ok(user) = fc_common::repo::users::authenticate(&state.pool, &creds).await {
|
||||||
Ok(user) => {
|
let session_id = Uuid::new_v4().to_string();
|
||||||
let session_id = Uuid::new_v4().to_string();
|
state
|
||||||
state
|
.sessions
|
||||||
.sessions
|
.insert(session_id.clone(), crate::state::SessionData {
|
||||||
.insert(session_id.clone(), crate::state::SessionData {
|
api_key: None,
|
||||||
api_key: None,
|
user: Some(user),
|
||||||
user: Some(user),
|
created_at: std::time::Instant::now(),
|
||||||
created_at: std::time::Instant::now(),
|
});
|
||||||
});
|
|
||||||
|
|
||||||
let cookie = format!(
|
let cookie = format!(
|
||||||
"fc_user_session={}; HttpOnly; SameSite=Strict; Path=/; \
|
"fc_user_session={session_id}; HttpOnly; SameSite=Strict; Path=/; \
|
||||||
Max-Age=86400",
|
Max-Age=86400"
|
||||||
session_id
|
);
|
||||||
);
|
return (
|
||||||
return (
|
[(axum::http::header::SET_COOKIE, cookie)],
|
||||||
[(axum::http::header::SET_COOKIE, cookie)],
|
Redirect::to("/"),
|
||||||
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();
|
.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());
|
hasher.update(token.as_bytes());
|
||||||
let key_hash = hex::encode(hasher.finalize());
|
let key_hash = hex::encode(hasher.finalize());
|
||||||
|
|
||||||
match fc_common::repo::api_keys::get_by_hash(&state.pool, &key_hash).await {
|
if let Ok(Some(api_key)) = 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();
|
||||||
let session_id = Uuid::new_v4().to_string();
|
state
|
||||||
state
|
.sessions
|
||||||
.sessions
|
.insert(session_id.clone(), crate::state::SessionData {
|
||||||
.insert(session_id.clone(), crate::state::SessionData {
|
api_key: Some(api_key),
|
||||||
api_key: Some(api_key),
|
user: None,
|
||||||
user: None,
|
created_at: std::time::Instant::now(),
|
||||||
created_at: std::time::Instant::now(),
|
});
|
||||||
});
|
|
||||||
|
|
||||||
let cookie = format!(
|
let cookie = format!(
|
||||||
"fc_session={}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400",
|
"fc_session={session_id}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400"
|
||||||
session_id
|
);
|
||||||
);
|
(
|
||||||
(
|
[(axum::http::header::SET_COOKIE, cookie)],
|
||||||
[(axum::http::header::SET_COOKIE, cookie)],
|
Redirect::to("/"),
|
||||||
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()
|
.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 {
|
} else {
|
||||||
let tmpl = LoginTemplate {
|
let tmpl = LoginTemplate {
|
||||||
|
|
@ -1207,6 +1241,168 @@ async fn logout_action(
|
||||||
.into_response()
|
.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> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/login", get(login_page).post(login_action))
|
.route("/login", get(login_page).post(login_action))
|
||||||
|
|
@ -1223,4 +1419,6 @@ pub fn router() -> Router<AppState> {
|
||||||
.route("/queue", get(queue_page))
|
.route("/queue", get(queue_page))
|
||||||
.route("/channels", get(channels_page))
|
.route("/channels", get(channels_page))
|
||||||
.route("/admin", get(admin_page))
|
.route("/admin", get(admin_page))
|
||||||
|
.route("/users", get(users_page))
|
||||||
|
.route("/starred", get(starred_page))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,7 @@ struct CreateJobsetBody {
|
||||||
enabled: Option<bool>,
|
enabled: Option<bool>,
|
||||||
flake_mode: Option<bool>,
|
flake_mode: Option<bool>,
|
||||||
check_interval: Option<i32>,
|
check_interval: Option<i32>,
|
||||||
|
state: Option<fc_common::models::JobsetState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_project_jobset(
|
async fn create_project_jobset(
|
||||||
|
|
@ -157,6 +158,7 @@ async fn create_project_jobset(
|
||||||
check_interval: body.check_interval,
|
check_interval: body.check_interval,
|
||||||
branch: None,
|
branch: None,
|
||||||
scheduling_shares: None,
|
scheduling_shares: None,
|
||||||
|
state: body.state,
|
||||||
};
|
};
|
||||||
input
|
input
|
||||||
.validate()
|
.validate()
|
||||||
|
|
@ -243,6 +245,7 @@ async fn setup_project(
|
||||||
check_interval: None,
|
check_interval: None,
|
||||||
branch: None,
|
branch: None,
|
||||||
scheduling_shares: None,
|
scheduling_shares: None,
|
||||||
|
state: None,
|
||||||
};
|
};
|
||||||
input
|
input
|
||||||
.validate()
|
.validate()
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@
|
||||||
<a href="/builds">Builds</a>
|
<a href="/builds">Builds</a>
|
||||||
<a href="/queue">Queue</a>
|
<a href="/queue">Queue</a>
|
||||||
<a href="/channels">Channels</a>
|
<a href="/channels">Channels</a>
|
||||||
|
<a href="/starred">Starred</a>
|
||||||
|
<a href="/users">Users</a>
|
||||||
<a href="/admin">Admin</a>
|
<a href="/admin">Admin</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-auth">
|
<div class="nav-auth">
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,23 @@
|
||||||
<dd><code>{{ jobset.nix_expression }}</code></dd>
|
<dd><code>{{ jobset.nix_expression }}</code></dd>
|
||||||
<dt>Flake mode</dt>
|
<dt>Flake mode</dt>
|
||||||
<dd>{% if jobset.flake_mode %}Yes{% else %}No{% endif %}</dd>
|
<dd>{% if jobset.flake_mode %}Yes{% else %}No{% endif %}</dd>
|
||||||
<dt>Enabled</dt>
|
<dt>State</dt>
|
||||||
<dd>{% if jobset.enabled %}Yes{% else %}No{% endif %}</dd>
|
<dd>
|
||||||
|
{% match jobset.state %}
|
||||||
|
{% when fc_common::models::JobsetState::Disabled %}
|
||||||
|
<span class="badge badge-cancelled">Disabled</span>
|
||||||
|
{% when fc_common::models::JobsetState::Enabled %}
|
||||||
|
<span class="badge badge-completed">Enabled</span>
|
||||||
|
{% when fc_common::models::JobsetState::OneShot %}
|
||||||
|
<span class="badge badge-pending">One-Shot</span>
|
||||||
|
{% when fc_common::models::JobsetState::OneAtATime %}
|
||||||
|
<span class="badge badge-running">One-at-a-Time</span>
|
||||||
|
{% endmatch %}
|
||||||
|
</dd>
|
||||||
<dt>Check interval</dt>
|
<dt>Check interval</dt>
|
||||||
<dd>{{ jobset.check_interval }}s</dd>
|
<dd>{{ jobset.check_interval }}s</dd>
|
||||||
|
<dt>Last checked</dt>
|
||||||
|
<dd>{% if let Some(t) = jobset.last_checked_at %}{{ t.format("%Y-%m-%d %H:%M:%S") }}{% else %}Never{% endif %}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{% if !eval_summaries.is_empty() %}
|
{% if !eval_summaries.is_empty() %}
|
||||||
|
|
|
||||||
82
crates/server/templates/starred.html
Normal file
82
crates/server/templates/starred.html
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Starred Jobs - FC CI{% endblock %}
|
||||||
|
{% block auth %}
|
||||||
|
{% if !auth_name.is_empty() %}
|
||||||
|
<span class="auth-user">{{ auth_name }}</span>
|
||||||
|
<form method="POST" action="/logout"><button type="submit">Logout</button></form>
|
||||||
|
{% else %}
|
||||||
|
<a href="/login">Login</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Starred Jobs</h1>
|
||||||
|
|
||||||
|
{% if !is_logged_in %}
|
||||||
|
<div class="empty">
|
||||||
|
<div class="empty-title">Login required</div>
|
||||||
|
<div class="empty-hint">Please <a href="/login">login</a> to view your starred jobs.</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% if starred_jobs.is_empty() %}
|
||||||
|
<div class="empty">
|
||||||
|
<div class="empty-title">No starred jobs</div>
|
||||||
|
<div class="empty-hint">Star jobs from project or build pages to track them here.</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Project</th>
|
||||||
|
<th>Jobset</th>
|
||||||
|
<th>Job Name</th>
|
||||||
|
<th>Latest Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in starred_jobs %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/project/{{ s.project_id }}">{{ s.project_name }}</a></td>
|
||||||
|
<td>
|
||||||
|
{% if let Some(id) = s.jobset_id %}
|
||||||
|
<a href="/jobset/{{ id }}">{{ s.jobset_name }}</a>
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ s.job_name }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-{{ s.status_class }}">{{ s.status_text }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-small" onclick="unstar('{{ s.id }}')">Unstar</button>
|
||||||
|
{% if let Some(id) = s.latest_build_id %}
|
||||||
|
<a href="/build/{{ id }}" class="btn btn-small">View Build</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
async function unstar(id) {
|
||||||
|
if (!confirm('Remove this job from your starred list?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/me/starred/' + id, { method: 'DELETE' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
throw new Error(err.error || err.message || 'Failed to unstar');
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
} catch(err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
168
crates/server/templates/users.html
Normal file
168
crates/server/templates/users.html
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Users - FC CI{% endblock %}
|
||||||
|
{% block auth %}
|
||||||
|
{% if !auth_name.is_empty() %}
|
||||||
|
<span class="auth-user">{{ auth_name }}</span>
|
||||||
|
<form method="POST" action="/logout"><button type="submit">Logout</button></form>
|
||||||
|
{% else %}
|
||||||
|
<a href="/login">Login</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>User Management</h1>
|
||||||
|
|
||||||
|
{% if is_admin %}
|
||||||
|
<details>
|
||||||
|
<summary>Create User</summary>
|
||||||
|
<div class="form-card">
|
||||||
|
<form id="create-user-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="user-username">Username</label>
|
||||||
|
<input type="text" id="user-username" required pattern="[a-zA-Z0-9_-]+" minlength="3" maxlength="50">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="user-email">Email</label>
|
||||||
|
<input type="email" id="user-email" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="user-fullname">Full Name (optional)</label>
|
||||||
|
<input type="text" id="user-fullname">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="user-password">Password</label>
|
||||||
|
<input type="password" id="user-password" required minlength="8">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="user-role">Role</label>
|
||||||
|
<select id="user-role">
|
||||||
|
<option value="read-only" selected>read-only</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
<option value="create-projects">create-projects</option>
|
||||||
|
<option value="eval-jobset">eval-jobset</option>
|
||||||
|
<option value="cancel-build">cancel-build</option>
|
||||||
|
<option value="restart-jobs">restart-jobs</option>
|
||||||
|
<option value="bump-to-front">bump-to-front</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">Create User</button>
|
||||||
|
</form>
|
||||||
|
<div id="user-msg"></div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if users.is_empty() %}
|
||||||
|
<div class="empty">
|
||||||
|
<div class="empty-title">No users</div>
|
||||||
|
<div class="empty-hint">Create a user above to enable user authentication.</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Enabled</th>
|
||||||
|
<th>Last Login</th>
|
||||||
|
{% if is_admin %}<th>Actions</th>{% endif %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in users %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ u.username }}</td>
|
||||||
|
<td>{{ u.email }}</td>
|
||||||
|
<td><span class="badge badge-pending">{{ u.role }}</span></td>
|
||||||
|
<td>{{ u.user_type }}</td>
|
||||||
|
<td>{% if u.enabled %}Yes{% else %}No{% endif %}</td>
|
||||||
|
<td>{{ u.last_login_at }}</td>
|
||||||
|
{% if is_admin %}
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-small" onclick="toggleUser('{{ u.id }}', {{ !u.enabled }})">
|
||||||
|
{% if u.enabled %}Disable{% else %}Enable{% endif %}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger btn-small" onclick="deleteUser('{{ u.id }}')">Delete</button>
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="pagination">
|
||||||
|
{% if has_prev %}
|
||||||
|
<a href="/users?limit={{ limit }}&offset={{ prev_offset }}" class="btn btn-small">Previous</a>
|
||||||
|
{% endif %}
|
||||||
|
<span>Page {{ page }} of {{ total_pages }}</span>
|
||||||
|
{% if has_next %}
|
||||||
|
<a href="/users?limit={{ limit }}&offset={{ next_offset }}" class="btn btn-small">Next</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
{% if is_admin %}
|
||||||
|
<script>
|
||||||
|
document.getElementById('create-user-form')?.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const msg = document.getElementById('user-msg');
|
||||||
|
try {
|
||||||
|
const fullName = document.getElementById('user-fullname').value;
|
||||||
|
const res = await fetch('/api/v1/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: document.getElementById('user-username').value,
|
||||||
|
email: document.getElementById('user-email').value,
|
||||||
|
full_name: fullName || null,
|
||||||
|
password: document.getElementById('user-password').value,
|
||||||
|
role: document.getElementById('user-role').value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok) {
|
||||||
|
msg.innerHTML = '<div class="flash-message flash-success">User created successfully!</div>';
|
||||||
|
setTimeout(() => window.location.reload(), 1500);
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || data.message || 'Unknown error');
|
||||||
|
}
|
||||||
|
} catch(err) {
|
||||||
|
showError(msg, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
async function toggleUser(id, enable) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/users/' + id, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ enabled: enable }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
throw new Error(err.error || err.message || 'Failed to update user');
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
} catch(err) {
|
||||||
|
alert(escapeHtml(err.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function deleteUser(id) {
|
||||||
|
if (!confirm('Delete this user? This action cannot be undone.')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/users/' + id, { method: 'DELETE' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
throw new Error(err.error || err.message || 'Failed to delete user');
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
} catch(err) {
|
||||||
|
alert(escapeHtml(err.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue