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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user