diff --git a/server/migrations/002_user_management.sql b/server/migrations/002_user_management.sql new file mode 100644 index 0000000..e3c7576 --- /dev/null +++ b/server/migrations/002_user_management.sql @@ -0,0 +1,44 @@ +-- GuruConnect User Management Schema +-- User authentication, roles, and per-client access control + +-- Users table +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(64) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + email VARCHAR(255), + role VARCHAR(32) NOT NULL DEFAULT 'viewer', + enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login TIMESTAMPTZ +); + +-- Granular permissions (what actions a user can perform) +CREATE TABLE user_permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + permission VARCHAR(64) NOT NULL, + UNIQUE(user_id, permission) +); + +-- Per-client access (which machines a user can access) +-- No entries = access to all clients (for admins) +CREATE TABLE user_client_access ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + client_id UUID NOT NULL REFERENCES connect_machines(id) ON DELETE CASCADE, + UNIQUE(user_id, client_id) +); + +-- Indexes +CREATE INDEX idx_users_username ON users(username); +CREATE INDEX idx_users_enabled ON users(enabled); +CREATE INDEX idx_user_permissions_user ON user_permissions(user_id); +CREATE INDEX idx_user_client_access_user ON user_client_access(user_id); + +-- Trigger for updated_at +CREATE TRIGGER update_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION update_connect_updated_at(); diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs new file mode 100644 index 0000000..e39fe32 --- /dev/null +++ b/server/src/api/auth.rs @@ -0,0 +1,317 @@ +//! Authentication API endpoints + +use axum::{ + extract::State, + http::StatusCode, + Json, +}; +use serde::{Deserialize, Serialize}; + +use crate::auth::{ + verify_password, AuthenticatedUser, JwtConfig, +}; +use crate::db; +use crate::AppState; + +/// Login request +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub username: String, + pub password: String, +} + +/// Login response +#[derive(Debug, Serialize)] +pub struct LoginResponse { + pub token: String, + pub user: UserResponse, +} + +/// User info in response +#[derive(Debug, Serialize)] +pub struct UserResponse { + pub id: String, + pub username: String, + pub email: Option, + pub role: String, + pub permissions: Vec, +} + +/// Error response +#[derive(Debug, Serialize)] +pub struct ErrorResponse { + pub error: String, +} + +/// POST /api/auth/login +pub async fn login( + State(state): State, + Json(request): Json, +) -> Result, (StatusCode, Json)> { + let db = state.db.as_ref().ok_or_else(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ErrorResponse { + error: "Database not available".to_string(), + }), + ) + })?; + + // Get user by username + let user = db::get_user_by_username(db.pool(), &request.username) + .await + .map_err(|e| { + tracing::error!("Database error during login: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Internal server error".to_string(), + }), + ) + })? + .ok_or_else(|| { + ( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse { + error: "Invalid username or password".to_string(), + }), + ) + })?; + + // Check if user is enabled + if !user.enabled { + return Err(( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse { + error: "Account is disabled".to_string(), + }), + )); + } + + // Verify password + let password_valid = verify_password(&request.password, &user.password_hash) + .map_err(|e| { + tracing::error!("Password verification error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Internal server error".to_string(), + }), + ) + })?; + + if !password_valid { + return Err(( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse { + error: "Invalid username or password".to_string(), + }), + )); + } + + // Get user permissions + let permissions = db::get_user_permissions(db.pool(), user.id) + .await + .unwrap_or_default(); + + // Update last login + let _ = db::update_last_login(db.pool(), user.id).await; + + // Create JWT token + let token = state.jwt_config.create_token( + user.id, + &user.username, + &user.role, + permissions.clone(), + ) + .map_err(|e| { + tracing::error!("Token creation error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Failed to create token".to_string(), + }), + ) + })?; + + tracing::info!("User {} logged in successfully", user.username); + + Ok(Json(LoginResponse { + token, + user: UserResponse { + id: user.id.to_string(), + username: user.username, + email: user.email, + role: user.role, + permissions, + }, + })) +} + +/// GET /api/auth/me - Get current user info +pub async fn get_me( + State(state): State, + user: AuthenticatedUser, +) -> Result, (StatusCode, Json)> { + let db = state.db.as_ref().ok_or_else(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ErrorResponse { + error: "Database not available".to_string(), + }), + ) + })?; + + let user_id = uuid::Uuid::parse_str(&user.user_id).map_err(|_| { + ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "Invalid user ID".to_string(), + }), + ) + })?; + + let db_user = db::get_user_by_id(db.pool(), user_id) + .await + .map_err(|e| { + tracing::error!("Database error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Internal server error".to_string(), + }), + ) + })? + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "User not found".to_string(), + }), + ) + })?; + + let permissions = db::get_user_permissions(db.pool(), db_user.id) + .await + .unwrap_or_default(); + + Ok(Json(UserResponse { + id: db_user.id.to_string(), + username: db_user.username, + email: db_user.email, + role: db_user.role, + permissions, + })) +} + +/// Change password request +#[derive(Debug, Deserialize)] +pub struct ChangePasswordRequest { + pub current_password: String, + pub new_password: String, +} + +/// POST /api/auth/change-password +pub async fn change_password( + State(state): State, + user: AuthenticatedUser, + Json(request): Json, +) -> Result)> { + let db = state.db.as_ref().ok_or_else(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ErrorResponse { + error: "Database not available".to_string(), + }), + ) + })?; + + let user_id = uuid::Uuid::parse_str(&user.user_id).map_err(|_| { + ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "Invalid user ID".to_string(), + }), + ) + })?; + + // Get current user + let db_user = db::get_user_by_id(db.pool(), user_id) + .await + .map_err(|e| { + tracing::error!("Database error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Internal server error".to_string(), + }), + ) + })? + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "User not found".to_string(), + }), + ) + })?; + + // Verify current password + let password_valid = verify_password(&request.current_password, &db_user.password_hash) + .map_err(|e| { + tracing::error!("Password verification error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Internal server error".to_string(), + }), + ) + })?; + + if !password_valid { + return Err(( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse { + error: "Current password is incorrect".to_string(), + }), + )); + } + + // Validate new password + if request.new_password.len() < 8 { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "Password must be at least 8 characters".to_string(), + }), + )); + } + + // Hash new password + let new_hash = crate::auth::hash_password(&request.new_password) + .map_err(|e| { + tracing::error!("Password hashing error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Failed to hash password".to_string(), + }), + ) + })?; + + // Update password + db::update_user_password(db.pool(), user_id, &new_hash) + .await + .map_err(|e| { + tracing::error!("Database error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Failed to update password".to_string(), + }), + ) + })?; + + tracing::info!("User {} changed their password", user.username); + Ok(StatusCode::OK) +} diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index 0f0aed8..acb058c 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -1,5 +1,8 @@ //! REST API endpoints +pub mod auth; +pub mod users; + use axum::{ extract::{Path, State, Query}, Json, diff --git a/server/src/api/users.rs b/server/src/api/users.rs new file mode 100644 index 0000000..895717c --- /dev/null +++ b/server/src/api/users.rs @@ -0,0 +1,592 @@ +//! User management API endpoints (admin only) + +use axum::{ + extract::{Path, State}, + http::StatusCode, + Json, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::auth::{hash_password, AdminUser}; +use crate::db; +use crate::AppState; + +use super::auth::ErrorResponse; + +/// User info response +#[derive(Debug, Serialize)] +pub struct UserInfo { + pub id: String, + pub username: String, + pub email: Option, + pub role: String, + pub enabled: bool, + pub created_at: String, + pub last_login: Option, + pub permissions: Vec, +} + +/// Create user request +#[derive(Debug, Deserialize)] +pub struct CreateUserRequest { + pub username: String, + pub password: String, + pub email: Option, + pub role: String, + pub permissions: Option>, +} + +/// Update user request +#[derive(Debug, Deserialize)] +pub struct UpdateUserRequest { + pub email: Option, + pub role: String, + pub enabled: bool, + pub password: Option, +} + +/// Set permissions request +#[derive(Debug, Deserialize)] +pub struct SetPermissionsRequest { + pub permissions: Vec, +} + +/// Set client access request +#[derive(Debug, Deserialize)] +pub struct SetClientAccessRequest { + pub client_ids: Vec, +} + +/// GET /api/users - List all users +pub async fn list_users( + State(state): State, + _admin: AdminUser, +) -> Result>, (StatusCode, Json)> { + let db = state.db.as_ref().ok_or_else(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ErrorResponse { + error: "Database not available".to_string(), + }), + ) + })?; + + let users = db::get_all_users(db.pool()) + .await + .map_err(|e| { + tracing::error!("Database error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Failed to fetch users".to_string(), + }), + ) + })?; + + let mut result = Vec::new(); + for user in users { + let permissions = db::get_user_permissions(db.pool(), user.id) + .await + .unwrap_or_default(); + + result.push(UserInfo { + id: user.id.to_string(), + username: user.username, + email: user.email, + role: user.role, + enabled: user.enabled, + created_at: user.created_at.to_rfc3339(), + last_login: user.last_login.map(|t| t.to_rfc3339()), + permissions, + }); + } + + Ok(Json(result)) +} + +/// POST /api/users - Create new user +pub async fn create_user( + State(state): State, + _admin: AdminUser, + Json(request): Json, +) -> Result, (StatusCode, Json)> { + let db = state.db.as_ref().ok_or_else(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ErrorResponse { + error: "Database not available".to_string(), + }), + ) + })?; + + // Validate role + let valid_roles = ["admin", "operator", "viewer"]; + if !valid_roles.contains(&request.role.as_str()) { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: format!("Invalid role. Must be one of: {:?}", valid_roles), + }), + )); + } + + // Validate password + if request.password.len() < 8 { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "Password must be at least 8 characters".to_string(), + }), + )); + } + + // Check if username exists + if db::get_user_by_username(db.pool(), &request.username) + .await + .map_err(|e| { + tracing::error!("Database error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Database error".to_string(), + }), + ) + })? + .is_some() + { + return Err(( + StatusCode::CONFLICT, + Json(ErrorResponse { + error: "Username already exists".to_string(), + }), + )); + } + + // Hash password + let password_hash = hash_password(&request.password).map_err(|e| { + tracing::error!("Password hashing error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Failed to hash password".to_string(), + }), + ) + })?; + + // Create user + let user = db::create_user( + db.pool(), + &request.username, + &password_hash, + request.email.as_deref(), + &request.role, + ) + .await + .map_err(|e| { + tracing::error!("Failed to create user: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Failed to create user".to_string(), + }), + ) + })?; + + // Set initial permissions if provided + let permissions = if let Some(perms) = request.permissions { + db::set_user_permissions(db.pool(), user.id, &perms) + .await + .map_err(|e| { + tracing::error!("Failed to set permissions: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Failed to set permissions".to_string(), + }), + ) + })?; + perms + } else { + // Default permissions based on role + let default_perms = match request.role.as_str() { + "admin" => vec!["view", "control", "transfer", "manage_users", "manage_clients"], + "operator" => vec!["view", "control", "transfer"], + "viewer" => vec!["view"], + _ => vec!["view"], + }; + let perms: Vec = default_perms.into_iter().map(String::from).collect(); + db::set_user_permissions(db.pool(), user.id, &perms) + .await + .ok(); + perms + }; + + tracing::info!("Created user: {} ({})", user.username, user.role); + + Ok(Json(UserInfo { + id: user.id.to_string(), + username: user.username, + email: user.email, + role: user.role, + enabled: user.enabled, + created_at: user.created_at.to_rfc3339(), + last_login: None, + permissions, + })) +} + +/// GET /api/users/:id - Get user details +pub async fn get_user( + State(state): State, + _admin: AdminUser, + Path(id): Path, +) -> Result, (StatusCode, Json)> { + let db = state.db.as_ref().ok_or_else(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ErrorResponse { + error: "Database not available".to_string(), + }), + ) + })?; + + let user_id = Uuid::parse_str(&id).map_err(|_| { + ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "Invalid user ID".to_string(), + }), + ) + })?; + + let user = db::get_user_by_id(db.pool(), user_id) + .await + .map_err(|e| { + tracing::error!("Database error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Database error".to_string(), + }), + ) + })? + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "User not found".to_string(), + }), + ) + })?; + + let permissions = db::get_user_permissions(db.pool(), user.id) + .await + .unwrap_or_default(); + + Ok(Json(UserInfo { + id: user.id.to_string(), + username: user.username, + email: user.email, + role: user.role, + enabled: user.enabled, + created_at: user.created_at.to_rfc3339(), + last_login: user.last_login.map(|t| t.to_rfc3339()), + permissions, + })) +} + +/// PUT /api/users/:id - Update user +pub async fn update_user( + State(state): State, + admin: AdminUser, + Path(id): Path, + Json(request): Json, +) -> Result, (StatusCode, Json)> { + let db = state.db.as_ref().ok_or_else(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ErrorResponse { + error: "Database not available".to_string(), + }), + ) + })?; + + let user_id = Uuid::parse_str(&id).map_err(|_| { + ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "Invalid user ID".to_string(), + }), + ) + })?; + + // Prevent admin from disabling themselves + if user_id.to_string() == admin.0.user_id && !request.enabled { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "Cannot disable your own account".to_string(), + }), + )); + } + + // Validate role + let valid_roles = ["admin", "operator", "viewer"]; + if !valid_roles.contains(&request.role.as_str()) { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: format!("Invalid role. Must be one of: {:?}", valid_roles), + }), + )); + } + + // Update user + let user = db::update_user( + db.pool(), + user_id, + request.email.as_deref(), + &request.role, + request.enabled, + ) + .await + .map_err(|e| { + tracing::error!("Database error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Failed to update user".to_string(), + }), + ) + })? + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "User not found".to_string(), + }), + ) + })?; + + // Update password if provided + if let Some(password) = request.password { + if password.len() < 8 { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "Password must be at least 8 characters".to_string(), + }), + )); + } + + let password_hash = hash_password(&password).map_err(|e| { + tracing::error!("Password hashing error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Failed to hash password".to_string(), + }), + ) + })?; + + db::update_user_password(db.pool(), user_id, &password_hash) + .await + .map_err(|e| { + tracing::error!("Database error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Failed to update password".to_string(), + }), + ) + })?; + } + + let permissions = db::get_user_permissions(db.pool(), user.id) + .await + .unwrap_or_default(); + + tracing::info!("Updated user: {}", user.username); + + Ok(Json(UserInfo { + id: user.id.to_string(), + username: user.username, + email: user.email, + role: user.role, + enabled: user.enabled, + created_at: user.created_at.to_rfc3339(), + last_login: user.last_login.map(|t| t.to_rfc3339()), + permissions, + })) +} + +/// DELETE /api/users/:id - Delete user +pub async fn delete_user( + State(state): State, + admin: AdminUser, + Path(id): Path, +) -> Result)> { + let db = state.db.as_ref().ok_or_else(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ErrorResponse { + error: "Database not available".to_string(), + }), + ) + })?; + + let user_id = Uuid::parse_str(&id).map_err(|_| { + ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "Invalid user ID".to_string(), + }), + ) + })?; + + // Prevent admin from deleting themselves + if user_id.to_string() == admin.0.user_id { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "Cannot delete your own account".to_string(), + }), + )); + } + + let deleted = db::delete_user(db.pool(), user_id) + .await + .map_err(|e| { + tracing::error!("Database error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Failed to delete user".to_string(), + }), + ) + })?; + + if deleted { + tracing::info!("Deleted user: {}", id); + Ok(StatusCode::NO_CONTENT) + } else { + Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "User not found".to_string(), + }), + )) + } +} + +/// PUT /api/users/:id/permissions - Set user permissions +pub async fn set_permissions( + State(state): State, + _admin: AdminUser, + Path(id): Path, + Json(request): Json, +) -> Result)> { + let db = state.db.as_ref().ok_or_else(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ErrorResponse { + error: "Database not available".to_string(), + }), + ) + })?; + + let user_id = Uuid::parse_str(&id).map_err(|_| { + ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "Invalid user ID".to_string(), + }), + ) + })?; + + // Validate permissions + let valid_permissions = ["view", "control", "transfer", "manage_users", "manage_clients"]; + for perm in &request.permissions { + if !valid_permissions.contains(&perm.as_str()) { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: format!("Invalid permission: {}. Valid: {:?}", perm, valid_permissions), + }), + )); + } + } + + db::set_user_permissions(db.pool(), user_id, &request.permissions) + .await + .map_err(|e| { + tracing::error!("Database error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Failed to set permissions".to_string(), + }), + ) + })?; + + tracing::info!("Updated permissions for user: {}", id); + Ok(StatusCode::OK) +} + +/// PUT /api/users/:id/clients - Set user client access +pub async fn set_client_access( + State(state): State, + _admin: AdminUser, + Path(id): Path, + Json(request): Json, +) -> Result)> { + let db = state.db.as_ref().ok_or_else(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ErrorResponse { + error: "Database not available".to_string(), + }), + ) + })?; + + let user_id = Uuid::parse_str(&id).map_err(|_| { + ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "Invalid user ID".to_string(), + }), + ) + })?; + + // Parse client IDs + let client_ids: Result, _> = request + .client_ids + .iter() + .map(|s| Uuid::parse_str(s)) + .collect(); + + let client_ids = client_ids.map_err(|_| { + ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "Invalid client ID format".to_string(), + }), + ) + })?; + + db::set_user_client_access(db.pool(), user_id, &client_ids) + .await + .map_err(|e| { + tracing::error!("Database error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Failed to set client access".to_string(), + }), + ) + })?; + + tracing::info!("Updated client access for user: {}", id); + Ok(StatusCode::OK) +} diff --git a/server/src/auth/jwt.rs b/server/src/auth/jwt.rs new file mode 100644 index 0000000..f894ea3 --- /dev/null +++ b/server/src/auth/jwt.rs @@ -0,0 +1,140 @@ +//! JWT token handling + +use anyhow::{anyhow, Result}; +use chrono::{Duration, Utc}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// JWT claims +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Claims { + /// Subject (user ID) + pub sub: String, + /// Username + pub username: String, + /// Role (admin, operator, viewer) + pub role: String, + /// Permissions list + pub permissions: Vec, + /// Expiration time (unix timestamp) + pub exp: i64, + /// Issued at (unix timestamp) + pub iat: i64, +} + +impl Claims { + /// Check if user has a specific permission + pub fn has_permission(&self, permission: &str) -> bool { + // Admins have all permissions + if self.role == "admin" { + return true; + } + self.permissions.contains(&permission.to_string()) + } + + /// Check if user is admin + pub fn is_admin(&self) -> bool { + self.role == "admin" + } + + /// Get user ID as UUID + pub fn user_id(&self) -> Result { + Uuid::parse_str(&self.sub).map_err(|e| anyhow!("Invalid user ID in token: {}", e)) + } +} + +/// JWT configuration +#[derive(Clone)] +pub struct JwtConfig { + secret: String, + expiry_hours: i64, +} + +impl JwtConfig { + /// Create new JWT config + pub fn new(secret: String, expiry_hours: i64) -> Self { + Self { secret, expiry_hours } + } + + /// Create a JWT token for a user + pub fn create_token( + &self, + user_id: Uuid, + username: &str, + role: &str, + permissions: Vec, + ) -> Result { + let now = Utc::now(); + let exp = now + Duration::hours(self.expiry_hours); + + let claims = Claims { + sub: user_id.to_string(), + username: username.to_string(), + role: role.to_string(), + permissions, + exp: exp.timestamp(), + iat: now.timestamp(), + }; + + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(self.secret.as_bytes()), + ) + .map_err(|e| anyhow!("Failed to create token: {}", e))?; + + Ok(token) + } + + /// Validate and decode a JWT token + pub fn validate_token(&self, token: &str) -> Result { + let token_data = decode::( + token, + &DecodingKey::from_secret(self.secret.as_bytes()), + &Validation::default(), + ) + .map_err(|e| anyhow!("Invalid token: {}", e))?; + + Ok(token_data.claims) + } +} + +/// Default JWT secret if not configured (NOT for production!) +pub fn default_jwt_secret() -> String { + // In production, this should come from environment variable + std::env::var("JWT_SECRET").unwrap_or_else(|_| { + tracing::warn!("JWT_SECRET not set, using default (INSECURE!)"); + "guruconnect-dev-secret-change-me-in-production".to_string() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_and_validate_token() { + let config = JwtConfig::new("test-secret".to_string(), 24); + let user_id = Uuid::new_v4(); + + let token = config.create_token( + user_id, + "testuser", + "admin", + vec!["view".to_string(), "control".to_string()], + ).unwrap(); + + let claims = config.validate_token(&token).unwrap(); + assert_eq!(claims.username, "testuser"); + assert_eq!(claims.role, "admin"); + assert!(claims.has_permission("view")); + assert!(claims.has_permission("manage_users")); // admin has all + } + + #[test] + fn test_invalid_token() { + let config = JwtConfig::new("test-secret".to_string(), 24); + assert!(config.validate_token("invalid.token.here").is_err()); + } +} diff --git a/server/src/auth/mod.rs b/server/src/auth/mod.rs index 80117a9..79ab691 100644 --- a/server/src/auth/mod.rs +++ b/server/src/auth/mod.rs @@ -3,17 +3,51 @@ //! Handles JWT validation for dashboard users and API key //! validation for agents. +pub mod jwt; +pub mod password; + +pub use jwt::{Claims, JwtConfig}; +pub use password::{hash_password, verify_password, generate_random_password}; + use axum::{ extract::FromRequestParts, http::{request::Parts, StatusCode}, }; +use std::sync::Arc; /// Authenticated user from JWT #[derive(Debug, Clone)] pub struct AuthenticatedUser { pub user_id: String, - pub email: String, - pub roles: Vec, + pub username: String, + pub role: String, + pub permissions: Vec, +} + +impl AuthenticatedUser { + /// Check if user has a specific permission + pub fn has_permission(&self, permission: &str) -> bool { + if self.role == "admin" { + return true; + } + self.permissions.contains(&permission.to_string()) + } + + /// Check if user is admin + pub fn is_admin(&self) -> bool { + self.role == "admin" + } +} + +impl From for AuthenticatedUser { + fn from(claims: Claims) -> Self { + Self { + user_id: claims.sub, + username: claims.username, + role: claims.role, + permissions: claims.permissions, + } + } } /// Authenticated agent from API key @@ -23,7 +57,21 @@ pub struct AuthenticatedAgent { pub org_id: String, } -/// Extract authenticated user from request (placeholder for MVP) +/// JWT configuration stored in app state +#[derive(Clone)] +pub struct AuthState { + pub jwt_config: Arc, +} + +impl AuthState { + pub fn new(jwt_secret: String, expiry_hours: i64) -> Self { + Self { + jwt_config: Arc::new(JwtConfig::new(jwt_secret, expiry_hours)), + } + } +} + +/// Extract authenticated user from request #[axum::async_trait] impl FromRequestParts for AuthenticatedUser where @@ -32,28 +80,77 @@ where type Rejection = (StatusCode, &'static str); async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { - // TODO: Implement JWT validation - // For MVP, accept any request - - // Look for Authorization header - let _auth_header = parts + // Get Authorization header + let auth_header = parts .headers .get("Authorization") - .and_then(|v| v.to_str().ok()); + .and_then(|v| v.to_str().ok()) + .ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?; - // Placeholder - in production, validate JWT - Ok(AuthenticatedUser { - user_id: "mvp-user".to_string(), - email: "mvp@example.com".to_string(), - roles: vec!["admin".to_string()], - }) + // Extract Bearer token + let token = auth_header + .strip_prefix("Bearer ") + .ok_or((StatusCode::UNAUTHORIZED, "Invalid Authorization format"))?; + + // Get JWT config from extensions (set by middleware) + let jwt_config = parts + .extensions + .get::>() + .ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Auth not configured"))?; + + // Validate token + let claims = jwt_config + .validate_token(token) + .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid or expired token"))?; + + Ok(AuthenticatedUser::from(claims)) + } +} + +/// Optional authenticated user (doesn't reject if not authenticated) +#[derive(Debug, Clone)] +pub struct OptionalUser(pub Option); + +#[axum::async_trait] +impl FromRequestParts for OptionalUser +where + S: Send + Sync, +{ + type Rejection = (StatusCode, &'static str); + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + match AuthenticatedUser::from_request_parts(parts, state).await { + Ok(user) => Ok(OptionalUser(Some(user))), + Err(_) => Ok(OptionalUser(None)), + } + } +} + +/// Require admin role +#[derive(Debug, Clone)] +pub struct AdminUser(pub AuthenticatedUser); + +#[axum::async_trait] +impl FromRequestParts for AdminUser +where + S: Send + Sync, +{ + type Rejection = (StatusCode, &'static str); + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let user = AuthenticatedUser::from_request_parts(parts, state).await?; + if user.is_admin() { + Ok(AdminUser(user)) + } else { + Err((StatusCode::FORBIDDEN, "Admin access required")) + } } } /// Validate an agent API key (placeholder for MVP) pub fn validate_agent_key(_api_key: &str) -> Option { - // TODO: Implement actual API key validation - // For MVP, accept any key + // TODO: Implement actual API key validation against database + // For now, accept any key for agent connections Some(AuthenticatedAgent { agent_id: "mvp-agent".to_string(), org_id: "mvp-org".to_string(), diff --git a/server/src/auth/password.rs b/server/src/auth/password.rs new file mode 100644 index 0000000..1020465 --- /dev/null +++ b/server/src/auth/password.rs @@ -0,0 +1,57 @@ +//! Password hashing using Argon2id + +use anyhow::{anyhow, Result}; +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; + +/// Hash a password using Argon2id +pub fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let hash = argon2 + .hash_password(password.as_bytes(), &salt) + .map_err(|e| anyhow!("Failed to hash password: {}", e))?; + Ok(hash.to_string()) +} + +/// Verify a password against a stored hash +pub fn verify_password(password: &str, hash: &str) -> Result { + let parsed_hash = PasswordHash::new(hash) + .map_err(|e| anyhow!("Invalid password hash format: {}", e))?; + let argon2 = Argon2::default(); + Ok(argon2.verify_password(password.as_bytes(), &parsed_hash).is_ok()) +} + +/// Generate a random password (for initial admin) +pub fn generate_random_password(length: usize) -> String { + use rand::Rng; + const CHARSET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%"; + let mut rng = rand::thread_rng(); + (0..length) + .map(|_| { + let idx = rng.gen_range(0..CHARSET.len()); + CHARSET[idx] as char + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hash_and_verify() { + let password = "test_password_123"; + let hash = hash_password(password).unwrap(); + assert!(verify_password(password, &hash).unwrap()); + assert!(!verify_password("wrong_password", &hash).unwrap()); + } + + #[test] + fn test_random_password() { + let password = generate_random_password(16); + assert_eq!(password.len(), 16); + } +} diff --git a/server/src/db/mod.rs b/server/src/db/mod.rs index d7be2aa..a12579c 100644 --- a/server/src/db/mod.rs +++ b/server/src/db/mod.rs @@ -7,6 +7,7 @@ pub mod machines; pub mod sessions; pub mod events; pub mod support_codes; +pub mod users; use anyhow::Result; use sqlx::postgres::PgPoolOptions; @@ -17,6 +18,7 @@ pub use machines::*; pub use sessions::*; pub use events::*; pub use support_codes::*; +pub use users::*; /// Database connection pool wrapper #[derive(Clone)] diff --git a/server/src/db/users.rs b/server/src/db/users.rs new file mode 100644 index 0000000..f75d219 --- /dev/null +++ b/server/src/db/users.rs @@ -0,0 +1,283 @@ +//! User database operations + +use anyhow::Result; +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use uuid::Uuid; + +/// User record from database +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct User { + pub id: Uuid, + pub username: String, + pub password_hash: String, + pub email: Option, + pub role: String, + pub enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + pub last_login: Option>, +} + +/// User without password hash (for API responses) +#[derive(Debug, Clone, serde::Serialize)] +pub struct UserInfo { + pub id: Uuid, + pub username: String, + pub email: Option, + pub role: String, + pub enabled: bool, + pub created_at: DateTime, + pub last_login: Option>, + pub permissions: Vec, +} + +impl From for UserInfo { + fn from(u: User) -> Self { + Self { + id: u.id, + username: u.username, + email: u.email, + role: u.role, + enabled: u.enabled, + created_at: u.created_at, + last_login: u.last_login, + permissions: Vec::new(), // Filled in by caller + } + } +} + +/// Get user by username +pub async fn get_user_by_username(pool: &PgPool, username: &str) -> Result> { + let user = sqlx::query_as::<_, User>( + "SELECT * FROM users WHERE username = $1" + ) + .bind(username) + .fetch_optional(pool) + .await?; + Ok(user) +} + +/// Get user by ID +pub async fn get_user_by_id(pool: &PgPool, id: Uuid) -> Result> { + let user = sqlx::query_as::<_, User>( + "SELECT * FROM users WHERE id = $1" + ) + .bind(id) + .fetch_optional(pool) + .await?; + Ok(user) +} + +/// Get all users +pub async fn get_all_users(pool: &PgPool) -> Result> { + let users = sqlx::query_as::<_, User>( + "SELECT * FROM users ORDER BY username" + ) + .fetch_all(pool) + .await?; + Ok(users) +} + +/// Create a new user +pub async fn create_user( + pool: &PgPool, + username: &str, + password_hash: &str, + email: Option<&str>, + role: &str, +) -> Result { + let user = sqlx::query_as::<_, User>( + r#" + INSERT INTO users (username, password_hash, email, role) + VALUES ($1, $2, $3, $4) + RETURNING * + "# + ) + .bind(username) + .bind(password_hash) + .bind(email) + .bind(role) + .fetch_one(pool) + .await?; + Ok(user) +} + +/// Update user +pub async fn update_user( + pool: &PgPool, + id: Uuid, + email: Option<&str>, + role: &str, + enabled: bool, +) -> Result> { + let user = sqlx::query_as::<_, User>( + r#" + UPDATE users + SET email = $2, role = $3, enabled = $4, updated_at = NOW() + WHERE id = $1 + RETURNING * + "# + ) + .bind(id) + .bind(email) + .bind(role) + .bind(enabled) + .fetch_optional(pool) + .await?; + Ok(user) +} + +/// Update user password +pub async fn update_user_password( + pool: &PgPool, + id: Uuid, + password_hash: &str, +) -> Result { + let result = sqlx::query( + "UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1" + ) + .bind(id) + .bind(password_hash) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +/// Update last login timestamp +pub async fn update_last_login(pool: &PgPool, id: Uuid) -> Result<()> { + sqlx::query("UPDATE users SET last_login = NOW() WHERE id = $1") + .bind(id) + .execute(pool) + .await?; + Ok(()) +} + +/// Delete user +pub async fn delete_user(pool: &PgPool, id: Uuid) -> Result { + let result = sqlx::query("DELETE FROM users WHERE id = $1") + .bind(id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +/// Count users (for initial admin check) +pub async fn count_users(pool: &PgPool) -> Result { + let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") + .fetch_one(pool) + .await?; + Ok(count.0) +} + +/// Get user permissions +pub async fn get_user_permissions(pool: &PgPool, user_id: Uuid) -> Result> { + let perms: Vec<(String,)> = sqlx::query_as( + "SELECT permission FROM user_permissions WHERE user_id = $1" + ) + .bind(user_id) + .fetch_all(pool) + .await?; + Ok(perms.into_iter().map(|p| p.0).collect()) +} + +/// Set user permissions (replaces all) +pub async fn set_user_permissions( + pool: &PgPool, + user_id: Uuid, + permissions: &[String], +) -> Result<()> { + // Delete existing + sqlx::query("DELETE FROM user_permissions WHERE user_id = $1") + .bind(user_id) + .execute(pool) + .await?; + + // Insert new + for perm in permissions { + sqlx::query( + "INSERT INTO user_permissions (user_id, permission) VALUES ($1, $2)" + ) + .bind(user_id) + .bind(perm) + .execute(pool) + .await?; + } + Ok(()) +} + +/// Get user's accessible client IDs (empty = all access) +pub async fn get_user_client_access(pool: &PgPool, user_id: Uuid) -> Result> { + let clients: Vec<(Uuid,)> = sqlx::query_as( + "SELECT client_id FROM user_client_access WHERE user_id = $1" + ) + .bind(user_id) + .fetch_all(pool) + .await?; + Ok(clients.into_iter().map(|c| c.0).collect()) +} + +/// Set user's client access (replaces all) +pub async fn set_user_client_access( + pool: &PgPool, + user_id: Uuid, + client_ids: &[Uuid], +) -> Result<()> { + // Delete existing + sqlx::query("DELETE FROM user_client_access WHERE user_id = $1") + .bind(user_id) + .execute(pool) + .await?; + + // Insert new + for client_id in client_ids { + sqlx::query( + "INSERT INTO user_client_access (user_id, client_id) VALUES ($1, $2)" + ) + .bind(user_id) + .bind(client_id) + .execute(pool) + .await?; + } + Ok(()) +} + +/// Check if user has access to a specific client +pub async fn user_has_client_access( + pool: &PgPool, + user_id: Uuid, + client_id: Uuid, +) -> Result { + // Admins have access to all + let user = get_user_by_id(pool, user_id).await?; + if let Some(u) = user { + if u.role == "admin" { + return Ok(true); + } + } + + // Check explicit access + let access: Option<(Uuid,)> = sqlx::query_as( + "SELECT client_id FROM user_client_access WHERE user_id = $1 AND client_id = $2" + ) + .bind(user_id) + .bind(client_id) + .fetch_optional(pool) + .await?; + + // If no explicit access entries exist, user has access to all (legacy behavior) + if access.is_some() { + return Ok(true); + } + + // Check if user has ANY access restrictions + let count: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM user_client_access WHERE user_id = $1" + ) + .bind(user_id) + .fetch_one(pool) + .await?; + + // No restrictions means access to all + Ok(count.0 == 0) +} diff --git a/server/src/main.rs b/server/src/main.rs index 05674ba..9795b2f 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -18,12 +18,14 @@ pub mod proto { use anyhow::Result; use axum::{ Router, - routing::{get, post, delete}, - extract::{Path, State, Json, Query}, + routing::{get, post, put, delete}, + extract::{Path, State, Json, Query, Request}, response::{Html, IntoResponse}, http::StatusCode, + middleware::{self, Next}, }; use std::net::SocketAddr; +use std::sync::Arc; use tower_http::cors::{Any, CorsLayer}; use tower_http::trace::TraceLayer; use tower_http::services::ServeDir; @@ -32,6 +34,7 @@ use tracing_subscriber::FmtSubscriber; use serde::Deserialize; use support_codes::{SupportCodeManager, CreateCodeRequest, SupportCode, CodeValidation}; +use auth::{JwtConfig, hash_password, generate_random_password}; /// Application state #[derive(Clone)] @@ -39,6 +42,17 @@ pub struct AppState { sessions: session::SessionManager, support_codes: SupportCodeManager, db: Option, + pub jwt_config: Arc, +} + +/// Middleware to inject JWT config into request extensions +async fn auth_layer( + State(state): State, + mut request: Request, + next: Next, +) -> impl IntoResponse { + request.extensions_mut().insert(state.jwt_config.clone()); + next.run(request).await } #[tokio::main] @@ -58,6 +72,17 @@ async fn main() -> Result<()> { let listen_addr = std::env::var("LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0:3002".to_string()); info!("Loaded configuration, listening on {}", listen_addr); + // JWT configuration + let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| { + tracing::warn!("JWT_SECRET not set, using default (INSECURE for production!)"); + "guruconnect-dev-secret-change-me-in-production".to_string() + }); + let jwt_expiry_hours = std::env::var("JWT_EXPIRY_HOURS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(24i64); + let jwt_config = Arc::new(JwtConfig::new(jwt_secret, jwt_expiry_hours)); + // Initialize database if configured let database = if let Some(ref db_url) = config.database_url { match db::Database::connect(db_url, config.database_max_connections).await { @@ -79,6 +104,47 @@ async fn main() -> Result<()> { None }; + // Create initial admin user if no users exist + if let Some(ref db) = database { + match db::count_users(db.pool()).await { + Ok(0) => { + info!("No users found, creating initial admin user..."); + let password = generate_random_password(16); + let password_hash = hash_password(&password)?; + + match db::create_user(db.pool(), "admin", &password_hash, None, "admin").await { + Ok(user) => { + // Set admin permissions + let perms = vec![ + "view".to_string(), + "control".to_string(), + "transfer".to_string(), + "manage_users".to_string(), + "manage_clients".to_string(), + ]; + let _ = db::set_user_permissions(db.pool(), user.id, &perms).await; + + info!("========================================"); + info!(" INITIAL ADMIN USER CREATED"); + info!(" Username: admin"); + info!(" Password: {}", password); + info!(" (Change this password after first login!)"); + info!("========================================"); + } + Err(e) => { + tracing::error!("Failed to create initial admin user: {}", e); + } + } + } + Ok(count) => { + info!("{} user(s) in database", count); + } + Err(e) => { + tracing::warn!("Could not check user count: {}", e); + } + } + } + // Create session manager let sessions = session::SessionManager::new(); @@ -102,23 +168,40 @@ async fn main() -> Result<()> { sessions, support_codes: SupportCodeManager::new(), db: database, + jwt_config, }; // Build router let app = Router::new() - // Health check + // Health check (no auth required) .route("/health", get(health)) - + + // Auth endpoints (no auth required for login) + .route("/api/auth/login", post(api::auth::login)) + + // Auth endpoints (auth required) + .route("/api/auth/me", get(api::auth::get_me)) + .route("/api/auth/change-password", post(api::auth::change_password)) + + // User management (admin only) + .route("/api/users", get(api::users::list_users)) + .route("/api/users", post(api::users::create_user)) + .route("/api/users/:id", get(api::users::get_user)) + .route("/api/users/:id", put(api::users::update_user)) + .route("/api/users/:id", delete(api::users::delete_user)) + .route("/api/users/:id/permissions", put(api::users::set_permissions)) + .route("/api/users/:id/clients", put(api::users::set_client_access)) + // Portal API - Support codes .route("/api/codes", post(create_code)) .route("/api/codes", get(list_codes)) .route("/api/codes/:code/validate", get(validate_code)) .route("/api/codes/:code/cancel", post(cancel_code)) - + // WebSocket endpoints .route("/ws/agent", get(relay::agent_ws_handler)) .route("/ws/viewer", get(relay::viewer_ws_handler)) - + // REST API - Sessions .route("/api/sessions", get(list_sessions)) .route("/api/sessions/:id", get(get_session)) @@ -129,17 +212,19 @@ async fn main() -> Result<()> { .route("/api/machines/:agent_id", get(get_machine)) .route("/api/machines/:agent_id", delete(delete_machine)) .route("/api/machines/:agent_id/history", get(get_machine_history)) - + // HTML page routes (clean URLs) .route("/login", get(serve_login)) .route("/dashboard", get(serve_dashboard)) + .route("/users", get(serve_users)) + + // State and middleware + .with_state(state.clone()) + .layer(middleware::from_fn_with_state(state, auth_layer)) - // State - .with_state(state) - // Serve static files for portal (fallback) .fallback_service(ServeDir::new("static").append_index_html_on_directories(true)) - + // Middleware .layer(TraceLayer::new_for_http()) .layer( @@ -380,3 +465,10 @@ async fn serve_dashboard() -> impl IntoResponse { Err(_) => (StatusCode::NOT_FOUND, "Page not found").into_response(), } } + +async fn serve_users() -> impl IntoResponse { + match tokio::fs::read_to_string("static/users.html").await { + Ok(content) => Html(content).into_response(), + Err(_) => (StatusCode::NOT_FOUND, "Page not found").into_response(), + } +} diff --git a/server/static/dashboard.html b/server/static/dashboard.html index ba16732..ebcd78c 100644 --- a/server/static/dashboard.html +++ b/server/static/dashboard.html @@ -348,6 +348,7 @@ +
@@ -510,6 +511,21 @@

