CC2 R4 #6: real TOTP 2FA (setup + verify + disable + login flow)

- auth/auth_v2.py:
  - pyotp-based TOTP (RFC 6238, base32 secret, ±30s window)
  - new pgz_sport.user_2fa table (auto-created)
  - QR code embedded as data: URL via qrcode lib
  - 8 single-use recovery codes generated at setup
  - /2fa/setup, /2fa/verify, /2fa/disable, /2fa/status endpoints
  - Login flow: when 2FA enabled, requires totp field; recovery codes
    accepted and consumed on use
- static/login.html: TOTP field appears when login returns 2FA_REQUIRED
- static/admin_users.html: full 2FA panel in Sigurnost tab
  (status badge, QR + secret + recovery code display, verify input)

Live tests pass:
  T1 status (no setup) → enabled:false
  T2 setup → secret + 1.5KB QR PNG + 8 recovery codes
  T3 verify wrong code → 401
  T4 verify real TOTP → enabled:true
  T5 login w/o TOTP after enable → 401 detail=2FA_REQUIRED
  T6 login w/ TOTP → 200
This commit is contained in:
Damir Radulić
2026-05-05 00:50:28 +02:00
parent a0db65fc31
commit bd3773434e
10 changed files with 4594 additions and 225 deletions
+353 -2
View File
@@ -992,13 +992,364 @@ async function reSign(sid) {
} catch (e) { toast('Greška: ' + e.message, true); }
}
// ════════════════════════════════════════════════════
// MODUL 4 — ČLANOVI / DASHBOARD osobe (CRM Dashboard)
// ════════════════════════════════════════════════════
let CLANOVI_LAST_QUERY = '';
async function loadClanovi() {
const root = $('#page-clanovi');
root.innerHTML = `
<div class="toolbar">
<input id="cl-q" type="text" placeholder="Pretraži po imenu / OIB-u (min 2 slova)…" style="min-width:340px;flex:1" oninput="searchClanovi(this.value)">
<input id="cl-klub-filter" type="number" placeholder="Klub ID (filter)" onchange="searchClanovi($('#cl-q').value)">
<div class="grow"></div>
<span style="font-size:11px;color:var(--t3)">Klik na karticu → puni dashboard člana</span>
</div>
<div id="cl-results"><div class="loading">Upišite ime za pretragu…</div></div>
`;
// initial: load nekoliko poznatih ID-ova kao primjer
if (!CLANOVI_LAST_QUERY) {
document.getElementById('cl-q').value = 'Mateo';
searchClanovi('Mateo');
}
}
let _searchTimer;
function searchClanovi(q) {
clearTimeout(_searchTimer);
CLANOVI_LAST_QUERY = q;
if (!q || q.length < 2) {
$('#cl-results').innerHTML = '<div class="loading">Upišite ime za pretragu (min 2 slova)…</div>';
return;
}
_searchTimer = setTimeout(async () => {
const klub = $('#cl-klub-filter').value;
const params = new URLSearchParams({q, limit: 30});
if (klub) params.append('klub_id', klub);
try {
const data = await apiR('/clanovi/search?' + params);
$('#cnt-clanovi').textContent = data.count;
const cards = (data.rows || []).map(r => `
<div class="card" style="margin-bottom:8px;cursor:pointer" onclick="openClanPanel(${r.id})">
<div class="card-b" style="display:flex;align-items:center;gap:14px">
<div style="width:48px;height:48px;border-radius:50%;background:var(--bg3);overflow:hidden;display:flex;align-items:center;justify-content:center;flex-shrink:0;border:1px solid var(--rim)">
${r.slika_url ? `<img src="${esc(r.slika_url)}" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">` : `<span style="font-size:18px;font-weight:600;color:var(--t2)">${esc((r.ime||'?')[0]+(r.prezime||'?')[0])}</span>`}
</div>
<div style="flex:1">
<div style="font-weight:600">${esc(r.ime)} ${esc(r.prezime)}</div>
<div style="font-size:11px;color:var(--t3)">${esc(r.klub || '—')} · ${esc(r.pozicija || '—')}${r.broj_dresa ? ' · #'+r.broj_dresa : ''}</div>
</div>
<div><span class="tag bl">#${r.id}</span></div>
</div>
</div>`).join('');
$('#cl-results').innerHTML = `
<div style="color:var(--t3);font-size:12px;margin-bottom:8px">${data.count} rezultat${data.count==1?'':'a'}</div>
${cards || '<div class="empty">Nema rezultata.</div>'}`;
} catch (e) { $('#cl-results').innerHTML = `<div class="empty">Greška: ${esc(e.message)}</div>`; }
}, 250);
}
window._OPEN_PANEL_CID = null;
async function openClanPanel(cid) {
window._OPEN_PANEL_CID = cid;
loadClanPanel(cid);
}
function closeClanPanel() {
window._OPEN_PANEL_CID = null;
closeModal();
}
async function loadClanPanel(cid) {
let d, perms;
try {
d = await apiR('/clanovi/' + cid + '/full');
perms = await apiR('/clanovi/permissions?role=' + encodeURIComponent(CURRENT_ROLE));
} catch (e) { return toast('Greška: ' + e.message, true); }
const c = d.clan, k = d.klub || {};
const editable = perms.editable; // 'ALL' ili lista polja
const canEdit = (field) => editable === 'ALL' || (Array.isArray(editable) && editable.includes(field));
const canUploadAvatar = canEdit('slika_url');
const av = c.slika_url_full || c.slika_url || '';
const initials = ((c.ime||'?')[0]+(c.prezime||'?')[0]).toUpperCase();
// helper za render polja s edit/no-edit
const f = (key, label, val, type='text') => {
const ed = canEdit(key);
const safe = val == null || val === '' ? '—' : String(val);
return `
<div class="payment-row">
<div class="l">${esc(label)}${ed?'':' <span style="color:var(--t3);font-size:9px">🔒</span>'}</div>
<div class="v" style="display:flex;align-items:center;gap:6px">
<span id="fld-${key}-display" style="font-family:inherit">${esc(safe)}</span>
${ed ? `<button class="btn sm" onclick="editFieldInline('${key}', '${esc(label)}', ${JSON.stringify(val||'').replace(/"/g,'&quot;')}, '${type}', ${cid})">✎</button>` : ''}
</div>
</div>`;
};
const kpiHtml = `
<div class="kpi-grid" style="margin-bottom:12px">
<div class="kpi b"><div class="kpi-l">Sezone</div><div class="kpi-v">${fmt(d.kpi.broj_sezona)}</div></div>
<div class="kpi g"><div class="kpi-l">Nastupi</div><div class="kpi-v">${fmt(d.kpi.nastupi_total)}</div></div>
<div class="kpi a"><div class="kpi-l">Pogoci</div><div class="kpi-v">${fmt(d.kpi.pogoci_total)}</div></div>
<div class="kpi r"><div class="kpi-l">Dug članarina</div><div class="kpi-v">${fmtEur(d.kpi.dug_clanarina_eur)}</div></div>
<div class="kpi ${d.kpi.lijecnicki_status==='vazeci'?'g':d.kpi.lijecnicki_status==='uskoro'?'a':'r'}"><div class="kpi-l">Liječnički</div><div class="kpi-v" style="font-size:14px">${esc(d.kpi.lijecnicki_status||'—')}</div><div class="kpi-s">${d.kpi.lijecnicki_dana_do_isteka != null ? d.kpi.lijecnicki_dana_do_isteka+' dana' : ''}</div></div>
</div>`;
const sectionPersonal = `
<div class="card-h" style="background:transparent;border:none;padding:6px 0;margin-top:6px"><div class="card-t">📋 Osobni podaci</div></div>
<div class="payment-card">
${f('ime','Ime',c.ime)}
${f('prezime','Prezime',c.prezime)}
${f('oib','OIB',c.oib)}
${f('datum_rodenja','Datum rođenja',c.datum_rodenja||c.datum_rodjenja,'date')}
${f('mjesto_rodenja','Mjesto rođenja',c.mjesto_rodenja||c.mjesto_rodjenja)}
${f('spol','Spol',c.spol)}
</div>`;
const sectionKontakt = `
<div class="card-h" style="background:transparent;border:none;padding:6px 0;margin-top:6px"><div class="card-t">📞 Kontakt</div></div>
<div class="payment-card">
${f('email','E-mail',c.email)}
${f('telefon','Telefon',c.telefon)}
${f('adresa','Adresa',c.adresa)}
${f('grad','Grad',c.grad)}
${f('postanski_broj','Pošt. broj',c.postanski_broj)}
</div>`;
const sectionSport = `
<div class="card-h" style="background:transparent;border:none;padding:6px 0;margin-top:6px"><div class="card-t">⚽ Sport</div></div>
<div class="payment-card">
${f('sport','Sport',c.sport)}
${f('kategorija','Kategorija',c.kategorija)}
${f('podkategorija','Podkategorija',c.podkategorija)}
${f('pozicija','Pozicija',c.pozicija)}
${f('dominantna_noga','Dominantna noga',c.dominantna_noga)}
${f('visina_cm','Visina (cm)',c.visina_cm,'number')}
${f('tezina_kg','Težina (kg)',c.tezina_kg,'number')}
${f('broj_dresa','Broj dresa',c.broj_dresa,'number')}
${f('uloga','Uloga',c.uloga)}
${f('uloga_detalj','Uloga (detalj)',c.uloga_detalj)}
</div>`;
const sectionStatus = `
<div class="card-h" style="background:transparent;border:none;padding:6px 0;margin-top:6px"><div class="card-t">📊 Status</div></div>
<div class="payment-card">
${f('aktivan','Aktivan',c.aktivan)}
${f('datum_pristupa','Datum pristupa',c.datum_pristupa,'date')}
${f('datum_napustanja','Datum napuštanja',c.datum_napustanja,'date')}
${f('kategoriziran','Kategoriziran',c.kategoriziran)}
${f('kategorija_hoo','HOO kategorija',c.kategorija_hoo,'number')}
${f('reprezentativac','Reprezentativac',c.reprezentativac)}
${f('reprezentacija_kategorija','Reprezentacija (kat.)',c.reprezentacija_kategorija)}
${f('stipendiran','Stipendiran',c.stipendiran)}
${f('stipendija_iznos','Stipendija (€)',c.stipendija_iznos,'number')}
${f('licenca_broj','Licenca broj',c.licenca_broj)}
${f('licenca_vrijedi_do','Licenca vrijedi do',c.licenca_vrijedi_do,'date')}
${f('radno_pravni_status','Radno-pravni status',c.radno_pravni_status)}
</div>`;
const sectionKlub = `
<div class="card-h" style="background:transparent;border:none;padding:6px 0;margin-top:6px"><div class="card-t">⬢ Klub</div></div>
<div class="payment-card">
<div class="payment-row"><div class="l">Trenutni klub</div><div class="v">${esc(k.naziv || '—')}</div></div>
${k.savez_naziv ? `<div class="payment-row"><div class="l">Savez</div><div class="v">${esc(k.savez_naziv)}</div></div>` : ''}
${k.oib ? `<div class="payment-row"><div class="l">OIB kluba</div><div class="v">${esc(k.oib)}</div></div>` : ''}
${k.iban ? `<div class="payment-row"><div class="l">IBAN</div><div class="v">${esc(k.iban)}</div></div>` : ''}
</div>
${d.povijest_klubova && d.povijest_klubova.length ? `
<div style="margin-top:8px"><b>Povijest klubova (${d.povijest_klubova.length}):</b></div>
<table style="margin-top:6px"><thead><tr><th>Klub</th><th>Od</th><th>Do</th><th># sezona</th></tr></thead>
<tbody>${d.povijest_klubova.map(p=>`<tr><td>${esc(p.klub_naziv)}</td><td>${esc(p.od)}</td><td>${esc(p.do_)}</td><td>${p.broj_sezona}</td></tr>`).join('')}</tbody>
</table>` : ''}
`;
const tabSezone = `
<table><thead><tr><th>Sezona</th><th>Klub</th><th>Natjecanje</th><th>Nast.</th><th>Pog.</th><th>Asist.</th><th>Žuti</th><th>Crv.</th><th>Min.</th></tr></thead>
<tbody>${(d.sezone||[]).map(s=>`<tr><td><b>${esc(s.sezona)}</b></td><td>${esc(s.klub_naziv||'—')}</td><td>${esc(s.natjecanje||'—')}</td><td>${fmt(s.nastupi)}</td><td>${fmt(s.pogoci)}</td><td>${fmt(s.asistencije)}</td><td>${fmt(s.zuti_kartoni)}</td><td>${fmt(s.crveni_kartoni)}</td><td>${fmt(s.minute_total)}</td></tr>`).join('') || '<tr><td colspan="9" class="empty">Nema podataka.</td></tr>'}</tbody>
</table>`;
const tabUtakmice = `
<table><thead><tr><th>Datum</th><th>Domaćin</th><th>Gost</th><th>Rezultat</th><th>Natj.</th><th>Pog.</th><th>Min.</th><th></th></tr></thead>
<tbody>${(d.utakmice_zadnje20||[]).map(u=>`<tr><td>${fmtDate(u.datum)}</td><td>${esc(u.domacin||'—')}</td><td>${esc(u.gost||'—')}</td><td><b>${esc(u.rezultat||'—')}</b></td><td>${esc(u.natjecanje||'—')}</td><td>${fmt(u.pogoci)}</td><td>${fmt(u.minute)}</td><td>${u.utakmica_url?`<a class="btn sm" href="${esc(u.utakmica_url)}" target="_blank">↗</a>`:''}</td></tr>`).join('') || '<tr><td colspan="8" class="empty">Nema utakmica.</td></tr>'}</tbody>
</table>`;
const tabLij = `
<table><thead><tr><th>Datum</th><th>Vrijedi do</th><th>Status</th><th>Vrsta</th><th>Ustanova</th><th>Liječnik</th><th>Plaćeno</th></tr></thead>
<tbody>${(d.lijecnicki||[]).map(l=>`<tr><td>${fmtDate(l.datum_pregleda)}</td><td>${fmtDate(l.vrijedi_do)}</td><td><span class="tag ${({vazeci:'gr',uskoro:'am',istekao:'rd'})[l.status_calc]||'gy'}">${esc(l.status_calc)} (${l.dana_do_isteka}d)</span></td><td>${esc(l.vrsta_pregleda||'—')}</td><td>${esc(l.ustanova||'—')}</td><td>${esc(l.lijecnik||'—')}</td><td>${l.placeno?'<span class="tag gr">DA</span>':'<span class="tag rd">NE</span>'}</td></tr>`).join('') || '<tr><td colspan="7" class="empty">Nema pregleda.</td></tr>'}</tbody>
</table>`;
const tabClanarine = `
<table><thead><tr><th>God.</th><th>Razdoblje</th><th>Propisan</th><th>Plaćeno</th><th>Dug</th><th>Status</th><th>Datum upl.</th><th></th></tr></thead>
<tbody>${(d.clanarine||[]).map(cl=>`<tr><td>${esc(cl.godina)}</td><td>${esc(cl.razdoblje||'—')}</td><td>${fmtEur(cl.iznos_propisan)}</td><td>${fmtEur(cl.iznos_placen)}</td><td><b style="color:${cl.dug>0?'var(--err)':'var(--ok)'}">${fmtEur(cl.dug)}</b></td><td><span class="tag ${statusTag(cl.status)}">${esc(cl.status)}</span></td><td>${fmtDate(cl.datum_uplate)}</td><td><a class="btn sm" href="${API}/clanarine/${cl.id}/uplatnica.pdf" target="_blank">📄</a></td></tr>`).join('') || '<tr><td colspan="8" class="empty">Nema članarina.</td></tr>'}</tbody>
</table>`;
const tabDokumenti = `
<table><thead><tr><th>God.</th><th>Naslov</th><th>Vrsta</th><th>Snippet</th><th></th></tr></thead>
<tbody>${(d.dokumenti||[]).map(dk=>`<tr><td>${esc(dk.godina)}</td><td><b>${esc(dk.title||'—')}</b></td><td>${esc(dk.vrsta||'—')}</td><td style="font-size:11px;color:var(--t3);max-width:280px">${esc((dk.snippet||'').substring(0,140))}</td><td>${dk.pdf_url?`<a class="btn sm" href="${esc(dk.pdf_url)}" target="_blank">📄</a>`:dk.url?`<a class="btn sm" href="${esc(dk.url)}" target="_blank">↗</a>`:''}</td></tr>`).join('') || '<tr><td colspan="5" class="empty">Nema dokumenata.</td></tr>'}</tbody>
</table>`;
const tabObrasci = `
<table><thead><tr><th>Obrazac</th><th>Ref.</th><th>Status</th><th>Predano</th><th></th></tr></thead>
<tbody>${(d.obrasci||[]).map(o=>`<tr><td><b>${esc(o.template_naziv||o.template_code)}</b></td><td><code style="font-size:10px">${esc(o.reference_no||'')}</code></td><td><span class="tag ${({draft:'gy',submitted:'am',approved:'gr',rejected:'rd'})[o.status]||'gy'}">${esc(o.status)}</span></td><td>${fmtDate(o.submitted_at||o.created_at)}</td><td><a class="btn sm" href="${API}/forms/submissions/${o.id}/pdf" target="_blank">📄</a></td></tr>`).join('') || '<tr><td colspan="5" class="empty">Nema obrazaca.</td></tr>'}</tbody>
</table>`;
const tabNagrade = (d.nagrade && d.nagrade.length) ? `
<table><thead><tr><th>Godina</th><th>Natjecanje</th><th>Razina</th><th>Disciplina</th><th>Plasman</th><th>Klub</th></tr></thead>
<tbody>${d.nagrade.map(n=>`<tr><td>${esc(n.godina)}</td><td>${esc(n.natjecanje||'—')}</td><td>${esc(n.razina_natjecanja||'—')}</td><td>${esc(n.disciplina||'—')}</td><td><b>${n.plasman||'—'}</b></td><td>${esc(n.klub_naziv||'—')}</td></tr>`).join('')}</tbody>
</table>` : '';
// Modal — širi nego standardno
$('#modal').style.maxWidth = '1100px';
openModal(`
<div class="modal-h">
<div class="modal-t">👤 ${esc(c.ime)} ${esc(c.prezime)} <span style="color:var(--t3);font-size:11px;font-weight:400">#${cid} · ${esc(CURRENT_ROLE)} (${editable === 'ALL' ? 'full edit' : Array.isArray(editable) ? editable.length+' edit polja' : 'no edit'})</span></div>
<div class="modal-x" onclick="closeClanPanel()">×</div>
</div>
<div class="modal-b">
<div style="display:grid;grid-template-columns:140px 1fr;gap:18px;margin-bottom:14px">
<div style="text-align:center">
<div id="avatar-display" style="width:140px;height:140px;border-radius:8px;background:var(--bg3);border:1px solid var(--rim);display:flex;align-items:center;justify-content:center;overflow:hidden">
${av ? `<img src="${esc(av)}?_=${Date.now()}" style="width:100%;height:100%;object-fit:cover">` : `<span style="font-size:48px;font-weight:700;color:var(--t2)">${esc(initials)}</span>`}
</div>
${canUploadAvatar ? `
<input type="file" id="avatar-file" accept="image/*" style="display:none" onchange="uploadAvatar(${cid})">
<button class="btn primary sm" style="margin-top:8px;width:100%" onclick="document.getElementById('avatar-file').click()">📷 Upload</button>
` : `<div style="font-size:10px;color:var(--t3);margin-top:6px">🔒 Bez dozvole</div>`}
</div>
<div>
${kpiHtml}
<div style="font-size:12px;color:var(--t3)">Dob: <b style="color:var(--t1)">${c.dob_calc != null ? c.dob_calc + ' god.' : '—'}</b> · Slika: <code style="font-size:10px">${esc(c.slika_url || '—').substring(0,60)}</code></div>
</div>
</div>
<div class="cp-tabs" style="display:flex;gap:0;border-bottom:1px solid var(--rim);margin-bottom:12px;flex-wrap:wrap">
<div class="cp-tab active" onclick="cpTab('osobni')" data-cpt="osobni" style="padding:10px 14px;cursor:pointer;border-bottom:2px solid var(--pgz-blue);font-weight:600">Osobni</div>
<div class="cp-tab" onclick="cpTab('sezone')" data-cpt="sezone" style="padding:10px 14px;cursor:pointer;color:var(--t2)">Sezone (${d.sezone.length})</div>
<div class="cp-tab" onclick="cpTab('utakmice')" data-cpt="utakmice" style="padding:10px 14px;cursor:pointer;color:var(--t2)">Utakmice (${d.utakmice_zadnje20.length})</div>
<div class="cp-tab" onclick="cpTab('lij')" data-cpt="lij" style="padding:10px 14px;cursor:pointer;color:var(--t2)">Liječnički (${d.lijecnicki.length})</div>
<div class="cp-tab" onclick="cpTab('clanarine')" data-cpt="clanarine" style="padding:10px 14px;cursor:pointer;color:var(--t2)">Članarine (${d.clanarine.length})</div>
<div class="cp-tab" onclick="cpTab('dokumenti')" data-cpt="dokumenti" style="padding:10px 14px;cursor:pointer;color:var(--t2)">Dokumenti (${d.dokumenti.length})</div>
<div class="cp-tab" onclick="cpTab('obrasci')" data-cpt="obrasci" style="padding:10px 14px;cursor:pointer;color:var(--t2)">Obrasci (${d.obrasci.length})</div>
${tabNagrade ? `<div class="cp-tab" onclick="cpTab('nagrade')" data-cpt="nagrade" style="padding:10px 14px;cursor:pointer;color:var(--t2)">Nagrade (${d.nagrade.length})</div>` : ''}
</div>
<div id="cp-osobni" class="cp-page">
${sectionPersonal}
${sectionKontakt}
${sectionSport}
${sectionStatus}
${sectionKlub}
<div class="card-h" style="background:transparent;border:none;padding:6px 0;margin-top:6px"><div class="card-t">📝 Napomena</div></div>
<div class="payment-card">${f('napomena','Napomena',c.napomena)}</div>
<div class="card-h" style="background:transparent;border:none;padding:6px 0;margin-top:6px"><div class="card-t">📖 Biografija</div></div>
<div class="payment-card">${f('biografija','Biografija',c.biografija,'textarea')}</div>
</div>
<div id="cp-sezone" class="cp-page" style="display:none">${tabSezone}</div>
<div id="cp-utakmice" class="cp-page" style="display:none">${tabUtakmice}</div>
<div id="cp-lij" class="cp-page" style="display:none">${tabLij}</div>
<div id="cp-clanarine" class="cp-page" style="display:none">${tabClanarine}</div>
<div id="cp-dokumenti" class="cp-page" style="display:none">${tabDokumenti}</div>
<div id="cp-obrasci" class="cp-page" style="display:none">${tabObrasci}</div>
${tabNagrade ? `<div id="cp-nagrade" class="cp-page" style="display:none">${tabNagrade}</div>` : ''}
</div>
`);
}
function cpTab(name) {
$$('.cp-tab').forEach(t => {
const active = t.dataset.cpt === name;
t.style.borderBottom = active ? '2px solid var(--pgz-blue)' : '';
t.style.color = active ? 'var(--t1)' : 'var(--t2)';
t.style.fontWeight = active ? '600' : '500';
t.classList.toggle('active', active);
});
$$('.cp-page').forEach(p => p.style.display = (p.id === 'cp-' + name) ? 'block' : 'none');
}
function editFieldInline(key, label, currentVal, type, cid) {
const inputType = type === 'date' ? 'date' : type === 'number' ? 'number' : 'text';
const isTextarea = type === 'textarea';
const isBool = typeof currentVal === 'boolean';
let inputHtml;
if (isBool) {
inputHtml = `<select id="ef-input"><option value="true" ${currentVal===true?'selected':''}>true</option><option value="false" ${currentVal===false?'selected':''}>false</option></select>`;
} else if (isTextarea) {
inputHtml = `<textarea id="ef-input" style="width:100%;min-height:80px">${esc(currentVal||'')}</textarea>`;
} else {
inputHtml = `<input id="ef-input" type="${inputType}" value="${esc(currentVal||'')}" style="width:100%">`;
}
const promptHtml = `
<div class="modal-h">
<div class="modal-t">✎ ${esc(label)}</div>
<div class="modal-x" onclick="closeModal();loadClanPanel(${cid})">×</div>
</div>
<div class="modal-b">
<div class="field"><label>${esc(label)} (${esc(key)})</label>${inputHtml}</div>
<div style="text-align:right;margin-top:14px">
<button class="btn" onclick="closeModal();loadClanPanel(${cid})">Odustani</button>
<button class="btn primary" onclick="saveField('${key}', ${cid}, ${isBool}, ${isTextarea ? 'false' : type==='number'?'true':'false'})">💾 Spremi</button>
</div>
</div>`;
$('#modal').style.maxWidth = '500px';
openModal(promptHtml);
setTimeout(() => $('#ef-input')?.focus(), 50);
}
async function saveField(key, cid, isBool, isNumber) {
const el = $('#ef-input');
let val = el.value;
if (isBool) val = val === 'true';
else if (isNumber) val = val === '' ? null : Number(val);
else if (val === '') val = null;
try {
const r = await apiR('/clanovi/' + cid, {method:'PUT', body: {[key]: val}});
if (r.rejected_fields && r.rejected_fields.includes(key)) {
toast(`Polje "${key}" odbijeno za rolu ${CURRENT_ROLE}`, true);
} else {
toast(`✓ Polje ${key} spremljeno (rola ${CURRENT_ROLE})`);
}
closeModal();
$('#modal').style.maxWidth = '1100px';
loadClanPanel(cid);
} catch (e) { toast('Greška: ' + e.message, true); }
}
async function uploadAvatar(cid) {
const inp = $('#avatar-file');
if (!inp.files || !inp.files[0]) return;
const fd = new FormData();
fd.append('file', inp.files[0]);
try {
const r = await fetch(API + '/clanovi/' + cid + '/avatar', {
method: 'POST',
headers: {'X-Role': CURRENT_ROLE},
body: fd,
});
if (!r.ok) throw new Error(`HTTP ${r.status}: ${await r.text()}`);
const d = await r.json();
toast(`✓ Avatar uploaded: ${d.size_bytes} bytes`);
loadClanPanel(cid);
} catch (e) { toast('Greška upload-a: ' + e.message, true); }
}
// ────────────────────────────────────────────────────
// init
// ────────────────────────────────────────────────────
loadClanarine();
// preload counts
// postavi role iz localStorage u dropdown
const _roleSel = document.getElementById('g-role');
if (_roleSel) _roleSel.value = CURRENT_ROLE;
loadClanovi();
// preload counts za sve tabove
(async () => {
try {
const cl = await api('/clanarine?limit=1');
$('#cnt-clanarine').textContent = cl.summary?.total ?? '?';
const lj = await api('/lijecnicki?limit=1');
$('#cnt-lijecnicki').textContent = lj.summary?.total ?? '?';
const fm = await api('/forms');