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>> {
|
) -> Json<ApiResponse<serde_json::Value>> {
|
||||||
let pool = &state.app_state.db_pool;
|
let pool = &state.app_state.db_pool;
|
||||||
|
|
||||||
let user_result =
|
let user_result = sqlx::query(
|
||||||
sqlx::query("SELECT username, password_hash, role, must_change_password FROM users WHERE username = ?")
|
"SELECT username, password_hash, display_name, role, must_change_password FROM users WHERE username = ?",
|
||||||
.bind(&payload.username)
|
)
|
||||||
.fetch_optional(pool)
|
.bind(&payload.username)
|
||||||
.await;
|
.fetch_optional(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
match user_result {
|
match user_result {
|
||||||
Ok(Some(row)) => {
|
Ok(Some(row)) => {
|
||||||
@@ -31,6 +32,9 @@ pub(super) async fn handle_login(
|
|||||||
if bcrypt::verify(&payload.password, &hash).unwrap_or(false) {
|
if bcrypt::verify(&payload.password, &hash).unwrap_or(false) {
|
||||||
let username = row.get::<String, _>("username");
|
let username = row.get::<String, _>("username");
|
||||||
let role = row.get::<String, _>("role");
|
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 must_change_password = row.get::<bool, _>("must_change_password");
|
||||||
let token = state
|
let token = state
|
||||||
.session_manager
|
.session_manager
|
||||||
@@ -41,7 +45,7 @@ pub(super) async fn handle_login(
|
|||||||
"must_change_password": must_change_password,
|
"must_change_password": must_change_password,
|
||||||
"user": {
|
"user": {
|
||||||
"username": username,
|
"username": username,
|
||||||
"name": "Administrator",
|
"name": display_name,
|
||||||
"role": role
|
"role": role
|
||||||
}
|
}
|
||||||
})))
|
})))
|
||||||
@@ -69,11 +73,23 @@ pub(super) async fn handle_auth_status(
|
|||||||
if let Some(token) = token
|
if let Some(token) = token
|
||||||
&& let Some(session) = state.session_manager.validate_session(token).await
|
&& 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!({
|
return Json(ApiResponse::success(serde_json::json!({
|
||||||
"authenticated": true,
|
"authenticated": true,
|
||||||
"user": {
|
"user": {
|
||||||
"username": session.username,
|
"username": session.username,
|
||||||
"name": "Administrator",
|
"name": display_name,
|
||||||
"role": session.role
|
"role": session.role
|
||||||
}
|
}
|
||||||
})));
|
})));
|
||||||
@@ -90,12 +106,29 @@ pub(super) struct ChangePasswordRequest {
|
|||||||
|
|
||||||
pub(super) async fn handle_change_password(
|
pub(super) async fn handle_change_password(
|
||||||
State(state): State<DashboardState>,
|
State(state): State<DashboardState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
Json(payload): Json<ChangePasswordRequest>,
|
Json(payload): Json<ChangePasswordRequest>,
|
||||||
) -> Json<ApiResponse<serde_json::Value>> {
|
) -> Json<ApiResponse<serde_json::Value>> {
|
||||||
let pool = &state.app_state.db_pool;
|
let pool = &state.app_state.db_pool;
|
||||||
|
|
||||||
// For now, always change 'admin' user
|
// Extract the authenticated user from the session token
|
||||||
let user_result = sqlx::query("SELECT password_hash FROM users WHERE username = 'admin'")
|
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)
|
.fetch_one(pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -109,9 +142,10 @@ pub(super) async fn handle_change_password(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let update_result = sqlx::query(
|
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(new_hash)
|
||||||
|
.bind(&username)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -128,3 +162,51 @@ pub(super) async fn handle_change_password(
|
|||||||
Err(e) => Json(ApiResponse::error(format!("User not found: {}", e))),
|
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(
|
pub(super) async fn handle_create_client(
|
||||||
State(state): State<DashboardState>,
|
State(state): State<DashboardState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
Json(payload): Json<CreateClientRequest>,
|
Json(payload): Json<CreateClientRequest>,
|
||||||
) -> Json<ApiResponse<serde_json::Value>> {
|
) -> 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 pool = &state.app_state.db_pool;
|
||||||
|
|
||||||
let client_id = payload
|
let client_id = payload
|
||||||
@@ -189,9 +194,14 @@ pub(super) async fn handle_get_client(
|
|||||||
|
|
||||||
pub(super) async fn handle_update_client(
|
pub(super) async fn handle_update_client(
|
||||||
State(state): State<DashboardState>,
|
State(state): State<DashboardState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(payload): Json<UpdateClientPayload>,
|
Json(payload): Json<UpdateClientPayload>,
|
||||||
) -> Json<ApiResponse<serde_json::Value>> {
|
) -> 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 pool = &state.app_state.db_pool;
|
||||||
|
|
||||||
// Build dynamic UPDATE query from provided fields
|
// 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(
|
pub(super) async fn handle_delete_client(
|
||||||
State(state): State<DashboardState>,
|
State(state): State<DashboardState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Json<ApiResponse<serde_json::Value>> {
|
) -> 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 pool = &state.app_state.db_pool;
|
||||||
|
|
||||||
// Don't allow deleting the default client
|
// Don't allow deleting the default client
|
||||||
@@ -418,9 +433,14 @@ pub(super) struct CreateTokenRequest {
|
|||||||
|
|
||||||
pub(super) async fn handle_create_client_token(
|
pub(super) async fn handle_create_client_token(
|
||||||
State(state): State<DashboardState>,
|
State(state): State<DashboardState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(payload): Json<CreateTokenRequest>,
|
Json(payload): Json<CreateTokenRequest>,
|
||||||
) -> Json<ApiResponse<serde_json::Value>> {
|
) -> 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 pool = &state.app_state.db_pool;
|
||||||
|
|
||||||
// Verify client exists
|
// Verify client exists
|
||||||
@@ -462,8 +482,13 @@ pub(super) async fn handle_create_client_token(
|
|||||||
|
|
||||||
pub(super) async fn handle_delete_client_token(
|
pub(super) async fn handle_delete_client_token(
|
||||||
State(state): State<DashboardState>,
|
State(state): State<DashboardState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
Path((client_id, token_id)): Path<(String, i64)>,
|
Path((client_id, token_id)): Path<(String, i64)>,
|
||||||
) -> Json<ApiResponse<serde_json::Value>> {
|
) -> 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 pool = &state.app_state.db_pool;
|
||||||
|
|
||||||
let result = sqlx::query("DELETE FROM client_tokens WHERE id = ? AND client_id = ?")
|
let result = sqlx::query("DELETE FROM client_tokens WHERE id = ? AND client_id = ?")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ mod providers;
|
|||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
mod system;
|
mod system;
|
||||||
mod usage;
|
mod usage;
|
||||||
|
mod users;
|
||||||
mod websocket;
|
mod websocket;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -67,7 +68,16 @@ pub fn router(state: AppState) -> Router {
|
|||||||
// API endpoints
|
// API endpoints
|
||||||
.route("/api/auth/login", post(auth::handle_login))
|
.route("/api/auth/login", post(auth::handle_login))
|
||||||
.route("/api/auth/status", get(auth::handle_auth_status))
|
.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/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/summary", get(usage::handle_usage_summary))
|
||||||
.route("/api/usage/time-series", get(usage::handle_time_series))
|
.route("/api/usage/time-series", get(usage::handle_time_series))
|
||||||
.route("/api/usage/clients", get(usage::handle_clients_usage))
|
.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(
|
pub(super) async fn handle_update_model(
|
||||||
State(state): State<DashboardState>,
|
State(state): State<DashboardState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(payload): Json<UpdateModelRequest>,
|
Json(payload): Json<UpdateModelRequest>,
|
||||||
) -> Json<ApiResponse<serde_json::Value>> {
|
) -> 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 pool = &state.app_state.db_pool;
|
||||||
|
|
||||||
// Find provider_id for this model in registry
|
// 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(
|
pub(super) async fn handle_update_provider(
|
||||||
State(state): State<DashboardState>,
|
State(state): State<DashboardState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
Path(name): Path<String>,
|
Path(name): Path<String>,
|
||||||
Json(payload): Json<UpdateProviderRequest>,
|
Json(payload): Json<UpdateProviderRequest>,
|
||||||
) -> Json<ApiResponse<serde_json::Value>> {
|
) -> 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 pool = &state.app_state.db_pool;
|
||||||
|
|
||||||
// Update or insert into database
|
// 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 pool = &state.app_state.db_pool;
|
||||||
let backup_id = format!("backup-{}", chrono::Utc::now().timestamp());
|
let backup_id = format!("backup-{}", chrono::Utc::now().timestamp());
|
||||||
let backup_path = format!("data/{}.db", backup_id);
|
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(
|
pub(super) async fn handle_update_settings(
|
||||||
State(_state): State<DashboardState>,
|
State(state): State<DashboardState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
) -> Json<ApiResponse<serde_json::Value>> {
|
) -> Json<ApiResponse<serde_json::Value>> {
|
||||||
|
if let Err(e) = super::auth::require_admin(&state, &headers).await {
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
Json(ApiResponse::error(
|
Json(ApiResponse::error(
|
||||||
"Changing settings at runtime is not yet supported. Please update your config file and restart the server."
|
"Changing settings at runtime is not yet supported. Please update your config file and restart the server."
|
||||||
.to_string(),
|
.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,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username TEXT UNIQUE NOT NULL,
|
username TEXT UNIQUE NOT NULL,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
|
display_name TEXT,
|
||||||
role TEXT DEFAULT 'admin',
|
role TEXT DEFAULT 'admin',
|
||||||
must_change_password BOOLEAN DEFAULT FALSE,
|
must_change_password BOOLEAN DEFAULT FALSE,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
@@ -153,6 +154,11 @@ async fn run_migrations(pool: &DbPool) -> Result<()> {
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await;
|
.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)
|
// 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")
|
let _ = sqlx::query("ALTER TABLE llm_requests ADD COLUMN cache_read_tokens INTEGER DEFAULT 0")
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
|
|||||||
@@ -840,6 +840,24 @@ body {
|
|||||||
border: 1px solid var(--bg3);
|
border: 1px solid var(--bg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Role / status badges */
|
||||||
|
.badge {
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success { background: rgba(184, 187, 38, 0.2); color: var(--green-light); }
|
||||||
|
.badge-info { background: rgba(69, 133, 136, 0.2); color: var(--blue-light); }
|
||||||
|
.badge-warning { background: rgba(250, 189, 47, 0.2); color: var(--yellow-light); }
|
||||||
|
.badge-danger { background: rgba(251, 73, 52, 0.2); color: var(--red-light); }
|
||||||
|
|
||||||
/* Charts */
|
/* Charts */
|
||||||
.chart-container {
|
.chart-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -907,6 +925,37 @@ body {
|
|||||||
.btn-danger { background: var(--red); color: var(--fg0); }
|
.btn-danger { background: var(--red); color: var(--fg0); }
|
||||||
.btn-danger:hover { background: var(--red-light); }
|
.btn-danger:hover { background: var(--red-light); }
|
||||||
|
|
||||||
|
/* Small inline action buttons (edit, delete, copy) */
|
||||||
|
.btn-action {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--bg3);
|
||||||
|
color: var(--fg3);
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action:hover {
|
||||||
|
background: var(--bg2);
|
||||||
|
color: var(--fg0);
|
||||||
|
border-color: var(--bg4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action.danger:hover {
|
||||||
|
background: rgba(251, 73, 52, 0.15);
|
||||||
|
color: var(--red-light);
|
||||||
|
border-color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Modals */
|
/* Modals */
|
||||||
.modal {
|
.modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>LLM Proxy Gateway - Admin Dashboard</title>
|
<title>LLM Proxy Gateway - Admin Dashboard</title>
|
||||||
<link rel="stylesheet" href="/css/dashboard.css?v=6">
|
<link rel="stylesheet" href="/css/dashboard.css?v=7">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="icon" href="img/logo-icon.png" type="image/png" sizes="any">
|
<link rel="icon" href="img/logo-icon.png" type="image/png" sizes="any">
|
||||||
<link rel="apple-touch-icon" href="img/logo-icon.png">
|
<link rel="apple-touch-icon" href="img/logo-icon.png">
|
||||||
@@ -57,9 +57,9 @@
|
|||||||
<nav class="sidebar">
|
<nav class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<img src="img/logo-icon.png" alt="LLM Proxy" class="sidebar-logo">
|
<img src="img/logo-icon.png" alt="LLM Proxy" class="sidebar-logo" onerror="this.style.display='none'; this.nextElementSibling.style.display='inline-block';">
|
||||||
<i class="fas fa-shield-alt logo-fallback" style="opacity: 0.3;"></i>
|
<i class="fas fa-shield-alt logo-fallback" style="display: none;"></i>
|
||||||
<span>Gateway</span>
|
<span>LLM Proxy</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="sidebar-toggle" id="sidebar-toggle">
|
<button class="sidebar-toggle" id="sidebar-toggle">
|
||||||
<i class="fas fa-bars"></i>
|
<i class="fas fa-bars"></i>
|
||||||
@@ -105,7 +105,11 @@
|
|||||||
|
|
||||||
<div class="menu-section">
|
<div class="menu-section">
|
||||||
<h3 class="menu-title">SYSTEM</h3>
|
<h3 class="menu-title">SYSTEM</h3>
|
||||||
<a href="#settings" class="menu-item" data-page="settings" data-tooltip="System Settings">
|
<a href="#users" class="menu-item admin-only" data-page="users" data-tooltip="User Accounts">
|
||||||
|
<i class="fas fa-user-shield"></i>
|
||||||
|
<span>User Management</span>
|
||||||
|
</a>
|
||||||
|
<a href="#settings" class="menu-item admin-only" data-page="settings" data-tooltip="System Settings">
|
||||||
<i class="fas fa-cog"></i>
|
<i class="fas fa-cog"></i>
|
||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -122,8 +126,8 @@
|
|||||||
<i class="fas fa-user-circle"></i>
|
<i class="fas fa-user-circle"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-details">
|
<div class="user-details">
|
||||||
<span class="user-name">Administrator</span>
|
<span class="user-name">Loading...</span>
|
||||||
<span class="user-role">Super Admin</span>
|
<span class="user-role">...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="logout-btn" id="logout-btn" title="Logout">
|
<button class="logout-btn" id="logout-btn" title="Logout">
|
||||||
@@ -166,19 +170,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scripts (cache-busted with version query params) -->
|
<!-- Scripts (cache-busted with version query params) -->
|
||||||
<script src="/js/api.js?v=6"></script>
|
<script src="/js/api.js?v=7"></script>
|
||||||
<script src="/js/auth.js?v=6"></script>
|
<script src="/js/auth.js?v=7"></script>
|
||||||
<script src="/js/dashboard.js?v=6"></script>
|
<script src="/js/dashboard.js?v=7"></script>
|
||||||
<script src="/js/websocket.js?v=6"></script>
|
<script src="/js/websocket.js?v=7"></script>
|
||||||
<script src="/js/charts.js?v=6"></script>
|
<script src="/js/charts.js?v=7"></script>
|
||||||
<script src="/js/pages/overview.js?v=6"></script>
|
<script src="/js/pages/overview.js?v=7"></script>
|
||||||
<script src="/js/pages/analytics.js?v=6"></script>
|
<script src="/js/pages/analytics.js?v=7"></script>
|
||||||
<script src="/js/pages/costs.js?v=6"></script>
|
<script src="/js/pages/costs.js?v=7"></script>
|
||||||
<script src="/js/pages/clients.js?v=6"></script>
|
<script src="/js/pages/clients.js?v=7"></script>
|
||||||
<script src="/js/pages/providers.js?v=6"></script>
|
<script src="/js/pages/providers.js?v=7"></script>
|
||||||
<script src="/js/pages/models.js?v=6"></script>
|
<script src="/js/pages/models.js?v=7"></script>
|
||||||
<script src="/js/pages/monitoring.js?v=6"></script>
|
<script src="/js/pages/monitoring.js?v=7"></script>
|
||||||
<script src="/js/pages/settings.js?v=6"></script>
|
<script src="/js/pages/settings.js?v=7"></script>
|
||||||
<script src="/js/pages/logs.js?v=6"></script>
|
<script src="/js/pages/logs.js?v=7"></script>
|
||||||
|
<script src="/js/pages/users.js?v=7"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -87,7 +87,20 @@ class AuthManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logout() {
|
async logout() {
|
||||||
|
// Revoke server-side session first
|
||||||
|
try {
|
||||||
|
if (this.token) {
|
||||||
|
await fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${this.token}` }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Best-effort — still clear local state even if server call fails
|
||||||
|
console.warn('Server logout failed:', e);
|
||||||
|
}
|
||||||
|
|
||||||
// Clear localStorage
|
// Clear localStorage
|
||||||
localStorage.removeItem('dashboard_token');
|
localStorage.removeItem('dashboard_token');
|
||||||
localStorage.removeItem('dashboard_user');
|
localStorage.removeItem('dashboard_user');
|
||||||
@@ -104,14 +117,6 @@ class AuthManager {
|
|||||||
this.showToast('Successfully logged out', 'info');
|
this.showToast('Successfully logged out', 'info');
|
||||||
}
|
}
|
||||||
|
|
||||||
generateToken() {
|
|
||||||
// Generate a simple token for demo purposes
|
|
||||||
// In production, this would come from the server
|
|
||||||
const timestamp = Date.now();
|
|
||||||
const random = Math.random().toString(36).substring(2);
|
|
||||||
return btoa(`${timestamp}:${random}`).replace(/=/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
showLogin() {
|
showLogin() {
|
||||||
const loginScreen = document.getElementById('login-screen');
|
const loginScreen = document.getElementById('login-screen');
|
||||||
const dashboard = document.getElementById('dashboard');
|
const dashboard = document.getElementById('dashboard');
|
||||||
@@ -156,11 +161,12 @@ class AuthManager {
|
|||||||
const userRoleElement = document.querySelector('.user-role');
|
const userRoleElement = document.querySelector('.user-role');
|
||||||
|
|
||||||
if (userNameElement && this.user) {
|
if (userNameElement && this.user) {
|
||||||
userNameElement.textContent = this.user.name;
|
userNameElement.textContent = this.user.name || this.user.username || 'User';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userRoleElement && this.user) {
|
if (userRoleElement && this.user) {
|
||||||
userRoleElement.textContent = this.user.role;
|
const roleLabels = { admin: 'Administrator', viewer: 'Viewer' };
|
||||||
|
userRoleElement.textContent = roleLabels[this.user.role] || this.user.role || 'User';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class Dashboard {
|
|||||||
this.setupSidebar();
|
this.setupSidebar();
|
||||||
this.setupRefresh();
|
this.setupRefresh();
|
||||||
this.updateTime();
|
this.updateTime();
|
||||||
|
this.applyRoleGating();
|
||||||
|
|
||||||
// Load initial page from hash or default to overview
|
// Load initial page from hash or default to overview
|
||||||
const initialPage = window.location.hash.substring(1) || 'overview';
|
const initialPage = window.location.hash.substring(1) || 'overview';
|
||||||
@@ -23,6 +24,20 @@ class Dashboard {
|
|||||||
setInterval(() => this.updateTime(), 1000);
|
setInterval(() => this.updateTime(), 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hide admin-only menu items and mutation controls for non-admin users */
|
||||||
|
applyRoleGating() {
|
||||||
|
const user = window.authManager && window.authManager.user;
|
||||||
|
const isAdmin = user && user.role === 'admin';
|
||||||
|
|
||||||
|
// Toggle visibility of admin-only sidebar items
|
||||||
|
document.querySelectorAll('.menu-item.admin-only').forEach(item => {
|
||||||
|
item.style.display = isAdmin ? '' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store role for use by page scripts
|
||||||
|
window._userRole = isAdmin ? 'admin' : 'viewer';
|
||||||
|
}
|
||||||
|
|
||||||
setupNavigation() {
|
setupNavigation() {
|
||||||
const menuItems = document.querySelectorAll('.menu-item');
|
const menuItems = document.querySelectorAll('.menu-item');
|
||||||
menuItems.forEach(item => {
|
menuItems.forEach(item => {
|
||||||
@@ -103,7 +118,8 @@ class Dashboard {
|
|||||||
'monitoring': 'Monitoring',
|
'monitoring': 'Monitoring',
|
||||||
'settings': 'Settings',
|
'settings': 'Settings',
|
||||||
'logs': 'Logs',
|
'logs': 'Logs',
|
||||||
'models': 'Models'
|
'models': 'Models',
|
||||||
|
'users': 'User Management'
|
||||||
};
|
};
|
||||||
if (titleElement) titleElement.textContent = titles[page] || 'Dashboard';
|
if (titleElement) titleElement.textContent = titles[page] || 'Dashboard';
|
||||||
|
|
||||||
@@ -145,6 +161,7 @@ class Dashboard {
|
|||||||
case 'settings': return '<div class="loading-placeholder">Loading settings...</div>';
|
case 'settings': return '<div class="loading-placeholder">Loading settings...</div>';
|
||||||
case 'analytics': return this.getAnalyticsTemplate();
|
case 'analytics': return this.getAnalyticsTemplate();
|
||||||
case 'costs': return this.getCostsTemplate();
|
case 'costs': return this.getCostsTemplate();
|
||||||
|
case 'users': return this.getUsersTemplate();
|
||||||
default: return '<div class="empty-state"><h3>Page not found</h3></div>';
|
default: return '<div class="empty-state"><h3>Page not found</h3></div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,6 +236,7 @@ class Dashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getClientsTemplate() {
|
getClientsTemplate() {
|
||||||
|
const isAdmin = window._userRole === 'admin';
|
||||||
return `
|
return `
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -226,9 +244,9 @@ class Dashboard {
|
|||||||
<h3 class="card-title">API Clients</h3>
|
<h3 class="card-title">API Clients</h3>
|
||||||
<p class="card-subtitle">Manage tokens and access</p>
|
<p class="card-subtitle">Manage tokens and access</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" id="add-client">
|
${isAdmin ? `<button class="btn btn-primary" id="add-client">
|
||||||
<i class="fas fa-plus"></i> Create Client
|
<i class="fas fa-plus"></i> Create Client
|
||||||
</button>
|
</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="table" id="clients-table">
|
<table class="table" id="clients-table">
|
||||||
@@ -474,6 +492,30 @@ class Dashboard {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getUsersTemplate() {
|
||||||
|
return `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="card-title">Dashboard Users</h3>
|
||||||
|
<p class="card-subtitle">Manage accounts and roles</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" id="add-user">
|
||||||
|
<i class="fas fa-user-plus"></i> Create User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table" id="users-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Username</th><th>Display Name</th><th>Role</th><th>Created</th><th>Status</th><th>Actions</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ class ClientsPage {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
${window._userRole === 'admin' ? `
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<button class="btn-action" title="Edit" onclick="window.clientsPage.editClient('${client.id}')">
|
<button class="btn-action" title="Edit" onclick="window.clientsPage.editClient('${client.id}')">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
@@ -67,6 +68,7 @@ class ClientsPage {
|
|||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
` : ''}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -55,11 +55,13 @@ class ModelsPage {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
${window._userRole === 'admin' ? `
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<button class="btn-action" title="Edit Access/Pricing" onclick="window.modelsPage.configureModel('${model.id}')">
|
<button class="btn-action" title="Edit Access/Pricing" onclick="window.modelsPage.configureModel('${model.id}')">
|
||||||
<i class="fas fa-cog"></i>
|
<i class="fas fa-cog"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
` : ''}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -84,9 +84,11 @@ class ProvidersPage {
|
|||||||
<button class="btn btn-secondary btn-sm" onclick="window.providersPage.testProvider('${provider.id}')">
|
<button class="btn btn-secondary btn-sm" onclick="window.providersPage.testProvider('${provider.id}')">
|
||||||
<i class="fas fa-vial"></i> Test
|
<i class="fas fa-vial"></i> Test
|
||||||
</button>
|
</button>
|
||||||
|
${window._userRole === 'admin' ? `
|
||||||
<button class="btn btn-primary btn-sm" onclick="window.providersPage.configureProvider('${provider.id}')">
|
<button class="btn btn-primary btn-sm" onclick="window.providersPage.configureProvider('${provider.id}')">
|
||||||
<i class="fas fa-cog"></i> Config
|
<i class="fas fa-cog"></i> Config
|
||||||
</button>
|
</button>
|
||||||
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class SettingsPage {
|
|||||||
<label>Application Version</label>
|
<label>Application Version</label>
|
||||||
<input type="text" value="${this.settings.server.version}" disabled>
|
<input type="text" value="${this.settings.server.version}" disabled>
|
||||||
</div>
|
</div>
|
||||||
|
${window._userRole === 'admin' ? `
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label>Authentication Tokens</label>
|
<label>Authentication Tokens</label>
|
||||||
<div class="tokens-list" style="display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem;">
|
<div class="tokens-list" style="display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem;">
|
||||||
@@ -52,6 +53,7 @@ class SettingsPage {
|
|||||||
</div>
|
</div>
|
||||||
<small>Auth tokens are configured via environment variables or <code>config.toml</code>.</small>
|
<small>Auth tokens are configured via environment variables or <code>config.toml</code>.</small>
|
||||||
</div>
|
</div>
|
||||||
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -95,12 +97,16 @@ class SettingsPage {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
|
${window._userRole === 'admin' ? `
|
||||||
<button class="btn btn-secondary" onclick="window.settingsPage.refreshRegistry()">
|
<button class="btn btn-secondary" onclick="window.settingsPage.refreshRegistry()">
|
||||||
<i class="fas fa-sync"></i> Force Registry Refresh
|
<i class="fas fa-sync"></i> Force Registry Refresh
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-danger" onclick="window.settingsPage.triggerBackup()">
|
<button class="btn btn-danger" onclick="window.settingsPage.triggerBackup()">
|
||||||
<i class="fas fa-file-archive"></i> Export Database Backup
|
<i class="fas fa-file-archive"></i> Export Database Backup
|
||||||
</button>
|
</button>
|
||||||
|
` : `
|
||||||
|
<span style="color: var(--fg4); font-size: 0.85rem;">Admin access required for registry refresh and backups.</span>
|
||||||
|
`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
290
static/js/pages/users.js
Normal file
290
static/js/pages/users.js
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
// User Management Page
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
let usersData = [];
|
||||||
|
|
||||||
|
window.initUsers = async function () {
|
||||||
|
await loadUsers();
|
||||||
|
setupEventListeners();
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
try {
|
||||||
|
usersData = await window.api.get('/users');
|
||||||
|
renderUsersTable();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load users:', err);
|
||||||
|
const tbody = document.querySelector('#users-table tbody');
|
||||||
|
if (tbody) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="6" class="text-center" style="padding:2rem;color:var(--red);">Failed to load users: ${err.message}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUsersTable() {
|
||||||
|
const tbody = document.querySelector('#users-table tbody');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
if (!usersData || usersData.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center" style="padding:2rem;color:var(--fg4);">No users found</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = usersData.map(user => {
|
||||||
|
const roleBadge = user.role === 'admin'
|
||||||
|
? '<span class="badge badge-success">Admin</span>'
|
||||||
|
: '<span class="badge badge-info">Viewer</span>';
|
||||||
|
|
||||||
|
const statusBadge = user.must_change_password
|
||||||
|
? '<span class="badge badge-warning">Must Change Password</span>'
|
||||||
|
: '<span class="badge badge-success">Active</span>';
|
||||||
|
|
||||||
|
const created = user.created_at
|
||||||
|
? luxon.DateTime.fromISO(user.created_at).toRelative()
|
||||||
|
: 'Unknown';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${escapeHtml(user.username)}</strong></td>
|
||||||
|
<td>${escapeHtml(user.display_name || user.username)}</td>
|
||||||
|
<td>${roleBadge}</td>
|
||||||
|
<td>${created}</td>
|
||||||
|
<td>${statusBadge}</td>
|
||||||
|
<td>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="window._editUser(${user.id})" title="Edit">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="window._resetUserPassword(${user.id}, '${escapeHtml(user.username)}')" title="Reset Password">
|
||||||
|
<i class="fas fa-key"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="window._deleteUser(${user.id}, '${escapeHtml(user.username)}')" title="Delete">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupEventListeners() {
|
||||||
|
const addBtn = document.getElementById('add-user');
|
||||||
|
if (addBtn) {
|
||||||
|
addBtn.onclick = () => showCreateModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create User Modal ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function showCreateModal() {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'modal-overlay';
|
||||||
|
overlay.id = 'user-modal-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="modal" style="max-width: 480px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Create New User</h3>
|
||||||
|
<button class="modal-close" id="user-modal-close"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Username</label>
|
||||||
|
<input type="text" id="new-username" class="form-control" placeholder="e.g. jsmith" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Display Name (optional)</label>
|
||||||
|
<input type="text" id="new-display-name" class="form-control" placeholder="e.g. John Smith">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" id="new-password" class="form-control" placeholder="Minimum 4 characters" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Role</label>
|
||||||
|
<select id="new-role" class="form-control">
|
||||||
|
<option value="viewer">Viewer (read-only)</option>
|
||||||
|
<option value="admin">Admin (full access)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="user-modal-cancel">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="user-modal-save">
|
||||||
|
<i class="fas fa-user-plus"></i> Create User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
document.getElementById('user-modal-close').onclick = () => overlay.remove();
|
||||||
|
document.getElementById('user-modal-cancel').onclick = () => overlay.remove();
|
||||||
|
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
|
||||||
|
|
||||||
|
document.getElementById('user-modal-save').onclick = async () => {
|
||||||
|
const username = document.getElementById('new-username').value.trim();
|
||||||
|
const display_name = document.getElementById('new-display-name').value.trim() || null;
|
||||||
|
const password = document.getElementById('new-password').value;
|
||||||
|
const role = document.getElementById('new-role').value;
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
window.authManager.showToast('Username is required', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 4) {
|
||||||
|
window.authManager.showToast('Password must be at least 4 characters', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.api.post('/users', { username, password, display_name, role });
|
||||||
|
overlay.remove();
|
||||||
|
window.authManager.showToast(`User '${username}' created`, 'success');
|
||||||
|
await loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
window.authManager.showToast(err.message, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('new-username').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit User Modal ────────────────────────────────────────────
|
||||||
|
|
||||||
|
window._editUser = function (id) {
|
||||||
|
const user = usersData.find(u => u.id === id);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'modal-overlay';
|
||||||
|
overlay.id = 'user-edit-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="modal" style="max-width: 480px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Edit User: ${escapeHtml(user.username)}</h3>
|
||||||
|
<button class="modal-close" id="edit-modal-close"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Display Name</label>
|
||||||
|
<input type="text" id="edit-display-name" class="form-control" value="${escapeHtml(user.display_name || '')}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Role</label>
|
||||||
|
<select id="edit-role" class="form-control">
|
||||||
|
<option value="viewer" ${user.role === 'viewer' ? 'selected' : ''}>Viewer (read-only)</option>
|
||||||
|
<option value="admin" ${user.role === 'admin' ? 'selected' : ''}>Admin (full access)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="edit-modal-cancel">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="edit-modal-save">
|
||||||
|
<i class="fas fa-save"></i> Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
document.getElementById('edit-modal-close').onclick = () => overlay.remove();
|
||||||
|
document.getElementById('edit-modal-cancel').onclick = () => overlay.remove();
|
||||||
|
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
|
||||||
|
|
||||||
|
document.getElementById('edit-modal-save').onclick = async () => {
|
||||||
|
const display_name = document.getElementById('edit-display-name').value.trim() || null;
|
||||||
|
const role = document.getElementById('edit-role').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.api.put(`/users/${id}`, { display_name, role });
|
||||||
|
overlay.remove();
|
||||||
|
window.authManager.showToast('User updated', 'success');
|
||||||
|
await loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
window.authManager.showToast(err.message, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Reset Password Modal ───────────────────────────────────────
|
||||||
|
|
||||||
|
window._resetUserPassword = function (id, username) {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'modal-overlay';
|
||||||
|
overlay.id = 'pw-reset-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="modal" style="max-width: 420px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Reset Password: ${escapeHtml(username)}</h3>
|
||||||
|
<button class="modal-close" id="pw-modal-close"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>New Password</label>
|
||||||
|
<input type="password" id="reset-password" class="form-control" placeholder="Minimum 4 characters">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
|
||||||
|
<input type="checkbox" id="reset-must-change" checked>
|
||||||
|
Require password change on next login
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="pw-modal-cancel">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="pw-modal-save">
|
||||||
|
<i class="fas fa-key"></i> Reset Password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
document.getElementById('pw-modal-close').onclick = () => overlay.remove();
|
||||||
|
document.getElementById('pw-modal-cancel').onclick = () => overlay.remove();
|
||||||
|
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
|
||||||
|
|
||||||
|
document.getElementById('pw-modal-save').onclick = async () => {
|
||||||
|
const password = document.getElementById('reset-password').value;
|
||||||
|
const must_change_password = document.getElementById('reset-must-change').checked;
|
||||||
|
|
||||||
|
if (password.length < 4) {
|
||||||
|
window.authManager.showToast('Password must be at least 4 characters', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.api.put(`/users/${id}`, { password, must_change_password });
|
||||||
|
overlay.remove();
|
||||||
|
window.authManager.showToast(`Password reset for '${username}'`, 'success');
|
||||||
|
await loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
window.authManager.showToast(err.message, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('reset-password').focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Delete User ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
window._deleteUser = function (id, username) {
|
||||||
|
if (!confirm(`Delete user '${username}'? This action cannot be undone.`)) return;
|
||||||
|
|
||||||
|
window.api.delete(`/users/${id}`).then(() => {
|
||||||
|
window.authManager.showToast(`User '${username}' deleted`, 'success');
|
||||||
|
loadUsers();
|
||||||
|
}).catch(err => {
|
||||||
|
window.authManager.showToast(err.message, 'error');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user