CC2 R6: middleware-wide JWT, avatar demo mode, mock mailer, login rate limit
#1 JWT middleware extended: - Was: /api/admin/* only - Now: any POST/PUT/PATCH/DELETE under /api/* requires Bearer JWT - Whitelist (still anonymous): /api/auth/login, /refresh, /forgot-password, /password/reset, /reset-password, /setup-password, /google; /api/gdpr/consent; any path ending /avatar - 14 mutating endpoints verified to return 401 without token #2 Avatar upload demo mode (routers/clan_panel_router.py): - Anonymous → returns {demo_mode:true, slika_url:null, message:'Demo mode — slika nije spremljena. Prijavite se za pravu pohranu.'}, no FS write, no DB write - Authenticated (valid JWT, allowed role) → real save as before - Auth check now uses auth.auth_v2.decode_token (proper secret + revocation) instead of the broken local _resolve_role #3 Mock mailer (auth/mailer.py): - send_email writes RFC 822 .eml to /tmp/pgz_mailbox + appends to INDEX.jsonl - send_password_reset, send_invite helpers with HR text + HTML alt - Real SMTP active when PGZ_SMTP_HOST is set (env-driven, off by default) - forgot-password and admin invite both call mailer; audit logs mail status #5 Rate limiting on /api/auth/login: - Per-user: 5 wrong attempts → 5-minute DB-backed lockout (was 5 → 15 min). Configurable via PGZ_LOGIN_LOCK_THRESHOLD/MINUTES. - Per-IP: 10 fails / 5-min sliding window in-memory → HTTP 429 Configurable via PGZ_LOGIN_IP_THRESHOLD/WINDOW_SEC. Successful login clears the IP counter. - Failed attempts respond '(N/5) — račun je zaključan na 5 minuta' - New audit actions: login.ratelimit.ip; login.fail meta now includes fails count, locked, lock_minutes #4 Live test report: 46/46 across 6 demo users — login, JWT gate on 14 mutating endpoints, public path whitelist, demo-mode avatar + real save, forgot-password e-mail to mailbox, no-leak unknown email, 5-fail lockout, 423 during lockout, audit coverage.
This commit is contained in:
+69
-12
@@ -408,10 +408,11 @@ async function enrichEntity(kind, id){
|
||||
<thead><tr style="background:var(--bg2)"><th style="text-align:left;padding:6px 8px;width:160px">Polje</th><th style="text-align:left;padding:6px 8px;width:240px">Trenutno</th><th style="text-align:left;padding:6px 8px">Predloženo</th></tr></thead>
|
||||
<tbody id="enrich-diff-${kind}-${id}">${rows}</tbody>
|
||||
</table>
|
||||
<div style="padding:8px 10px;background:var(--bg2);display:flex;gap:8px;justify-content:flex-end">
|
||||
<button class="btn" onclick="enrichSelectAll('${kind}',${id},true)">Označi sve</button>
|
||||
<button class="btn" onclick="enrichSelectAll('${kind}',${id},false)">Poništi sve</button>
|
||||
<button class="btn primary" onclick="enrichApply('${kind}',${id})">💾 Spremi izmjene</button>
|
||||
<div style="padding:8px 10px;background:var(--bg2);display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap">
|
||||
<button class="btn" onclick="enrichSelectAll('${kind}',${id},true)">Označi sve</button>
|
||||
<button class="btn" onclick="enrichSelectAll('${kind}',${id},false)">Poništi sve</button>
|
||||
<button class="btn" onclick="document.getElementById('enrich-out-${kind}-${id}').innerHTML=''">❌ Odustani</button>
|
||||
<button class="btn primary" onclick="enrichApply('${kind}',${id})">💾 SPREMI IZMJENE</button>
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
@@ -454,11 +455,39 @@ function enrichSelectAll(kind, id, on){
|
||||
tbody.querySelectorAll('input[type=checkbox]').forEach(cb => { cb.checked = !!on; });
|
||||
}
|
||||
|
||||
// Reusable toast component (success / error / info / warn).
|
||||
window.toast = function(msg, type, duration){
|
||||
type = type || 'success';
|
||||
duration = duration || 3000;
|
||||
const palette = {
|
||||
success: ['#1ec773', '#0b1a16'],
|
||||
error: ['#ff6b6b', '#1a0b0b'],
|
||||
info: ['#4a9eff', '#04132b'],
|
||||
warn: ['#ffb84a', '#1a1004'],
|
||||
}[type] || ['#4a9eff', '#04132b'];
|
||||
const t = document.createElement('div');
|
||||
t.className = 'pgz-toast pgz-toast-' + type;
|
||||
t.style.cssText = 'position:fixed;right:20px;bottom:20px;'+
|
||||
'background:'+palette[0]+';color:'+palette[1]+';'+
|
||||
'padding:12px 18px;border-radius:8px;font-weight:700;font-size:14px;'+
|
||||
'z-index:99999;box-shadow:0 6px 22px rgba(0,0,0,.45);'+
|
||||
'transform:translateY(40px);opacity:0;transition:all .25s ease-out;'+
|
||||
'max-width:380px;line-height:1.45;';
|
||||
t.innerHTML = msg;
|
||||
document.body.appendChild(t);
|
||||
requestAnimationFrame(()=>{ t.style.transform='translateY(0)'; t.style.opacity='1'; });
|
||||
setTimeout(()=>{
|
||||
t.style.transform='translateY(40px)'; t.style.opacity='0';
|
||||
setTimeout(()=>t.remove(), 280);
|
||||
}, duration);
|
||||
return t;
|
||||
};
|
||||
|
||||
async function enrichApply(kind, id){
|
||||
const target = document.getElementById('enrich-out-'+kind+'-'+id);
|
||||
const tbody = document.getElementById('enrich-diff-'+kind+'-'+id);
|
||||
const preview = (window._enrichPreviews||{})[kind+':'+id];
|
||||
if(!preview){ alert('Prvo pokreni "▶ Pokreni"'); return; }
|
||||
if(!preview){ toast('⚠ Prvo pokreni "▶ Pokreni"', 'warn'); return; }
|
||||
const proposed = preview.proposed || {};
|
||||
const fields = {};
|
||||
if(tbody){
|
||||
@@ -469,7 +498,7 @@ async function enrichApply(kind, id){
|
||||
} else {
|
||||
Object.assign(fields, proposed);
|
||||
}
|
||||
if(!Object.keys(fields).length){ alert('Označi barem jedno polje za primjenu.'); return; }
|
||||
if(!Object.keys(fields).length){ toast('Označi barem jedno polje za primjenu.', 'warn'); return; }
|
||||
if(target) target.innerHTML = '<div class="loading">⏳ Spremam u bazu…</div>';
|
||||
try{
|
||||
const r = await fetch(API+'/v2/enrich/'+kind+'/'+id+'/apply', {
|
||||
@@ -484,18 +513,46 @@ async function enrichApply(kind, id){
|
||||
else if(kind === 'savez' && typeof openSavez === 'function') await openSavez(id);
|
||||
else if(kind === 'sportas' && typeof openSportas === 'function') await openSportas(id);
|
||||
setTimeout(() => enrichEntity(kind, id), 350);
|
||||
const cnt = Object.keys(data.applied||{}).length;
|
||||
const t = document.createElement('div');
|
||||
t.style.cssText = 'position:fixed;bottom:20px;right:20px;background:var(--ok,#1ec773);color:#0b1a16;padding:10px 16px;border-radius:6px;font-weight:700;z-index:9999;box-shadow:0 4px 16px rgba(0,0,0,.4)';
|
||||
t.textContent = '✓ Spremljeno '+cnt+' polja u bazu';
|
||||
document.body.appendChild(t);
|
||||
setTimeout(()=>t.remove(), 3500);
|
||||
const cnt = data.applied_count != null ? data.applied_count : Object.keys(data.applied||{}).length;
|
||||
const fieldsList = (data.applied_fields || Object.keys(data.applied||{})).join(', ');
|
||||
if(cnt){
|
||||
toast('✅ Spremljeno <b>'+cnt+'</b> polja u bazu'
|
||||
+ (fieldsList ? '<br><span style="opacity:.85;font-weight:500;font-size:12px">'+esc(fieldsList)+'</span>' : ''),
|
||||
'success', 3500);
|
||||
} else {
|
||||
toast('Nema novih izmjena za spremiti.', 'info', 2500);
|
||||
}
|
||||
}catch(e){
|
||||
console.error(e);
|
||||
toast('❌ Greška pri spremanju: '+esc(e.message||String(e)), 'error', 4500);
|
||||
if(target) target.innerHTML = '<div class="empty" style="color:var(--bad,#ff6b6b)">Greška pri spremanju: '+esc(e.message||String(e))+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk enrichment — used by "Obogati sve" buttons in list views
|
||||
async function enrichBulk(kind, limit, coverage_max){
|
||||
limit = limit || 50; coverage_max = coverage_max || 70;
|
||||
if(!confirm('Pokreni obogaćivanje za '+limit+' nasumično odabranih ('+kind+', coverage<'+coverage_max+'%)?')) return;
|
||||
toast('⏳ Pokrećem bulk obogaćivanje za '+limit+' '+kind+'…', 'info', 2500);
|
||||
try{
|
||||
const r = await fetch(API+'/v2/enrich/bulk', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({kind, limit, coverage_max}),
|
||||
});
|
||||
const data = await r.json();
|
||||
if(!r.ok) throw new Error(data.detail || ('HTTP '+r.status));
|
||||
toast('✅ Bulk gotov: <b>'+data.processed+'</b>/'+data.requested+' obrađeno, '+
|
||||
'dodano <b>'+data.fields_total+'</b> polja u DB ('+data.elapsed_s+'s)',
|
||||
'success', 5000);
|
||||
// Reload the section so new values appear
|
||||
if(typeof loadSection === 'function' && _state && _state.section) loadSection(_state.section);
|
||||
}catch(e){
|
||||
console.error(e);
|
||||
toast('❌ Bulk greška: '+esc(e.message||String(e)), 'error', 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function enrichBlock(kind, id){
|
||||
return `
|
||||
<div class="card" id="enrich-card-${kind}-${id}">
|
||||
|
||||
Reference in New Issue
Block a user