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,8 +1086,7 @@ async fn login_action(
password: password.clone(),
};
match fc_common::repo::users::authenticate(&state.pool, &creds).await {
Ok(user) => {
if let Ok(user) = fc_common::repo::users::authenticate(&state.pool, &creds).await {
let session_id = Uuid::new_v4().to_string();
state
.sessions
@ -1056,17 +1097,15 @@ async fn login_action(
});
let cookie = format!(
"fc_user_session={}; HttpOnly; SameSite=Strict; Path=/; \
Max-Age=86400",
session_id
"fc_user_session={session_id}; HttpOnly; SameSite=Strict; Path=/; \
Max-Age=86400"
);
return (
[(axum::http::header::SET_COOKIE, cookie)],
Redirect::to("/"),
)
.into_response();
},
Err(_) => {
} else {
let tmpl = LoginTemplate {
error: Some("Invalid username or password".to_string()),
};
@ -1076,7 +1115,6 @@ async fn login_action(
.unwrap_or_else(|e| format!("Template error: {e}")),
)
.into_response();
},
}
}
@ -1099,8 +1137,7 @@ 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)) => {
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
@ -1111,16 +1148,14 @@ async fn login_action(
});
let cookie = format!(
"fc_session={}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400",
session_id
"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()),
};
@ -1130,7 +1165,6 @@ async fn login_action(
.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()

View file

@ -15,6 +15,8 @@
<a href="/builds">Builds</a>
<a href="/queue">Queue</a>
<a href="/channels">Channels</a>
<a href="/starred">Starred</a>
<a href="/users">Users</a>
<a href="/admin">Admin</a>
</div>
<div class="nav-auth">

View file

@ -15,10 +15,23 @@
<dd><code>{{ jobset.nix_expression }}</code></dd>
<dt>Flake mode</dt>
<dd>{% if jobset.flake_mode %}Yes{% else %}No{% endif %}</dd>
<dt>Enabled</dt>
<dd>{% if jobset.enabled %}Yes{% else %}No{% endif %}</dd>
<dt>State</dt>
<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>
<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>
{% if !eval_summaries.is_empty() %}

View 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 %}

View 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 %}