use roles guards

This commit is contained in:
Thanakorn S
2026-05-22 08:45:35 +07:00
parent f2b87cfd0f
commit cf25732da0
19 changed files with 133 additions and 66 deletions
@@ -3,6 +3,8 @@ session_start();
require '../../../assets/utils/db_auth.php'; require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/ChartOfAccounts.php'; require '../../../assets/utils/classes_ac/ChartOfAccounts.php';
require_role($user_role, ['owner', 'admin']);
if (empty($data['account_code']) || empty($data['account_name']) || empty($data['account_type'])) { if (empty($data['account_code']) || empty($data['account_name']) || empty($data['account_type'])) {
$answer['message'] = 'Account code, name, and type are required'; $answer['message'] = 'Account code, name, and type are required';
exit(json_encode($answer)); exit(json_encode($answer));
@@ -3,6 +3,8 @@ session_start();
require '../../../assets/utils/db_auth.php'; require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/DepartmentManager.php'; require '../../../assets/utils/classes_ac/DepartmentManager.php';
require_role($user_role, ['owner', 'admin']);
if (empty($data['dept_code']) || empty($data['dept_name'])) { if (empty($data['dept_code']) || empty($data['dept_name'])) {
$answer['message'] = 'Department code and name are required'; $answer['message'] = 'Department code and name are required';
exit(json_encode($answer)); exit(json_encode($answer));
@@ -12,6 +12,8 @@ require '../../../assets/utils/classes_ac/posting/ReceiptPosting.php';
require '../../../assets/utils/classes_ac/posting/PaymentPosting.php'; require '../../../assets/utils/classes_ac/posting/PaymentPosting.php';
require '../../../assets/utils/classes_ac/posting/PurchasePosting.php'; require '../../../assets/utils/classes_ac/posting/PurchasePosting.php';
require_role($user_role, ['owner', 'admin']);
$doc_type = trim((string)($data['doc_type'] ?? '')); $doc_type = trim((string)($data['doc_type'] ?? ''));
$id = (int)($data['id'] ?? 0); $id = (int)($data['id'] ?? 0);
$formula_id = (int)($data['formula_id'] ?? 0) ?: null; $formula_id = (int)($data['formula_id'] ?? 0) ?: null;
@@ -3,6 +3,8 @@ session_start();
require '../../../assets/utils/db_auth.php'; require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/ChartOfAccounts.php'; require '../../../assets/utils/classes_ac/ChartOfAccounts.php';
require_role($user_role, ['owner', 'admin']);
$id = (int)($data['id'] ?? 0); $id = (int)($data['id'] ?? 0);
if (!$id) { if (!$id) {
$answer['message'] = 'Missing id'; $answer['message'] = 'Missing id';
@@ -3,6 +3,8 @@ session_start();
require '../../../assets/utils/db_auth.php'; require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/DepartmentManager.php'; require '../../../assets/utils/classes_ac/DepartmentManager.php';
require_role($user_role, ['owner', 'admin']);
$id = (int)($data['id'] ?? 0); $id = (int)($data['id'] ?? 0);
if (!$id) { $answer['message'] = 'Missing id'; exit(json_encode($answer)); } if (!$id) { $answer['message'] = 'Missing id'; exit(json_encode($answer)); }
@@ -4,6 +4,8 @@ require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/PostingWindowGuard.php'; require '../../../assets/utils/classes_ac/PostingWindowGuard.php';
require '../../../assets/utils/classes_ac/GlManager.php'; require '../../../assets/utils/classes_ac/GlManager.php';
require_role($user_role, ['owner', 'admin']);
$gl_id = (int)($data['gl_id'] ?? 0); $gl_id = (int)($data['gl_id'] ?? 0);
$reference = trim((string)($data['reference'] ?? '')); $reference = trim((string)($data['reference'] ?? ''));
$description = trim((string)($data['description'] ?? '')); $description = trim((string)($data['description'] ?? ''));
+1
View File
@@ -156,6 +156,7 @@ class UserManager {
u.email, u.email,
u.profile_picture, u.profile_picture,
u.status, u.status,
u.license,
(m.invite_token IS NOT NULL) AS is_pending_invite (m.invite_token IS NOT NULL) AS is_pending_invite
FROM company_map_user m FROM company_map_user m
JOIN user u ON u.user_id = m.user_id JOIN user u ON u.user_id = m.user_id
+14 -19
View File
@@ -1,7 +1,6 @@
<?php <?php
$current_page = basename($_SERVER['PHP_SELF']); $current_page = basename($_SERVER['PHP_SELF']);
$setting_role = $_SESSION['login_role'] ?? 'viewer'; $setting_role = $_SESSION['login_role'] ?? 'viewer';
$setting_can_admin = in_array($setting_role, ['owner', 'admin'], true);
?> ?>
<!-- SIDEBAR --> <!-- SIDEBAR -->
@@ -38,15 +37,13 @@
</a> </a>
</li> </li>
<?php if ($setting_can_admin): ?> <li>
<li> <a class="nav-link <?php echo $current_page === 'users.php' ? 'active' : ''; ?>"
<a class="nav-link <?php echo $current_page === 'users.php' ? 'active' : ''; ?>" href="<?php echo $server_url?>setting/users.php">
href="<?php echo $server_url?>setting/users.php"> <i class="ti ti-users"></i>
<i class="ti ti-users"></i> <span class="nav-text">Users Access</span>
<span class="nav-text">Users Access</span> </a>
</a> </li>
</li>
<?php endif; ?>
<li> <li>
<a class="nav-link <?php echo $current_page === 'smtp.php' ? 'active' : ''; ?>" <a class="nav-link <?php echo $current_page === 'smtp.php' ? 'active' : ''; ?>"
@@ -56,15 +53,13 @@
</a> </a>
</li> </li>
<?php if ($setting_can_admin): ?> <li>
<li> <a class="nav-link <?php echo $current_page === 'system_config.php' ? 'active' : ''; ?>"
<a class="nav-link <?php echo $current_page === 'system_config.php' ? 'active' : ''; ?>" href="<?php echo $server_url?>setting/system_config.php">
href="<?php echo $server_url?>setting/system_config.php"> <i class="ti ti-adjustments-cog"></i>
<i class="ti ti-adjustments-cog"></i> <span class="nav-text">System Configuration</span>
<span class="nav-text">System Configuration</span> </a>
</a> </li>
</li>
<?php endif; ?>
<!-- Back --> <!-- Back -->
<li class="nav-text-space mt-2"> <li class="nav-text-space mt-2">
+46 -1
View File
@@ -113,11 +113,23 @@
<div class="col-12"> <div class="col-12">
<label class="form-label">Password <span class="text-danger">*</span></label> <label class="form-label">Password <span class="text-danger">*</span></label>
<div class="input-group"> <div class="input-group">
<input type="password" class="form-control" id="password" placeholder="Choose a strong password"> <input type="password" class="form-control" id="password" placeholder="Choose a strong password"
oninput="on_password_input(this.value)">
<button class="btn btn-outline-secondary toggle-pw" type="button" data-target="password"> <button class="btn btn-outline-secondary toggle-pw" type="button" data-target="password">
<i class="ti ti-eye"></i> <i class="ti ti-eye"></i>
</button> </button>
</div> </div>
<div class="mt-2">
<div class="progress" style="height:5px;">
<div id="pw_strength_bar" class="progress-bar"
style="width:0%;transition:width .25s,background-color .25s;border-radius:4px;"></div>
</div>
<div class="d-flex justify-content-between align-items-start mt-1 gap-2">
<small id="pw_strength_label" class="fw-semibold" style="white-space:nowrap;">—</small>
<small id="pw_feedback" class="text-muted text-end"></small>
</div>
</div>
<div class="form-text">Minimum required strength: <strong>Strong (3/4)</strong></div>
</div> </div>
<div class="col-12"> <div class="col-12">
<label class="form-label">Confirm Password <span class="text-danger">*</span></label> <label class="form-label">Confirm Password <span class="text-danger">*</span></label>
@@ -141,8 +153,36 @@
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/zxcvbn@4.4.2/dist/zxcvbn.js"></script>
<script> <script>
const STRENGTH_LEVELS = [
{ label: 'Very weak', color: '#dc3545', pct: 20 },
{ label: 'Weak', color: '#fd7e14', pct: 40 },
{ label: 'Fair', color: '#ffc107', pct: 60 },
{ label: 'Strong', color: '#198754', pct: 80 },
{ label: 'Very strong', color: '#0d6efd', pct: 100 },
];
var pw_score = -1;
function on_password_input(pw) {
if (!pw) {
pw_score = -1;
$('#pw_strength_bar').css({ width: '0%', backgroundColor: '' });
$('#pw_strength_label').text('—').css('color', '');
$('#pw_feedback').text('');
return;
}
const user_inputs = [$('#name').val(), $('#surname').val(), $('#username').val()].filter(Boolean);
const result = zxcvbn(pw, user_inputs);
pw_score = result.score;
const lvl = STRENGTH_LEVELS[pw_score];
$('#pw_strength_bar').css({ width: lvl.pct + '%', backgroundColor: lvl.color });
$('#pw_strength_label').text(lvl.label).css('color', lvl.color);
$('#pw_feedback').text(result.feedback.warning || result.feedback.suggestions[0] || '');
}
$(function () { $(function () {
$(document).on('click', '.toggle-pw', function () { $(document).on('click', '.toggle-pw', function () {
const $input = $('#' + $(this).data('target')); const $input = $('#' + $(this).data('target'));
@@ -164,6 +204,11 @@
return; return;
} }
if (pw_score < 3) {
bootbox.alert('Password is too weak. Please choose a stronger password (Strong or above).');
return;
}
if (password !== confirm) { if (password !== confirm) {
bootbox.alert('Passwords do not match.'); bootbox.alert('Passwords do not match.');
return; return;
@@ -4,6 +4,8 @@ require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/OrderManager.php'; require '../../../assets/utils/classes/OrderManager.php';
require '../../../assets/utils/classes/QuotationManager.php'; require '../../../assets/utils/classes/QuotationManager.php';
require_role($user_role, ['owner', 'admin', 'staff']);
$quotation_id = (int)($data['quotation_id'] ?? 0); $quotation_id = (int)($data['quotation_id'] ?? 0);
$convert_items = $data['convert_items'] ?? []; $convert_items = $data['convert_items'] ?? [];
+2
View File
@@ -4,6 +4,8 @@ require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/UsageGuard.php'; require '../../../assets/utils/classes/UsageGuard.php';
require '../../../assets/utils/classes/OrderManager.php'; require '../../../assets/utils/classes/OrderManager.php';
require_role($user_role, ['owner', 'admin', 'staff']);
$action = $data['action'] ?? ''; $action = $data['action'] ?? '';
$data['items'] = json_decode($data['items'] ?? '[]', true) ?: []; $data['items'] = json_decode($data['items'] ?? '[]', true) ?: [];
@@ -3,6 +3,8 @@ session_start();
require '../../../assets/utils/db_auth.php'; require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/QuotationManager.php'; require '../../../assets/utils/classes/QuotationManager.php';
require_role($user_role, ['owner', 'admin', 'staff']);
$id = (int)($data['id'] ?? 0); $id = (int)($data['id'] ?? 0);
$action = $data['action_type'] ?? ''; $action = $data['action_type'] ?? '';
+1 -1
View File
@@ -4,7 +4,7 @@ require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/CompanySettingManager.php'; require '../../../assets/utils/classes/CompanySettingManager.php';
if (($data['action'] ?? '') === 'update') { if (($data['action'] ?? '') === 'update') {
require_role($user_role, ['owner', 'admin']); if ($user_role !== 'owner') { http_response_code(403); exit(json_encode(['success' => 0, 'message' => 'Only the owner can modify this setting.'])); }
} }
$csm = new CompanySettingManager($pdo1, $company_id, $pdo2); $csm = new CompanySettingManager($pdo1, $company_id, $pdo2);
+1 -1
View File
@@ -3,7 +3,7 @@
require '../../../assets/utils/db_auth.php'; require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/CompanyProfileManager.php'; require '../../../assets/utils/classes/CompanyProfileManager.php';
require_role($user_role, ['owner', 'admin']); if ($user_role !== 'owner') { http_response_code(403); exit(json_encode(['success' => 0, 'message' => 'Only the owner can modify this setting.'])); }
try { try {
$companyProfile = new CompanyProfileManager($pdo1, $company_id); $companyProfile = new CompanyProfileManager($pdo1, $company_id);
+1 -1
View File
@@ -3,7 +3,7 @@
require '../../../assets/utils/db_auth.php'; require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/SmtpManager.php'; require '../../../assets/utils/classes/SmtpManager.php';
require_role($user_role, ['owner', 'admin']); if ($user_role !== 'owner') { http_response_code(403); exit(json_encode(['success' => 0, 'message' => 'Only the owner can modify this setting.'])); }
try { try {
$smtp = new SmtpManager($pdo1, $company_id, $method, $pinkey, $iv); $smtp = new SmtpManager($pdo1, $company_id, $method, $pinkey, $iv);
+1 -1
View File
@@ -3,7 +3,7 @@
require '../../../assets/utils/db_auth.php'; require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/UserManager.php'; require '../../../assets/utils/classes/UserManager.php';
require_role($user_role, ['owner', 'admin']); if ($user_role !== 'owner') { http_response_code(403); exit(json_encode(['success' => 0, 'message' => 'Only the owner can modify this setting.'])); }
$action = $data['action'] ?? ''; $action = $data['action'] ?? '';
$um = new UserManager($pdo1, $company_id, $user_id); $um = new UserManager($pdo1, $company_id, $user_id);
+1 -1
View File
@@ -3,7 +3,7 @@
require '../../../assets/utils/db_auth.php'; require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/SmtpManager.php'; require '../../../assets/utils/classes/SmtpManager.php';
require_role($user_role, ['owner', 'admin']); if ($user_role !== 'owner') { http_response_code(403); exit(json_encode(['success' => 0, 'message' => 'Only the owner can modify this setting.'])); }
try { try {
$smtp = new SmtpManager($pdo1, $company_id, $method, $pinkey, $iv); $smtp = new SmtpManager($pdo1, $company_id, $method, $pinkey, $iv);
-4
View File
@@ -1,10 +1,6 @@
<?php <?php
session_start(); session_start();
require '../config.php'; require '../config.php';
if (!in_array($_SESSION['login_role'] ?? 'viewer', ['owner', 'admin'], true)) {
http_response_code(403);
exit('Access denied.');
}
require '../include_header.php'; require '../include_header.php';
?> ?>
+49 -37
View File
@@ -1,10 +1,6 @@
<?php <?php
session_start(); session_start();
require '../config.php'; require '../config.php';
if (!in_array($_SESSION['login_role'] ?? 'viewer', ['owner', 'admin'], true)) {
http_response_code(403);
exit('Access denied.');
}
require '../include_header.php'; require '../include_header.php';
?> ?>
@@ -23,7 +19,7 @@
<h1 class="fs-3 mb-1">Users Access</h1> <h1 class="fs-3 mb-1">Users Access</h1>
<p class="mb-0">Manage team members and their roles in your company</p> <p class="mb-0">Manage team members and their roles in your company</p>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2" id="owner_actions" style="display:none!important;">
<button class="btn btn-primary" onclick="open_invite_modal()"> <button class="btn btn-primary" onclick="open_invite_modal()">
<i class="ti ti-user-plus me-1"></i>Add User <i class="ti ti-user-plus me-1"></i>Add User
</button> </button>
@@ -68,14 +64,15 @@
<th class="ps-4">User</th> <th class="ps-4">User</th>
<th>Email</th> <th>Email</th>
<th>Role</th> <th>Role</th>
<th>License</th>
<th>App Access</th> <th>App Access</th>
<th>Joined</th> <th>Joined</th>
<th class="text-end pe-4">Actions</th> <th class="text-end pe-4 owner-only-col">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody id="users_tbody"> <tbody id="users_tbody">
<tr> <tr>
<td colspan="6" class="text-center py-5 text-muted"> <td colspan="7" class="text-center py-5 text-muted">
<i class="ti ti-loader-2 fs-2 d-block mb-2"></i>Loading… <i class="ti ti-loader-2 fs-2 d-block mb-2"></i>Loading…
</td> </td>
</tr> </tr>
@@ -202,6 +199,7 @@
const avatar_ph = '<?php echo $server_url?>assets/images/logo.svg'; const avatar_ph = '<?php echo $server_url?>assets/images/logo.svg';
const owner_app_access = '<?php echo htmlspecialchars($_SESSION['login_app_access'] ?? 'wms', ENT_QUOTES); ?>'; const owner_app_access = '<?php echo htmlspecialchars($_SESSION['login_app_access'] ?? 'wms', ENT_QUOTES); ?>';
const APP_REGISTRY = <?php echo json_encode($app_registry); ?>; const APP_REGISTRY = <?php echo json_encode($app_registry); ?>;
const is_owner = user_role === 'owner';
let _users_data = []; let _users_data = [];
@@ -209,6 +207,8 @@
// On load // On load
// ═══════════════════════════════════════════════ // ═══════════════════════════════════════════════
$(function () { $(function () {
if (is_owner) $('#owner_actions').show();
else $('.owner-only-col').hide();
load_users(); load_users();
}); });
@@ -239,7 +239,7 @@
if (!rows.length) { if (!rows.length) {
tbody.html(` tbody.html(`
<tr> <tr>
<td colspan="6" class="text-center py-5 text-muted"> <td colspan="${is_owner ? 7 : 6}" class="text-center py-5 text-muted">
<i class="ti ti-users-group fs-2 d-block mb-2"></i>No users found. <i class="ti ti-users-group fs-2 d-block mb-2"></i>No users found.
</td> </td>
</tr>`); </tr>`);
@@ -252,8 +252,9 @@
: `<span class="avatar-initials rounded-circle d-inline-flex align-items-center justify-content-center bg-light border fw-semibold text-secondary" : `<span class="avatar-initials rounded-circle d-inline-flex align-items-center justify-content-center bg-light border fw-semibold text-secondary"
style="width:36px;height:36px;font-size:13px;">${initials(u.name, u.surname)}</span>`; style="width:36px;height:36px;font-size:13px;">${initials(u.name, u.surname)}</span>`;
const role_badge = role_html(u.role); const role_badge = role_html(u.role);
const access_badge = app_access_html(u.app_access); const license_badge = license_html(u.license);
const access_badge = app_access_html(u.app_access);
const joined = u.created_at const joined = u.created_at
? new Date(u.created_at).toLocaleDateString('en-GB', {day:'2-digit', month:'short', year:'numeric'}) ? new Date(u.created_at).toLocaleDateString('en-GB', {day:'2-digit', month:'short', year:'numeric'})
@@ -262,30 +263,30 @@
const is_pending = u.status === 'pending' && u.is_pending_invite == 1; const is_pending = u.status === 'pending' && u.is_pending_invite == 1;
let actions = ''; let actions = '';
if (u.role === 'owner') { if (is_owner && u.role !== 'owner') {
// owner — no actions if (is_pending) {
} else if (is_pending) { actions += `
actions += ` <button class="btn btn-sm btn-ghost-secondary" title="Resend invitation"
<button class="btn btn-sm btn-ghost-secondary" title="Resend invitation" onclick="resend_invite(${u.map_id}, '${esc(u.email)}')">
onclick="resend_invite(${u.map_id}, '${esc(u.email)}')"> <i class="ti ti-send"></i>
<i class="ti ti-send"></i> </button>`;
</button>`; actions += `
actions += ` <button class="btn btn-sm btn-ghost-danger" title="Cancel invitation"
<button class="btn btn-sm btn-ghost-danger" title="Cancel invitation" onclick="remove_user(${u.map_id}, '${esc(u.email)}')">
onclick="remove_user(${u.map_id}, '${esc(u.email)}')"> <i class="ti ti-user-minus"></i>
<i class="ti ti-user-minus"></i> </button>`;
</button>`; } else {
} else { actions += `
actions += ` <button class="btn btn-sm btn-ghost-secondary" title="Edit access"
<button class="btn btn-sm btn-ghost-secondary" title="Edit access" onclick="open_edit_modal(${u.map_id})">
onclick="open_edit_modal(${u.map_id})"> <i class="ti ti-shield-half"></i>
<i class="ti ti-shield-half"></i> </button>`;
</button>`; actions += `
actions += ` <button class="btn btn-sm btn-ghost-danger" title="Remove user"
<button class="btn btn-sm btn-ghost-danger" title="Remove user" onclick="remove_user(${u.map_id}, '${esc(u.name)} ${esc(u.surname)}')">
onclick="remove_user(${u.map_id}, '${esc(u.name)} ${esc(u.surname)}')"> <i class="ti ti-user-minus"></i>
<i class="ti ti-user-minus"></i> </button>`;
</button>`; }
} }
const display_role = is_pending const display_role = is_pending
@@ -306,15 +307,17 @@
</td> </td>
<td style="color:#495057;">${esc(u.email)}</td> <td style="color:#495057;">${esc(u.email)}</td>
<td>${display_role}</td> <td>${display_role}</td>
<td>${license_badge}</td>
<td>${is_pending ? '—' : access_badge}</td> <td>${is_pending ? '—' : access_badge}</td>
<td style="color:#495057;">${joined}</td> <td style="color:#495057;">${joined}</td>
<td class="text-end pe-4"> <td class="text-end pe-4 owner-only-col">
<div class="d-flex justify-content-end gap-1">${actions}</div> <div class="d-flex justify-content-end gap-1">${actions}</div>
</td> </td>
</tr>`; </tr>`;
}).join(''); }).join('');
tbody.html(html); tbody.html(html);
if (!is_owner) $('.owner-only-col').hide();
} }
@@ -460,6 +463,15 @@
.replace(/>/g,'&gt;').replace(/"/g,'&quot;'); .replace(/>/g,'&gt;').replace(/"/g,'&quot;');
} }
function license_html(license) {
const map = {
owner: ['bg-success text-white', 'ti-crown', 'Owner'],
user: ['bg-light text-dark border', 'ti-user', 'User'],
};
const [cls, icon, label] = map[license] || ['bg-light text-dark border', 'ti-user', license || '—'];
return `<span class="badge ${cls}"><i class="ti ${icon} me-1"></i>${label}</span>`;
}
function role_html(role) { function role_html(role) {
const map = { const map = {
owner: ['bg-dark', 'ti-crown', 'Owner'], owner: ['bg-dark', 'ti-crown', 'Owner'],
@@ -467,7 +479,7 @@
staff: ['bg-info', 'ti-tool', 'Staff'], staff: ['bg-info', 'ti-tool', 'Staff'],
viewer: ['bg-secondary', 'ti-eye', 'Viewer'], viewer: ['bg-secondary', 'ti-eye', 'Viewer'],
}; };
const [cls, icon, label] = map[role] || ['bg-label-secondary', 'ti-user', 'Unknown']; const [cls, icon, label] = map[role] || ['bg-secondary', 'ti-user', 'Unknown'];
return `<span class="badge ${cls}"><i class="ti ${icon} me-1"></i>${label}</span>`; return `<span class="badge ${cls}"><i class="ti ${icon} me-1"></i>${label}</span>`;
} }
@@ -481,7 +493,7 @@
return `<span class="badge bg-secondary"><i class="ti ti-apps me-1"></i>All Apps</span>`; return `<span class="badge bg-secondary"><i class="ti ti-apps me-1"></i>All Apps</span>`;
} }
const app = APP_REGISTRY[access]; const app = APP_REGISTRY[access];
if (!app) return `<span class="badge bg-label-secondary">${access || '—'}</span>`; if (!app) return `<span class="badge bg-secondary">${access || '—'}</span>`;
return `<span class="badge ${app.color}"><i class="ti ${app.icon} me-1"></i>${app.label}</span>`; return `<span class="badge ${app.color}"><i class="ti ${app.icon} me-1"></i>${app.label}</span>`;
} }