feat: add multi-user RBAC with admin/viewer roles and user management
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
This commit is contained in:
@@ -19,11 +19,12 @@ pub(super) async fn handle_login(
|
||||
) -> Json<ApiResponse<serde_json::Value>> {
|
||||
let pool = &state.app_state.db_pool;
|
||||
|
||||
let user_result =
|
||||
sqlx::query("SELECT username, password_hash, role, must_change_password FROM users WHERE username = ?")
|
||||
.bind(&payload.username)
|
||||
.fetch_optional(pool)
|
||||
.await;
|
||||
let user_result = sqlx::query(
|
||||
"SELECT username, password_hash, display_name, role, must_change_password FROM users WHERE username = ?",
|
||||
)
|
||||
.bind(&payload.username)
|
||||
.fetch_optional(pool)
|
||||
.await;
|
||||
|
||||
match user_result {
|
||||
Ok(Some(row)) => {
|
||||
@@ -31,6 +32,9 @@ pub(super) async fn handle_login(
|
||||
if bcrypt::verify(&payload.password, &hash).unwrap_or(false) {
|
||||
let username = row.get::<String, _>("username");
|
||||
let role = row.get::<String, _>("role");
|
||||
let display_name = row
|
||||
.get::<Option<String>, _>("display_name")
|
||||
.unwrap_or_else(|| username.clone());
|
||||
let must_change_password = row.get::<bool, _>("must_change_password");
|
||||
let token = state
|
||||
.session_manager
|
||||
@@ -41,7 +45,7 @@ pub(super) async fn handle_login(
|
||||
"must_change_password": must_change_password,
|
||||
"user": {
|
||||
"username": username,
|
||||
"name": "Administrator",
|
||||
"name": display_name,
|
||||
"role": role
|
||||
}
|
||||
})))
|
||||
@@ -69,11 +73,23 @@ pub(super) async fn handle_auth_status(
|
||||
if let Some(token) = token
|
||||
&& let Some(session) = state.session_manager.validate_session(token).await
|
||||
{
|
||||
// Look up display_name from DB
|
||||
let display_name = sqlx::query_scalar::<_, Option<String>>(
|
||||
"SELECT display_name FROM users WHERE username = ?",
|
||||
)
|
||||
.bind(&session.username)
|
||||
.fetch_optional(&state.app_state.db_pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| session.username.clone());
|
||||
|
||||
return Json(ApiResponse::success(serde_json::json!({
|
||||
"authenticated": true,
|
||||
"user": {
|
||||
"username": session.username,
|
||||
"name": "Administrator",
|
||||
"name": display_name,
|
||||
"role": session.role
|
||||
}
|
||||
})));
|
||||
@@ -90,12 +106,29 @@ pub(super) struct ChangePasswordRequest {
|
||||
|
||||
pub(super) async fn handle_change_password(
|
||||
State(state): State<DashboardState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(payload): Json<ChangePasswordRequest>,
|
||||
) -> Json<ApiResponse<serde_json::Value>> {
|
||||
let pool = &state.app_state.db_pool;
|
||||
|
||||
// For now, always change 'admin' user
|
||||
let user_result = sqlx::query("SELECT password_hash FROM users WHERE username = 'admin'")
|
||||
// Extract the authenticated user from the session token
|
||||
let token = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "));
|
||||
|
||||
let session = match token {
|
||||
Some(t) => state.session_manager.validate_session(t).await,
|
||||
None => None,
|
||||
};
|
||||
|
||||
let username = match session {
|
||||
Some(s) => s.username,
|
||||
None => return Json(ApiResponse::error("Not authenticated".to_string())),
|
||||
};
|
||||
|
||||
let user_result = sqlx::query("SELECT password_hash FROM users WHERE username = ?")
|
||||
.bind(&username)
|
||||
.fetch_one(pool)
|
||||
.await;
|
||||
|
||||
@@ -109,9 +142,10 @@ pub(super) async fn handle_change_password(
|
||||
};
|
||||
|
||||
let update_result = sqlx::query(
|
||||
"UPDATE users SET password_hash = ?, must_change_password = FALSE WHERE username = 'admin'",
|
||||
"UPDATE users SET password_hash = ?, must_change_password = FALSE WHERE username = ?",
|
||||
)
|
||||
.bind(new_hash)
|
||||
.bind(&username)
|
||||
.execute(pool)
|
||||
.await;
|
||||
|
||||
@@ -128,3 +162,51 @@ pub(super) async fn handle_change_password(
|
||||
Err(e) => Json(ApiResponse::error(format!("User not found: {}", e))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_logout(
|
||||
State(state): State<DashboardState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Json<ApiResponse<serde_json::Value>> {
|
||||
let token = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "));
|
||||
|
||||
if let Some(token) = token {
|
||||
state.session_manager.revoke_session(token).await;
|
||||
}
|
||||
|
||||
Json(ApiResponse::success(serde_json::json!({ "message": "Logged out" })))
|
||||
}
|
||||
|
||||
/// Helper: Extract and validate a session from the Authorization header.
|
||||
/// Returns the Session if valid, or an error response.
|
||||
pub(super) async fn extract_session(
|
||||
state: &DashboardState,
|
||||
headers: &axum::http::HeaderMap,
|
||||
) -> Result<super::sessions::Session, Json<ApiResponse<serde_json::Value>>> {
|
||||
let token = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "));
|
||||
|
||||
match token {
|
||||
Some(t) => match state.session_manager.validate_session(t).await {
|
||||
Some(session) => Ok(session),
|
||||
None => Err(Json(ApiResponse::error("Session expired or invalid".to_string()))),
|
||||
},
|
||||
None => Err(Json(ApiResponse::error("Not authenticated".to_string()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: Extract session and require admin role.
|
||||
pub(super) async fn require_admin(
|
||||
state: &DashboardState,
|
||||
headers: &axum::http::HeaderMap,
|
||||
) -> Result<super::sessions::Session, Json<ApiResponse<serde_json::Value>>> {
|
||||
let session = extract_session(state, headers).await?;
|
||||
if session.role != "admin" {
|
||||
return Err(Json(ApiResponse::error("Admin access required".to_string())));
|
||||
}
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
@@ -85,8 +85,13 @@ pub(super) async fn handle_get_clients(State(state): State<DashboardState>) -> J
|
||||
|
||||
pub(super) async fn handle_create_client(
|
||||
State(state): State<DashboardState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(payload): Json<CreateClientRequest>,
|
||||
) -> Json<ApiResponse<serde_json::Value>> {
|
||||
if let Err(e) = super::auth::require_admin(&state, &headers).await {
|
||||
return e;
|
||||
}
|
||||
|
||||
let pool = &state.app_state.db_pool;
|
||||
|
||||
let client_id = payload
|
||||
@@ -189,9 +194,14 @@ pub(super) async fn handle_get_client(
|
||||
|
||||
pub(super) async fn handle_update_client(
|
||||
State(state): State<DashboardState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Json(payload): Json<UpdateClientPayload>,
|
||||
) -> Json<ApiResponse<serde_json::Value>> {
|
||||
if let Err(e) = super::auth::require_admin(&state, &headers).await {
|
||||
return e;
|
||||
}
|
||||
|
||||
let pool = &state.app_state.db_pool;
|
||||
|
||||
// Build dynamic UPDATE query from provided fields
|
||||
@@ -281,8 +291,13 @@ pub(super) async fn handle_update_client(
|
||||
|
||||
pub(super) async fn handle_delete_client(
|
||||
State(state): State<DashboardState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> Json<ApiResponse<serde_json::Value>> {
|
||||
if let Err(e) = super::auth::require_admin(&state, &headers).await {
|
||||
return e;
|
||||
}
|
||||
|
||||
let pool = &state.app_state.db_pool;
|
||||
|
||||
// Don't allow deleting the default client
|
||||
@@ -418,9 +433,14 @@ pub(super) struct CreateTokenRequest {
|
||||
|
||||
pub(super) async fn handle_create_client_token(
|
||||
State(state): State<DashboardState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Json(payload): Json<CreateTokenRequest>,
|
||||
) -> Json<ApiResponse<serde_json::Value>> {
|
||||
if let Err(e) = super::auth::require_admin(&state, &headers).await {
|
||||
return e;
|
||||
}
|
||||
|
||||
let pool = &state.app_state.db_pool;
|
||||
|
||||
// Verify client exists
|
||||
@@ -462,8 +482,13 @@ pub(super) async fn handle_create_client_token(
|
||||
|
||||
pub(super) async fn handle_delete_client_token(
|
||||
State(state): State<DashboardState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Path((client_id, token_id)): Path<(String, i64)>,
|
||||
) -> Json<ApiResponse<serde_json::Value>> {
|
||||
if let Err(e) = super::auth::require_admin(&state, &headers).await {
|
||||
return e;
|
||||
}
|
||||
|
||||
let pool = &state.app_state.db_pool;
|
||||
|
||||
let result = sqlx::query("DELETE FROM client_tokens WHERE id = ? AND client_id = ?")
|
||||
|
||||
@@ -7,6 +7,7 @@ mod providers;
|
||||
pub mod sessions;
|
||||
mod system;
|
||||
mod usage;
|
||||
mod users;
|
||||
mod websocket;
|
||||
|
||||
use axum::{
|
||||
@@ -67,7 +68,16 @@ pub fn router(state: AppState) -> Router {
|
||||
// API endpoints
|
||||
.route("/api/auth/login", post(auth::handle_login))
|
||||
.route("/api/auth/status", get(auth::handle_auth_status))
|
||||
.route("/api/auth/logout", post(auth::handle_logout))
|
||||
.route("/api/auth/change-password", post(auth::handle_change_password))
|
||||
.route(
|
||||
"/api/users",
|
||||
get(users::handle_get_users).post(users::handle_create_user),
|
||||
)
|
||||
.route(
|
||||
"/api/users/{id}",
|
||||
put(users::handle_update_user).delete(users::handle_delete_user),
|
||||
)
|
||||
.route("/api/usage/summary", get(usage::handle_usage_summary))
|
||||
.route("/api/usage/time-series", get(usage::handle_time_series))
|
||||
.route("/api/usage/clients", get(usage::handle_clients_usage))
|
||||
|
||||
@@ -152,9 +152,14 @@ pub(super) async fn handle_get_models(
|
||||
|
||||
pub(super) async fn handle_update_model(
|
||||
State(state): State<DashboardState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Json(payload): Json<UpdateModelRequest>,
|
||||
) -> Json<ApiResponse<serde_json::Value>> {
|
||||
if let Err(e) = super::auth::require_admin(&state, &headers).await {
|
||||
return e;
|
||||
}
|
||||
|
||||
let pool = &state.app_state.db_pool;
|
||||
|
||||
// Find provider_id for this model in registry
|
||||
|
||||
@@ -238,9 +238,14 @@ pub(super) async fn handle_get_provider(
|
||||
|
||||
pub(super) async fn handle_update_provider(
|
||||
State(state): State<DashboardState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Path(name): Path<String>,
|
||||
Json(payload): Json<UpdateProviderRequest>,
|
||||
) -> Json<ApiResponse<serde_json::Value>> {
|
||||
if let Err(e) = super::auth::require_admin(&state, &headers).await {
|
||||
return e;
|
||||
}
|
||||
|
||||
let pool = &state.app_state.db_pool;
|
||||
|
||||
// Update or insert into database
|
||||
|
||||
@@ -275,7 +275,14 @@ pub(super) async fn handle_system_logs(State(state): State<DashboardState>) -> J
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_system_backup(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
||||
pub(super) async fn handle_system_backup(
|
||||
State(state): State<DashboardState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Json<ApiResponse<serde_json::Value>> {
|
||||
if let Err(e) = super::auth::require_admin(&state, &headers).await {
|
||||
return e;
|
||||
}
|
||||
|
||||
let pool = &state.app_state.db_pool;
|
||||
let backup_id = format!("backup-{}", chrono::Utc::now().timestamp());
|
||||
let backup_path = format!("data/{}.db", backup_id);
|
||||
@@ -331,8 +338,13 @@ pub(super) async fn handle_get_settings(State(state): State<DashboardState>) ->
|
||||
}
|
||||
|
||||
pub(super) async fn handle_update_settings(
|
||||
State(_state): State<DashboardState>,
|
||||
State(state): State<DashboardState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Json<ApiResponse<serde_json::Value>> {
|
||||
if let Err(e) = super::auth::require_admin(&state, &headers).await {
|
||||
return e;
|
||||
}
|
||||
|
||||
Json(ApiResponse::error(
|
||||
"Changing settings at runtime is not yet supported. Please update your config file and restart the server."
|
||||
.to_string(),
|
||||
|
||||
287
src/dashboard/users.rs
Normal file
287
src/dashboard/users.rs
Normal file
@@ -0,0 +1,287 @@
|
||||
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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,7 @@ async fn run_migrations(pool: &DbPool) -> Result<()> {
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
role TEXT DEFAULT 'admin',
|
||||
must_change_password BOOLEAN DEFAULT FALSE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
@@ -153,6 +154,11 @@ async fn run_migrations(pool: &DbPool) -> Result<()> {
|
||||
.execute(pool)
|
||||
.await;
|
||||
|
||||
// Add display_name column if it doesn't exist (migration for existing DBs)
|
||||
let _ = sqlx::query("ALTER TABLE users ADD COLUMN display_name TEXT")
|
||||
.execute(pool)
|
||||
.await;
|
||||
|
||||
// Add cache token columns if they don't exist (migration for existing DBs)
|
||||
let _ = sqlx::query("ALTER TABLE llm_requests ADD COLUMN cache_read_tokens INTEGER DEFAULT 0")
|
||||
.execute(pool)
|
||||
|
||||
Reference in New Issue
Block a user