0046b8d695
#1 JWT middleware: - pgz_sport_api.py: starlette middleware require_jwt_on_admin runs before every /api/admin/* route. Even routes that lack Depends(require_user) cannot be reached without a valid Bearer token (verifies signature, exp, typ='access', revocation via user_sessions). OPTIONS passes for CORS. #2 Invitation flow: - pgz_sport.user_action_tokens table (token_hash, user_id, kind, expires_at, used_at, created_by, ip, meta). Single-use, raw token never persisted. - POST /api/admin/users/{id}/invite — issues 'invite' token (TTL 7d), marks must_change_pwd, revokes existing sessions, returns invite_link. - GET /api/auth/setup-password?token=X — preflight (no consume). - POST /api/auth/setup-password — consumes token, sets password, sets email_verified=true. #3 Password reset flow: - POST /api/auth/forgot-password — generic 'ako račun postoji' response; issues 'reset' token (TTL 2h) only for active users. Token returned in response only on localhost or if PGZ_REVEAL_RESET_TOKEN=1. - GET /api/auth/reset-password?token=X — preflight. - POST /api/auth/reset-password — consumes token, sets new password, revokes all active sessions. #4 Audit coverage (auth events): - login.ok, login.fail (with reason), login.locked, login.2fa_required, login.2fa_fail, logout, auth.refresh, password.change, password.reset.ok, password.reset.fail, password.forgot.issue, password.forgot.miss, invite.consume.ok, invite.consume.fail, user.invite, user.create, user.update, user.delete, user.role.change, user.suspend, user.unsuspend, user.password.reset, 2fa.verify.ok, 2fa.verify.fail, 2fa.disable. #5 Live tests: 41/41 across 6 demo users (incl. fresh invited+deleted user). Phase 2 verifies 14 endpoints reject no-auth and accept valid Bearer.
151 lines
6.6 KiB
Plaintext
151 lines
6.6 KiB
Plaintext
<!DOCTYPE html>
|
|
<html lang="hr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Audit Log — PGŽ Sport</title>
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<link rel="icon" href="data:,">
|
|
<style>
|
|
:root { --bg0:#08090e; --bg1:#11141d; --bg2:#1a1f2c; --txt:#e6e9ef; --muted:#7a8294;
|
|
--pgz-blue:#003087; --pgz-gold:#F4C430; --green:#1a8754; --red:#dc3545; --orange:#fd7e14; }
|
|
* { box-sizing:border-box; margin:0; padding:0; }
|
|
body { font-family:'Inter',system-ui,sans-serif; background:var(--bg0); color:var(--txt); padding:24px; line-height:1.5; }
|
|
h1 { color:var(--pgz-gold); margin-bottom:6px; }
|
|
.sub { color:var(--muted); margin-bottom:24px; }
|
|
.toolbar { display:flex; gap:12px; margin-bottom:18px; flex-wrap:wrap; }
|
|
.btn { background:var(--pgz-blue); color:white; border:none; padding:9px 16px; border-radius:6px; cursor:pointer; font-weight:500; }
|
|
.btn:hover { background:#0040b8; }
|
|
.btn.secondary { background:var(--bg2); }
|
|
input,select { background:var(--bg2); color:var(--txt); border:1px solid #2a3144; padding:9px 12px; border-radius:6px; min-width:160px; }
|
|
table { width:100%; border-collapse:collapse; background:var(--bg1); border-radius:8px; overflow:hidden; margin-top:8px; }
|
|
th { background:var(--bg2); padding:12px; text-align:left; color:var(--pgz-gold); font-size:0.85rem; text-transform:uppercase; }
|
|
td { padding:11px 12px; border-top:1px solid #1a1f2c; font-size:0.92rem; }
|
|
tr:hover { background:#13182a; }
|
|
.badge { padding:3px 9px; border-radius:11px; font-size:0.75rem; font-weight:600; }
|
|
.b-create { background:rgba(26,135,84,0.2); color:#7fdca5; }
|
|
.b-update { background:rgba(253,126,20,0.2); color:#ffaa66; }
|
|
.b-delete { background:rgba(220,53,69,0.2); color:#ff7e85; }
|
|
.b-seal { background:rgba(244,196,48,0.2); color:var(--pgz-gold); }
|
|
.tx-link { color:#5fa8d3; text-decoration:none; font-family:monospace; font-size:0.85rem; }
|
|
.tx-link:hover { text-decoration:underline; }
|
|
.empty { padding:60px; text-align:center; color:var(--muted); }
|
|
.stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:12px; margin-bottom:18px; }
|
|
.stat { background:var(--bg1); padding:14px; border-radius:8px; border-left:3px solid var(--pgz-blue); }
|
|
.stat .v { font-size:1.6rem; font-weight:700; color:var(--pgz-gold); }
|
|
.stat .l { color:var(--muted); font-size:0.82rem; text-transform:uppercase; margin-top:3px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>📜 Audit Log</h1>
|
|
<div class="sub">Kompletna povijest izmjena s blockchain pečatima na Polygon PoS</div>
|
|
|
|
<div class="stats" id="stats">
|
|
<div class="stat"><div class="v" id="s-total">—</div><div class="l">Ukupno akcija</div></div>
|
|
<div class="stat"><div class="v" id="s-today">—</div><div class="l">Danas</div></div>
|
|
<div class="stat"><div class="v" id="s-sealed">—</div><div class="l">Polygon zapečaćeno</div></div>
|
|
<div class="stat"><div class="v" id="s-users">—</div><div class="l">Aktivni korisnici</div></div>
|
|
</div>
|
|
|
|
<div class="toolbar">
|
|
<input id="f-q" placeholder="🔍 Pretraži..." />
|
|
<select id="f-action">
|
|
<option value="">Sve akcije</option>
|
|
<option value="create">CREATE</option>
|
|
<option value="update">UPDATE</option>
|
|
<option value="delete">DELETE</option>
|
|
<option value="seal">SEAL</option>
|
|
</select>
|
|
<select id="f-resource">
|
|
<option value="">Svi resursi</option>
|
|
<option value="users">Korisnici</option>
|
|
<option value="klubovi">Klubovi</option>
|
|
<option value="invoices">Računi</option>
|
|
<option value="putni_nalozi">Putni nalozi</option>
|
|
<option value="sufinanciranje">Sufinanciranje</option>
|
|
</select>
|
|
<button class="btn" onclick="load()">Filtriraj</button>
|
|
<button class="btn secondary" onclick="window.location.href='/app'">← Natrag na app</button>
|
|
</div>
|
|
|
|
<table id="tbl">
|
|
<thead>
|
|
<tr>
|
|
<th>Vrijeme</th>
|
|
<th>Korisnik</th>
|
|
<th>Akcija</th>
|
|
<th>Resurs</th>
|
|
<th>Detalji</th>
|
|
<th>Polygon Tx</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="tbody">
|
|
<tr><td colspan="6" class="empty">⏳ Učitavam...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<script>
|
|
async function load() {
|
|
const q = document.getElementById('f-q').value;
|
|
const action = document.getElementById('f-action').value;
|
|
const resource = document.getElementById('f-resource').value;
|
|
const tbody = document.getElementById('tbody');
|
|
|
|
let url = '/sport/api/audit/log?limit=200';
|
|
if (q) url += '&q=' + encodeURIComponent(q);
|
|
if (action) url += '&action=' + action;
|
|
if (resource) url += '&resource=' + resource;
|
|
|
|
try {
|
|
const r = await fetch(url);
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
const data = await r.json();
|
|
const items = data.items || data.entries || data || [];
|
|
|
|
if (!items.length) {
|
|
tbody.innerHTML = '<tr><td colspan="6" class="empty">📭 Nema zapisa</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = items.map(item => {
|
|
const action = (item.action || 'unknown').toLowerCase();
|
|
const klasa = action.includes('seal') ? 'b-seal' :
|
|
action.includes('create') ? 'b-create' :
|
|
action.includes('update') ? 'b-update' :
|
|
action.includes('delete') ? 'b-delete' : 'b-update';
|
|
const tx = item.tx_hash || item.polygon_tx || '';
|
|
const txLink = tx ? `<a href="https://polygonscan.com/tx/${tx}" target="_blank" class="tx-link">${tx.substring(0,16)}...</a>` : '<span style="color:#5a6072">—</span>';
|
|
const ts = new Date(item.created_at || item.timestamp).toLocaleString('hr-HR');
|
|
const details = item.details || item.diff || item.message || '';
|
|
const detStr = typeof details === 'object' ? JSON.stringify(details).substring(0,80)+'...' : String(details).substring(0,80);
|
|
|
|
return `<tr>
|
|
<td>${ts}</td>
|
|
<td>${item.user_email || item.user_name || item.actor || '—'}</td>
|
|
<td><span class="badge ${klasa}">${(item.action || '').toUpperCase()}</span></td>
|
|
<td>${item.resource_type || item.resource || item.target || '—'}</td>
|
|
<td>${detStr}</td>
|
|
<td>${txLink}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
} catch (e) {
|
|
tbody.innerHTML = `<tr><td colspan="6" class="empty">⚠ Greška: ${e.message}</td></tr>`;
|
|
}
|
|
|
|
// Stats
|
|
try {
|
|
const sr = await fetch('/sport/api/audit/stats');
|
|
if (sr.ok) {
|
|
const s = await sr.json();
|
|
document.getElementById('s-total').textContent = s.total || '—';
|
|
document.getElementById('s-today').textContent = s.today || '—';
|
|
document.getElementById('s-sealed').textContent = s.sealed || '—';
|
|
document.getElementById('s-users').textContent = s.users || '—';
|
|
}
|
|
} catch(e) {}
|
|
}
|
|
load();
|
|
setInterval(load, 30000);
|
|
</script>
|
|
</body>
|
|
</html>
|