Files
GopherGate/src/dashboard/users.rs
hobokenchicken dd54c14ff8 feat(openai): implement Responses API streaming and proactive routing
This commit adds support for the OpenAI Responses API in both streaming and non-streaming modes. It also implements proactive routing for gpt-5 and codex models and cleans up unused 'session' variable warnings across the dashboard source files.
2026-03-06 20:16:43 +00:00

291 lines
9.9 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>> {
let (_session, _) = match auth::require_admin(&state, &headers).await {
Ok((session, new_token)) => (session, new_token),
Err(e) => 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>> {
let (_session, _) = match auth::require_admin(&state, &headers).await {
Ok((session, new_token)) => (session, new_token),
Err(e) => 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>> {
let (_session, _) = match auth::require_admin(&state, &headers).await {
Ok((session, new_token)) => (session, new_token),
Err(e) => 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((session, new_token)) => (session, new_token),
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)))
}
}
}