4fc8327789
Orchestrator-side: - routers/img_proxy_router.py: 4xx/5xx → 1x1 transparent PNG (eliminates cascade <img onerror>) - static/sport2.html: removed standalone three.min.js (3d-force-graph bundles), bumped to 1.73.4 CC3 (before limit hit): - Logo home link applied to ALL HTML pages (admin.html, admin_users.html, audit.html, crm.html, erp.html, kpi.html, login.html) - Backups in _backups/*.cc3_pre_logo.$ts CC4 R3 (before plan mode): - _backups/r3_cc4/ocr.py.pre_S2.$ts Audit screenshots (80 pages) committed to _audit/audit_20260505_023639/shots/
826 lines
48 KiB
HTML
826 lines
48 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="hr">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>PGŽ Sport · Admin · Korisnici</title>
|
||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%2306080d'/><text x='16' y='23' text-anchor='middle' font-size='18' font-family='monospace' fill='%2300f0ff'>P</text></svg>">
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root {
|
||
--bg: #06080d; --bg-2: #0d1117; --bg-3: #161b22; --bg-4: #1c2129;
|
||
--border: #1f2937; --text: #e6edf3; --text-2: #8b949e; --text-3: #6e7681;
|
||
--accent: #00f0ff; --accent-2: #00b8d4;
|
||
--green: #56d364; --yellow: #d29922; --red: #f85149; --purple: #bc8cff; --orange: #ff9e64;
|
||
}
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { font-family: 'Inter', system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; font-size: 14px; line-height: 1.5; }
|
||
|
||
.app { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; transition: grid-template-columns 0.2s; }
|
||
.app.collapsed { grid-template-columns: 60px 1fr; }
|
||
.app.collapsed .sb-text, .app.collapsed .brand-text, .app.collapsed .user-info > div { display: none; }
|
||
.app.collapsed .nav-item { justify-content: center; padding: 12px 0; }
|
||
.app.collapsed .brand { justify-content: center; padding: 18px 0; }
|
||
.app.collapsed .nav-section { display: none; }
|
||
.app.collapsed .user-box { padding: 10px 8px; }
|
||
.app.collapsed .user-info { justify-content: center; }
|
||
.app.collapsed .user-info .menu-btn { display: none; }
|
||
|
||
.sidebar { background: var(--bg-2); border-right: 1px solid var(--border); display: flex; flex-direction: column; padding: 0; position: relative; }
|
||
.brand { display: flex; align-items: center; gap: 12px; padding: 18px 20px; border-bottom: 1px solid var(--border); }
|
||
.brand-mark { width: 32px; height: 32px; flex-shrink: 0; background: var(--accent); color: var(--bg); border-radius: 6px; display: grid; place-items: center; font-weight: 700; font-family: 'JetBrains Mono', monospace; }
|
||
.brand-text h1 { font-size: 14px; font-weight: 700; letter-spacing: 0.5px; }
|
||
.brand-text .sub { font-size: 10px; color: var(--text-3); font-family: 'JetBrains Mono', monospace; }
|
||
|
||
.sb-toggle { position: absolute; top: 16px; right: -12px; background: var(--bg-3); border: 1px solid var(--border); width: 24px; height: 24px; border-radius: 50%; color: var(--text-2); cursor: pointer; display: grid; place-items: center; font-size: 12px; z-index: 10; }
|
||
.sb-toggle:hover { color: var(--accent); border-color: var(--accent); }
|
||
|
||
nav.sb-nav { padding: 8px 0; flex: 1; overflow-y: auto; }
|
||
.nav-item { display: flex; align-items: center; gap: 10px; padding: 10px 20px; cursor: pointer; color: var(--text-2); font-size: 13px; border-left: 3px solid transparent; transition: all 0.12s; text-decoration: none; }
|
||
.nav-item:hover { background: var(--bg-3); color: var(--text); }
|
||
.nav-item.active { color: var(--accent); background: rgba(0,240,255,0.05); border-left-color: var(--accent); }
|
||
.nav-item .icon { font-size: 16px; width: 18px; flex-shrink: 0; }
|
||
.nav-section { padding: 12px 20px 4px; font-size: 10px; color: var(--text-3); text-transform: uppercase; letter-spacing: 1px; font-weight: 700; }
|
||
|
||
.user-box { margin-top: auto; padding: 14px 16px; border-top: 1px solid var(--border); }
|
||
.user-info { display: flex; align-items: center; gap: 10px; }
|
||
.avatar { width: 32px; height: 32px; border-radius: 50%; background: var(--accent); color: var(--bg); display: grid; place-items: center; font-size: 12px; font-weight: 700; flex-shrink: 0; }
|
||
.user-info .name { font-size: 12px; font-weight: 600; }
|
||
.user-info .role { font-size: 10px; color: var(--text-3); font-family: 'JetBrains Mono', monospace; }
|
||
.user-info .menu-btn { margin-left: auto; background: none; border: 0; color: var(--text-3); cursor: pointer; font-size: 16px; padding: 4px; }
|
||
.user-info .menu-btn:hover { color: var(--accent); }
|
||
.dropdown { position: absolute; bottom: 60px; left: 14px; right: 14px; background: var(--bg-3); border: 1px solid var(--border); border-radius: 6px; padding: 6px; display: none; box-shadow: 0 -8px 24px rgba(0,0,0,0.5); z-index: 20; }
|
||
.dropdown.show { display: block; }
|
||
.dropdown a { display: block; padding: 8px 10px; color: var(--text-2); font-size: 12px; cursor: pointer; border-radius: 4px; text-decoration: none; }
|
||
.dropdown a:hover { background: var(--bg-4); color: var(--accent); }
|
||
|
||
main.main { padding: 20px 28px; overflow-y: auto; }
|
||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid var(--border); gap: 16px; flex-wrap: wrap; }
|
||
.page-header h2 { font-size: 22px; font-weight: 700; }
|
||
.page-header .meta { color: var(--text-3); font-size: 12px; font-family: 'JetBrains Mono', monospace; }
|
||
.page-header .actions { display: flex; gap: 10px; }
|
||
|
||
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: 6px; cursor: pointer; font-family: inherit; font-size: 13px; font-weight: 500; border: 1px solid var(--border); background: var(--bg-3); color: var(--text); text-decoration: none; transition: all 0.12s; }
|
||
.btn:hover { border-color: var(--accent); color: var(--accent); }
|
||
.btn.primary { background: var(--accent); color: var(--bg); border-color: var(--accent); font-weight: 600; }
|
||
.btn.primary:hover { background: var(--accent-2); color: var(--bg); }
|
||
.btn.danger { color: var(--red); border-color: rgba(248,81,73,0.3); }
|
||
.btn.danger:hover { background: rgba(248,81,73,0.1); border-color: var(--red); }
|
||
|
||
.filter-bar { display: grid; grid-template-columns: 1fr repeat(4, minmax(120px, 180px)); gap: 10px; margin-bottom: 16px; }
|
||
.filter-bar input, .filter-bar select { background: var(--bg-2); border: 1px solid var(--border); color: var(--text); padding: 8px 12px; border-radius: 6px; font-family: inherit; font-size: 13px; }
|
||
.filter-bar input:focus, .filter-bar select:focus { outline: none; border-color: var(--accent); }
|
||
@media (max-width: 1100px) { .filter-bar { grid-template-columns: 1fr; } }
|
||
|
||
.section { background: var(--bg-2); border: 1px solid var(--border); border-radius: 8px; padding: 18px; margin-bottom: 18px; }
|
||
.section h3 { font-size: 14px; font-weight: 600; margin-bottom: 12px; color: var(--accent); display: flex; justify-content: space-between; align-items: center; }
|
||
.section h3 small { color: var(--text-3); font-weight: 400; font-family: 'JetBrains Mono', monospace; font-size: 11px; }
|
||
|
||
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 18px; }
|
||
.kpi-card { background: var(--bg-2); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; position: relative; overflow: hidden; }
|
||
.kpi-card::before { content: ''; position: absolute; top: 0; left: 0; width: 3px; height: 100%; background: var(--accent); }
|
||
.kpi-card.green::before { background: var(--green); }
|
||
.kpi-card.yellow::before { background: var(--yellow); }
|
||
.kpi-card.purple::before { background: var(--purple); }
|
||
.kpi-card.red::before { background: var(--red); }
|
||
.kpi-label { font-size: 10px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.7px; font-weight: 600; }
|
||
.kpi-value { font-size: 26px; font-weight: 700; margin-top: 4px; font-family: 'JetBrains Mono', monospace; }
|
||
.kpi-sub { font-size: 11px; color: var(--text-2); margin-top: 2px; }
|
||
|
||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||
th { text-align: left; padding: 8px 10px; color: var(--text-3); font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border); white-space: nowrap; font-weight: 600; }
|
||
td { padding: 10px; border-bottom: 1px solid var(--border); color: var(--text); }
|
||
tr:hover td { background: var(--bg-3); }
|
||
td.num, th.num { text-align: right; font-family: 'JetBrains Mono', monospace; }
|
||
td.actions-col { text-align: right; white-space: nowrap; }
|
||
td.actions-col .btn { padding: 4px 8px; font-size: 11px; }
|
||
|
||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; line-height: 1.5; }
|
||
.badge.green { background: rgba(86,211,100,0.15); color: var(--green); }
|
||
.badge.yellow { background: rgba(210,153,34,0.15); color: var(--yellow); }
|
||
.badge.red { background: rgba(248,81,73,0.15); color: var(--red); }
|
||
.badge.gray { background: rgba(110,118,129,0.15); color: var(--text-3); }
|
||
.badge.purple { background: rgba(188,140,255,0.15); color: var(--purple); }
|
||
.badge.cyan { background: rgba(0,240,255,0.15); color: var(--accent); }
|
||
|
||
.tab-content { display: none; }
|
||
.tab-content.active { display: block; }
|
||
|
||
.modal-bg { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: none; z-index: 100; backdrop-filter: blur(2px); }
|
||
.modal-bg.show { display: grid; place-items: center; }
|
||
.modal { background: var(--bg-2); border: 1px solid var(--border); border-radius: 10px; padding: 24px; width: min(540px, 92vw); max-height: 92vh; overflow-y: auto; position: relative; }
|
||
.modal h3 { font-size: 18px; margin-bottom: 16px; }
|
||
.modal .close { position: absolute; top: 14px; right: 14px; background: none; border: 0; color: var(--text-3); cursor: pointer; font-size: 20px; }
|
||
.field { margin-bottom: 14px; }
|
||
.field label { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-3); margin-bottom: 6px; font-weight: 600; }
|
||
.field input, .field select, .field textarea { width: 100%; background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 10px 12px; border-radius: 6px; font-family: inherit; font-size: 13px; }
|
||
.field input:focus, .field select:focus { outline: none; border-color: var(--accent); }
|
||
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||
.modal-actions { display: flex; gap: 10px; margin-top: 20px; justify-content: flex-end; }
|
||
|
||
.toast { position: fixed; bottom: 24px; right: 24px; background: var(--bg-2); border: 1px solid var(--border); padding: 12px 16px; border-radius: 8px; font-size: 13px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); z-index: 200; transform: translateY(100px); opacity: 0; transition: all 0.3s; }
|
||
.toast.show { transform: translateY(0); opacity: 1; }
|
||
.toast.success { border-left: 3px solid var(--green); }
|
||
.toast.error { border-left: 3px solid var(--red); }
|
||
.empty { text-align: center; padding: 40px 20px; color: var(--text-3); }
|
||
|
||
.audit-row { font-family: 'JetBrains Mono', monospace; font-size: 11px; }
|
||
.audit-action { background: var(--bg-3); padding: 2px 6px; border-radius: 3px; font-size: 11px; color: var(--accent); }
|
||
|
||
.cookie { position: fixed; bottom: 16px; left: 16px; right: 16px; max-width: 560px; margin: 0 auto; background: var(--bg-2); border: 1px solid var(--border); border-radius: 10px; padding: 14px 18px; display: none; z-index: 1000; box-shadow: 0 12px 40px rgba(0,0,0,0.5); }
|
||
.cookie.show { display: block; }
|
||
.cookie h4 { font-size: 13px; margin-bottom: 4px; }
|
||
.cookie p { font-size: 11px; color: var(--text-2); margin-bottom: 10px; }
|
||
.cookie-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||
.cookie-actions button { background: transparent; border: 1px solid var(--border); color: var(--text-2); padding: 5px 12px; border-radius: 4px; font-family: inherit; font-size: 11px; cursor: pointer; }
|
||
.cookie-actions button.primary { background: var(--accent); border-color: var(--accent); color: var(--bg); font-weight: 600; }
|
||
|
||
@media (max-width: 768px) {
|
||
.app { grid-template-columns: 1fr; }
|
||
.sidebar { display: none; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="app" id="appShell">
|
||
<aside class="sidebar">
|
||
<button class="sb-toggle" id="sbToggle" title="Sklopi/raširi">⮜</button>
|
||
<div class="brand">
|
||
<div class="brand-mark">P</div>
|
||
<div class="brand-text">
|
||
<h1><a href="/" style="text-decoration:none;color:inherit" title="Početna">PGŽ SPORT</a></h1>
|
||
<div class="sub">Admin · Auth v3.0</div>
|
||
</div>
|
||
</div>
|
||
<nav class="sb-nav">
|
||
<div class="nav-item active" data-tab="overview"><span class="icon">⊞</span><span class="sb-text">Pregled</span></div>
|
||
<div class="nav-section sb-text">Multi-tenant</div>
|
||
<div class="nav-item" data-tab="users"><span class="icon">⊙</span><span class="sb-text">Korisnici</span></div>
|
||
<div class="nav-item" data-tab="tenants"><span class="icon">⌂</span><span class="sb-text">Tenanti</span></div>
|
||
<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-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>
|
||
<a class="nav-item" href="/admin"><span class="icon">€</span><span class="sb-text">ERP / CRM / OCR</span></a>
|
||
<a class="nav-item" href="/static/sport2.html"><span class="icon">◊</span><span class="sb-text">Javni portal</span></a>
|
||
</nav>
|
||
<div class="user-box">
|
||
<div class="user-info">
|
||
<div class="avatar" id="userAvatar">?</div>
|
||
<div>
|
||
<div class="name" id="userName">—</div>
|
||
<div class="role" id="userRole">—</div>
|
||
</div>
|
||
<button class="menu-btn" id="userMenuBtn">⋮</button>
|
||
</div>
|
||
<div class="dropdown" id="userDropdown">
|
||
<a id="menuExport">📥 Izvezi moje podatke</a>
|
||
<a id="menuChangePwd">🔑 Promijeni lozinku</a>
|
||
<a id="menuErase">🗑️ Zatraži brisanje računa</a>
|
||
<a id="menuLogout" style="color: var(--red)">⏻ Odjava</a>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<main class="main">
|
||
<div class="tab-content active" id="tab-overview">
|
||
<div class="page-header">
|
||
<div><h2>Pregled</h2><span class="meta" id="overviewMeta">učitavam…</span></div>
|
||
</div>
|
||
<div class="kpi-grid" id="overviewKpi"></div>
|
||
<div class="section">
|
||
<h3>Najnovije akcije <small>zadnjih 10</small></h3>
|
||
<table id="recentAuditTable"><thead><tr><th>Vrijeme</th><th>Korisnik</th><th>Akcija</th><th>Resurs</th><th>IP</th></tr></thead><tbody></tbody></table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tab-content" id="tab-users">
|
||
<div class="page-header">
|
||
<div><h2>Korisnici</h2><span class="meta" id="usersMeta">—</span></div>
|
||
<div class="actions">
|
||
<button class="btn" id="btnRefreshUsers">↻ Osvježi</button>
|
||
<button class="btn primary" id="btnNewUser">+ Dodaj korisnika</button>
|
||
</div>
|
||
</div>
|
||
<div class="filter-bar">
|
||
<input type="text" id="usrQ" placeholder="🔍 Traži po imenu, e-mailu, OIB-u…">
|
||
<select id="usrTenant"><option value="">Svi tenanti</option></select>
|
||
<select id="usrRole">
|
||
<option value="">Sve uloge</option>
|
||
<option value="super_admin">Super admin</option>
|
||
<option value="pgz_admin">PGŽ admin</option>
|
||
<option value="pgz_user">PGŽ user</option>
|
||
<option value="pgz_finance">PGŽ finance</option>
|
||
<option value="savez_admin">Savez admin</option>
|
||
<option value="klub_admin">Klub admin</option>
|
||
<option value="klub_trener">Klub trener</option>
|
||
<option value="klub_user">Klub user</option>
|
||
<option value="klub_clan">Klub član</option>
|
||
<option value="viewer">Viewer</option>
|
||
</select>
|
||
<select id="usrStatus">
|
||
<option value="">Svi statusi</option>
|
||
<option value="true">Aktivni</option>
|
||
<option value="false">Neaktivni</option>
|
||
</select>
|
||
<select id="usrLimit">
|
||
<option value="50">50</option>
|
||
<option value="100" selected>100</option>
|
||
<option value="200">200</option>
|
||
<option value="500">500</option>
|
||
</select>
|
||
</div>
|
||
<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>
|
||
<tbody id="usersTbody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tab-content" id="tab-tenants">
|
||
<div class="page-header"><h2>Tenanti</h2></div>
|
||
<div class="section"><h3>Hijerarhija</h3>
|
||
<table><thead><tr><th>ID</th><th>Slug</th><th>Naziv</th><th>Tip</th><th>OIB</th><th>Status</th></tr></thead><tbody id="tenantsTbody"></tbody></table>
|
||
</div>
|
||
<div class="section"><h3>Savezi</h3>
|
||
<table><thead><tr><th>ID</th><th>Naziv</th><th>Sport</th><th>Predsjednik</th><th>Tajnik</th></tr></thead><tbody id="savezi2Tbody"></tbody></table>
|
||
</div>
|
||
<div class="section"><h3>Klubovi <small id="klubCount">—</small></h3>
|
||
<table><thead><tr><th>ID</th><th>Naziv</th><th>Sport</th><th>Grad</th><th>OIB</th><th>Savez ID</th></tr></thead><tbody id="klubovi2Tbody"></tbody></table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tab-content" id="tab-audit">
|
||
<div class="page-header"><h2>Audit log</h2><div class="actions"><button class="btn" id="btnRefreshAudit">↻ Osvježi</button></div></div>
|
||
<div class="filter-bar">
|
||
<input type="text" id="auQ" placeholder="🔍 Filtriraj akciju (login, user.create, …)">
|
||
<input type="number" id="auUid" placeholder="user_id">
|
||
<select id="auLimit"><option value="50">50</option><option value="100" selected>100</option><option value="500">500</option></select>
|
||
<span></span><span></span>
|
||
</div>
|
||
<div class="section"><h3>Događaji <small id="auditCount">—</small></h3>
|
||
<table><thead><tr><th>Vrijeme</th><th>User</th><th>Akcija</th><th>Resurs</th><th>IP</th><th>UA</th><th>Meta</th></tr></thead><tbody id="auditTbody"></tbody></table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tab-content" id="tab-security">
|
||
<div class="page-header"><h2>Sigurnost</h2></div>
|
||
<div class="kpi-grid" id="secKpi"></div>
|
||
<div class="section">
|
||
<h3>Two-factor authentication (2FA) <small>moj račun</small></h3>
|
||
<div id="twofaPanel" style="display:flex;gap:14px;align-items:center;flex-wrap:wrap">
|
||
<span id="twofaStatus" class="badge gray">Učitavam…</span>
|
||
<button class="btn primary" id="btnEnable2FA">Omogući 2FA</button>
|
||
<button class="btn danger" id="btnDisable2FA" style="display:none">Onemogući 2FA</button>
|
||
</div>
|
||
<div id="twofaSetup" style="display:none;margin-top:14px;padding:14px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border)">
|
||
<div style="display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start">
|
||
<div style="flex:0 0 220px"><img id="twofaQr" style="background:#fff;padding:8px;border-radius:6px;width:220px;height:220px"></div>
|
||
<div style="flex:1;min-width:220px">
|
||
<div style="font-size:12px;color:var(--text-3);margin-bottom:6px">Skenirajte QR u aplikaciji (Google Authenticator, Authy, 1Password, …) ili upišite secret ručno:</div>
|
||
<code id="twofaSecret" style="display:block;padding:10px;background:var(--bg);border:1px solid var(--border);border-radius:5px;font-family:'JetBrains Mono',monospace;word-break:break-all;margin-bottom:14px"></code>
|
||
<div style="font-size:12px;color:var(--text-3);margin-bottom:6px">Kodovi za oporavak (sačuvajte ih sigurno):</div>
|
||
<div id="twofaRecovery" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:6px;font-family:'JetBrains Mono',monospace;font-size:12px;margin-bottom:14px"></div>
|
||
<div class="field">
|
||
<label>Potvrda — kod iz autentifikatora</label>
|
||
<input type="text" id="twofaConfirm" maxlength="8" inputmode="numeric" style="font-family:'JetBrains Mono',monospace;letter-spacing:4px;text-align:center;font-size:18px">
|
||
</div>
|
||
<button class="btn primary" id="btnVerify2FA">Potvrdi i aktiviraj</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="section"><h3>Zaključani / failed-login računi</h3>
|
||
<table><thead><tr><th>E-mail</th><th>Uloga</th><th class="num">Pokušaja</th><th>Zaključan do</th><th class="actions-col">Akcije</th></tr></thead><tbody id="lockedTbody"></tbody></table>
|
||
</div>
|
||
<div class="section"><h3>Sesije</h3>
|
||
<table><thead><tr><th>—</th></tr></thead><tbody id="sessionsTbody"><tr><td class="empty">Sesije se prate per-user kroz audit log (login.ok / logout / auth.refresh)</td></tr></tbody></table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tab-content" id="tab-gdpr">
|
||
<div class="page-header"><h2>GDPR</h2></div>
|
||
<div class="kpi-grid" id="gdprKpi"></div>
|
||
<div class="section"><h3>Zahtjevi za brisanje <small>Art. 17</small></h3>
|
||
<table><thead><tr><th>ID</th><th>Korisnik</th><th>E-mail</th><th>Razlog</th><th>Status</th><th>Zatraženo</th><th class="actions-col">Akcije</th></tr></thead><tbody id="erasureTbody"></tbody></table>
|
||
</div>
|
||
<div class="section"><h3>Pristanak na kolačiće <small>moja povijest</small></h3>
|
||
<table><thead><tr><th>Vrijeme</th><th>Session</th><th>Nužni</th><th>Analitički</th><th>Marketing</th><th>IP</th><th>Verzija</th></tr></thead><tbody id="consentTbody"></tbody></table>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
<div class="modal-bg" id="userModalBg">
|
||
<div class="modal">
|
||
<button class="close" onclick="closeModal('userModal')">×</button>
|
||
<h3 id="userModalTitle">+ Dodaj korisnika</h3>
|
||
<form id="userForm">
|
||
<input type="hidden" id="uf_id">
|
||
<div class="field-row">
|
||
<div class="field"><label>E-mail *</label><input type="email" id="uf_email" required></div>
|
||
<div class="field"><label>Telefon</label><input type="text" id="uf_telefon"></div>
|
||
</div>
|
||
<div class="field-row">
|
||
<div class="field"><label>Ime</label><input type="text" id="uf_ime"></div>
|
||
<div class="field"><label>Prezime</label><input type="text" id="uf_prezime"></div>
|
||
</div>
|
||
<div class="field-row">
|
||
<div class="field"><label>Uloga *</label>
|
||
<select id="uf_role" required>
|
||
<option value="pgz_admin">PGŽ admin</option>
|
||
<option value="pgz_user">PGŽ user</option>
|
||
<option value="pgz_finance">PGŽ finance</option>
|
||
<option value="savez_admin">Savez admin</option>
|
||
<option value="savez_user">Savez user</option>
|
||
<option value="klub_admin">Klub admin</option>
|
||
<option value="klub_trener">Klub trener</option>
|
||
<option value="klub_user">Klub user</option>
|
||
<option value="klub_clan" selected>Klub član</option>
|
||
<option value="viewer">Viewer</option>
|
||
</select></div>
|
||
<div class="field"><label>OIB</label><input type="text" id="uf_oib" maxlength="11"></div>
|
||
</div>
|
||
<div class="field-row">
|
||
<div class="field"><label>Klub ID</label><input type="number" id="uf_klub_id"></div>
|
||
<div class="field"><label>Savez ID</label><input type="number" id="uf_savez_id"></div>
|
||
</div>
|
||
<div class="field" id="uf_pwd_field">
|
||
<label>Lozinka <small style="color:var(--text-3)">(prazno = generiraj privremenu)</small></label>
|
||
<input type="text" id="uf_password" placeholder="Ostavi prazno za auto-generiranu">
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button type="button" class="btn" onclick="closeModal('userModal')">Odustani</button>
|
||
<button type="submit" class="btn primary" id="uf_submit">Spremi</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-bg" id="pwdModalBg">
|
||
<div class="modal">
|
||
<button class="close" onclick="closeModal('pwdModal')">×</button>
|
||
<h3>Promjena lozinke</h3>
|
||
<form id="pwdForm">
|
||
<div class="field"><label>Stara lozinka</label><input type="password" id="pf_old"></div>
|
||
<div class="field"><label>Nova lozinka *</label><input type="password" id="pf_new" required minlength="8"></div>
|
||
<div class="field"><label>Potvrdi novu *</label><input type="password" id="pf_new2" required minlength="8"></div>
|
||
<div class="modal-actions">
|
||
<button type="button" class="btn" onclick="closeModal('pwdModal')">Odustani</button>
|
||
<button type="submit" class="btn primary">Promijeni</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="cookie" class="cookie">
|
||
<h4>🍪 Kolačići</h4>
|
||
<p>Koristimo nužne kolačiće za prijavu i sigurnost. Ostali kolačići samo uz vaše odobrenje.</p>
|
||
<div class="cookie-actions">
|
||
<button class="primary" id="cookieAccept">Prihvati sve</button>
|
||
<button id="cookieNecessary">Samo nužni</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="toast" class="toast"></div>
|
||
|
||
<script>
|
||
const API = '/sport/api';
|
||
const TOKEN_KEY = 'pgz_access', REFRESH_KEY = 'pgz_refresh', USER_KEY = 'pgz_user';
|
||
const $ = s => document.querySelector(s);
|
||
const $$ = s => document.querySelectorAll(s);
|
||
|
||
function getToken() { return localStorage.getItem(TOKEN_KEY) || sessionStorage.getItem(TOKEN_KEY); }
|
||
function getUser() { try { return JSON.parse(localStorage.getItem(USER_KEY) || sessionStorage.getItem(USER_KEY) || 'null'); } catch { return null; } }
|
||
function clearAuth() { for (const k of [TOKEN_KEY, REFRESH_KEY, USER_KEY]) { localStorage.removeItem(k); sessionStorage.removeItem(k); } }
|
||
async function refreshToken() {
|
||
const rt = localStorage.getItem(REFRESH_KEY) || sessionStorage.getItem(REFRESH_KEY);
|
||
if (!rt) return null;
|
||
try {
|
||
const r = await fetch(API + '/auth/refresh', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({refresh_token: rt}) });
|
||
if (!r.ok) return null;
|
||
const d = await r.json();
|
||
const store = localStorage.getItem(REFRESH_KEY) ? localStorage : sessionStorage;
|
||
store.setItem(TOKEN_KEY, d.access_token);
|
||
return d.access_token;
|
||
} catch { return null; }
|
||
}
|
||
async function api(path, opts = {}) {
|
||
let tok = getToken();
|
||
if (!tok) { location.href = '/static/login.html'; return null; }
|
||
const headers = Object.assign({}, opts.headers || {}, {'Authorization': 'Bearer ' + tok});
|
||
if (opts.body && !(opts.body instanceof FormData) && !headers['Content-Type']) {
|
||
headers['Content-Type'] = 'application/json';
|
||
if (typeof opts.body !== 'string') opts.body = JSON.stringify(opts.body);
|
||
}
|
||
let r = await fetch(API + path, Object.assign({}, opts, {headers}));
|
||
if (r.status === 401) {
|
||
const newTok = await refreshToken();
|
||
if (!newTok) { clearAuth(); location.href = '/static/login.html'; return null; }
|
||
headers['Authorization'] = 'Bearer ' + newTok;
|
||
r = await fetch(API + path, Object.assign({}, opts, {headers}));
|
||
}
|
||
return r;
|
||
}
|
||
async function apiJson(path, opts) { const r = await api(path, opts); if (!r) return null; try { return await r.json(); } catch { return null; } }
|
||
|
||
function toast(msg, type='success') {
|
||
const t = $('#toast'); t.textContent = msg;
|
||
t.className = 'toast show ' + type;
|
||
setTimeout(() => t.classList.remove('show'), 3500);
|
||
}
|
||
function fmtDateTime(d) { if (!d) return '—'; try { return new Date(d).toLocaleString('hr-HR'); } catch { return d; } }
|
||
function escapeHtml(s) { if (s == null) return ''; return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||
function roleBadge(r) {
|
||
const map = { super_admin:'red', pgz_admin:'cyan', pgz_user:'cyan', pgz_finance:'cyan', pgz_zzjz:'cyan',
|
||
savez_admin:'purple', savez_user:'purple', klub_admin:'green', klub_trener:'green', klub_user:'green', klub_clan:'green', viewer:'gray' };
|
||
return `<span class="badge ${map[r]||'gray'}">${escapeHtml(r||'—')}</span>`;
|
||
}
|
||
function statusBadge(active) { return active ? '<span class="badge green">Aktivan</span>' : '<span class="badge gray">Neaktivan</span>'; }
|
||
function openModal(name) { $('#'+name+'Bg').classList.add('show'); }
|
||
function closeModal(name) { $('#'+name+'Bg').classList.remove('show'); }
|
||
|
||
// Sidebar collapse
|
||
const sbState = localStorage.getItem('pgz_sidebar') || 'expanded';
|
||
if (sbState === 'collapsed') $('#appShell').classList.add('collapsed');
|
||
$('#sbToggle').textContent = $('#appShell').classList.contains('collapsed') ? '⮞' : '⮜';
|
||
$('#sbToggle').addEventListener('click', () => {
|
||
$('#appShell').classList.toggle('collapsed');
|
||
const c = $('#appShell').classList.contains('collapsed');
|
||
localStorage.setItem('pgz_sidebar', c ? 'collapsed' : 'expanded');
|
||
$('#sbToggle').textContent = c ? '⮞' : '⮜';
|
||
});
|
||
|
||
// Tabs
|
||
function activate(tab) {
|
||
$$('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.tab === tab));
|
||
$$('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + tab));
|
||
if (tab === 'overview') loadOverview();
|
||
if (tab === 'users') loadUsers();
|
||
if (tab === 'tenants') loadTenants();
|
||
if (tab === 'audit') loadAudit();
|
||
if (tab === 'security') loadSecurity();
|
||
if (tab === 'gdpr') loadGdpr();
|
||
history.replaceState(null, '', '#' + tab);
|
||
}
|
||
$$('.nav-item[data-tab]').forEach(n => n.addEventListener('click', () => activate(n.dataset.tab)));
|
||
|
||
// User dropdown
|
||
$('#userMenuBtn').addEventListener('click', e => { e.stopPropagation(); $('#userDropdown').classList.toggle('show'); });
|
||
document.addEventListener('click', () => $('#userDropdown').classList.remove('show'));
|
||
$('#userDropdown').addEventListener('click', e => e.stopPropagation());
|
||
|
||
$('#menuLogout').addEventListener('click', async () => {
|
||
await api('/auth/logout', {method:'POST'});
|
||
clearAuth();
|
||
location.href = '/static/login.html';
|
||
});
|
||
$('#menuExport').addEventListener('click', async () => {
|
||
const r = await api('/users/me/gdpr-export', {method:'POST'}); if (!r) return;
|
||
const blob = await r.blob();
|
||
const cd = r.headers.get('content-disposition') || '';
|
||
const m = cd.match(/filename="?([^";]+)"?/);
|
||
const fn = m ? m[1] : `pgz_data_export_${Date.now()}.json`;
|
||
const u = URL.createObjectURL(blob);
|
||
const a = document.createElement('a'); a.href = u; a.download = fn;
|
||
a.click(); URL.revokeObjectURL(u);
|
||
toast('Podaci preuzeti (Art. 20 GDPR)');
|
||
});
|
||
$('#menuChangePwd').addEventListener('click', () => openModal('pwdModal'));
|
||
$('#menuErase').addEventListener('click', async () => {
|
||
const reason = prompt('Razlog brisanja računa (opcionalno):'); if (reason === null) return;
|
||
const conf = prompt('Za potvrdu unesite svoj e-mail:'); if (!conf) return;
|
||
const r = await apiJson('/gdpr/erase', {method:'POST', body:{reason, confirm_email: conf}});
|
||
if (r && r.status === 'ok') toast('Zahtjev za brisanje #' + r.request_id + ' zaprimljen');
|
||
else toast(r?.detail || 'Greška', 'error');
|
||
});
|
||
|
||
$('#pwdForm').addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const oldp = $('#pf_old').value, newp = $('#pf_new').value, n2 = $('#pf_new2').value;
|
||
if (newp !== n2) return toast('Lozinke se ne poklapaju', 'error');
|
||
const r = await apiJson('/auth/password/change', {method:'POST', body:{old_password: oldp, new_password: newp}});
|
||
if (r && r.status === 'ok') { toast('Lozinka promijenjena'); closeModal('pwdModal'); $('#pwdForm').reset(); }
|
||
else toast(r?.detail || 'Greška', 'error');
|
||
});
|
||
|
||
// Overview
|
||
async function loadOverview() {
|
||
const u = getUser();
|
||
$('#overviewMeta').textContent = `${u?.email || ''} · tenant ${u?.tenant_name || ''} · tier ${u?.tier ?? '?'}`;
|
||
const ul = await apiJson('/admin/users?limit=1');
|
||
const al = await apiJson('/admin/audit?limit=10');
|
||
const act = await apiJson('/admin/users?aktivan=true&limit=1');
|
||
$('#overviewKpi').innerHTML = `
|
||
<div class="kpi-card"><div class="kpi-label">Korisnici</div><div class="kpi-value">${ul?.total ?? '—'}</div><div class="kpi-sub">u tenant scope-u</div></div>
|
||
<div class="kpi-card green"><div class="kpi-label">Aktivni</div><div class="kpi-value">${act?.total ?? '—'}</div></div>
|
||
<div class="kpi-card yellow"><div class="kpi-label">Audit /10</div><div class="kpi-value">${al?.count ?? '—'}</div></div>
|
||
<div class="kpi-card purple"><div class="kpi-label">Tenant</div><div class="kpi-value" style="font-size:14px">${escapeHtml(u?.tenant_type||'')}</div><div class="kpi-sub">${escapeHtml(u?.tenant_name||'')}</div></div>
|
||
`;
|
||
$('#recentAuditTable tbody').innerHTML = (al?.results || []).slice(0,10).map(a => `
|
||
<tr><td>${fmtDateTime(a.created_at)}</td>
|
||
<td>${escapeHtml(a.actor_email||'')}<br><small style="color:var(--text-3)">${escapeHtml(a.actor_name||'')}</small></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></tr>`).join('') || '<tr><td colspan="5" class="empty">Nema događaja</td></tr>';
|
||
}
|
||
|
||
// Users
|
||
let usersDebounce = null;
|
||
async function loadUsers() {
|
||
const q = $('#usrQ').value, t = $('#usrTenant').value, r = $('#usrRole').value, ak = $('#usrStatus').value, lim = $('#usrLimit').value;
|
||
const params = new URLSearchParams();
|
||
if (q) params.set('q', q);
|
||
if (r) params.set('user_type', r);
|
||
if (ak !== '') params.set('aktivan', ak);
|
||
if (t) { const [tt, ti] = t.split(':'); if (tt && ti) { params.set('tenant_type', tt); params.set('tenant_id', ti); } }
|
||
params.set('limit', lim || 100);
|
||
const data = await apiJson('/admin/users?' + params.toString());
|
||
if (!data) return;
|
||
$('#usersCount').textContent = `${data.count}/${data.total} prikazano`;
|
||
$('#usersMeta').textContent = `${data.total} ukupno · ${data.count} prikazano`;
|
||
$('#usersTbody').innerHTML = (data.results || []).map(u => `
|
||
<tr><td>${u.id}</td>
|
||
<td><strong>${escapeHtml(u.email)}</strong>${u.must_change_pwd?'<br><span class="badge yellow">Promijeniti lozinku</span>':''}</td>
|
||
<td>${escapeHtml(u.full_name || ((u.ime||'')+' '+(u.prezime||'')).trim() || '—')}</td>
|
||
<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>${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>
|
||
</td></tr>
|
||
`).join('') || '<tr><td colspan="8" class="empty">Nema korisnika</td></tr>';
|
||
}
|
||
['usrQ','usrTenant','usrRole','usrStatus','usrLimit'].forEach(id => {
|
||
$('#'+id).addEventListener('input', () => { clearTimeout(usersDebounce); usersDebounce = setTimeout(loadUsers, 300); });
|
||
});
|
||
$('#btnRefreshUsers').addEventListener('click', loadUsers);
|
||
|
||
async function loadTenantSelect() {
|
||
const d = await apiJson('/admin/tenants'); if (!d) return;
|
||
const opts = ['<option value="">Svi tenanti</option>'];
|
||
for (const t of (d.tenants || [])) opts.push(`<option value="">— ${escapeHtml(t.display_name)} —</option>`);
|
||
for (const s of (d.savezi || [])) opts.push(`<option value="savez:${s.id}">savez · ${escapeHtml(s.naziv)}</option>`);
|
||
for (const k of (d.klubovi || [])) opts.push(`<option value="klub:${k.id}">klub · ${escapeHtml(k.naziv)}</option>`);
|
||
$('#usrTenant').innerHTML = opts.join('');
|
||
}
|
||
|
||
$('#btnNewUser').addEventListener('click', () => {
|
||
$('#userModalTitle').textContent = '+ Dodaj korisnika';
|
||
$('#userForm').reset();
|
||
$('#uf_id').value = '';
|
||
$('#uf_email').disabled = false;
|
||
$('#uf_pwd_field').style.display = '';
|
||
openModal('userModal');
|
||
});
|
||
|
||
async function editUser(id) {
|
||
const r = await apiJson('/admin/users/' + id); if (!r) return;
|
||
$('#userModalTitle').textContent = '✎ Uredi korisnika #' + id;
|
||
$('#uf_id').value = r.id;
|
||
$('#uf_email').value = r.email || '';
|
||
$('#uf_email').disabled = true;
|
||
$('#uf_telefon').value = r.telefon || '';
|
||
$('#uf_ime').value = r.ime || '';
|
||
$('#uf_prezime').value = r.prezime || '';
|
||
$('#uf_role').value = r.user_type || 'klub_clan';
|
||
$('#uf_oib').value = r.oib || '';
|
||
$('#uf_klub_id').value = r.klub_id || '';
|
||
$('#uf_savez_id').value = r.savez_id || '';
|
||
$('#uf_pwd_field').style.display = 'none';
|
||
openModal('userModal');
|
||
}
|
||
$('#userForm').addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const id = $('#uf_id').value;
|
||
const body = {
|
||
email: $('#uf_email').value.trim(),
|
||
full_name: ($('#uf_ime').value + ' ' + $('#uf_prezime').value).trim() || null,
|
||
ime: $('#uf_ime').value || null, prezime: $('#uf_prezime').value || null,
|
||
user_type: $('#uf_role').value,
|
||
klub_id: $('#uf_klub_id').value ? +$('#uf_klub_id').value : null,
|
||
savez_id: $('#uf_savez_id').value ? +$('#uf_savez_id').value : null,
|
||
telefon: $('#uf_telefon').value || null,
|
||
oib: $('#uf_oib').value || null,
|
||
};
|
||
if ($('#uf_password').value) body.password = $('#uf_password').value;
|
||
let r;
|
||
if (id) { delete body.email; r = await apiJson('/admin/users/' + id, {method:'PUT', body}); }
|
||
else { r = await apiJson('/admin/users', {method:'POST', body}); }
|
||
if (r && (r.status === 'ok' || r.id)) {
|
||
if (r.temporary_password) {
|
||
alert('Korisnik kreiran. Privremena lozinka:\n\n' + r.temporary_password + '\n\nPošaljite ju korisniku sigurnim kanalom.');
|
||
}
|
||
toast(id ? 'Korisnik ažuriran' : 'Korisnik kreiran');
|
||
closeModal('userModal');
|
||
$('#uf_email').disabled = false;
|
||
loadUsers();
|
||
} else { toast(r?.detail || 'Greška', 'error'); }
|
||
});
|
||
async function resetPwd(id) {
|
||
if (!confirm('Resetirati lozinku ovog korisnika? Sve sesije će biti poništene.')) return;
|
||
const r = await apiJson('/admin/users/' + id + '/reset-password', {method:'POST'});
|
||
if (r?.status === 'ok') { alert('Privremena lozinka:\n\n' + r.temporary_password); toast('Lozinka resetirana'); }
|
||
else toast(r?.detail || 'Greška', 'error');
|
||
}
|
||
async function toggleSuspend(id, active) {
|
||
const path = active ? '/admin/users/' + id + '/suspend' : '/admin/users/' + id + '/unsuspend';
|
||
const body = active ? {reason: prompt('Razlog (opcionalno):') || null, minutes: null} : {};
|
||
const r = await apiJson(path, {method:'POST', body});
|
||
if (r?.status === 'ok') { toast(active?'Suspendiran':'Aktiviran'); loadUsers(); }
|
||
else toast(r?.detail || 'Greška', 'error');
|
||
}
|
||
async function deleteUser(id, email) {
|
||
if (!confirm(`Stvarno obrisati korisnika ${email}?\n(Soft delete — račun će biti deaktiviran.)`)) return;
|
||
const r = await apiJson('/admin/users/' + id, {method:'DELETE'});
|
||
if (r?.status === 'ok') { toast('Obrisano'); loadUsers(); }
|
||
else toast(r?.detail || 'Greška', 'error');
|
||
}
|
||
|
||
// Tenants
|
||
async function loadTenants() {
|
||
const d = await apiJson('/admin/tenants'); if (!d) return;
|
||
$('#tenantsTbody').innerHTML = (d.tenants || []).map(t => `
|
||
<tr><td>${t.id}</td><td><code>${escapeHtml(t.slug)}</code></td>
|
||
<td><strong>${escapeHtml(t.display_name)}</strong></td>
|
||
<td><span class="badge cyan">${escapeHtml(t.type||'—')}</span></td>
|
||
<td>${escapeHtml(t.oib||'—')}</td>
|
||
<td><span class="badge ${t.status==='active'?'green':'gray'}">${escapeHtml(t.status||'—')}</span></td></tr>
|
||
`).join('') || '<tr><td colspan="6" class="empty">—</td></tr>';
|
||
$('#savezi2Tbody').innerHTML = (d.savezi || []).map(s => `
|
||
<tr><td>${s.id}</td><td>${escapeHtml(s.naziv)}</td><td>${escapeHtml(s.sport||'—')}</td>
|
||
<td>${escapeHtml(s.predsjednik||'—')}</td><td>${escapeHtml(s.tajnik||'—')}</td></tr>
|
||
`).join('') || '<tr><td colspan="5" class="empty">—</td></tr>';
|
||
$('#klubCount').textContent = `${(d.klubovi||[]).length} prikazano`;
|
||
$('#klubovi2Tbody').innerHTML = (d.klubovi || []).slice(0, 200).map(k => `
|
||
<tr><td>${k.id}</td><td>${escapeHtml(k.naziv)}</td><td>${escapeHtml(k.sport||'—')}</td>
|
||
<td>${escapeHtml(k.grad||'—')}</td><td>${escapeHtml(k.oib||'—')}</td><td>${k.savez_id||'—'}</td></tr>
|
||
`).join('') || '<tr><td colspan="6" class="empty">—</td></tr>';
|
||
}
|
||
|
||
// Audit
|
||
let auditDebounce = null;
|
||
async function loadAudit() {
|
||
const q = $('#auQ').value, uid = $('#auUid').value, lim = $('#auLimit').value;
|
||
const params = new URLSearchParams();
|
||
if (q) params.set('action', q);
|
||
if (uid) params.set('user_id', uid);
|
||
params.set('limit', lim || 100);
|
||
const d = await apiJson('/admin/audit?' + params.toString()); if (!d) return;
|
||
$('#auditCount').textContent = `${d.count} događaja`;
|
||
$('#auditTbody').innerHTML = (d.results || []).map(a => `
|
||
<tr><td class="audit-row">${fmtDateTime(a.created_at)}</td>
|
||
<td>${escapeHtml(a.actor_email||'—')}</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" title="${escapeHtml(a.user_agent||'')}">${escapeHtml((a.user_agent||'').substring(0,40))}</td>
|
||
<td class="audit-row" style="max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title='${escapeHtml(JSON.stringify(a.meta||{}))}'>${escapeHtml(JSON.stringify(a.meta||{}).substring(0,50))}</td></tr>
|
||
`).join('') || '<tr><td colspan="7" class="empty">Nema događaja</td></tr>';
|
||
}
|
||
['auQ','auUid','auLimit'].forEach(id => {
|
||
$('#'+id).addEventListener('input', () => { clearTimeout(auditDebounce); auditDebounce = setTimeout(loadAudit, 300); });
|
||
});
|
||
$('#btnRefreshAudit').addEventListener('click', loadAudit);
|
||
|
||
// Security
|
||
async function loadSecurity() {
|
||
const all = await apiJson('/admin/users?limit=500');
|
||
const locked = (all?.results || []).filter(u => u.locked_until || (u.failed_login_count||0) >= 3);
|
||
const lockedNow = locked.filter(u => u.locked_until);
|
||
const active = (all?.results || []).filter(u => u.aktivan).length;
|
||
const inactive = (all?.total || 0) - active;
|
||
const audit = await apiJson('/admin/audit?action=login.fail&limit=20');
|
||
const failedRecent = audit?.count || 0;
|
||
$('#secKpi').innerHTML = `
|
||
<div class="kpi-card"><div class="kpi-label">Aktivni</div><div class="kpi-value">${active}</div></div>
|
||
<div class="kpi-card yellow"><div class="kpi-label">Neaktivni</div><div class="kpi-value">${inactive}</div></div>
|
||
<div class="kpi-card red"><div class="kpi-label">Zaključani</div><div class="kpi-value">${lockedNow.length}</div></div>
|
||
<div class="kpi-card purple"><div class="kpi-label">Login fail recent</div><div class="kpi-value">${failedRecent}</div></div>
|
||
`;
|
||
$('#lockedTbody').innerHTML = locked.map(u => `
|
||
<tr><td>${escapeHtml(u.email)}</td><td>${roleBadge(u.user_type)}</td>
|
||
<td class="num">${u.failed_login_count||0}</td>
|
||
<td>${fmtDateTime(u.locked_until)}</td>
|
||
<td class="actions-col">
|
||
<button class="btn" onclick="resetPwd(${u.id})">🔑 Reset</button>
|
||
<button class="btn primary" onclick="toggleSuspend(${u.id}, false)">▶ Otključaj</button>
|
||
</td></tr>
|
||
`).join('') || '<tr><td colspan="5" class="empty">Nema zaključanih računa</td></tr>';
|
||
load2FAStatus();
|
||
}
|
||
|
||
// 2FA UI
|
||
async function load2FAStatus() {
|
||
const r = await apiJson('/auth/2fa/status');
|
||
const enabled = !!(r && r.enabled);
|
||
$('#twofaStatus').className = 'badge ' + (enabled ? 'green' : 'gray');
|
||
$('#twofaStatus').textContent = enabled ? '✓ Omogućen' : 'Onemogućen';
|
||
$('#btnEnable2FA').style.display = enabled ? 'none' : '';
|
||
$('#btnDisable2FA').style.display = enabled ? '' : 'none';
|
||
$('#twofaSetup').style.display = 'none';
|
||
}
|
||
$('#btnEnable2FA').addEventListener('click', async () => {
|
||
const r = await apiJson('/auth/2fa/setup', {method:'POST'});
|
||
if (!r || !r.qr_png) return toast(r?.detail || 'Greška', 'error');
|
||
$('#twofaQr').src = r.qr_png;
|
||
$('#twofaSecret').textContent = r.secret;
|
||
$('#twofaRecovery').innerHTML = (r.recovery_codes||[]).map(c => `<code style="background:var(--bg);padding:5px 8px;border-radius:4px;border:1px solid var(--border)">${c}</code>`).join('');
|
||
$('#twofaSetup').style.display = '';
|
||
$('#twofaConfirm').focus();
|
||
});
|
||
$('#btnVerify2FA').addEventListener('click', async () => {
|
||
const code = ($('#twofaConfirm').value || '').trim().replace(/\s/g,'');
|
||
if (!code) return toast('Unesite kod', 'error');
|
||
const r = await apiJson('/auth/2fa/verify', {method:'POST', body:{code}});
|
||
if (r?.status === 'ok') { toast('2FA omogućen ✓'); load2FAStatus(); }
|
||
else toast(r?.detail || 'Neispravan kod', 'error');
|
||
});
|
||
$('#btnDisable2FA').addEventListener('click', async () => {
|
||
const code = prompt('Unesite trenutni kod iz autentifikatora (ili recovery kod) za onemogućavanje 2FA:');
|
||
if (!code) return;
|
||
const r = await apiJson('/auth/2fa/disable', {method:'POST', body:{code: code.trim()}});
|
||
if (r?.status === 'ok') { toast('2FA onemogućen'); load2FAStatus(); }
|
||
else toast(r?.detail || 'Greška', 'error');
|
||
});
|
||
|
||
// GDPR
|
||
async function loadGdpr() {
|
||
const er = await apiJson('/admin/gdpr/erasure-requests');
|
||
const my = await apiJson('/gdpr/consent');
|
||
const consentRecent = my?.history || [];
|
||
$('#gdprKpi').innerHTML = `
|
||
<div class="kpi-card"><div class="kpi-label">Zahtjevi za brisanje</div><div class="kpi-value">${er?.count||0}</div></div>
|
||
<div class="kpi-card yellow"><div class="kpi-label">Pending</div><div class="kpi-value">${(er?.results||[]).filter(r=>r.status==='pending').length}</div></div>
|
||
<div class="kpi-card green"><div class="kpi-label">Pristanci /50</div><div class="kpi-value">${consentRecent.length}</div></div>
|
||
`;
|
||
$('#erasureTbody').innerHTML = (er?.results || []).map(r => `
|
||
<tr><td>${r.id}</td><td>${r.user_id || '—'}</td>
|
||
<td>${escapeHtml(r.email||'—')}</td>
|
||
<td>${escapeHtml(r.reason||'—')}</td>
|
||
<td><span class="badge ${r.status==='pending'?'yellow':r.status==='completed'?'green':'gray'}">${r.status}</span></td>
|
||
<td>${fmtDateTime(r.requested_at)}</td>
|
||
<td class="actions-col">
|
||
${r.status==='pending' ? `
|
||
<button class="btn primary" onclick="processErasure(${r.id}, 'approve')">✓ Odobri</button>
|
||
<button class="btn danger" onclick="processErasure(${r.id}, 'deny')">✕ Odbij</button>` : '—'}
|
||
</td></tr>
|
||
`).join('') || '<tr><td colspan="7" class="empty">Nema zahtjeva</td></tr>';
|
||
$('#consentTbody').innerHTML = consentRecent.map(c => `
|
||
<tr><td class="audit-row">${fmtDateTime(c.consent_at)}</td>
|
||
<td class="audit-row">${escapeHtml(c.session_id||'—')}</td>
|
||
<td>${c.necessary?'✓':'—'}</td>
|
||
<td>${c.analytics?'✓':'—'}</td>
|
||
<td>${c.marketing?'✓':'—'}</td>
|
||
<td class="audit-row">${escapeHtml(c.ip||'—')}</td>
|
||
<td><code>${escapeHtml(c.policy_version||'—')}</code></td></tr>
|
||
`).join('') || '<tr><td colspan="7" class="empty">Nema zapisa</td></tr>';
|
||
}
|
||
async function processErasure(id, decision) {
|
||
const note = prompt('Bilješka (opcionalno):'); if (note === null) return;
|
||
const r = await apiJson(`/admin/gdpr/erasure-requests/${id}/process`, {method:'POST', body:{decision, note, anonymize: decision==='approve'}});
|
||
if (r?.status) { toast('Zahtjev: ' + r.status); loadGdpr(); } else toast(r?.detail || 'Greška', 'error');
|
||
}
|
||
|
||
// Cookie consent
|
||
async function showCookieIfNeeded() { if (!localStorage.getItem('pgz_consent')) $('#cookie').classList.add('show'); }
|
||
async function saveConsent(necessary, analytics, marketing) {
|
||
const session_id = localStorage.getItem('pgz_session_id') ||
|
||
(() => { const s = crypto.randomUUID(); localStorage.setItem('pgz_session_id', s); return s; })();
|
||
localStorage.setItem('pgz_consent', JSON.stringify({necessary, analytics, marketing, ts: Date.now()}));
|
||
$('#cookie').classList.remove('show');
|
||
await fetch(API + '/gdpr/consent', { method: 'POST', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({necessary, analytics, marketing, session_id}) }).catch(()=>{});
|
||
}
|
||
$('#cookieAccept').addEventListener('click', () => saveConsent(true, true, true));
|
||
$('#cookieNecessary').addEventListener('click', () => saveConsent(true, false, false));
|
||
|
||
// Init
|
||
(async () => {
|
||
const tok = getToken();
|
||
if (!tok) { location.href = '/static/login.html'; return; }
|
||
const r = await api('/auth/me');
|
||
if (!r || !r.ok) { clearAuth(); location.href = '/static/login.html'; return; }
|
||
const me = await r.json();
|
||
localStorage.setItem(USER_KEY, JSON.stringify(me));
|
||
$('#userName').textContent = me.full_name || me.email;
|
||
$('#userRole').textContent = (me.user_type || me.role || '') + ' · tier ' + (me.tier ?? '?');
|
||
$('#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');
|
||
showCookieIfNeeded();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|