Files
trf_certest/public/userarea/user-admin.php
T

1417 lines
56 KiB
PHP

<?php
include('include/headscript.php');
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!$user->hasRole('Admin') && !$user->hasRole('SuperUser')) {
header('Location: import_dashboard.php');
exit;
}
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
/*
* Default values for new users.
*/
$defaultStatus = 'Active';
/*
* Avatar upload directory.
* This path assumes this page is inside /public and avatars are stored in /upload/users.
*/
$avatarUploadDir = dirname(__DIR__) . '/upload/users/';
$avatarPublicPath = '../upload/users/';
if (!is_dir($avatarUploadDir)) {
mkdir($avatarUploadDir, 0755, true);
}
/*
* Create CSRF token.
*/
if (empty($_SESSION['user_admin_csrf'])) {
$_SESSION['user_admin_csrf'] = bin2hex(random_bytes(32));
}
$csrfToken = $_SESSION['user_admin_csrf'];
function cleanInput($value)
{
return trim((string)($value ?? ''));
}
function redirectWithMessage($type, $message)
{
$_SESSION['user_admin_flash'] = [
'type' => $type,
'message' => $message
];
header('Location: ' . $_SERVER['PHP_SELF']);
exit;
}
function getEnvValue($key, $default = '')
{
if (isset($_ENV[$key]) && $_ENV[$key] !== '') {
return $_ENV[$key];
}
$value = getenv($key);
if ($value !== false && $value !== '') {
return $value;
}
return $default;
}
function uploadAvatarIfPresent($inputName, $avatarUploadDir)
{
if (
empty($_FILES[$inputName]) ||
empty($_FILES[$inputName]['name']) ||
$_FILES[$inputName]['error'] === UPLOAD_ERR_NO_FILE
) {
return null;
}
if ($_FILES[$inputName]['error'] !== UPLOAD_ERR_OK) {
throw new Exception('Avatar upload failed.');
}
$maxSize = 3 * 1024 * 1024;
if ($_FILES[$inputName]['size'] > $maxSize) {
throw new Exception('Avatar is too large. Maximum size is 3 MB.');
}
$allowedMimeTypes = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
'image/gif' => 'gif'
];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($_FILES[$inputName]['tmp_name']);
if (!isset($allowedMimeTypes[$mimeType])) {
throw new Exception('Invalid avatar format. Allowed formats: JPG, PNG, WEBP, GIF.');
}
$extension = $allowedMimeTypes[$mimeType];
$filename = 'user_' . time() . '_' . bin2hex(random_bytes(6)) . '.' . $extension;
$destination = rtrim($avatarUploadDir, '/') . '/' . $filename;
if (!move_uploaded_file($_FILES[$inputName]['tmp_name'], $destination)) {
throw new Exception('Unable to save avatar.');
}
return $filename;
}
function isRoleAllowedForManagement(PDO $pdo, $roleId)
{
/*
* Admin role cannot be assigned from this page.
*/
$stmt = $pdo->prepare("
SELECT id
FROM auth_roles
WHERE id = :id
AND name <> 'Admin'
LIMIT 1
");
$stmt->execute([':id' => (int)$roleId]);
return (bool)$stmt->fetch(PDO::FETCH_ASSOC);
}
function isUserAdmin(PDO $pdo, $userId)
{
$stmt = $pdo->prepare("
SELECT u.id
FROM auth_users u
INNER JOIN auth_roles r ON r.id = u.role_id
WHERE u.id = :id
AND r.name = 'Admin'
LIMIT 1
");
$stmt->execute([':id' => (int)$userId]);
return (bool)$stmt->fetch(PDO::FETCH_ASSOC);
}
function smtpReadLine($socket)
{
$response = '';
while ($line = fgets($socket, 515)) {
$response .= $line;
if (isset($line[3]) && $line[3] === ' ') {
break;
}
}
return $response;
}
function smtpCommand($socket, $command, array $expectedCodes)
{
if ($command !== null) {
fwrite($socket, $command . "\r\n");
}
$response = smtpReadLine($socket);
$code = (int)substr($response, 0, 3);
if (!in_array($code, $expectedCodes, true)) {
throw new Exception('SMTP error after command [' . $command . ']: ' . trim($response));
}
return $response;
}
function buildSmartTrfWelcomeEmailHtml($firstName, $lastName, $email, $temporaryPassword, $loginUrl)
{
$fullName = trim($firstName . ' ' . $lastName);
$safeFullName = htmlspecialchars($fullName, ENT_QUOTES, 'UTF-8');
$safeEmail = htmlspecialchars($email, ENT_QUOTES, 'UTF-8');
$safePassword = htmlspecialchars($temporaryPassword, ENT_QUOTES, 'UTF-8');
$safeLoginUrl = htmlspecialchars($loginUrl, ENT_QUOTES, 'UTF-8');
return '
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Welcome to SmartTRF</title>
</head>
<body style="margin:0;padding:0;background:#f4f7fb;font-family:Arial,Helvetica,sans-serif;color:#1f2937;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f7fb;padding:30px 0;">
<tr>
<td align="center">
<table width="620" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:16px;overflow:hidden;box-shadow:0 10px 30px rgba(15,23,42,0.08);">
<tr>
<td style="background:linear-gradient(135deg,#0f172a,#1d4ed8);padding:34px 36px;">
<div style="font-size:26px;font-weight:700;color:#ffffff;letter-spacing:0.2px;">SmartTRF</div>
<div style="font-size:14px;color:#dbeafe;margin-top:6px;">Your digital workspace for sample and TRF management</div>
</td>
</tr>
<tr>
<td style="padding:34px 36px 10px 36px;">
<h1 style="font-size:22px;line-height:1.3;margin:0 0 14px 0;color:#111827;">Welcome, ' . $safeFullName . '</h1>
<p style="font-size:15px;line-height:1.7;margin:0;color:#374151;">
Your SmartTRF account has been created. You can now access the platform using the credentials below.
</p>
</td>
</tr>
<tr>
<td style="padding:18px 36px;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;border:1px solid #e5e7eb;border-radius:12px;">
<tr>
<td style="padding:20px 22px;">
<div style="font-size:13px;text-transform:uppercase;letter-spacing:0.08em;color:#64748b;font-weight:700;margin-bottom:12px;">
Access credentials
</div>
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="font-size:14px;color:#64748b;padding:7px 0;width:130px;">Email</td>
<td style="font-size:15px;color:#111827;font-weight:600;padding:7px 0;">' . $safeEmail . '</td>
</tr>
<tr>
<td style="font-size:14px;color:#64748b;padding:7px 0;width:130px;">Temporary password</td>
<td style="font-size:16px;color:#111827;font-weight:700;padding:7px 0;font-family:Consolas,Monaco,monospace;">' . $safePassword . '</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:6px 36px 18px 36px;">
<div style="background:#fff7ed;border:1px solid #fed7aa;border-radius:12px;padding:16px 18px;">
<div style="font-size:15px;font-weight:700;color:#9a3412;margin-bottom:5px;">Security recommendation</div>
<div style="font-size:14px;line-height:1.6;color:#7c2d12;">
This is a temporary password. For security reasons, please change it after your first login.
</div>
</div>
</td>
</tr>
<tr>
<td align="center" style="padding:12px 36px 34px 36px;">
<a href="' . $safeLoginUrl . '" style="display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;font-size:15px;font-weight:700;padding:14px 26px;border-radius:10px;">
Access SmartTRF
</a>
<div style="font-size:12px;line-height:1.6;color:#64748b;margin-top:18px;">
If the button does not work, copy and paste this link into your browser:<br>
<span style="color:#2563eb;">' . $safeLoginUrl . '</span>
</div>
</td>
</tr>
<tr>
<td style="background:#f8fafc;padding:22px 36px;border-top:1px solid #e5e7eb;">
<div style="font-size:12px;line-height:1.6;color:#64748b;">
This message was automatically generated by SmartTRF. Please do not reply to this email.
</div>
</td>
</tr>
</table>
<div style="font-size:12px;color:#94a3b8;margin-top:18px;">
SmartTRF · C.E. Soft
</div>
</td>
</tr>
</table>
</body>
</html>';
}
function buildSmartTrfWelcomeEmailText($firstName, $lastName, $email, $temporaryPassword, $loginUrl)
{
$fullName = trim($firstName . ' ' . $lastName);
return "Welcome to SmartTRF\n\n" .
"Hello " . $fullName . ",\n\n" .
"Your SmartTRF account has been created.\n\n" .
"Access credentials:\n" .
"Email: " . $email . "\n" .
"Temporary password: " . $temporaryPassword . "\n\n" .
"For security reasons, please change this temporary password after your first login.\n\n" .
"Login URL:\n" .
$loginUrl . "\n\n" .
"This message was automatically generated by SmartTRF.";
}
function sendSmartTrfWelcomeEmail($toEmail, $firstName, $lastName, $temporaryPassword)
{
$smtpHost = getEnvValue('MAIL_HOST');
$smtpPort = (int)getEnvValue('MAIL_PORT', 465);
$smtpUsername = getEnvValue('MAIL_USERNAME');
$smtpPassword = getEnvValue('MAIL_PASSWORD');
$smtpEncryption = strtolower(getEnvValue('MAIL_ENCRYPTION', 'ssl'));
$fromAddress = getEnvValue('MAIL_FROM_ADDRESS', $smtpUsername);
$fromName = getEnvValue('MAIL_FROM_NAME', 'SmartTRF');
$loginUrl = getEnvValue('APP_URL', 'https://trf.cesoft.io/public/login');
if ($smtpHost === '' || $smtpUsername === '' || $smtpPassword === '' || $fromAddress === '') {
throw new Exception('SMTP configuration is incomplete.');
}
$subject = 'Welcome to SmartTRF - Your account has been created';
$htmlBody = buildSmartTrfWelcomeEmailHtml($firstName, $lastName, $toEmail, $temporaryPassword, $loginUrl);
$textBody = buildSmartTrfWelcomeEmailText($firstName, $lastName, $toEmail, $temporaryPassword, $loginUrl);
$boundary = 'smarttrf_' . bin2hex(random_bytes(12));
$encodedSubject = '=?UTF-8?B?' . base64_encode($subject) . '?=';
$encodedFromName = '=?UTF-8?B?' . base64_encode($fromName) . '?=';
$headers = [];
$headers[] = 'From: ' . $encodedFromName . ' <' . $fromAddress . '>';
$headers[] = 'To: <' . $toEmail . '>';
$headers[] = 'Subject: ' . $encodedSubject;
$headers[] = 'MIME-Version: 1.0';
$headers[] = 'Content-Type: multipart/alternative; boundary="' . $boundary . '"';
$headers[] = 'Date: ' . date('r');
$headers[] = 'Message-ID: <' . bin2hex(random_bytes(16)) . '@smarttrf>';
$message = '';
$message .= '--' . $boundary . "\r\n";
$message .= "Content-Type: text/plain; charset=UTF-8\r\n";
$message .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
$message .= $textBody . "\r\n\r\n";
$message .= '--' . $boundary . "\r\n";
$message .= "Content-Type: text/html; charset=UTF-8\r\n";
$message .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
$message .= $htmlBody . "\r\n\r\n";
$message .= '--' . $boundary . "--\r\n";
$smtpMessage = implode("\r\n", $headers) . "\r\n\r\n" . $message;
$transportHost = ($smtpEncryption === 'ssl' ? 'ssl://' : '') . $smtpHost;
$socket = stream_socket_client(
$transportHost . ':' . $smtpPort,
$errno,
$errstr,
30,
STREAM_CLIENT_CONNECT
);
if (!$socket) {
throw new Exception('SMTP connection failed: ' . $errstr . ' (' . $errno . ')');
}
stream_set_timeout($socket, 30);
try {
smtpCommand($socket, null, [220]);
smtpCommand($socket, 'EHLO ' . ($_SERVER['SERVER_NAME'] ?? 'localhost'), [250]);
if ($smtpEncryption === 'tls') {
smtpCommand($socket, 'STARTTLS', [220]);
if (!stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
throw new Exception('Unable to enable TLS encryption.');
}
smtpCommand($socket, 'EHLO ' . ($_SERVER['SERVER_NAME'] ?? 'localhost'), [250]);
}
smtpCommand($socket, 'AUTH LOGIN', [334]);
smtpCommand($socket, base64_encode($smtpUsername), [334]);
smtpCommand($socket, base64_encode($smtpPassword), [235]);
smtpCommand($socket, 'MAIL FROM:<' . $fromAddress . '>', [250]);
smtpCommand($socket, 'RCPT TO:<' . $toEmail . '>', [250, 251]);
smtpCommand($socket, 'DATA', [354]);
fwrite($socket, $smtpMessage . "\r\n.\r\n");
smtpCommand($socket, null, [250]);
smtpCommand($socket, 'QUIT', [221]);
} finally {
fclose($socket);
}
return true;
}
/*
* Load roles available for creation/edit.
* Admin is intentionally excluded.
*/
$stmtRoles = $pdo->query("
SELECT id, name, display_name
FROM auth_roles
WHERE name <> 'Admin'
ORDER BY display_name ASC, name ASC
");
$manageableRoles = $stmtRoles->fetchAll(PDO::FETCH_ASSOC);
if (empty($manageableRoles)) {
redirectWithMessage('error', 'No manageable roles found. Please create at least one non-admin role.');
}
$defaultRoleId = (int)$manageableRoles[0]['id'];
/*
* Handle form actions.
*/
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$postedToken = $_POST['csrf_token'] ?? '';
if (!hash_equals($csrfToken, $postedToken)) {
throw new Exception('Invalid security token.');
}
$action = cleanInput($_POST['action'] ?? '');
if ($action === 'create') {
$firstName = cleanInput($_POST['first_name'] ?? '');
$lastName = cleanInput($_POST['last_name'] ?? '');
$email = cleanInput($_POST['email'] ?? '');
$password = (string)($_POST['password'] ?? '');
$roleId = (int)($_POST['role_id'] ?? 0);
$limsUserId = cleanInput($_POST['lims_user_id'] ?? '');
$limsGlobalUserId = cleanInput($_POST['lims_global_user_id'] ?? '');
if ($firstName === '' || $lastName === '' || $email === '' || $password === '' || $roleId <= 0) {
throw new Exception('Please fill all required fields.');
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new Exception('Invalid email address.');
}
if (!isRoleAllowedForManagement($pdo, $roleId)) {
throw new Exception('You cannot assign the Admin role from this page.');
}
$stmt = $pdo->prepare("SELECT id FROM auth_users WHERE email = :email LIMIT 1");
$stmt->execute([':email' => $email]);
if ($stmt->fetch()) {
throw new Exception('This email already exists.');
}
$avatarFilename = uploadAvatarIfPresent('avatar', $avatarUploadDir);
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
$sql = "
INSERT INTO auth_users
(
email,
username,
password,
first_name,
last_name,
avatar,
role_id,
status,
lims_user_id,
lims_global_user_id,
created_at,
updated_at
)
VALUES
(
:email,
NULL,
:password,
:first_name,
:last_name,
:avatar,
:role_id,
:status,
:lims_user_id,
:lims_global_user_id,
NOW(),
NOW()
)
";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':email' => $email,
':password' => $passwordHash,
':first_name' => $firstName,
':last_name' => $lastName,
':avatar' => $avatarFilename,
':role_id' => $roleId,
':status' => $defaultStatus,
':lims_user_id' => ($limsUserId !== '' ? (int)$limsUserId : null),
':lims_global_user_id' => ($limsGlobalUserId !== '' ? (int)$limsGlobalUserId : null)
]);
try {
sendSmartTrfWelcomeEmail($email, $firstName, $lastName, $password);
redirectWithMessage('success', 'User created successfully. Welcome email sent.');
} catch (Exception $mailException) {
error_log('SmartTRF welcome email error: ' . $mailException->getMessage());
redirectWithMessage('warning', 'User created successfully, but the welcome email was not sent: ' . $mailException->getMessage());
}
}
if ($action === 'update') {
$userId = (int)($_POST['user_id'] ?? 0);
$firstName = cleanInput($_POST['first_name'] ?? '');
$lastName = cleanInput($_POST['last_name'] ?? '');
$email = cleanInput($_POST['email'] ?? '');
$password = (string)($_POST['password'] ?? '');
$roleId = (int)($_POST['role_id'] ?? 0);
$limsUserId = cleanInput($_POST['lims_user_id'] ?? '');
$limsGlobalUserId = cleanInput($_POST['lims_global_user_id'] ?? '');
if ($userId <= 0) {
throw new Exception('Invalid user ID.');
}
if (isUserAdmin($pdo, $userId)) {
throw new Exception('Admin users can be viewed but cannot be edited from this page.');
}
if ($firstName === '' || $lastName === '' || $email === '' || $roleId <= 0) {
throw new Exception('Please fill all required fields.');
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new Exception('Invalid email address.');
}
if (!isRoleAllowedForManagement($pdo, $roleId)) {
throw new Exception('You cannot assign the Admin role from this page.');
}
$stmt = $pdo->prepare("SELECT id FROM auth_users WHERE email = :email AND id <> :id LIMIT 1");
$stmt->execute([
':email' => $email,
':id' => $userId
]);
if ($stmt->fetch()) {
throw new Exception('This email is already used by another user.');
}
$avatarFilename = uploadAvatarIfPresent('avatar', $avatarUploadDir);
$fields = [
'email = :email',
'first_name = :first_name',
'last_name = :last_name',
'role_id = :role_id',
'lims_user_id = :lims_user_id',
'lims_global_user_id = :lims_global_user_id',
'updated_at = NOW()'
];
$params = [
':email' => $email,
':first_name' => $firstName,
':last_name' => $lastName,
':role_id' => $roleId,
':lims_user_id' => ($limsUserId !== '' ? (int)$limsUserId : null),
':lims_global_user_id' => ($limsGlobalUserId !== '' ? (int)$limsGlobalUserId : null),
':id' => $userId
];
if ($avatarFilename !== null) {
$fields[] = 'avatar = :avatar';
$params[':avatar'] = $avatarFilename;
}
if ($password !== '') {
$fields[] = 'password = :password';
$params[':password'] = password_hash($password, PASSWORD_DEFAULT);
}
$sql = "UPDATE auth_users SET " . implode(', ', $fields) . " WHERE id = :id";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
redirectWithMessage('success', 'User updated successfully.');
}
if ($action === 'delete') {
$userId = (int)($_POST['user_id'] ?? 0);
if ($userId <= 0) {
throw new Exception('Invalid user ID.');
}
if (isset($iduserlogin) && (int)$iduserlogin === $userId) {
throw new Exception('You cannot delete your own user.');
}
if (isUserAdmin($pdo, $userId)) {
throw new Exception('Admin users can be viewed but cannot be deleted from this page.');
}
$stmt = $pdo->prepare("DELETE FROM auth_users WHERE id = :id");
$stmt->execute([':id' => $userId]);
redirectWithMessage('success', 'User deleted successfully.');
}
throw new Exception('Invalid action.');
} catch (Exception $e) {
redirectWithMessage('error', $e->getMessage());
}
}
/*
* Load users with role information.
*/
$stmt = $pdo->query("
SELECT
u.id,
u.first_name,
u.last_name,
u.email,
u.avatar,
u.status,
u.role_id,
u.lims_user_id,
u.lims_global_user_id,
u.created_at,
u.updated_at,
r.name AS role_name,
r.display_name AS role_display_name
FROM auth_users u
LEFT JOIN auth_roles r ON r.id = u.role_id
ORDER BY u.first_name ASC, u.last_name ASC, u.email ASC
");
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
$flash = $_SESSION['user_admin_flash'] ?? null;
unset($_SESSION['user_admin_flash']);
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="assets/images/favicon-32x32.png" type="image/png" />
<?php include('cssinclude.php'); ?>
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<title>User Administration - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?></title>
<style>
.user-avatar {
width: 42px;
height: 42px;
object-fit: cover;
border-radius: 50%;
border: 1px solid #e5e7eb;
background: #f8f9fa;
}
.user-avatar-preview {
width: 74px;
height: 74px;
object-fit: cover;
border-radius: 50%;
border: 1px solid #e5e7eb;
background: #f8f9fa;
}
.table-actions {
white-space: nowrap;
}
.modal-avatar-box {
display: flex;
align-items: center;
gap: 15px;
}
.small-muted {
font-size: 12px;
color: #6c757d;
}
.role-badge-admin {
background: #212529;
color: #fff;
}
.role-badge-user {
background: #0d6efd;
color: #fff;
}
.role-badge-superuser {
background: #6f42c1;
color: #fff;
}
.role-badge-default {
background: #6c757d;
color: #fff;
}
.select2-container {
width: 100% !important;
}
.lims-loading {
font-size: 12px;
color: #6c757d;
}
.password-note {
background: #fff7ed;
border: 1px solid #fed7aa;
color: #9a3412;
border-radius: 10px;
padding: 10px 12px;
font-size: 13px;
line-height: 1.5;
}
</style>
</head>
<body>
<div class="wrapper">
<?php include('include/navbar.php'); ?>
<?php include('include/topbar.php'); ?>
<div class="page-wrapper">
<div class="page-content">
<?php include('top_stat_widget.php'); ?>
<div class="card radius-10">
<div class="card-header">
<div class="d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-0">User Administration</h6>
<small class="text-muted">Create, edit, change password and delete system users.</small>
</div>
<button type="button" class="btn btn-primary" id="btnAddUser">
<i class="bx bx-plus"></i> Add User
</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table id="usersTable" class="table table-striped table-bordered align-middle">
<thead>
<tr>
<th style="width: 70px;">Avatar</th>
<th>First Name</th>
<th>Last Name</th>
<th>Email</th>
<th>Role</th>
<th>Accettatore</th>
<th>LIMS Global</th>
<th>Status</th>
<th style="width: 190px;">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $userRow): ?>
<?php
$avatar = !empty($userRow['avatar']) ? $avatarPublicPath . $userRow['avatar'] : $avatarPublicPath . 'profile.png';
$roleName = $userRow['role_name'] ?? '';
$roleDisplayName = $userRow['role_display_name'] ?: $roleName;
$isAdminUser = ($roleName === 'Admin');
$roleBadgeClass = 'role-badge-default';
if ($roleName === 'Admin') {
$roleBadgeClass = 'role-badge-admin';
} elseif ($roleName === 'User') {
$roleBadgeClass = 'role-badge-user';
} elseif ($roleName === 'SuperUser') {
$roleBadgeClass = 'role-badge-superuser';
}
$cannotDeleteSelf = isset($iduserlogin) && (int)$iduserlogin === (int)$userRow['id'];
?>
<tr>
<td>
<img src="<?= htmlspecialchars($avatar, ENT_QUOTES, 'UTF-8'); ?>" class="user-avatar" alt="Avatar">
</td>
<td><?= htmlspecialchars($userRow['first_name'] ?? '', ENT_QUOTES, 'UTF-8'); ?></td>
<td><?= htmlspecialchars($userRow['last_name'] ?? '', ENT_QUOTES, 'UTF-8'); ?></td>
<td><?= htmlspecialchars($userRow['email'] ?? '', ENT_QUOTES, 'UTF-8'); ?></td>
<td>
<span class="badge <?= htmlspecialchars($roleBadgeClass, ENT_QUOTES, 'UTF-8'); ?>">
<?= htmlspecialchars($roleDisplayName ?? '', ENT_QUOTES, 'UTF-8'); ?>
</span>
</td>
<td
class="lims-acceptor-display"
data-lims-user-id="<?= htmlspecialchars($userRow['lims_user_id'] ?? '', ENT_QUOTES, 'UTF-8'); ?>">
<?= htmlspecialchars($userRow['lims_user_id'] ?? '', ENT_QUOTES, 'UTF-8'); ?>
</td>
<td
class="lims-global-display"
data-lims-global-user-id="<?= htmlspecialchars($userRow['lims_global_user_id'] ?? '', ENT_QUOTES, 'UTF-8'); ?>">
<?= htmlspecialchars($userRow['lims_global_user_id'] ?? '', ENT_QUOTES, 'UTF-8'); ?>
</td>
<td>
<span class="badge bg-success">
<?= htmlspecialchars($userRow['status'] ?? '', ENT_QUOTES, 'UTF-8'); ?>
</span>
</td>
<td class="table-actions">
<?php if ($isAdminUser): ?>
<button type="button" class="btn btn-sm btn-outline-secondary" disabled>
Admin locked
</button>
<?php else: ?>
<button
type="button"
class="btn btn-sm btn-outline-primary btnEditUser"
data-id="<?= (int)$userRow['id']; ?>"
data-first-name="<?= htmlspecialchars($userRow['first_name'] ?? '', ENT_QUOTES, 'UTF-8'); ?>"
data-last-name="<?= htmlspecialchars($userRow['last_name'] ?? '', ENT_QUOTES, 'UTF-8'); ?>"
data-email="<?= htmlspecialchars($userRow['email'] ?? '', ENT_QUOTES, 'UTF-8'); ?>"
data-role-id="<?= (int)$userRow['role_id']; ?>"
data-lims-user-id="<?= htmlspecialchars($userRow['lims_user_id'] ?? '', ENT_QUOTES, 'UTF-8'); ?>"
data-lims-global-user-id="<?= htmlspecialchars($userRow['lims_global_user_id'] ?? '', ENT_QUOTES, 'UTF-8'); ?>"
data-avatar="<?= htmlspecialchars($avatar, ENT_QUOTES, 'UTF-8'); ?>">
Edit
</button>
<?php if ($cannotDeleteSelf): ?>
<button type="button" class="btn btn-sm btn-outline-secondary" disabled>
Current user
</button>
<?php else: ?>
<button
type="button"
class="btn btn-sm btn-outline-danger btnDeleteUser"
data-id="<?= (int)$userRow['id']; ?>"
data-name="<?= htmlspecialchars(trim(($userRow['first_name'] ?? '') . ' ' . ($userRow['last_name'] ?? '')), ENT_QUOTES, 'UTF-8'); ?>">
Delete
</button>
<?php endif; ?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="lims-loading mt-2" id="limsLoadingMessage">
Loading LIMS users and acceptors...
</div>
</div>
</div>
</div>
</div>
<div class="overlay toggle-icon"></div>
<a href="javaScript:;" class="back-to-top"><i class='bx bxs-up-arrow-alt'></i></a>
<?php include('include/footer.php'); ?>
</div>
<!-- Add/Edit User Modal -->
<div class="modal fade" id="userModal" tabindex="-1" aria-labelledby="userModalTitle" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<form method="POST" enctype="multipart/form-data" class="modal-content" id="userForm">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8'); ?>">
<input type="hidden" name="action" id="formAction" value="create">
<input type="hidden" name="user_id" id="userId" value="">
<div class="modal-header">
<h5 class="modal-title" id="userModalTitle">Add User</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-md-6">
<label for="firstName" class="form-label">First Name *</label>
<input type="text" class="form-control" id="firstName" name="first_name" required>
</div>
<div class="col-md-6">
<label for="lastName" class="form-label">Last Name *</label>
<input type="text" class="form-control" id="lastName" name="last_name" required>
</div>
<div class="col-md-12">
<label for="email" class="form-label">Email *</label>
<input type="email" class="form-control" id="email" name="email" required>
<div class="small-muted mt-1" id="emailHelp">
When a new user is created, SmartTRF sends a welcome email with the temporary password.
</div>
</div>
<div class="col-md-12">
<label for="roleId" class="form-label">Role *</label>
<select class="form-select" id="roleId" name="role_id" required>
<option value="">Select role</option>
<?php foreach ($manageableRoles as $role): ?>
<option value="<?= (int)$role['id']; ?>">
<?= htmlspecialchars($role['display_name'] ?: $role['name'], ENT_QUOTES, 'UTF-8'); ?>
</option>
<?php endforeach; ?>
</select>
<div class="small-muted mt-1">
Admin role is visible in the table but cannot be assigned from this page.
</div>
</div>
<div class="col-md-6">
<label for="limsUserId" class="form-label">Accettatore</label>
<select class="form-select" id="limsUserId" name="lims_user_id">
<option value="">Select acceptor</option>
</select>
<div class="small-muted mt-1">
Values loaded from CustomField ID 244.
</div>
</div>
<div class="col-md-6">
<label for="limsGlobalUserId" class="form-label">LIMS Global</label>
<select class="form-select" id="limsGlobalUserId" name="lims_global_user_id">
<option value="">Select LIMS user</option>
</select>
<div class="small-muted mt-1">
Values loaded from LIMS users list.
</div>
</div>
<div class="col-md-12">
<label for="password" class="form-label" id="passwordLabel">Temporary Password *</label>
<input type="password" class="form-control" id="password" name="password">
<div class="small-muted" id="passwordHelp">
For new users, this temporary password will be sent by email. When editing, leave this field empty to keep the current password unchanged.
</div>
</div>
<div class="col-md-12">
<div class="password-note" id="passwordSecurityNote">
<strong>Security note:</strong> the password entered during user creation is sent to the user as a temporary password. The email will recommend changing it after the first login.
</div>
</div>
<div class="col-md-12">
<label for="avatar" class="form-label">Avatar</label>
<div class="modal-avatar-box mb-2">
<img src="<?= htmlspecialchars($avatarPublicPath . 'profile.png', ENT_QUOTES, 'UTF-8'); ?>" id="avatarPreview" class="user-avatar-preview" alt="Avatar preview">
<div>
<input type="file" class="form-control" id="avatar" name="avatar" accept="image/jpeg,image/png,image/webp,image/gif">
<div class="small-muted mt-1">Allowed formats: JPG, PNG, WEBP, GIF. Max 3 MB.</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary" id="saveUserButton">Save User</button>
</div>
</form>
</div>
</div>
<!-- Delete Form -->
<form method="POST" id="deleteUserForm" style="display:none;">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8'); ?>">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="user_id" id="deleteUserId" value="">
</form>
<?php include('jsinclude.php'); ?>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const userModalElement = document.getElementById('userModal');
const userModal = new bootstrap.Modal(userModalElement);
const formAction = document.getElementById('formAction');
const userId = document.getElementById('userId');
const userModalTitle = document.getElementById('userModalTitle');
const saveUserButton = document.getElementById('saveUserButton');
const firstName = document.getElementById('firstName');
const lastName = document.getElementById('lastName');
const email = document.getElementById('email');
const roleId = document.getElementById('roleId');
const limsUserId = document.getElementById('limsUserId');
const limsGlobalUserId = document.getElementById('limsGlobalUserId');
const password = document.getElementById('password');
const passwordLabel = document.getElementById('passwordLabel');
const passwordHelp = document.getElementById('passwordHelp');
const passwordSecurityNote = document.getElementById('passwordSecurityNote');
const avatar = document.getElementById('avatar');
const avatarPreview = document.getElementById('avatarPreview');
const limsLoadingMessage = document.getElementById('limsLoadingMessage');
const defaultAvatar = "<?= htmlspecialchars($avatarPublicPath . 'profile.png', ENT_QUOTES, 'UTF-8'); ?>";
const defaultRoleId = "<?= (int)$defaultRoleId; ?>";
let limsGlobalUsersMap = {};
let limsAcceptorsMap = {};
/*
* Adjust only these endpoints if the page is moved to another folder.
*/
const limsGlobalUsersEndpoint = 'get_utenti.php';
const limsAcceptorsEndpoint = 'get_customfield_values.php?field_ids=244';
function initSelect2() {
if (typeof $ === 'undefined') {
console.error('jQuery is not loaded. Select2 cannot start.');
return;
}
if (typeof $.fn.select2 === 'undefined') {
console.error('Select2 is not loaded. Check select2.min.js include.');
return;
}
$('#roleId').select2({
dropdownParent: $('#userModal'),
width: '100%',
minimumResultsForSearch: 0,
placeholder: 'Select role'
});
$('#limsUserId').select2({
dropdownParent: $('#userModal'),
width: '100%',
placeholder: 'Select acceptor',
allowClear: true,
minimumResultsForSearch: 0
});
$('#limsGlobalUserId').select2({
dropdownParent: $('#userModal'),
width: '100%',
placeholder: 'Select LIMS user',
allowClear: true,
minimumResultsForSearch: 0
});
}
function destroySelect2IfActive(selector) {
if (typeof $ !== 'undefined' && $.fn.select2 && $(selector).hasClass('select2-hidden-accessible')) {
$(selector).select2('destroy');
}
}
function refreshLimsSelect2() {
if (typeof $ === 'undefined' || typeof $.fn.select2 === 'undefined') {
return;
}
destroySelect2IfActive('#limsUserId');
destroySelect2IfActive('#limsGlobalUserId');
$('#limsUserId').select2({
dropdownParent: $('#userModal'),
width: '100%',
placeholder: 'Select acceptor',
allowClear: true,
minimumResultsForSearch: 0
});
$('#limsGlobalUserId').select2({
dropdownParent: $('#userModal'),
width: '100%',
placeholder: 'Select LIMS user',
allowClear: true,
minimumResultsForSearch: 0
});
$('#limsUserId').trigger('change');
$('#limsGlobalUserId').trigger('change');
}
function setSelectValue(selectElement, value) {
selectElement.value = value || '';
if (typeof $ !== 'undefined' && $.fn.select2) {
$(selectElement).trigger('change');
}
}
function clearSelect(selectElement, placeholderText) {
selectElement.innerHTML = '';
const option = document.createElement('option');
option.value = '';
option.textContent = placeholderText;
selectElement.appendChild(option);
}
function appendOption(selectElement, value, text) {
if (value === null || value === undefined || value === '') {
return;
}
const option = document.createElement('option');
option.value = String(value);
option.textContent = text || String(value);
selectElement.appendChild(option);
}
function normalizeLimsGlobalUsers(data) {
const list = Array.isArray(data) ? data : (data.value || []);
const normalized = [];
list.forEach(function(item) {
const id = item.IdUtente ?? item.id ?? item.ID ?? null;
const text = item.Nome || item.Nominativo || item.UserName || item.Email || id;
if (id !== null && id !== undefined && id !== '') {
normalized.push({
id: String(id),
text: String(text)
});
}
});
normalized.sort(function(a, b) {
return String(a.text).localeCompare(String(b.text), 'it', {
sensitivity: 'base'
});
});
return normalized;
}
function normalizeAcceptors(data) {
/*
* Expected response:
* {
* "244": [
* { ... }
* ]
* }
*/
const list = data['244'] || data[244] || [];
const normalized = [];
list.forEach(function(item) {
const id =
item.IdCustomFieldValue ??
item.IdCustomFieldsValue ??
item.IdCustomFieldValues ??
item.Id ??
item.ID ??
item.id ??
item.ValueId ??
item.value_id ??
item.Codice ??
null;
const text =
item.Value ??
item.Valore ??
item.Nome ??
item.Name ??
item.Descrizione ??
item.Description ??
item.Text ??
item.text ??
item.Label ??
item.label ??
id;
if (id !== null && id !== undefined && id !== '') {
normalized.push({
id: String(id),
text: String(text)
});
}
});
normalized.sort(function(a, b) {
return String(a.text).localeCompare(String(b.text), 'it', {
sensitivity: 'base'
});
});
return normalized;
}
function populateLimsGlobalUsers(users) {
clearSelect(limsGlobalUserId, 'Select LIMS user');
limsGlobalUsersMap = {};
users.forEach(function(user) {
limsGlobalUsersMap[user.id] = user.text;
appendOption(limsGlobalUserId, user.id, user.text);
});
}
function populateAcceptors(acceptors) {
clearSelect(limsUserId, 'Select acceptor');
limsAcceptorsMap = {};
acceptors.forEach(function(item) {
limsAcceptorsMap[item.id] = item.text;
appendOption(limsUserId, item.id, item.text);
});
}
function updateTableDisplayNames() {
document.querySelectorAll('.lims-global-display').forEach(function(cell) {
const id = String(cell.dataset.limsGlobalUserId || '');
if (id && limsGlobalUsersMap[id]) {
cell.textContent = limsGlobalUsersMap[id];
}
});
document.querySelectorAll('.lims-acceptor-display').forEach(function(cell) {
const id = String(cell.dataset.limsUserId || '');
if (id && limsAcceptorsMap[id]) {
cell.textContent = limsAcceptorsMap[id];
}
});
}
async function loadLimsDropdowns() {
try {
const [globalResponse, acceptorsResponse] = await Promise.all([
fetch(limsGlobalUsersEndpoint, {
cache: 'no-store'
}),
fetch(limsAcceptorsEndpoint, {
cache: 'no-store'
})
]);
if (!globalResponse.ok) {
throw new Error('Unable to load LIMS global users.');
}
if (!acceptorsResponse.ok) {
throw new Error('Unable to load LIMS acceptors.');
}
const globalData = await globalResponse.json();
const acceptorsData = await acceptorsResponse.json();
const globalUsers = normalizeLimsGlobalUsers(globalData);
const acceptors = normalizeAcceptors(acceptorsData);
populateLimsGlobalUsers(globalUsers);
populateAcceptors(acceptors);
updateTableDisplayNames();
refreshLimsSelect2();
if (limsLoadingMessage) {
limsLoadingMessage.textContent = 'LIMS users and acceptors loaded.';
}
} catch (error) {
console.error(error);
if (limsLoadingMessage) {
limsLoadingMessage.textContent = 'Warning: unable to load LIMS users or acceptors.';
}
Swal.fire({
icon: 'warning',
title: 'LIMS lists not loaded',
text: error.message || 'Unable to load LIMS dropdown values.'
});
}
}
function resetModalForCreate() {
formAction.value = 'create';
userId.value = '';
userModalTitle.textContent = 'Add User';
saveUserButton.textContent = 'Create User';
firstName.value = '';
lastName.value = '';
email.value = '';
setSelectValue(roleId, defaultRoleId);
setSelectValue(limsUserId, '');
setSelectValue(limsGlobalUserId, '');
password.value = '';
avatar.value = '';
avatarPreview.src = defaultAvatar;
password.required = true;
passwordLabel.textContent = 'Temporary Password *';
passwordHelp.textContent = 'Password is required when creating a new user. It will be sent by email as a temporary password.';
passwordSecurityNote.style.display = 'block';
}
function setModalForEdit(button) {
formAction.value = 'update';
userId.value = button.dataset.id || '';
userModalTitle.textContent = 'Edit User';
saveUserButton.textContent = 'Update User';
firstName.value = button.dataset.firstName || '';
lastName.value = button.dataset.lastName || '';
email.value = button.dataset.email || '';
setSelectValue(roleId, button.dataset.roleId || '');
setSelectValue(limsUserId, button.dataset.limsUserId || '');
setSelectValue(limsGlobalUserId, button.dataset.limsGlobalUserId || '');
password.value = '';
avatar.value = '';
avatarPreview.src = button.dataset.avatar || defaultAvatar;
password.required = false;
passwordLabel.textContent = 'Password';
passwordHelp.textContent = 'Leave this field empty to keep the current password unchanged. Type a new password only if you want to replace it.';
passwordSecurityNote.style.display = 'none';
}
document.getElementById('btnAddUser').addEventListener('click', function() {
resetModalForCreate();
userModal.show();
});
document.querySelectorAll('.btnEditUser').forEach(function(button) {
button.addEventListener('click', function() {
setModalForEdit(button);
userModal.show();
});
});
document.querySelectorAll('.btnDeleteUser').forEach(function(button) {
button.addEventListener('click', function() {
const id = button.dataset.id;
const name = button.dataset.name || 'this user';
Swal.fire({
title: 'Delete user?',
text: 'Are you sure you want to delete ' + name + '? This action cannot be undone.',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Yes, delete',
cancelButtonText: 'Cancel',
confirmButtonColor: '#d33'
}).then(function(result) {
if (result.isConfirmed) {
document.getElementById('deleteUserId').value = id;
document.getElementById('deleteUserForm').submit();
}
});
});
});
avatar.addEventListener('change', function(event) {
const file = event.target.files[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = function(e) {
avatarPreview.src = e.target.result;
};
reader.readAsDataURL(file);
});
initSelect2();
if (typeof $ !== 'undefined' && $.fn.DataTable) {
$('#usersTable').DataTable({
pageLength: 25,
order: [
[1, 'asc'],
[2, 'asc']
]
});
}
loadLimsDropdowns();
<?php if ($flash): ?>
Swal.fire({
icon: '<?= htmlspecialchars($flash['type'], ENT_QUOTES, 'UTF-8'); ?>',
title: '<?= $flash['type'] === 'success' ? 'Done' : ($flash['type'] === 'warning' ? 'Warning' : 'Error'); ?>',
text: '<?= htmlspecialchars($flash['message'], ENT_QUOTES, 'UTF-8'); ?>'
});
<?php endif; ?>
});
</script>
</body>
</html>