This commit is contained in:
Purple
2026-01-18 01:51:39 +00:00
parent 952bdc9832
commit 35bcbb2d34
4 changed files with 603 additions and 43 deletions

View File

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

View File

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

View File

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

View File

@@ -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';
</svg>
Integrations
</a>
<a href="?tab=users" class="settings-nav-item <?php echo $currentTab === 'users' ? 'active' : ''; ?>">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
Users
</a>
<a href="?tab=audit" class="settings-nav-item <?php echo $currentTab === 'audit' ? 'active' : ''; ?>">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
@@ -283,6 +292,71 @@ require_once __DIR__ . '/includes/header.php';
</div>
</div>
<!-- Users Section -->
<div class="settings-section <?php echo $currentTab === 'users' ? 'active' : ''; ?>" id="section-users">
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
User Management
</h2>
<p class="advanced-section-desc">Manage admin and staff users who can access this application. Users are authenticated via Cloudflare Access.</p>
<!-- Add User Form -->
<div class="form-grid" style="margin-top: 16px; margin-bottom: 24px;">
<div class="form-group">
<label class="form-label">Email Address</label>
<input type="email" class="form-input" id="newUserEmail" placeholder="user@example.com">
</div>
<div class="form-group">
<label class="form-label">Display Name (optional)</label>
<input type="text" class="form-input" id="newUserDisplayName" placeholder="John Doe">
</div>
</div>
<div class="form-group" style="margin-bottom: 16px;">
<label class="form-label">Role</label>
<select class="form-select" id="newUserRole" style="max-width: 300px;">
<option value="staff">Staff - Can view and edit Geofeed/PTR entries</option>
<option value="admin">Admin - Full access including settings</option>
</select>
</div>
<button class="btn btn-primary" onclick="addUser()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Add User
</button>
<!-- Users Table -->
<div class="table-container" style="margin-top: 24px;">
<table class="data-table" id="usersTable">
<thead>
<tr>
<th>Email</th>
<th>Display Name</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="usersTableBody">
<tr>
<td colspan="6" style="text-align: center; padding: 40px;">
<div class="loading"><div class="spinner"></div></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Audit Log Section -->
<div class="settings-section <?php echo $currentTab === 'audit' ? 'active' : ''; ?>" id="section-audit">
<div class="advanced-section">
@@ -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 = '<tr><td colspan="6" style="text-align: center; padding: 40px; color: var(--text-tertiary);">No users found. Add a user above.</td></tr>';
return;
}
tbody.innerHTML = data.users.map(user => `
<tr data-user-id="${user.id}">
<td>${escapeHtml(user.email)}</td>
<td>${user.display_name ? escapeHtml(user.display_name) : '<span style="color: var(--text-tertiary);">-</span>'}</td>
<td>
<span class="badge ${user.role === 'admin' ? 'badge-purple' : 'badge-blue'}">
${user.role === 'admin' ? 'Admin' : 'Staff'}
</span>
</td>
<td>
<span class="badge ${user.active == 1 ? 'badge-success' : 'badge-warning'}">
${user.active == 1 ? 'Active' : 'Inactive'}
</span>
</td>
<td>${formatDate(user.created_at)}</td>
<td>
<div style="display: flex; gap: 8px;">
<button class="btn btn-secondary btn-sm" onclick="toggleUser(${user.id})" title="${user.active == 1 ? 'Deactivate' : 'Activate'}">
${user.active == 1 ?
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>' :
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>'
}
</button>
<button class="btn btn-danger btn-sm" onclick="deleteUser(${user.id}, '${escapeHtml(user.email)}')" title="Delete">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</td>
</tr>
`).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'
});
}
</script>
<?php