Implement RBAC roles system

This commit is contained in:
2026-07-21 21:05:02 +01:00
parent 7973ee0ea4
commit e051958bea
35 changed files with 2338 additions and 2257 deletions
-1
View File
@@ -195,7 +195,6 @@
margin: 0.75rem 0 0;
font-size: clamp(1.4rem, 1.1rem + 1vw, 2.2rem);
line-height: 1.08;
max-width: 28rem;
}
.dashboard-hero-copy {
+11 -6
View File
@@ -206,7 +206,7 @@
}
}
function renderClientRow(client) {
function renderClientRow(client, hasActionsColumn) {
var connectedAt = client.connectedAt ? '<div>' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '</div>' + (client.lastSeenAt ? '<div class="subtle">' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '</div>' : '') : '<span class="empty">Unknown</span>';
var clientIpValue = normalizeDisplayIp(client.clientIp);
var clientIp = clientIpValue ? escapeHtml(clientIpValue) : '<span class="empty">Unknown</span>';
@@ -215,6 +215,7 @@
var clientName = clientNameValue ? escapeHtml(clientNameValue) : '<span class="empty">Unknown</span>';
var screenName = client.screen_name ? escapeHtml(client.screen_name) : '<span class="empty">Unknown</span>';
var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : '<span class="empty">No slide currently showing</span>';
var actionCell = hasActionsColumn ? '<td data-label="Actions">' + renderClientActionCell(client) + '</td>' : '';
return [
'<tr data-client-key="' + escapeHtml(getClientRowKey(client)) + '" data-client-id="' + escapeHtml(client.clientId || '') + '" data-client-screen-slug="' + escapeHtml(client.screen_slug || '') + '">',
@@ -224,7 +225,7 @@
'<td data-label="IP">' + clientIp + '</td>',
'<td data-label="Viewport">' + viewport + '</td>',
'<td data-label="Connected/Updated">' + connectedAt + '</td>',
'<td data-label="Actions">' + renderClientActionCell(client) + '</td>',
actionCell,
'</tr>'
].join('');
}
@@ -266,8 +267,10 @@
if (!tbody || !Array.isArray(state.clients)) {
return;
}
var table = document.getElementById('dashboard-clients-table');
var hasActionsColumn = Boolean(table && String(table.getAttribute('data-has-actions-column') || '').toLowerCase() === 'true');
if (!state.clients.length) {
tbody.innerHTML = '<tr><td colspan="7" class="empty">No connected clients yet.</td></tr>';
tbody.innerHTML = '<tr><td colspan="' + (hasActionsColumn ? '7' : '6') + '" class="empty">No connected clients yet.</td></tr>';
return;
}
var existingRows = {};
@@ -286,7 +289,7 @@
var row = existingRows[rowKey];
if (!row) {
var tempBody = document.createElement('tbody');
tempBody.innerHTML = renderClientRow(client);
tempBody.innerHTML = renderClientRow(client, hasActionsColumn);
row = tempBody.firstElementChild;
}
@@ -298,7 +301,7 @@
row.setAttribute('data-client-id', client.clientId || '');
row.setAttribute('data-client-device-id', client.deviceId || '');
row.setAttribute('data-client-screen-slug', client.screen_slug || '');
if (row.cells && row.cells.length >= 7) {
if (row.cells && row.cells.length >= 6) {
var connectedAt = client.connectedAt ? '<div>' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '</div>' + (client.lastSeenAt ? '<div class="subtle">' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '</div>' : '') : '<span class="empty">Unknown</span>';
var clientIpValue = normalizeDisplayIp(client.clientIp);
var clientIp = clientIpValue ? escapeHtml(clientIpValue) : '<span class="empty">Unknown</span>';
@@ -314,7 +317,9 @@
row.cells[3].innerHTML = clientIp;
row.cells[4].innerHTML = viewport;
row.cells[5].innerHTML = connectedAt;
updateClientActionCell(row.cells[6], client);
if (hasActionsColumn && row.cells.length >= 7) {
updateClientActionCell(row.cells[6], client);
}
}
var referenceNode = tbody.children[index] || null;
+111
View File
@@ -0,0 +1,111 @@
(function () {
function getGroupCheckboxes(group) {
return Array.prototype.slice.call(group.querySelectorAll('input[name="permission_keys[]"]'));
}
function getPermissionKey(checkbox) {
return String((checkbox && (checkbox.getAttribute('data-permission-key') || checkbox.value)) || '').trim().toLowerCase();
}
function getActionKey(checkbox) {
var permissionKey = getPermissionKey(checkbox);
var parts = permissionKey.split('.');
if (parts.length !== 2) {
return '';
}
return String(parts[1] || '').trim().toLowerCase();
}
function syncPermissionGroup(group) {
var checkboxes = getGroupCheckboxes(group);
if (!checkboxes.length) {
return;
}
var readCheckbox = null;
var nonReadChecked = false;
checkboxes.forEach(function (checkbox) {
if (getActionKey(checkbox) === 'read') {
readCheckbox = checkbox;
return;
}
if (checkbox.checked) {
nonReadChecked = true;
}
});
if (!readCheckbox) {
return;
}
if (!readCheckbox.checked && nonReadChecked) {
readCheckbox.checked = true;
}
if (!readCheckbox.checked) {
checkboxes.forEach(function (checkbox) {
if (getActionKey(checkbox) !== 'read') {
checkbox.checked = false;
}
});
}
}
function handleGroupChange(event) {
var checkbox = event.target && event.target.matches ? event.target : null;
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
return;
}
var actionKey = getActionKey(checkbox);
var group = checkbox.closest('.accordion-item');
if (!group) {
return;
}
var checkboxes = getGroupCheckboxes(group);
var readCheckbox = checkboxes.find(function (candidate) {
return getActionKey(candidate) === 'read';
}) || null;
if (!readCheckbox) {
return;
}
if (actionKey === 'read' && !checkbox.checked) {
checkboxes.forEach(function (candidate) {
if (candidate !== checkbox) {
candidate.checked = false;
}
});
return;
}
if (actionKey !== 'read' && checkbox.checked) {
readCheckbox.checked = true;
}
}
function initPermissionGroups() {
document.querySelectorAll('.accordion-item').forEach(function (group) {
syncPermissionGroup(group);
});
document.addEventListener('change', function (event) {
var checkbox = event.target;
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
return;
}
if (String(checkbox.getAttribute('name') || '') !== 'permission_keys[]') {
return;
}
handleGroupChange(event);
syncPermissionGroup(checkbox.closest('.accordion-item'));
});
}
initPermissionGroups();
}());
+9 -4
View File
@@ -40,7 +40,7 @@
function getMessageVariant(message, fallbackVariant) {
var text = String(message || '').trim();
if (/^(unable to delete|cannot delete|can't delete)|\bstill (?:in use|linked|assigned|used)\b/i.test(text)) {
if (/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i.test(text) || /\bdelete\b.*\b(?:before|first)\b/i.test(text) || /\bstill (?:in use|linked|assigned|used)\b/i.test(text)) {
return 'danger';
}
return String(fallbackVariant || 'success').trim().toLowerCase() || 'success';
@@ -63,7 +63,9 @@
if (existingBody) {
existingBody.textContent = text;
}
setToastVariant(existingToast, getMessageVariant(text, variant));
var nextVariant = getMessageVariant(text, variant);
existingToast.setAttribute('data-toast-variant', nextVariant);
setToastVariant(existingToast, nextVariant);
var existingInstance = getBootstrapToast(existingToast);
if (existingInstance) {
existingInstance.show();
@@ -80,7 +82,9 @@
toast.setAttribute('data-bs-autohide', 'true');
toast.setAttribute('data-bs-delay', '4000');
toast.innerHTML = '<div class="d-flex"><div class="toast-body"></div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Dismiss notification"></button></div>';
setToastVariant(toast, getMessageVariant(text, variant));
var toastVariant = getMessageVariant(text, variant);
toast.setAttribute('data-toast-variant', toastVariant);
setToastVariant(toast, toastVariant);
toast.querySelector('.toast-body').textContent = text;
toast.addEventListener('hidden.bs.toast', function () {
@@ -110,7 +114,8 @@
// ignore URL cleanup failures
}
setToastVariant(toast, getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'success'));
var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'success');
setToastVariant(toast, existingVariant);
var instance = getBootstrapToast(toast);
if (instance) {