Files
pgz-sport/static/admin_users.html
T
damir 67372d6c58 R7: GDPR /users/me/request-deletion alias + remove duplicate profileDeleteAccount
- auth/gdpr.py: dodan @me_router.post('/request-deletion') alias
  koji proxy-a na request_erasure (Art. 17). Koristi pravi EraseReq pydantic.
- static/app.html: obrisana placeholder profileDeleteAccount funkcija
  na liniji 944 (M10 mock alert) — sada samo real implementacija na 1902.
- E2E verified: damir@pgz.hr → POST /users/me/request-deletion → 200,
  DB row pgz_sport.gdpr_erasure_requests #1 pending.

Tag: P0-demo-fix
2026-05-05 02:06:34 +02:00

826 lines
48 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>PGŽ SPORT</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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>