feat: add multi-user RBAC with admin/viewer roles and user management
Some checks failed
CI / Check (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Formatting (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Release Build (push) Has been cancelled

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:
2026-03-02 15:58:33 -05:00
parent 5bf41be343
commit e07377adc0
17 changed files with 885 additions and 49 deletions

View File

@@ -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)
}

View File

@@ -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 = ?")

View File

@@ -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))

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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)))
}
}
}

View File

@@ -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)

View File

@@ -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;

View File

@@ -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>

View File

@@ -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';
} }
} }

View File

@@ -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', () => {

View File

@@ -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>
`; `;

View File

@@ -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>
`; `;

View File

@@ -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>
`; `;

View File

@@ -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
View 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;
}
})();