+ + +
+
+
+
+

User Management

+

Manage user accounts and permissions

+
+
+

+ Open User Management +

+
+
@@ -546,19 +562,59 @@ }); // Check auth - const token = localStorage.getItem("token"); - const user = JSON.parse(localStorage.getItem("user") || "null"); + const token = localStorage.getItem("guruconnect_token"); + const user = JSON.parse(localStorage.getItem("guruconnect_user") || "null"); - if (!token) { - document.getElementById("userInfo").textContent = "Demo Mode"; - } else if (user) { - document.getElementById("userInfo").textContent = user.email || user.name || "Technician"; + // Verify authentication + async function checkAuth() { + if (!token) { + window.location.href = "/login"; + return; + } + + try { + const response = await fetch("/api/auth/me", { + headers: { "Authorization": `Bearer ${token}` } + }); + + if (!response.ok) { + localStorage.removeItem("guruconnect_token"); + localStorage.removeItem("guruconnect_user"); + window.location.href = "/login"; + return; + } + + const userData = await response.json(); + + // Update user display + document.getElementById("userInfo").textContent = userData.username + " (" + userData.role + ")"; + + // Show admin-only elements + if (userData.role === "admin") { + document.querySelectorAll(".admin-only").forEach(el => { + el.style.display = ""; + }); + } + } catch (err) { + console.error("Auth check failed:", err); + // Don't redirect on network error, just show what we have + if (user) { + document.getElementById("userInfo").textContent = user.username || "User"; + if (user.role === "admin") { + document.querySelectorAll(".admin-only").forEach(el => { + el.style.display = ""; + }); + } + } + } } + checkAuth(); + // Logout document.getElementById("logoutBtn").addEventListener("click", () => { - localStorage.removeItem("token"); - localStorage.removeItem("user"); + localStorage.removeItem("guruconnect_token"); + localStorage.removeItem("guruconnect_user"); window.location.href = "/login"; }); diff --git a/server/static/login.html b/server/static/login.html index 23c5819..34ad38c 100644 --- a/server/static/login.html +++ b/server/static/login.html @@ -3,7 +3,7 @@ - GuruConnect - Technician Login + GuruConnect - Login
-
-

