Sidebar: +ERP +CRM +Dokumenti, godišnjaci import (18 PDFs), filter helpers
- pgz nav now includes /erp/full, /crm/v2, /admin/users, /dokumenti
- 4 dokumenti endpoints: list, godišnjaci/list, godišnjak/{godina} PDF, detail
- 18 godišnjaka u pgz_sport.dokumenti (2006-2024) with savez_id=333
- PGŽ filter helpers (window._pgz_filter_priority, togglePGZFilter)
- navItemClick handler for nav items with href
This commit is contained in:
+113
-7
@@ -162,6 +162,7 @@ td.actions-col .btn { padding: 4px 8px; font-size: 11px; }
|
||||
<div class="nav-section sb-text">Sigurnost</div>
|
||||
<div class="nav-item" data-tab="audit"><span class="icon">≡</span><span class="sb-text">Audit log</span></div>
|
||||
<div class="nav-item" data-tab="security"><span class="icon">⌬</span><span class="sb-text">Sigurnost</span></div>
|
||||
<div class="nav-item" data-tab="rbac"><span class="icon">🔑</span><span class="sb-text">RBAC matrica</span></div>
|
||||
<div class="nav-section sb-text">GDPR</div>
|
||||
<div class="nav-item" data-tab="gdpr"><span class="icon">🔒</span><span class="sb-text">GDPR</span></div>
|
||||
<div class="nav-section sb-text">Drugi moduli</div>
|
||||
@@ -237,7 +238,7 @@ td.actions-col .btn { padding: 4px 8px; font-size: 11px; }
|
||||
<div class="section">
|
||||
<h3>Lista korisnika <small id="usersCount">—</small></h3>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>E-mail</th><th>Ime</th><th>Uloga</th><th>Klub / Savez</th><th>Status</th><th>Zadnja prijava</th><th class="actions-col">Akcije</th></tr></thead>
|
||||
<thead><tr><th>ID</th><th>E-mail</th><th>Ime</th><th>Uloga</th><th>Klub / Savez</th><th>Status</th><th>2FA</th><th>Zadnja prijava</th><th class="actions-col">Akcije</th></tr></thead>
|
||||
<tbody id="usersTbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -304,6 +305,27 @@ td.actions-col .btn { padding: 4px 8px; font-size: 11px; }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="tab-rbac">
|
||||
<div class="page-header"><div><h2>RBAC matrica</h2><span class="meta">role-based access control · derivable from auth/admin_users.py</span></div></div>
|
||||
<div class="section">
|
||||
<h3>Matrica uloga & ovlasti <small>read-only</small></h3>
|
||||
<table id="rbacTable">
|
||||
<thead><tr>
|
||||
<th>Uloga</th><th>Scope</th>
|
||||
<th>List/View</th><th>Create</th><th>Edit</th><th>Reset pwd</th>
|
||||
<th>Suspend</th><th>Delete</th><th>Role change</th><th>Manage 2FA</th>
|
||||
<th>Audit log</th><th>Bulk CSV</th>
|
||||
</tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<p style="margin-top:14px;color:var(--text-2);font-size:12px">
|
||||
✓ = full · <span style="color:var(--yellow)">●</span> = u vlastitom scope-u (savez/klub) · — = nema ovlasti.<br>
|
||||
Hijerarhija: <strong>super_admin / pgz_admin</strong> > <strong>savez_admin</strong> (own savez) > <strong>klub_admin</strong> (own klub) > <strong>klub_user/trener/clan</strong> > <strong>viewer</strong>.<br>
|
||||
Role-change je rezerviran za PGŽ admine (pgz_sport.auth.admin_users:299).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="tab-gdpr">
|
||||
<div class="page-header"><h2>GDPR</h2></div>
|
||||
<div class="kpi-grid" id="gdprKpi"></div>
|
||||
@@ -363,6 +385,18 @@ td.actions-col .btn { padding: 4px 8px; font-size: 11px; }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-bg" id="userAuditModalBg">
|
||||
<div class="modal" style="width:min(820px,94vw)">
|
||||
<button class="close" onclick="closeModal('userAuditModal')">×</button>
|
||||
<h3 id="userAuditModalTitle">📜 Audit log korisnika</h3>
|
||||
<div style="font-size:12px;color:var(--text-3);margin-bottom:8px" id="userAuditMeta">—</div>
|
||||
<table>
|
||||
<thead><tr><th>Vrijeme</th><th>Akcija</th><th>Resurs</th><th>IP</th><th>Meta</th></tr></thead>
|
||||
<tbody id="userAuditTbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-bg" id="pwdModalBg">
|
||||
<div class="modal">
|
||||
<button class="close" onclick="closeModal('pwdModal')">×</button>
|
||||
@@ -466,6 +500,7 @@ function activate(tab) {
|
||||
if (tab === 'tenants') loadTenants();
|
||||
if (tab === 'audit') loadAudit();
|
||||
if (tab === 'security') loadSecurity();
|
||||
if (tab === 'rbac') loadRBAC();
|
||||
if (tab === 'gdpr') loadGdpr();
|
||||
history.replaceState(null, '', '#' + tab);
|
||||
}
|
||||
@@ -552,14 +587,57 @@ async function loadUsers() {
|
||||
<td>${roleBadge(u.user_type)}</td>
|
||||
<td>${escapeHtml(u.klub_naziv || u.savez_naziv || (u.klub_id?'klub#'+u.klub_id:u.savez_id?'savez#'+u.savez_id:'—'))}</td>
|
||||
<td>${statusBadge(u.aktivan)}${u.locked_until?'<br><span class="badge red">Locked</span>':''}</td>
|
||||
<td><span class="badge gray" id="tfa-${u.id}" data-2fa="?">…</span></td>
|
||||
<td>${fmtDateTime(u.last_login)}</td>
|
||||
<td class="actions-col">
|
||||
<button class="btn" onclick="editUser(${u.id})">✎</button>
|
||||
<button class="btn" onclick="resetPwd(${u.id})">🔑</button>
|
||||
<button class="btn" onclick="toggleSuspend(${u.id}, ${u.aktivan})">${u.aktivan?'⏸':'▶'}</button>
|
||||
<button class="btn danger" onclick="deleteUser(${u.id}, '${escapeHtml(u.email)}')">✕</button>
|
||||
<button class="btn" title="Uredi" onclick="editUser(${u.id})">✎</button>
|
||||
<button class="btn" title="Reset lozinke" onclick="resetPwd(${u.id})">🔑</button>
|
||||
<button class="btn" title="2FA" onclick="toggle2FA(${u.id})">🛡</button>
|
||||
<button class="btn" title="Audit log" onclick="openUserAudit(${u.id}, '${escapeHtml(u.email)}')">📜</button>
|
||||
<button class="btn" title="${u.aktivan?'Suspendiraj':'Aktiviraj'}" onclick="toggleSuspend(${u.id}, ${u.aktivan})">${u.aktivan?'⏸':'▶'}</button>
|
||||
<button class="btn danger" title="Obriši" onclick="deleteUser(${u.id}, '${escapeHtml(u.email)}')">✕</button>
|
||||
</td></tr>
|
||||
`).join('') || '<tr><td colspan="8" class="empty">Nema korisnika</td></tr>';
|
||||
`).join('') || '<tr><td colspan="9" class="empty">Nema korisnika</td></tr>';
|
||||
// Lazy-load per-user 2FA badges (parallel)
|
||||
(data.results || []).forEach(u => loadUser2FA(u.id));
|
||||
}
|
||||
|
||||
async function loadUser2FA(uid) {
|
||||
const cell = document.getElementById('tfa-' + uid);
|
||||
if (!cell) return;
|
||||
const r = await apiJson('/admin/users/' + uid + '/2fa-status');
|
||||
if (!r) { cell.textContent = '—'; cell.className = 'badge gray'; return; }
|
||||
const en = !!r.enabled;
|
||||
cell.textContent = en ? '✓ ON' : 'OFF';
|
||||
cell.className = 'badge ' + (en ? 'green' : 'gray');
|
||||
cell.dataset['2fa'] = en ? '1' : '0';
|
||||
}
|
||||
|
||||
async function toggle2FA(uid) {
|
||||
const cell = document.getElementById('tfa-' + uid);
|
||||
const en = cell && cell.dataset['2fa'] === '1';
|
||||
if (!en) {
|
||||
return alert('2FA je trenutno OFF za ovog korisnika.\n\n2FA se aktivira samo od strane korisnika (osobni QR scan).\nAdmin može samo prisilno deaktivirati ako korisnik izgubi autentifikator.');
|
||||
}
|
||||
if (!confirm('Prisilno isključiti 2FA za ovog korisnika?\nKorisnik gubi sve recovery kodove i mora ponovo postaviti 2FA.\nSesije se poništavaju.')) return;
|
||||
const r = await apiJson('/admin/users/' + uid + '/2fa-disable', {method:'POST'});
|
||||
if (r?.status === 'ok') { toast('2FA isključen'); loadUser2FA(uid); }
|
||||
else toast(r?.detail || 'Greška', 'error');
|
||||
}
|
||||
|
||||
async function openUserAudit(uid, email) {
|
||||
$('#userAuditModalTitle').textContent = '📜 Audit log — #' + uid;
|
||||
$('#userAuditMeta').textContent = email + ' · zadnjih 100 događaja';
|
||||
$('#userAuditTbody').innerHTML = '<tr><td colspan="5" class="empty">Učitavam…</td></tr>';
|
||||
openModal('userAuditModal');
|
||||
const d = await apiJson('/admin/audit?user_id=' + uid + '&limit=100');
|
||||
$('#userAuditTbody').innerHTML = (d?.results || []).map(a => `
|
||||
<tr><td class="audit-row">${fmtDateTime(a.created_at)}</td>
|
||||
<td><span class="audit-action">${escapeHtml(a.action||'')}</span></td>
|
||||
<td>${escapeHtml(a.resource_type||'—')} ${a.resource_id??''}</td>
|
||||
<td class="audit-row">${escapeHtml(a.ip_address||'—')}</td>
|
||||
<td class="audit-row" style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title='${escapeHtml(JSON.stringify(a.meta||{}))}'>${escapeHtml(JSON.stringify(a.meta||{}).substring(0,80))}</td></tr>
|
||||
`).join('') || '<tr><td colspan="5" class="empty">Nema događaja</td></tr>';
|
||||
}
|
||||
['usrQ','usrTenant','usrRole','usrStatus','usrLimit'].forEach(id => {
|
||||
$('#'+id).addEventListener('input', () => { clearTimeout(usersDebounce); usersDebounce = setTimeout(loadUsers, 300); });
|
||||
@@ -754,6 +832,34 @@ $('#btnDisable2FA').addEventListener('click', async () => {
|
||||
else toast(r?.detail || 'Greška', 'error');
|
||||
});
|
||||
|
||||
// RBAC matrix (read-only, derived from auth/admin_users.py _can_manage)
|
||||
const RBAC_ROWS = [
|
||||
// [role, scope, list, create, edit, reset, suspend, delete, role_change, manage_2fa, audit, bulk_csv]
|
||||
['super_admin', 'global', '✓','✓','✓','✓','✓','✓','✓','✓','✓','✓'],
|
||||
['pgz_admin', 'global', '✓','✓','✓','✓','✓','✓','✓','✓','✓','✓'],
|
||||
['pgz_finance', 'global', '✓','—','—','—','—','—','—','—','✓','—'],
|
||||
['savez_admin', 'own savez', '●','●','●','●','●','●','—','●','●','—'],
|
||||
['savez_user', 'own savez', '●','—','—','—','—','—','—','—','—','—'],
|
||||
['klub_admin', 'own klub', '●','●','●','●','●','●','—','●','●','—'],
|
||||
['klub_trener', 'own klub (RO)', '●','—','—','—','—','—','—','—','—','—'],
|
||||
['klub_user', 'own klub (RO)', '●','—','—','—','—','—','—','—','—','—'],
|
||||
['klub_clan', 'self only', '●','—','—','—','—','—','—','—','—','—'],
|
||||
['viewer', 'read-only', '●','—','—','—','—','—','—','—','—','—'],
|
||||
];
|
||||
function rbacCell(v) {
|
||||
if (v === '✓') return '<span style="color:var(--green);font-weight:600">✓</span>';
|
||||
if (v === '●') return '<span style="color:var(--yellow);font-weight:600">●</span>';
|
||||
return '<span style="color:var(--text-3)">—</span>';
|
||||
}
|
||||
function loadRBAC() {
|
||||
const tb = $('#rbacTable tbody');
|
||||
tb.innerHTML = RBAC_ROWS.map(r => `
|
||||
<tr><td>${roleBadge(r[0])}</td><td><small style="color:var(--text-3)">${escapeHtml(r[1])}</small></td>
|
||||
${r.slice(2).map(rbacCell).map(c=>'<td style="text-align:center">'+c+'</td>').join('')}
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// GDPR
|
||||
async function loadGdpr() {
|
||||
const er = await apiJson('/admin/gdpr/erasure-requests');
|
||||
@@ -818,7 +924,7 @@ $('#cookieNecessary').addEventListener('click', () => saveConsent(true, false, f
|
||||
$('#userAvatar').textContent = (me.full_name || me.email || '?')[0].toUpperCase();
|
||||
await loadTenantSelect();
|
||||
const initialTab = (location.hash || '#users').replace('#','');
|
||||
activate(['overview','users','tenants','audit','security','gdpr'].includes(initialTab) ? initialTab : 'users');
|
||||
activate(['overview','users','tenants','audit','security','rbac','gdpr'].includes(initialTab) ? initialTab : 'users');
|
||||
showCookieIfNeeded();
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user