diff --git a/database/schema.sql b/database/schema.sql index 9dfd932..7906c3d 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -116,6 +116,21 @@ CREATE TABLE IF NOT EXISTS user_sessions ( INDEX idx_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- Admin users table for RBAC +CREATE TABLE IF NOT EXISTS admin_users ( + id INT AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL, + role ENUM('staff', 'admin') NOT NULL DEFAULT 'staff', + display_name VARCHAR(255) DEFAULT NULL, + active TINYINT(1) DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by VARCHAR(255) DEFAULT NULL, + UNIQUE KEY unique_email (email), + INDEX idx_role (role), + INDEX idx_active (active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- PTR records cache table for Route53 A records CREATE TABLE IF NOT EXISTS ptr_records_cache ( id INT AUTO_INCREMENT PRIMARY KEY, diff --git a/webapp/api.php b/webapp/api.php index 9c4b52b..e79ba52 100644 --- a/webapp/api.php +++ b/webapp/api.php @@ -230,6 +230,23 @@ try { handleRoute53RecordsByPrefix($db); break; + // Admin user management (admin only) + case 'admin_users_list': + handleAdminUsersList($db); + break; + + case 'admin_user_save': + handleAdminUserSave($db); + break; + + case 'admin_user_delete': + handleAdminUserDelete($db); + break; + + case 'admin_user_toggle': + handleAdminUserToggle($db); + break; + default: jsonResponse(['error' => 'Invalid action'], 400); } @@ -3280,3 +3297,141 @@ function handleAwsUpdateARecord($db) { jsonResponse(['success' => false, 'error' => $e->getMessage()]); } } + +/** + * List all admin users (admin only) + */ +function handleAdminUsersList($db) { + require_once __DIR__ . '/includes/auth.php'; + + if (!isAdmin()) { + jsonResponse(['error' => 'Admin access required'], 403); + return; + } + + $users = getAdminUsers(); + jsonResponse(['success' => true, 'users' => $users]); +} + +/** + * Save (create or update) an admin user (admin only) + */ +function handleAdminUserSave($db) { + require_once __DIR__ . '/includes/auth.php'; + + if (!isAdmin()) { + jsonResponse(['error' => 'Admin access required'], 403); + return; + } + + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + jsonResponse(['error' => 'POST method required'], 405); + return; + } + + $input = json_decode(file_get_contents('php://input'), true); + + if (empty($input['email'])) { + jsonResponse(['error' => 'Email is required'], 400); + return; + } + + $email = $input['email']; + $role = $input['role'] ?? ROLE_STAFF; + $displayName = $input['display_name'] ?? null; + $createdBy = getAuditUser(); + + $success = saveAdminUser($email, $role, $displayName, $createdBy); + + if ($success) { + jsonResponse(['success' => true, 'message' => 'User saved successfully']); + } else { + jsonResponse(['error' => 'Failed to save user'], 500); + } +} + +/** + * Delete an admin user (admin only) + */ +function handleAdminUserDelete($db) { + require_once __DIR__ . '/includes/auth.php'; + + if (!isAdmin()) { + jsonResponse(['error' => 'Admin access required'], 403); + return; + } + + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + jsonResponse(['error' => 'POST method required'], 405); + return; + } + + $input = json_decode(file_get_contents('php://input'), true); + + if (empty($input['id'])) { + jsonResponse(['error' => 'User ID is required'], 400); + return; + } + + // Prevent deleting yourself + $currentUser = getCurrentUser(); + $stmt = $db->prepare("SELECT email FROM admin_users WHERE id = ?"); + $stmt->execute([$input['id']]); + $userToDelete = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($userToDelete && strtolower($userToDelete['email']) === strtolower($currentUser['email'])) { + jsonResponse(['error' => 'You cannot delete your own account'], 400); + return; + } + + $success = deleteAdminUser($input['id']); + + if ($success) { + jsonResponse(['success' => true, 'message' => 'User deleted successfully']); + } else { + jsonResponse(['error' => 'Failed to delete user or user not found'], 500); + } +} + +/** + * Toggle an admin user's active status (admin only) + */ +function handleAdminUserToggle($db) { + require_once __DIR__ . '/includes/auth.php'; + + if (!isAdmin()) { + jsonResponse(['error' => 'Admin access required'], 403); + return; + } + + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + jsonResponse(['error' => 'POST method required'], 405); + return; + } + + $input = json_decode(file_get_contents('php://input'), true); + + if (empty($input['id'])) { + jsonResponse(['error' => 'User ID is required'], 400); + return; + } + + // Prevent deactivating yourself + $currentUser = getCurrentUser(); + $stmt = $db->prepare("SELECT email FROM admin_users WHERE id = ?"); + $stmt->execute([$input['id']]); + $userToToggle = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($userToToggle && strtolower($userToToggle['email']) === strtolower($currentUser['email'])) { + jsonResponse(['error' => 'You cannot deactivate your own account'], 400); + return; + } + + $success = toggleAdminUser($input['id']); + + if ($success) { + jsonResponse(['success' => true, 'message' => 'User status toggled successfully']); + } else { + jsonResponse(['error' => 'Failed to toggle user status'], 500); + } +} diff --git a/webapp/includes/auth.php b/webapp/includes/auth.php index 32896b0..4e4e75e 100644 --- a/webapp/includes/auth.php +++ b/webapp/includes/auth.php @@ -3,6 +3,7 @@ * Authentication and RBAC Helper Functions * * Provides role-based access control with Cloudflare Access integration + * Admin users are stored in the database (admin_users table) */ // Role constants @@ -19,24 +20,20 @@ function getCurrentUser() { // Check for Cloudflare Access headers first // Try multiple header variations that Cloudflare might use $cfEmail = null; - $headerChecked = []; // Standard Cloudflare Access header if (!empty($_SERVER['HTTP_CF_ACCESS_AUTHENTICATED_USER_EMAIL'])) { $cfEmail = $_SERVER['HTTP_CF_ACCESS_AUTHENTICATED_USER_EMAIL']; - $headerChecked[] = 'HTTP_CF_ACCESS_AUTHENTICATED_USER_EMAIL'; } // Alternative: some proxies might pass it differently elseif (!empty($_SERVER['CF_ACCESS_AUTHENTICATED_USER_EMAIL'])) { $cfEmail = $_SERVER['CF_ACCESS_AUTHENTICATED_USER_EMAIL']; - $headerChecked[] = 'CF_ACCESS_AUTHENTICATED_USER_EMAIL'; } // Check via getallheaders() for non-standard server configs elseif (function_exists('getallheaders')) { $headers = getallheaders(); if (!empty($headers['Cf-Access-Authenticated-User-Email'])) { $cfEmail = $headers['Cf-Access-Authenticated-User-Email']; - $headerChecked[] = 'getallheaders:Cf-Access-Authenticated-User-Email'; } } @@ -66,7 +63,67 @@ function getCurrentUser() { } /** - * Get user role from database or default to staff + * Ensure admin_users table exists and seed from ADMIN_EMAILS if empty + */ +function ensureAdminUsersTable() { + if (!function_exists('getDB')) { + return false; + } + + try { + $db = getDB(); + + // Create table if not exists + $db->exec(" + CREATE TABLE IF NOT EXISTS admin_users ( + id INT AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL, + role ENUM('staff', 'admin') NOT NULL DEFAULT 'staff', + display_name VARCHAR(255) DEFAULT NULL, + active TINYINT(1) DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by VARCHAR(255) DEFAULT NULL, + UNIQUE KEY unique_email (email), + INDEX idx_role (role), + INDEX idx_active (active) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + "); + + // Check if table is empty + $count = $db->query("SELECT COUNT(*) FROM admin_users")->fetchColumn(); + + if ($count == 0) { + // Seed from ADMIN_EMAILS environment variable or constant + $adminEmails = getenv('ADMIN_EMAILS'); + if (empty($adminEmails) && defined('ADMIN_EMAILS')) { + $adminEmails = ADMIN_EMAILS; + } + + if (!empty($adminEmails)) { + $emails = array_map('trim', explode(',', $adminEmails)); + $stmt = $db->prepare("INSERT INTO admin_users (email, role, created_by) VALUES (?, 'admin', 'system_seed')"); + + foreach ($emails as $email) { + if (!empty($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) { + try { + $stmt->execute([strtolower($email)]); + } catch (Exception $e) { + // Ignore duplicates + } + } + } + } + } + + return true; + } catch (Exception $e) { + return false; + } +} + +/** + * Get user role from database * * @param string $email User email * @return string Role (admin or staff) @@ -79,45 +136,14 @@ function getUserRole($email) { // Normalize email for comparison (lowercase, trimmed) $email = strtolower(trim($email)); - // Try multiple ways to get ADMIN_EMAILS (different PHP configs handle env vars differently) - $adminEmails = ''; + // Ensure table exists and is seeded + ensureAdminUsersTable(); - // Method 1: getenv() - if (empty($adminEmails)) { - $adminEmails = getenv('ADMIN_EMAILS'); - } - - // Method 2: $_ENV superglobal - if (empty($adminEmails) && isset($_ENV['ADMIN_EMAILS'])) { - $adminEmails = $_ENV['ADMIN_EMAILS']; - } - - // Method 3: $_SERVER (some configs put env vars here) - if (empty($adminEmails) && isset($_SERVER['ADMIN_EMAILS'])) { - $adminEmails = $_SERVER['ADMIN_EMAILS']; - } - - // Method 4: Defined constant from config.php - if (empty($adminEmails) && defined('ADMIN_EMAILS')) { - $adminEmails = ADMIN_EMAILS; - } - - // Check if email is in admin list - if (!empty($adminEmails)) { - $adminList = array_map(function($e) { - return strtolower(trim($e)); - }, explode(',', $adminEmails)); - - if (in_array($email, $adminList)) { - return ROLE_ADMIN; - } - } - - // Try database lookup if getDB function exists + // Try database lookup if (function_exists('getDB')) { try { $db = getDB(); - $stmt = $db->prepare("SELECT role FROM users WHERE LOWER(email) = ? AND active = 1"); + $stmt = $db->prepare("SELECT role FROM admin_users WHERE LOWER(email) = ? AND active = 1"); $stmt->execute([$email]); $result = $stmt->fetch(PDO::FETCH_ASSOC); @@ -129,7 +155,7 @@ function getUserRole($email) { } } - // Default to staff if not found in env or database + // Default to staff if not found in database return ROLE_STAFF; } @@ -238,8 +264,26 @@ function getUserDisplayInfo() { $name = explode('@', $email)[0]; $initials = strtoupper(substr($name, 0, 2)); + // Try to get display name from database + $displayName = null; + if (function_exists('getDB')) { + try { + $db = getDB(); + $stmt = $db->prepare("SELECT display_name FROM admin_users WHERE LOWER(email) = ?"); + $stmt->execute([strtolower($email)]); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + if ($result && !empty($result['display_name'])) { + $displayName = $result['display_name']; + $nameParts = explode(' ', $displayName); + $initials = strtoupper(substr($nameParts[0], 0, 1) . (isset($nameParts[1]) ? substr($nameParts[1], 0, 1) : substr($nameParts[0], 1, 1))); + } + } catch (Exception $e) { + // Ignore + } + } + return [ - 'name' => ucfirst($name), + 'name' => $displayName ?: ucfirst($name), 'email' => $email, 'role' => $user['role'], 'initials' => $initials, @@ -247,3 +291,101 @@ function getUserDisplayInfo() { 'is_admin' => $user['role'] === ROLE_ADMIN ]; } + +/** + * Get all admin users from database + * + * @return array List of admin users + */ +function getAdminUsers() { + if (!function_exists('getDB')) { + return []; + } + + try { + $db = getDB(); + $stmt = $db->query("SELECT id, email, role, display_name, active, created_at, created_by FROM admin_users ORDER BY role DESC, email ASC"); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (Exception $e) { + return []; + } +} + +/** + * Add or update an admin user + * + * @param string $email User email + * @param string $role User role (staff or admin) + * @param string|null $displayName Display name + * @param string $createdBy Who created this user + * @return bool Success + */ +function saveAdminUser($email, $role, $displayName = null, $createdBy = null) { + if (!function_exists('getDB')) { + return false; + } + + $email = strtolower(trim($email)); + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + return false; + } + + if (!in_array($role, [ROLE_STAFF, ROLE_ADMIN])) { + $role = ROLE_STAFF; + } + + try { + $db = getDB(); + $stmt = $db->prepare(" + INSERT INTO admin_users (email, role, display_name, created_by) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE role = VALUES(role), display_name = VALUES(display_name), updated_at = CURRENT_TIMESTAMP + "); + $stmt->execute([$email, $role, $displayName, $createdBy]); + return true; + } catch (Exception $e) { + return false; + } +} + +/** + * Delete an admin user + * + * @param int $id User ID + * @return bool Success + */ +function deleteAdminUser($id) { + if (!function_exists('getDB')) { + return false; + } + + try { + $db = getDB(); + $stmt = $db->prepare("DELETE FROM admin_users WHERE id = ?"); + $stmt->execute([$id]); + return $stmt->rowCount() > 0; + } catch (Exception $e) { + return false; + } +} + +/** + * Toggle admin user active status + * + * @param int $id User ID + * @return bool Success + */ +function toggleAdminUser($id) { + if (!function_exists('getDB')) { + return false; + } + + try { + $db = getDB(); + $stmt = $db->prepare("UPDATE admin_users SET active = NOT active, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); + $stmt->execute([$id]); + return $stmt->rowCount() > 0; + } catch (Exception $e) { + return false; + } +} diff --git a/webapp/settings.php b/webapp/settings.php index dcb1046..3d0d8ef 100644 --- a/webapp/settings.php +++ b/webapp/settings.php @@ -45,7 +45,7 @@ $showSettingsLink = false; // Get current tab from URL $currentTab = $_GET['tab'] ?? 'integrations'; -$validTabs = ['integrations', 'audit', 'advanced', 'whitelabel', 'developer']; +$validTabs = ['integrations', 'users', 'audit', 'advanced', 'whitelabel', 'developer']; if (!in_array($currentTab, $validTabs)) { $currentTab = 'integrations'; } @@ -71,6 +71,15 @@ require_once __DIR__ . '/includes/header.php'; Integrations + + + + + + + + Users + @@ -283,6 +292,71 @@ require_once __DIR__ . '/includes/header.php'; + +
+
+

+ + + + + + + User Management +

+

Manage admin and staff users who can access this application. Users are authenticated via Cloudflare Access.

+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ + + +
+ + + + + + + + + + + + + + + + +
EmailDisplay NameRoleStatusCreatedActions
+
+
+
+
+
+
@@ -512,6 +586,9 @@ document.addEventListener('DOMContentLoaded', function() { loadWebhookSettings(); loadWebhookQueueStatus(); break; + case 'users': + loadUsers(); + break; case 'audit': loadAuditLog(); break; @@ -528,6 +605,177 @@ document.addEventListener('DOMContentLoaded', function() { break; } }); + +// User Management Functions +async function loadUsers() { + try { + const response = await fetch('api.php?action=admin_users_list', { + headers: { 'X-CSRF-Token': CSRF_TOKEN } + }); + const data = await response.json(); + + if (!data.success) { + throw new Error(data.error || 'Failed to load users'); + } + + const tbody = document.getElementById('usersTableBody'); + if (data.users.length === 0) { + tbody.innerHTML = 'No users found. Add a user above.'; + return; + } + + tbody.innerHTML = data.users.map(user => ` + + ${escapeHtml(user.email)} + ${user.display_name ? escapeHtml(user.display_name) : '-'} + + + ${user.role === 'admin' ? 'Admin' : 'Staff'} + + + + + ${user.active == 1 ? 'Active' : 'Inactive'} + + + ${formatDate(user.created_at)} + +
+ + +
+ + + `).join(''); + } catch (error) { + console.error('Error loading users:', error); + showNotification('Failed to load users: ' + error.message, 'error'); + } +} + +async function addUser() { + const email = document.getElementById('newUserEmail').value.trim(); + const displayName = document.getElementById('newUserDisplayName').value.trim(); + const role = document.getElementById('newUserRole').value; + + if (!email) { + showNotification('Please enter an email address', 'error'); + return; + } + + try { + const response = await fetch('api.php?action=admin_user_save', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': CSRF_TOKEN + }, + body: JSON.stringify({ + email: email, + display_name: displayName || null, + role: role + }) + }); + + const data = await response.json(); + + if (!data.success) { + throw new Error(data.error || 'Failed to add user'); + } + + showNotification('User added successfully', 'success'); + document.getElementById('newUserEmail').value = ''; + document.getElementById('newUserDisplayName').value = ''; + document.getElementById('newUserRole').value = 'staff'; + loadUsers(); + } catch (error) { + console.error('Error adding user:', error); + showNotification('Failed to add user: ' + error.message, 'error'); + } +} + +async function toggleUser(userId) { + try { + const response = await fetch('api.php?action=admin_user_toggle', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': CSRF_TOKEN + }, + body: JSON.stringify({ id: userId }) + }); + + const data = await response.json(); + + if (!data.success) { + throw new Error(data.error || 'Failed to toggle user status'); + } + + showNotification('User status updated', 'success'); + loadUsers(); + } catch (error) { + console.error('Error toggling user:', error); + showNotification('Failed to toggle user: ' + error.message, 'error'); + } +} + +async function deleteUser(userId, email) { + if (!confirm(`Are you sure you want to delete the user "${email}"? This action cannot be undone.`)) { + return; + } + + try { + const response = await fetch('api.php?action=admin_user_delete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': CSRF_TOKEN + }, + body: JSON.stringify({ id: userId }) + }); + + const data = await response.json(); + + if (!data.success) { + throw new Error(data.error || 'Failed to delete user'); + } + + showNotification('User deleted successfully', 'success'); + loadUsers(); + } catch (error) { + console.error('Error deleting user:', error); + showNotification('Failed to delete user: ' + error.message, 'error'); + } +} + +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function formatDate(dateStr) { + if (!dateStr) return '-'; + const date = new Date(dateStr); + return date.toLocaleDateString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); +}