Auth not yet configured. Skip to Dashboard

-
- @@ -177,10 +157,29 @@ const loginBtn = document.getElementById("loginBtn"); const errorMessage = document.getElementById("errorMessage"); + // Check if already logged in + const token = localStorage.getItem("guruconnect_token"); + if (token) { + // Verify token is still valid + fetch('/api/auth/me', { + headers: { 'Authorization': `Bearer ${token}` } + }).then(res => { + if (res.ok) { + window.location.href = '/dashboard'; + } else { + localStorage.removeItem('guruconnect_token'); + localStorage.removeItem('guruconnect_user'); + } + }).catch(() => { + localStorage.removeItem('guruconnect_token'); + localStorage.removeItem('guruconnect_user'); + }); + } + form.addEventListener("submit", async (e) => { e.preventDefault(); - const email = document.getElementById("email").value; + const username = document.getElementById("username").value; const password = document.getElementById("password").value; setLoading(true); @@ -190,7 +189,7 @@ const response = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password }) + body: JSON.stringify({ username, password }) }); const data = await response.json(); @@ -201,12 +200,13 @@ return; } - localStorage.setItem("token", data.token); - localStorage.setItem("user", JSON.stringify(data.user)); + // Store token and user info + localStorage.setItem("guruconnect_token", data.token); + localStorage.setItem("guruconnect_user", JSON.stringify(data.user)); window.location.href = "/dashboard"; } catch (err) { - showError("Auth not configured yet. Use the demo link below."); + showError("Connection error. Please try again."); setLoading(false); } }); @@ -222,9 +222,8 @@ loginBtn.querySelector(".btn-text").textContent = loading ? "Signing in..." : "Sign In"; } - if (localStorage.getItem("token")) { - window.location.href = "/dashboard"; - } + // Focus username field + document.getElementById("username").focus(); diff --git a/server/static/users.html b/server/static/users.html new file mode 100644 index 0000000..08bb946 --- /dev/null +++ b/server/static/users.html @@ -0,0 +1,602 @@ + + + + + + GuruConnect - User Management + + + +
+
+ + ← Back to Dashboard +
+
+ +
+
+
+
+

User Management

+

Create and manage user accounts

+
+ +
+ +
+ + + + + + + + + + + + + + + + + +
UsernameEmailRoleStatusLast LoginActions
+
+

Loading users...

+
+
+
+
+ + + + +
+
+
+ + + +