Add complete multi-user support with role-based access control: Backend: - Add users CRUD endpoints (GET/POST/PUT/DELETE /api/users) with admin-only guards - Add display_name column to users table with ALTER TABLE migration - Fix auth to use session-based user identity (not hardcoded 'admin') - Add POST /api/auth/logout to revoke server-side sessions - Add require_admin() and extract_session() helpers for clean RBAC - Guard all mutating endpoints (clients, providers, models, settings, backup) Frontend: - Add Users management page with create/edit/reset-password/delete modals - Add role gating: hide edit/delete buttons for viewers on clients, providers, models - Settings page hides auth tokens and admin actions for viewers - Logout now revokes server session before clearing localStorage - Sidebar shows real display_name and formatted role (Administrator/Viewer) - Fix sidebar header: single logo with onerror fallback, renamed to 'LLM Proxy' - Add badge and btn-action CSS classes for role pills and action buttons - Bump cache-bust to v=7
288 lines
9.6 KiB
Rust
288 lines
9.6 KiB
Rust
use axum::{
|
|
extract::{Path, State},
|
|
response::Json,
|
|
};
|
|
use serde::Deserialize;
|
|
use sqlx::Row;
|
|
use tracing::warn;
|
|
|
|
use super::{ApiResponse, DashboardState, auth};
|
|
|
|
// ── User management endpoints (admin-only) ──────────────────────────
|
|
|
|
pub(super) async fn handle_get_users(
|
|
State(state): State<DashboardState>,
|
|
headers: axum::http::HeaderMap,
|
|
) -> Json<ApiResponse<serde_json::Value>> {
|
|
if let Err(e) = auth::require_admin(&state, &headers).await {
|
|
return e;
|
|
}
|
|
|
|
let pool = &state.app_state.db_pool;
|
|
|
|
let result = sqlx::query(
|
|
"SELECT id, username, display_name, role, must_change_password, created_at FROM users ORDER BY created_at ASC",
|
|
)
|
|
.fetch_all(pool)
|
|
.await;
|
|
|
|
match result {
|
|
Ok(rows) => {
|
|
let users: Vec<serde_json::Value> = rows
|
|
.into_iter()
|
|
.map(|row| {
|
|
let username: String = row.get("username");
|
|
let display_name: Option<String> = row.get("display_name");
|
|
serde_json::json!({
|
|
"id": row.get::<i64, _>("id"),
|
|
"username": &username,
|
|
"display_name": display_name.as_deref().unwrap_or(&username),
|
|
"role": row.get::<String, _>("role"),
|
|
"must_change_password": row.get::<bool, _>("must_change_password"),
|
|
"created_at": row.get::<chrono::DateTime<chrono::Utc>, _>("created_at"),
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
Json(ApiResponse::success(serde_json::json!(users)))
|
|
}
|
|
Err(e) => {
|
|
warn!("Failed to fetch users: {}", e);
|
|
Json(ApiResponse::error("Failed to fetch users".to_string()))
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub(super) struct CreateUserRequest {
|
|
pub(super) username: String,
|
|
pub(super) password: String,
|
|
pub(super) display_name: Option<String>,
|
|
pub(super) role: Option<String>,
|
|
}
|
|
|
|
pub(super) async fn handle_create_user(
|
|
State(state): State<DashboardState>,
|
|
headers: axum::http::HeaderMap,
|
|
Json(payload): Json<CreateUserRequest>,
|
|
) -> Json<ApiResponse<serde_json::Value>> {
|
|
if let Err(e) = auth::require_admin(&state, &headers).await {
|
|
return e;
|
|
}
|
|
|
|
let pool = &state.app_state.db_pool;
|
|
|
|
// Validate role
|
|
let role = payload.role.as_deref().unwrap_or("viewer");
|
|
if role != "admin" && role != "viewer" {
|
|
return Json(ApiResponse::error("Role must be 'admin' or 'viewer'".to_string()));
|
|
}
|
|
|
|
// Validate username
|
|
let username = payload.username.trim();
|
|
if username.is_empty() || username.len() > 64 {
|
|
return Json(ApiResponse::error("Username must be 1-64 characters".to_string()));
|
|
}
|
|
|
|
// Validate password
|
|
if payload.password.len() < 4 {
|
|
return Json(ApiResponse::error("Password must be at least 4 characters".to_string()));
|
|
}
|
|
|
|
let password_hash = match bcrypt::hash(&payload.password, 12) {
|
|
Ok(h) => h,
|
|
Err(_) => return Json(ApiResponse::error("Failed to hash password".to_string())),
|
|
};
|
|
|
|
let result = sqlx::query(
|
|
r#"
|
|
INSERT INTO users (username, password_hash, display_name, role, must_change_password)
|
|
VALUES (?, ?, ?, ?, TRUE)
|
|
RETURNING id, username, display_name, role, must_change_password, created_at
|
|
"#,
|
|
)
|
|
.bind(username)
|
|
.bind(&password_hash)
|
|
.bind(&payload.display_name)
|
|
.bind(role)
|
|
.fetch_one(pool)
|
|
.await;
|
|
|
|
match result {
|
|
Ok(row) => {
|
|
let uname: String = row.get("username");
|
|
let display_name: Option<String> = row.get("display_name");
|
|
Json(ApiResponse::success(serde_json::json!({
|
|
"id": row.get::<i64, _>("id"),
|
|
"username": &uname,
|
|
"display_name": display_name.as_deref().unwrap_or(&uname),
|
|
"role": row.get::<String, _>("role"),
|
|
"must_change_password": row.get::<bool, _>("must_change_password"),
|
|
"created_at": row.get::<chrono::DateTime<chrono::Utc>, _>("created_at"),
|
|
})))
|
|
}
|
|
Err(e) => {
|
|
let msg = if e.to_string().contains("UNIQUE") {
|
|
format!("Username '{}' already exists", username)
|
|
} else {
|
|
format!("Failed to create user: {}", e)
|
|
};
|
|
warn!("Failed to create user: {}", e);
|
|
Json(ApiResponse::error(msg))
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub(super) struct UpdateUserRequest {
|
|
pub(super) display_name: Option<String>,
|
|
pub(super) role: Option<String>,
|
|
pub(super) password: Option<String>,
|
|
pub(super) must_change_password: Option<bool>,
|
|
}
|
|
|
|
pub(super) async fn handle_update_user(
|
|
State(state): State<DashboardState>,
|
|
headers: axum::http::HeaderMap,
|
|
Path(id): Path<i64>,
|
|
Json(payload): Json<UpdateUserRequest>,
|
|
) -> Json<ApiResponse<serde_json::Value>> {
|
|
if let Err(e) = auth::require_admin(&state, &headers).await {
|
|
return e;
|
|
}
|
|
|
|
let pool = &state.app_state.db_pool;
|
|
|
|
// Validate role if provided
|
|
if let Some(ref role) = payload.role {
|
|
if role != "admin" && role != "viewer" {
|
|
return Json(ApiResponse::error("Role must be 'admin' or 'viewer'".to_string()));
|
|
}
|
|
}
|
|
|
|
// Build dynamic update
|
|
let mut sets = Vec::new();
|
|
let mut string_binds: Vec<String> = Vec::new();
|
|
let mut has_password = false;
|
|
let mut has_must_change = false;
|
|
|
|
if let Some(ref display_name) = payload.display_name {
|
|
sets.push("display_name = ?");
|
|
string_binds.push(display_name.clone());
|
|
}
|
|
if let Some(ref role) = payload.role {
|
|
sets.push("role = ?");
|
|
string_binds.push(role.clone());
|
|
}
|
|
if let Some(ref password) = payload.password {
|
|
if password.len() < 4 {
|
|
return Json(ApiResponse::error("Password must be at least 4 characters".to_string()));
|
|
}
|
|
let hash = match bcrypt::hash(password, 12) {
|
|
Ok(h) => h,
|
|
Err(_) => return Json(ApiResponse::error("Failed to hash password".to_string())),
|
|
};
|
|
sets.push("password_hash = ?");
|
|
string_binds.push(hash);
|
|
has_password = true;
|
|
}
|
|
if let Some(mcp) = payload.must_change_password {
|
|
sets.push("must_change_password = ?");
|
|
has_must_change = true;
|
|
let _ = mcp; // used below in bind
|
|
}
|
|
|
|
if sets.is_empty() {
|
|
return Json(ApiResponse::error("No fields to update".to_string()));
|
|
}
|
|
|
|
let sql = format!("UPDATE users SET {} WHERE id = ?", sets.join(", "));
|
|
let mut query = sqlx::query(&sql);
|
|
|
|
for b in &string_binds {
|
|
query = query.bind(b);
|
|
}
|
|
if has_must_change {
|
|
query = query.bind(payload.must_change_password.unwrap());
|
|
}
|
|
let _ = has_password; // consumed above via string_binds
|
|
query = query.bind(id);
|
|
|
|
match query.execute(pool).await {
|
|
Ok(result) => {
|
|
if result.rows_affected() == 0 {
|
|
return Json(ApiResponse::error("User not found".to_string()));
|
|
}
|
|
// Fetch updated user
|
|
let row = sqlx::query(
|
|
"SELECT id, username, display_name, role, must_change_password, created_at FROM users WHERE id = ?",
|
|
)
|
|
.bind(id)
|
|
.fetch_one(pool)
|
|
.await;
|
|
|
|
match row {
|
|
Ok(row) => {
|
|
let uname: String = row.get("username");
|
|
let display_name: Option<String> = row.get("display_name");
|
|
Json(ApiResponse::success(serde_json::json!({
|
|
"id": row.get::<i64, _>("id"),
|
|
"username": &uname,
|
|
"display_name": display_name.as_deref().unwrap_or(&uname),
|
|
"role": row.get::<String, _>("role"),
|
|
"must_change_password": row.get::<bool, _>("must_change_password"),
|
|
"created_at": row.get::<chrono::DateTime<chrono::Utc>, _>("created_at"),
|
|
})))
|
|
}
|
|
Err(_) => Json(ApiResponse::success(serde_json::json!({ "message": "User updated" }))),
|
|
}
|
|
}
|
|
Err(e) => {
|
|
warn!("Failed to update user: {}", e);
|
|
Json(ApiResponse::error(format!("Failed to update user: {}", e)))
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) async fn handle_delete_user(
|
|
State(state): State<DashboardState>,
|
|
headers: axum::http::HeaderMap,
|
|
Path(id): Path<i64>,
|
|
) -> Json<ApiResponse<serde_json::Value>> {
|
|
let session = match auth::require_admin(&state, &headers).await {
|
|
Ok(s) => s,
|
|
Err(e) => return e,
|
|
};
|
|
|
|
let pool = &state.app_state.db_pool;
|
|
|
|
// Don't allow deleting yourself
|
|
let target_username: Option<String> =
|
|
sqlx::query_scalar::<_, String>("SELECT username FROM users WHERE id = ?")
|
|
.bind(id)
|
|
.fetch_optional(pool)
|
|
.await
|
|
.unwrap_or(None);
|
|
|
|
match target_username {
|
|
None => return Json(ApiResponse::error("User not found".to_string())),
|
|
Some(ref uname) if uname == &session.username => {
|
|
return Json(ApiResponse::error("Cannot delete your own account".to_string()));
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
let result = sqlx::query("DELETE FROM users WHERE id = ?")
|
|
.bind(id)
|
|
.execute(pool)
|
|
.await;
|
|
|
|
match result {
|
|
Ok(_) => Json(ApiResponse::success(serde_json::json!({ "message": "User deleted" }))),
|
|
Err(e) => {
|
|
warn!("Failed to delete user: {}", e);
|
|
Json(ApiResponse::error(format!("Failed to delete user: {}", e)))
|
|
}
|
|
}
|
|
}
|