From bd3773434eaf378ed0bd91185adb60713b66e5b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Raduli=C4=87?= Date: Tue, 5 May 2026 00:50:28 +0200 Subject: [PATCH] CC2 R4 #6: real TOTP 2FA (setup + verify + disable + login flow) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- _backups/app.html.cc3_post_profile.1777935003 | 1705 +++++++++++++++++ _backups/r3_cc5/crm.html.dashboard.1777934972 | 1362 +++++++++++++ auth/auth_v2.py | 153 +- routers/enrich_router.py | 420 ++-- static/admin_users.html | 59 + static/app.html | 271 ++- static/crm.html | 355 +++- static/erp.html | 472 ++++- static/login.html | 22 +- .../{99-ec0eb66a.png => 99-68860ddb.png} | Bin 10 files changed, 4594 insertions(+), 225 deletions(-) create mode 100644 _backups/app.html.cc3_post_profile.1777935003 create mode 100644 _backups/r3_cc5/crm.html.dashboard.1777934972 rename static/uploads/avatars/{99-ec0eb66a.png => 99-68860ddb.png} (100%) diff --git a/_backups/app.html.cc3_post_profile.1777935003 b/_backups/app.html.cc3_post_profile.1777935003 new file mode 100644 index 0000000..05fd6e4 --- /dev/null +++ b/_backups/app.html.cc3_post_profile.1777935003 @@ -0,0 +1,1705 @@ + + + + + +PGŽ SPORT — Operativna aplikacija + + + + + + + + + +
+ + +
+
+
+
Dashboard
+
Pregled stanja
+
+
+
+
+
DR
+
+
Damir Radulićpgz admin
+
Primorsko-goranska županija
+
+
+
+
+ +
+
Učitavanje...
+
+
+
+ + +
+ + + + + + + diff --git a/_backups/r3_cc5/crm.html.dashboard.1777934972 b/_backups/r3_cc5/crm.html.dashboard.1777934972 new file mode 100644 index 0000000..da6bf61 --- /dev/null +++ b/_backups/r3_cc5/crm.html.dashboard.1777934972 @@ -0,0 +1,1362 @@ + + + + + +PGŽ Sport — CRM (Članarine • Liječnički • Obrasci) + + + + +
+ +
·
+
CRM — Članarine • Liječnički • Obrasci
+
+ Round 3 / CC5 + ← portal + app → +
+
+ +
+
👤 Članovi
+
€ Članarine
+
⚕ Liječnički pregledi
+
📝 Obrasci
+
+ ROLA: + +
+
+ +
+
+ + + +
+ + + +
+ + + + + diff --git a/auth/auth_v2.py b/auth/auth_v2.py index fc313fe..53a3fe5 100644 --- a/auth/auth_v2.py +++ b/auth/auth_v2.py @@ -276,6 +276,7 @@ def _client(req: Request): class LoginReq(BaseModel): email: str password: str + totp: Optional[str] = None # 6-digit TOTP if 2FA enabled (or recovery code) class RefreshReq(BaseModel): refresh_token: str @@ -327,6 +328,32 @@ def login(req: LoginReq, request: Request): (hash_password(req.password), u["id"])) except Exception: pass + # 2FA gate — if user has enabled 2FA, demand a valid TOTP / recovery code + twofa_row = None + try: + twofa_row = db_one("SELECT secret, enabled, recovery_codes FROM pgz_sport.user_2fa WHERE user_id=%s", + (u["id"],)) + except Exception: pass + if twofa_row and twofa_row.get("enabled"): + code = (req.totp or "").strip().replace(" ", "") + if not code: + audit(u["id"], "login.2fa_required", ip=ip, ua=ua) + raise HTTPException(401, "2FA_REQUIRED") + ok = False + if code.isdigit() and len(code) in (6, 8) and HAS_PYOTP: + ok = _pyotp.TOTP(twofa_row["secret"]).verify(code, valid_window=1) + if not ok and twofa_row.get("recovery_codes"): + up = code.upper() + if up in (twofa_row["recovery_codes"] or []): + ok = True + # consume the recovery code so it can't be reused + remaining = [c for c in twofa_row["recovery_codes"] if c != up] + db_exec("UPDATE pgz_sport.user_2fa SET recovery_codes=%s, updated_at=now() WHERE user_id=%s", + (remaining, u["id"])) + if not ok: + audit(u["id"], "login.2fa_fail", ip=ip, ua=ua) + raise HTTPException(401, "Neispravan 2FA kod") + db_exec("""UPDATE pgz_sport.users SET failed_login_count=0, locked_until=NULL, last_login=now() WHERE id=%s""", (u["id"],)) @@ -520,20 +547,120 @@ def password_reset(req: ResetPwdReq, request: Request): return {"status": "ok", "message": "Ako račun postoji, administrator će vam poslati instrukcije."} -# ─────────────────────────── 2FA placeholders (TOTP) ─────────────────────────── +# ─────────────────────────── 2FA — real TOTP (RFC 6238) ─────────────────────────── +try: + import pyotp as _pyotp + HAS_PYOTP = True +except Exception: + HAS_PYOTP = False + +def _ensure_2fa_table(): + db_exec("""CREATE TABLE IF NOT EXISTS pgz_sport.user_2fa ( + user_id INTEGER PRIMARY KEY REFERENCES pgz_sport.users(id) ON DELETE CASCADE, + secret TEXT NOT NULL, + enabled BOOLEAN DEFAULT false, + verified_at TIMESTAMPTZ, + recovery_codes TEXT[], + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() + )""") +_ensure_2fa_table() + +def _build_qr_png(otpauth_url: str) -> str: + """Return a data: URL containing a base64 PNG of the QR code.""" + try: + import qrcode, io, base64 + img = qrcode.make(otpauth_url) + buf = io.BytesIO() + img.save(buf, format="PNG") + return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode() + except Exception as e: + return "" + +def _gen_recovery_codes(n: int = 8) -> List[str]: + return [secrets.token_hex(4).upper() for _ in range(n)] + @router.post("/2fa/setup") def twofa_setup(user = Depends(require_user)): - """Stub — generate TOTP secret + return otpauth URL. - Full TOTP verification will be added in M1.5.""" - secret = secrets.token_hex(20).upper() - db_exec("""ALTER TABLE pgz_sport.users - ADD COLUMN IF NOT EXISTS two_factor_secret text, - ADD COLUMN IF NOT EXISTS two_factor_enabled boolean DEFAULT false""") - db_exec("UPDATE pgz_sport.users SET two_factor_secret=%s WHERE id=%s", - (secret, user["id"])) - otpauth = f"otpauth://totp/PGŽ%20Sport:{user['email']}?secret={secret}&issuer=PGZSport" - return {"secret": secret, "otpauth": otpauth, "enabled": False} + """Generate a TOTP secret, store unverified, and return otpauth URL + QR + recovery codes. + The 2FA stays disabled until /2fa/verify confirms a valid TOTP code.""" + if not HAS_PYOTP: + raise HTTPException(503, "pyotp not installed on server") + secret = _pyotp.random_base32() # 32-char base32, RFC 4648 — what authenticator apps expect + recovery = _gen_recovery_codes() + db_exec("""INSERT INTO pgz_sport.user_2fa (user_id, secret, enabled, recovery_codes, updated_at) + VALUES (%s,%s,false,%s,now()) + ON CONFLICT (user_id) DO UPDATE SET + secret=EXCLUDED.secret, enabled=false, + recovery_codes=EXCLUDED.recovery_codes, updated_at=now()""", + (user["id"], secret, recovery)) + issuer = "PGŽ Sport" + otpauth = _pyotp.TOTP(secret).provisioning_uri(name=user["email"], issuer_name=issuer) + return { + "secret": secret, + "otpauth_url": otpauth, + "qr_png": _build_qr_png(otpauth), + "issuer": issuer, + "account": user["email"], + "recovery_codes": recovery, + "enabled": False, + "instructions": "Skenirajte QR u Google Authenticator / Authy / 1Password, zatim potvrdite kod kroz POST /api/auth/2fa/verify", + } + +class TwoFAVerifyReq(BaseModel): + code: str @router.post("/2fa/verify") -def twofa_verify(code: str = Body(..., embed=True), user = Depends(require_user)): - return {"status": "stub", "verified": False, "code_received": bool(code)} +def twofa_verify(req: TwoFAVerifyReq, request: Request, user = Depends(require_user)): + """Verify TOTP code; on success, mark 2FA enabled.""" + if not HAS_PYOTP: + raise HTTPException(503, "pyotp not installed on server") + row = db_one("SELECT secret, enabled FROM pgz_sport.user_2fa WHERE user_id=%s", + (user["id"],)) + if not row: + raise HTTPException(400, "2FA nije postavljen — pozovite /2fa/setup prvo") + code = (req.code or "").strip().replace(" ", "") + if not code or not code.isdigit() or len(code) not in (6, 8): + raise HTTPException(400, "Neispravan format koda (6-8 znamenki)") + totp = _pyotp.TOTP(row["secret"]) + # valid_window=1 → tolerate ±30s drift + if not totp.verify(code, valid_window=1): + ip, ua = _client(request) + audit(user["id"], "2fa.verify.fail", ip=ip, ua=ua) + raise HTTPException(401, "Neispravan TOTP kod") + db_exec("""UPDATE pgz_sport.user_2fa + SET enabled=true, verified_at=now(), updated_at=now() + WHERE user_id=%s""", (user["id"],)) + ip, ua = _client(request) + audit(user["id"], "2fa.verify.ok", ip=ip, ua=ua) + return {"status": "ok", "enabled": True} + +@router.post("/2fa/disable") +def twofa_disable(req: TwoFAVerifyReq, request: Request, user = Depends(require_user)): + """Disable 2FA — must verify a current TOTP code (or recovery code).""" + if not HAS_PYOTP: + raise HTTPException(503, "pyotp not installed on server") + row = db_one("SELECT secret, recovery_codes FROM pgz_sport.user_2fa WHERE user_id=%s", + (user["id"],)) + if not row: + raise HTTPException(404, "2FA nije postavljen") + code = (req.code or "").strip().replace(" ", "").upper() + valid = False + if code.isdigit() and len(code) in (6, 8): + valid = _pyotp.TOTP(row["secret"]).verify(code, valid_window=1) + elif row.get("recovery_codes") and code in (row["recovery_codes"] or []): + valid = True + if not valid: + raise HTTPException(401, "Neispravan kod") + db_exec("DELETE FROM pgz_sport.user_2fa WHERE user_id=%s", (user["id"],)) + ip, ua = _client(request) + audit(user["id"], "2fa.disable", ip=ip, ua=ua) + return {"status": "ok", "enabled": False} + +@router.get("/2fa/status") +def twofa_status(user = Depends(require_user)): + row = db_one("SELECT enabled, verified_at, created_at FROM pgz_sport.user_2fa WHERE user_id=%s", + (user["id"],)) + return {"enabled": bool(row and row.get("enabled")), + "configured": bool(row), + "verified_at": row.get("verified_at") if row else None} diff --git a/routers/enrich_router.py b/routers/enrich_router.py index 0c0308e..dc59e09 100644 --- a/routers/enrich_router.py +++ b/routers/enrich_router.py @@ -172,23 +172,51 @@ def _find_official_web(text: str, hint: str = '') -> Optional[str]: # ─── External sources ──────────────────────────────────────────────────── +def _wiki_variants(query: str) -> list[str]: + """Generate sensible Wikipedia HR title variants for a query. + + The summary REST API is title-exact; clubs are often listed under their + abbreviation (KK X, NK X, RK X, OK X), so we try those variants too. + """ + if not query: return [] + out, seen = [], set() + raw = query.strip() + def _push(v): + if v and v not in seen: seen.add(v); out.append(v) + _push(raw) + # KK Kvarner 2010 from Košarkaški klub KVARNER 2010 + parts = raw.split() + sport_to_abbr = { + 'košarkaški': 'KK', 'kosarkaski': 'KK', + 'nogometni': 'NK', 'rukometni': 'RK', + 'odbojkaški': 'OK', 'odbojkaski': 'OK', + 'vaterpolski':'VK', 'plivacki': 'PK', 'plivački': 'PK', + 'boćarski': 'BK', 'bocarski': 'BK', + } + if len(parts) >= 3 and parts[0].lower() in sport_to_abbr and parts[1].lower() == 'klub': + _push(sport_to_abbr[parts[0].lower()] + ' ' + ' '.join(p.capitalize() if p.isupper() else p for p in parts[2:])) + return out + def _wiki_summary(query: str) -> Optional[dict]: - if not query: return None - title = urllib.parse.quote(query.replace(' ', '_'), safe='') - body = _http_get(f'https://hr.wikipedia.org/api/rest_v1/page/summary/{title}', timeout=5) - if not body: return None - try: - d = json.loads(body) - if d.get('type') == 'disambiguation' or 'extract' not in d: return None + for variant in _wiki_variants(query): + title = urllib.parse.quote(variant.replace(' ', '_'), safe='') + body = _http_get(f'https://hr.wikipedia.org/api/rest_v1/page/summary/{title}', timeout=5) + if not body: continue + try: + d = json.loads(body) + except Exception: + continue + if d.get('type') in ('disambiguation', 'no-extract'): continue + if not d.get('extract'): continue return { 'source': 'wikipedia.hr', 'url': d.get('content_urls', {}).get('desktop', {}).get('page'), 'title': d.get('title'), 'extract': d.get('extract'), 'description': d.get('description'), + 'matched_variant': variant, } - except Exception: - return None + return None def _sport_pgz_search(query: str) -> Optional[dict]: @@ -616,8 +644,192 @@ def _propose_for_sportas(row: dict) -> dict: # ─── Endpoints ────────────────────────────────────────────────────────── -@router.post("/enrich/{kind}/{eid}") -def enrich_preview(kind: str, eid: int): +# ─── R4 — POST /v2/enrich/forensic/{finding_id} ───────────────────────── +def _extract_pep_name(finding: dict) -> Optional[str]: + """Pull the primary person name from a forensic_findings row.""" + title = (finding.get('title') or '').strip() + desc = (finding.get('description') or '').strip() + payload = finding.get('raw_data') or {} + if isinstance(payload, str): + try: payload = json.loads(payload) + except Exception: payload = {} + if isinstance(payload, dict): + for k in ('person_name', 'name', 'osoba'): + v = payload.get(k) + if v: return str(v).strip() + # Try entities_involved.entity_name + ents = finding.get('entities_involved') or [] + if isinstance(ents, str): + try: ents = json.loads(ents) + except Exception: ents = [] + if isinstance(ents, list): + for e in ents: + if isinstance(e, dict) and e.get('person_name'): + return str(e['person_name']).strip() + if isinstance(e, dict) and e.get('entity_name') and ' ' in (e.get('entity_name') or ''): + # Some entries store person names as entity_name when entity_type='person' + if (e.get('entity_type') or '').lower() in ('person','osoba'): + return str(e['entity_name']).strip() + # Fallback: extract a "Ime Prezime" from the title + m = re.search(r'\b([A-ZČĆŠĐŽ][a-zčćšđž]+)\s+([A-ZČĆŠĐŽ][a-zčćšđž]+(?:-[A-ZČĆŠĐŽ][a-zčćšđž]+)?)\b', title + ' ' + desc) + if m: return f"{m.group(1)} {m.group(2)}" + return None + + +def _gather_pep_evidence(name: str) -> list[dict]: + sources: list[dict] = [] + wiki = _wiki_summary(name) + if wiki: sources.append(wiki) + # DDG html-lite as a "Google snippet" replacement (often OK for HR PEPs) + ddg = 'https://html.duckduckgo.com/html/?q=' + urllib.parse.quote(f'"{name}" PGŽ Hrvatska') + page = _http_get(ddg, timeout=8) + if page: + # First result block + m = re.search(r']+class="result__a"[^>]+href="([^"]+)"[^>]*>([^<]{6,200})', page) + snippet_m = re.search(r']+class="result__snippet"[^>]*>(.*?)', page, re.S) + if m: + sources.append({ + 'source': 'duckduckgo', + 'url': html.unescape(m.group(1))[:500], + 'title': html.unescape(m.group(2)).strip()[:300], + 'extract': re.sub(r'<[^>]+>', ' ', snippet_m.group(1)).strip()[:600] if snippet_m else None, + }) + return sources + + +def _related_entities_for_pep(name: str) -> list[dict]: + """Pull civic.persons + their entity links so we have the structured graph.""" + out: list[dict] = [] + with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute("""SELECT id, name, function, party, county, city, oib, trust_tier + FROM civic.persons + WHERE upper(name) ILIKE upper(%s) + ORDER BY oib NULLS LAST, id LIMIT 10""", ('%'+name+'%',)) + for p in cur.fetchall(): + p = dict(p) + entry = { + 'kind': 'person', + 'person_id': p['id'], 'person_name': p['name'], + 'function': p.get('function'), 'party': p.get('party'), + 'county': p.get('county'), 'city': p.get('city'), + 'oib': p.get('oib'), 'trust_tier': p.get('trust_tier'), + 'entities': [], + } + if p.get('oib'): + cur.execute("""SELECT pel.entity_id, pel.roles, e.name AS entity_name, + e.oib AS entity_oib, e.entity_type, e.city, e.risk_score + FROM civic.person_entity_links pel + LEFT JOIN civic.entities e ON e.id = pel.entity_id + WHERE pel.person_oib=%s LIMIT 30""", (p['oib'],)) + for r in cur.fetchall(): + entry['entities'].append(dict(r)) + out.append(entry) + return out + + +@router.post("/enrich/forensic/{finding_id}") +def enrich_forensic_v2(finding_id: int, + body: dict = Body(default=None), + x_user_email: Optional[str] = Header(default=None), + x_user_id: Optional[int] = Header(default=None)): + """Enrich a forensic finding: gather Wiki + DDG snippets + civic graph, + write back to civic.forensic_findings.related_entities, and seal the + payload hash on Polygon (or queue for sealing). + """ + body = body or {} + explicit_name = (body.get('name') or '').strip() or None + + with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute("""SELECT id, finding_type, severity, title, description, + entities_involved, raw_data, related_entities, enrichment_metadata + FROM civic.forensic_findings WHERE id=%s""", (finding_id,)) + finding = cur.fetchone() + if not finding: + raise HTTPException(404, "finding not found") + finding = dict(finding) + + name = explicit_name or _extract_pep_name(finding) + if not name: + raise HTTPException(400, "could not derive a person/entity name; pass {name: \"…\"}") + + sources = _gather_pep_evidence(name) + related = _related_entities_for_pep(name) + + payload = { + 'finding_id': finding_id, + 'name': name, + 'sources': [{'source': s.get('source'), 'url': s.get('url'), + 'title': s.get('title')} for s in sources], + 'related_entities': related, + 'enriched_at': datetime.now(timezone.utc).isoformat(), + } + + # Persist back to the finding + enrichment_meta = finding.get('enrichment_metadata') or {} + if not isinstance(enrichment_meta, dict): enrichment_meta = {} + history = enrichment_meta.get('history') or [] + history.append({ + 'at': payload['enriched_at'], + 'sources': payload['sources'], + 'related_count': len(related), + 'user': x_user_email, + }) + enrichment_meta['history'] = history[-10:] + enrichment_meta['enriched_at'] = payload['enriched_at'] + enrichment_meta['enriched_by'] = x_user_email or 'system' + enrichment_meta['source_count'] = len(sources) + + with _db() as c, c.cursor() as cur: + cur.execute("""UPDATE civic.forensic_findings + SET related_entities = %s::jsonb, + enrichment_metadata = %s::jsonb + WHERE id=%s + RETURNING id""", + (json.dumps(related, default=str, ensure_ascii=False), + json.dumps(enrichment_meta, default=str, ensure_ascii=False), + finding_id)) + cur.fetchone() + + # Seal the enrichment payload hash on Polygon (or queue if no key) + seal_result: dict[str, Any] = {} + try: + sys_path_added = False + try: + from blockchain import seal as _seal_mod # noqa: E402 + except Exception: + import sys as _ssys + _ssys.path.insert(0, '/opt/pgz-sport') + from blockchain import seal as _seal_mod # noqa: E402 + sys_path_added = True + del sys_path_added # silence linters + h = _seal_mod.hash_payload(payload) + seal_result = _seal_mod.seal_to_polygon( + data_hash=h, + ref_id=str(finding_id), + action='forensic.enriched', + ref_type='forensic_finding', + payload=payload, + user_id=x_user_id, + user_email=x_user_email, + ) + except Exception as e: + seal_result = {'error': f'{type(e).__name__}: {e}'} + + return { + 'finding_id': finding_id, + 'name': name, + 'sources': sources, + 'related_entities': related, + 'related_count': len(related), + 'enrichment_metadata': enrichment_meta, + 'seal': seal_result, + } + + +from fastapi import Path as _FPath + +@router.post("/enrich/{kind:str}/{eid:int}") +def enrich_preview(kind: str = _FPath(..., regex='^(klub|savez|sportas)$'), eid: int = 0): row = _load_row(kind, eid) if kind == 'klub': res = _propose_for_klub(row) elif kind == 'savez': res = _propose_for_savez(row) @@ -736,8 +948,9 @@ def _apply_to_db(kind: str, eid: int, fields: dict, sources: list, user_email: O 'after': {k: after.get(k) for k in snap_keys if k in after}} -@router.post("/enrich/{kind}/{eid}/apply") -def enrich_apply(kind: str, eid: int, +@router.post("/enrich/{kind:str}/{eid:int}/apply") +def enrich_apply(kind: str = _FPath(..., regex='^(klub|savez|sportas)$'), + eid: int = 0, body: dict = Body(default=None), x_user_email: Optional[str] = Header(default=None), x_user_id: Optional[int] = Header(default=None)): @@ -1001,184 +1214,3 @@ def forensic_scan(req: dict = Body(...)): 'total_findings': total_findings, 'critical_findings': crit_findings, 'persons': persons, 'scanned_at': int(time.time())} - -# ─── R4 — POST /v2/enrich/forensic/{finding_id} ───────────────────────── -def _extract_pep_name(finding: dict) -> Optional[str]: - """Pull the primary person name from a forensic_findings row.""" - title = (finding.get('title') or '').strip() - desc = (finding.get('description') or '').strip() - payload = finding.get('raw_data') or {} - if isinstance(payload, str): - try: payload = json.loads(payload) - except Exception: payload = {} - if isinstance(payload, dict): - for k in ('person_name', 'name', 'osoba'): - v = payload.get(k) - if v: return str(v).strip() - # Try entities_involved.entity_name - ents = finding.get('entities_involved') or [] - if isinstance(ents, str): - try: ents = json.loads(ents) - except Exception: ents = [] - if isinstance(ents, list): - for e in ents: - if isinstance(e, dict) and e.get('person_name'): - return str(e['person_name']).strip() - if isinstance(e, dict) and e.get('entity_name') and ' ' in (e.get('entity_name') or ''): - # Some entries store person names as entity_name when entity_type='person' - if (e.get('entity_type') or '').lower() in ('person','osoba'): - return str(e['entity_name']).strip() - # Fallback: extract a "Ime Prezime" from the title - m = re.search(r'\b([A-ZČĆŠĐŽ][a-zčćšđž]+)\s+([A-ZČĆŠĐŽ][a-zčćšđž]+(?:-[A-ZČĆŠĐŽ][a-zčćšđž]+)?)\b', title + ' ' + desc) - if m: return f"{m.group(1)} {m.group(2)}" - return None - - -def _gather_pep_evidence(name: str) -> list[dict]: - sources: list[dict] = [] - wiki = _wiki_summary(name) - if wiki: sources.append(wiki) - # DDG html-lite as a "Google snippet" replacement (often OK for HR PEPs) - ddg = 'https://html.duckduckgo.com/html/?q=' + urllib.parse.quote(f'"{name}" PGŽ Hrvatska') - page = _http_get(ddg, timeout=8) - if page: - # First result block - m = re.search(r']+class="result__a"[^>]+href="([^"]+)"[^>]*>([^<]{6,200})', page) - snippet_m = re.search(r']+class="result__snippet"[^>]*>(.*?)', page, re.S) - if m: - sources.append({ - 'source': 'duckduckgo', - 'url': html.unescape(m.group(1))[:500], - 'title': html.unescape(m.group(2)).strip()[:300], - 'extract': re.sub(r'<[^>]+>', ' ', snippet_m.group(1)).strip()[:600] if snippet_m else None, - }) - return sources - - -def _related_entities_for_pep(name: str) -> list[dict]: - """Pull civic.persons + their entity links so we have the structured graph.""" - out: list[dict] = [] - with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: - cur.execute("""SELECT id, name, function, party, county, city, oib, trust_tier - FROM civic.persons - WHERE upper(name) ILIKE upper(%s) - ORDER BY oib NULLS LAST, id LIMIT 10""", ('%'+name+'%',)) - for p in cur.fetchall(): - p = dict(p) - entry = { - 'kind': 'person', - 'person_id': p['id'], 'person_name': p['name'], - 'function': p.get('function'), 'party': p.get('party'), - 'county': p.get('county'), 'city': p.get('city'), - 'oib': p.get('oib'), 'trust_tier': p.get('trust_tier'), - 'entities': [], - } - if p.get('oib'): - cur.execute("""SELECT pel.entity_id, pel.roles, e.name AS entity_name, - e.oib AS entity_oib, e.entity_type, e.city, e.risk_score - FROM civic.person_entity_links pel - LEFT JOIN civic.entities e ON e.id = pel.entity_id - WHERE pel.person_oib=%s LIMIT 30""", (p['oib'],)) - for r in cur.fetchall(): - entry['entities'].append(dict(r)) - out.append(entry) - return out - - -@router.post("/enrich/forensic/{finding_id}") -def enrich_forensic(finding_id: int, - body: dict = Body(default=None), - x_user_email: Optional[str] = Header(default=None), - x_user_id: Optional[int] = Header(default=None)): - """Enrich a forensic finding: gather Wiki + DDG snippets + civic graph, - write back to civic.forensic_findings.related_entities, and seal the - payload hash on Polygon (or queue for sealing). - """ - body = body or {} - explicit_name = (body.get('name') or '').strip() or None - - with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: - cur.execute("""SELECT id, finding_type, severity, title, description, - entities_involved, raw_data, related_entities, enrichment_metadata - FROM civic.forensic_findings WHERE id=%s""", (finding_id,)) - finding = cur.fetchone() - if not finding: - raise HTTPException(404, "finding not found") - finding = dict(finding) - - name = explicit_name or _extract_pep_name(finding) - if not name: - raise HTTPException(400, "could not derive a person/entity name; pass {name: \"…\"}") - - sources = _gather_pep_evidence(name) - related = _related_entities_for_pep(name) - - payload = { - 'finding_id': finding_id, - 'name': name, - 'sources': [{'source': s.get('source'), 'url': s.get('url'), - 'title': s.get('title')} for s in sources], - 'related_entities': related, - 'enriched_at': datetime.now(timezone.utc).isoformat(), - } - - # Persist back to the finding - enrichment_meta = finding.get('enrichment_metadata') or {} - if not isinstance(enrichment_meta, dict): enrichment_meta = {} - history = enrichment_meta.get('history') or [] - history.append({ - 'at': payload['enriched_at'], - 'sources': payload['sources'], - 'related_count': len(related), - 'user': x_user_email, - }) - enrichment_meta['history'] = history[-10:] - enrichment_meta['enriched_at'] = payload['enriched_at'] - enrichment_meta['enriched_by'] = x_user_email or 'system' - enrichment_meta['source_count'] = len(sources) - - with _db() as c, c.cursor() as cur: - cur.execute("""UPDATE civic.forensic_findings - SET related_entities = %s::jsonb, - enrichment_metadata = %s::jsonb - WHERE id=%s - RETURNING id""", - (json.dumps(related, default=str, ensure_ascii=False), - json.dumps(enrichment_meta, default=str, ensure_ascii=False), - finding_id)) - cur.fetchone() - - # Seal the enrichment payload hash on Polygon (or queue if no key) - seal_result: dict[str, Any] = {} - try: - sys_path_added = False - try: - from blockchain import seal as _seal_mod # noqa: E402 - except Exception: - import sys as _ssys - _ssys.path.insert(0, '/opt/pgz-sport') - from blockchain import seal as _seal_mod # noqa: E402 - sys_path_added = True - del sys_path_added # silence linters - h = _seal_mod.hash_payload(payload) - seal_result = _seal_mod.seal_to_polygon( - data_hash=h, - ref_id=str(finding_id), - action='forensic.enriched', - ref_type='forensic_finding', - payload=payload, - user_id=x_user_id, - user_email=x_user_email, - ) - except Exception as e: - seal_result = {'error': f'{type(e).__name__}: {e}'} - - return { - 'finding_id': finding_id, - 'name': name, - 'sources': sources, - 'related_entities': related, - 'related_count': len(related), - 'enrichment_metadata': enrichment_meta, - 'seal': seal_result, - } diff --git a/static/admin_users.html b/static/admin_users.html index f280733..ef286e8 100644 --- a/static/admin_users.html +++ b/static/admin_users.html @@ -271,6 +271,30 @@ td.actions-col .btn { padding: 4px 8px; font-size: 11px; }
+
+

Two-factor authentication (2FA) moj račun

+
+ Učitavam… + + +
+ +

Zaključani / failed-login računi

E-mailUlogaPokušajaZaključan doAkcije
@@ -692,8 +716,43 @@ async function loadSecurity() { `).join('') || 'Nema zaključanih računa'; + 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 => `${c}`).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'); diff --git a/static/app.html b/static/app.html index f7e2d10..05fd6e4 100644 --- a/static/app.html +++ b/static/app.html @@ -620,6 +620,7 @@ function logout(){ //=========== SECTION TITLES =========== const TITLES = { pgz: { + profil:['Moj profil','Osobni podaci i postavke'], dashboard:['Dashboard','Pregled stanja PGŽ Sporta'], korisnici:['Korisnici','Upravljanje korisnicima sustava'], savezi:['Savezi','246 sportskih saveza'], @@ -632,6 +633,7 @@ const TITLES = { forenzika:['Forenzika','Sumnjive transakcije / PEP'], }, savez: { + profil:['Moj profil','Osobni podaci'], dashboard:['Dashboard','Atletski savez PGŽ'], klubovi:['Naši klubovi','Klubovi člana saveza'], sportasi:['Naši sportaši','Registrirani sportaši saveza'], @@ -641,6 +643,7 @@ const TITLES = { racuni:['Računi','Računi saveza'], }, klub: { + profil:['Moj profil','Osobni podaci'], dashboard:['Dashboard','AK Kvarner Rijeka'], clanovi:['Članovi','Članovi kluba'], clanarine:['Članarine','Stanje članarina'], @@ -650,7 +653,8 @@ const TITLES = { racuni:['Računi','Troškovi kluba'], }, sportas: { - dashboard:['Moj profil','Luka Horvat'], + profil:['Moj profil','Osobni podaci'], + dashboard:['Pregled','Moja aktivnost'], clanarina:['Članarina','Stanje moje članarine'], lijecnicki:['Liječnički','Moj liječnički pregled'], dokumenti:['Moji dokumenti','Suglasnosti, ugovori'], @@ -673,6 +677,247 @@ function loadSection(){ //=========== SECTION RENDERERS =========== const SECTIONS = {}; +// ──────────────────────── PROFILE PAGE (shared by all roles) ──────────────────────── +function profileMe(){ + // Real user if available, else demo from ROLES table + if(_state.me) return _state.me; + const r = ROLES[_state.role] || ROLES.pgz; + const parts = String(r.user||'').split(/\s+/); + return { + id: 0, email:'demo@pgz.hr', + full_name: r.user, ime: parts[0]||'', prezime: parts.slice(1).join(' '), + user_type: _state.role==='pgz'?'pgz_admin':_state.role==='savez'?'savez_admin':_state.role==='klub'?'klub_admin':'klub_clan', + tenant_type: _state.role==='pgz'?'pgz':_state.role, + tenant_name: r.sub, tenant_id: null, tier: _state.role==='pgz'?0:_state.role==='savez'?1:2, + oib: '12345678901', telefon:'+385 91 234 5678', phone:null, + last_login: '2026-05-05T00:08:09', preferred_language:'hr', + avatar_url:null, two_factor_enabled:false, + gdpr_consent_at:null, created_at:'2026-04-01T08:00:00', + roles:[{code:'demo', naziv:r.name}] + }; +} +function profileRender(){ + const u = profileMe(); + const name = u.full_name || ((u.ime||'')+' '+(u.prezime||'')).trim() || u.email || '—'; + const av = u.avatar_url ? `` + : (u.google_picture ? `` : esc(initials(name))); + const lastLogin = u.last_login ? new Date(u.last_login).toLocaleString('hr-HR') : '—'; + const created = u.created_at ? new Date(u.created_at).toLocaleString('hr-HR') : '—'; + const gdpr = u.gdpr_consent_at ? new Date(u.gdpr_consent_at).toLocaleDateString('hr-HR') : null; + const roleLabel = (ROLES[_state.role]||{}).name || u.user_type || 'Korisnik'; + return ` +
+
+
+ ${av} +
📷 Promijeni sliku
+
+
+

${esc(name)}

+
${esc(roleLabel)} · ${esc(u.tenant_name || u.tenant_type || '')}
+
+ ${esc(u.user_type||'')} + ${u.aktivan!==false ? 'Aktivan' : 'Suspended'} + ${u.two_factor_enabled ? '2FA ON' : '2FA OFF'} + ${gdpr ? `GDPR ${esc(gdpr)}` : ''} +
+
+
+ + +
+
+ +
+

Osobni podaci ✏ Uredi sva polja

+
+
Ime
+
${esc(u.ime||'')||''}
+
+
+
+
Prezime
+
${esc(u.prezime||'')||''}
+
+
+
+
Puno ime
+
${esc(u.full_name||'')||''}
+
+
+
+
Email
+
${esc(u.email||'—')}
+
read-only
+
+
+
Telefon
+
${esc(u.telefon||u.phone||'')||''}
+
+
+
+
OIB
+
${esc(u.oib||'')||''}
+
+
+
+
Jezik sučelja
+
${esc(u.preferred_language||'hr')}
+
+
+
+ +
+

Tenant i ovlasti

+
Tenant
${esc(u.tenant_name || '—')}
+
Tip tenanta
${esc(u.tenant_type || '—')}
+
Tier
${u.tier!=null?u.tier:'—'} ${u.tier===0?'(PGŽ)':u.tier===1?'(savez)':u.tier===2?'(klub)':''}
+
User type
${esc(u.user_type || '—')}
+
Dodatne uloge
${(u.roles||[]).map(r => `${esc(r.code)}`).join('')||''}
+
+ +
+

Sigurnost 🔑 Promijeni lozinku

+
2FA
${u.two_factor_enabled?'Uključeno':'Nije postavljeno'}
+
Mora promijeniti lozinku
${u.must_change_pwd?'DA':'NE'}
+
GDPR pristanak
${gdpr || 'Nije zabilježen'}
${gdpr?'':''}
+
Status računa
${u.aktivan===false?'Suspended':'Aktivan'}
+
+ +
+

Aktivnost

+
Zadnji login
${esc(lastLogin)}
+
Račun kreiran
${esc(created)}
+
User ID
#${esc(u.id||0)}
+
+ +
+

GDPR i podaci

+
+ Imaš pravo na pristup, izmjenu i brisanje svojih osobnih podataka prema GDPR uredbi (čl. 15–17, 20). +
+
+ + + +
+
+
`; +} +SECTIONS['pgz:profil'] = profileRender; +SECTIONS['savez:profil'] = profileRender; +SECTIONS['klub:profil'] = profileRender; +SECTIONS['sportas:profil']= profileRender; + +// Profile actions +function pickAvatar(){ + if(!getToken()){ + alert('Avatar upload zahtijeva login (JWT). U demo modu nije dostupan.'); + return; + } + $('#avatar-input').click(); +} +async function onAvatarPick(input){ + const f = input.files && input.files[0]; + if(!f) return; + if(f.size > 5*1024*1024){ alert('Slika prevelika (>5 MB)'); return; } + const fd = new FormData(); fd.append('file', f); + const av = $('#prof-av-big'); + if(av) av.innerHTML = '
'; + const r = await apiAuth('/auth/me/avatar', {method:'POST', body:fd}); + input.value = ''; + if(r && r.avatar_url){ + if(_state.me) _state.me.avatar_url = r.avatar_url; + applyMeToHeader(); + loadSection(); // re-render profile + } else { + alert('Upload failed: '+(r&&r.status||'unknown')); + loadSection(); + } +} +async function profileEditField(field, label){ + const cur = (_state.me && _state.me[field]) || ''; + const v = prompt(`${label}:`, cur); + if(v == null) return; + if(!getToken()){ + if(_state.me){ _state.me[field] = v; } + else { + // demo: persist on local copy + if(!window._demoMe) window._demoMe = profileMe(); + window._demoMe[field] = v; + _state.me = window._demoMe; + } + applyMeToHeader(); + loadSection(); + return; + } + const r = await apiAuth('/auth/me', {method:'PUT', body: JSON.stringify({[field]: v})}); + if(r && !r.__error){ _state.me = r; applyMeToHeader(); loadSection(); } + else alert('Greška pri spremanju: '+(r&&r.status||'unknown')); +} +async function profileEditAll(){ + // Open drill-down panel with full edit form + const u = profileMe(); + openDetail('Uredi profil', ` +
+ ${['ime','prezime','full_name','telefon','oib'].map(f => ` +
+ + +
`).join('')} +
+ + +
+
+ + +
+
`); +} +async function profileSaveAll(ev){ + ev.preventDefault(); + const fd = new FormData(ev.target); + const obj = {}; fd.forEach((v,k) => { obj[k]=v; }); + if(!getToken()){ + Object.assign(_state.me || {}, obj); + applyMeToHeader(); closeDetail(); loadSection(); + return false; + } + const r = await apiAuth('/auth/me', {method:'PUT', body: JSON.stringify(obj)}); + if(r && !r.__error){ _state.me = r; applyMeToHeader(); closeDetail(); loadSection(); } + else alert('Greška: '+(r&&r.status||'unknown')); + return false; +} +async function profileChangePassword(){ + if(!getToken()){ alert('Login potreban (demo mode).'); return; } + const oldp = prompt('Stara lozinka:'); if(oldp==null) return; + const newp = prompt('Nova lozinka (min 8 znakova):'); if(newp==null) return; + if(newp.length < 8){ alert('Nova lozinka mora imati barem 8 znakova'); return; } + const r = await apiAuth('/auth/password/change', {method:'POST', body: JSON.stringify({old_password:oldp,new_password:newp})}); + if(r && r.status==='ok') alert('Lozinka promijenjena ✓'); else alert('Greška: '+(r&&r.status||'unknown')); +} +async function profileSetup2FA(){ + if(!getToken()){ alert('Login potreban (demo mode).'); return; } + const r = await apiAuth('/auth/2fa/setup', {method:'POST'}); + if(r && r.qr_url) { + openDetail('Postavi 2FA', `
Skeniraj QR kod u Google Authenticator / Authy
`); + } else alert('2FA setup failed'); +} +async function profileVerify2FA(){ + const code = $('#totp-code')?.value; + const r = await apiAuth('/auth/2fa/verify', {method:'POST', body: JSON.stringify({code})}); + if(r && r.status==='ok'){ alert('2FA aktivirano ✓'); closeDetail(); loadSection(); } + else alert('Pogrešan kod.'); +} +function profileDeleteAccount(){ + if(!confirm('Zaista zatraži brisanje računa? GDPR brisanje je nepovratno.')) return; + alert('Zahtjev za brisanje poslan na PGŽ admin (M10 — backend).'); +} + // ======================================================================= // PGŽ ADMIN — Dashboard // ======================================================================= @@ -689,7 +934,7 @@ SECTIONS['pgz:dashboard'] = async () => {
`; const reqHtml = MOCK.zahtjevi_pending.map(z => ` -
+
${esc(z.naziv)}
@@ -705,7 +950,7 @@ SECTIONS['pgz:dashboard'] = async () => {
`).join(''); const auditHtml = MOCK.audit.map(a => - `
${esc(a.ts)}
${esc(a.who)}
${a.what}
` + `
${esc(a.ts)}
${esc(a.who)}
${a.what}
` ).join(''); return ` @@ -803,12 +1048,12 @@ SECTIONS['pgz:savezi'] = async () => { const d = await api('/savezi') || {rows:[]}; const top = (d.rows||[]).slice(0,30); const rows = top.map(s => ` - + ${esc(s.naziv)} ${fmt(s.broj_klubova||'—')} ${fmt(s.broj_sportasa||'—')} ${esc(s.predsjednik||'—')} - + `).join(''); return `
🏅 Savezi PGŽ — top 30 (od ${d.count||246})
${rows||''}
NazivKluboviSportašiPredsjednik
Učitavam...
@@ -818,7 +1063,7 @@ SECTIONS['pgz:savezi'] = async () => { SECTIONS['pgz:klubovi'] = async () => { const d = await api('/klubovi?limit=40') || {rows:[]}; const rows = (d.rows||[]).slice(0,40).map(k => ` - + ${esc(k.naziv)} ${esc(k.savez||'—')} ${esc(k.grad||'—')} @@ -1433,14 +1678,26 @@ const MOCK = { }; //=========== INIT =========== -function init(){ +async function init(){ try { const r = localStorage.getItem('app-role'); if(r && ROLES[r]) _state.role = r; } catch(e){} restoreSidebar(); buildRoleSwitch(); + + // Try real auth (JWT) + const me = await loadCurrentUser(); + if(me){ + // Real-auth mode — hide demo role switcher (only super_admin can switch personas) + if(me.user_type !== 'super_admin'){ + const rs = $('#role-switch'); if(rs) rs.style.display='none'; + } + applyMeToHeader(); + } + // First page after login: Moj profil setRole(_state.role); + navTo('profil'); } window.addEventListener('DOMContentLoaded', init); diff --git a/static/crm.html b/static/crm.html index fdac375..da6bf61 100644 --- a/static/crm.html +++ b/static/crm.html @@ -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 = ` +
+ + +
+ Klik na karticu → puni dashboard člana +
+
Upišite ime za pretragu…
+ `; + // 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 = '
Upišite ime za pretragu (min 2 slova)…
'; + 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 => ` +
+
+
+ ${r.slika_url ? `` : `${esc((r.ime||'?')[0]+(r.prezime||'?')[0])}`} +
+
+
${esc(r.ime)} ${esc(r.prezime)}
+
${esc(r.klub || '—')} · ${esc(r.pozicija || '—')}${r.broj_dresa ? ' · #'+r.broj_dresa : ''}
+
+
#${r.id}
+
+
`).join(''); + $('#cl-results').innerHTML = ` +
${data.count} rezultat${data.count==1?'':'a'}
+ ${cards || '
Nema rezultata.
'}`; + } catch (e) { $('#cl-results').innerHTML = `
Greška: ${esc(e.message)}
`; } + }, 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 ` +
+
${esc(label)}${ed?'':' 🔒'}
+
+ ${esc(safe)} + ${ed ? `` : ''} +
+
`; + }; + + const kpiHtml = ` +
+
Sezone
${fmt(d.kpi.broj_sezona)}
+
Nastupi
${fmt(d.kpi.nastupi_total)}
+
Pogoci
${fmt(d.kpi.pogoci_total)}
+
Dug članarina
${fmtEur(d.kpi.dug_clanarina_eur)}
+
Liječnički
${esc(d.kpi.lijecnicki_status||'—')}
${d.kpi.lijecnicki_dana_do_isteka != null ? d.kpi.lijecnicki_dana_do_isteka+' dana' : ''}
+
`; + + const sectionPersonal = ` +
📋 Osobni podaci
+
+ ${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)} +
`; + + const sectionKontakt = ` +
📞 Kontakt
+
+ ${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)} +
`; + + const sectionSport = ` +
⚽ Sport
+
+ ${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)} +
`; + + const sectionStatus = ` +
📊 Status
+
+ ${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)} +
`; + + const sectionKlub = ` +
⬢ Klub
+
+
Trenutni klub
${esc(k.naziv || '—')}
+ ${k.savez_naziv ? `
Savez
${esc(k.savez_naziv)}
` : ''} + ${k.oib ? `
OIB kluba
${esc(k.oib)}
` : ''} + ${k.iban ? `
IBAN
${esc(k.iban)}
` : ''} +
+ ${d.povijest_klubova && d.povijest_klubova.length ? ` +
Povijest klubova (${d.povijest_klubova.length}):
+ + ${d.povijest_klubova.map(p=>``).join('')} +
KlubOdDo# sezona
${esc(p.klub_naziv)}${esc(p.od)}${esc(p.do_)}${p.broj_sezona}
` : ''} + `; + + const tabSezone = ` + + ${(d.sezone||[]).map(s=>``).join('') || ''} +
SezonaKlubNatjecanjeNast.Pog.Asist.ŽutiCrv.Min.
${esc(s.sezona)}${esc(s.klub_naziv||'—')}${esc(s.natjecanje||'—')}${fmt(s.nastupi)}${fmt(s.pogoci)}${fmt(s.asistencije)}${fmt(s.zuti_kartoni)}${fmt(s.crveni_kartoni)}${fmt(s.minute_total)}
Nema podataka.
`; + + const tabUtakmice = ` + + ${(d.utakmice_zadnje20||[]).map(u=>``).join('') || ''} +
DatumDomaćinGostRezultatNatj.Pog.Min.
${fmtDate(u.datum)}${esc(u.domacin||'—')}${esc(u.gost||'—')}${esc(u.rezultat||'—')}${esc(u.natjecanje||'—')}${fmt(u.pogoci)}${fmt(u.minute)}${u.utakmica_url?``:''}
Nema utakmica.
`; + + const tabLij = ` + + ${(d.lijecnicki||[]).map(l=>``).join('') || ''} +
DatumVrijedi doStatusVrstaUstanovaLiječnikPlaćeno
${fmtDate(l.datum_pregleda)}${fmtDate(l.vrijedi_do)}${esc(l.status_calc)} (${l.dana_do_isteka}d)${esc(l.vrsta_pregleda||'—')}${esc(l.ustanova||'—')}${esc(l.lijecnik||'—')}${l.placeno?'DA':'NE'}
Nema pregleda.
`; + + const tabClanarine = ` + + ${(d.clanarine||[]).map(cl=>``).join('') || ''} +
God.RazdobljePropisanPlaćenoDugStatusDatum upl.
${esc(cl.godina)}${esc(cl.razdoblje||'—')}${fmtEur(cl.iznos_propisan)}${fmtEur(cl.iznos_placen)}${fmtEur(cl.dug)}${esc(cl.status)}${fmtDate(cl.datum_uplate)}📄
Nema članarina.
`; + + const tabDokumenti = ` + + ${(d.dokumenti||[]).map(dk=>``).join('') || ''} +
God.NaslovVrstaSnippet
${esc(dk.godina)}${esc(dk.title||'—')}${esc(dk.vrsta||'—')}${esc((dk.snippet||'').substring(0,140))}${dk.pdf_url?`📄`:dk.url?``:''}
Nema dokumenata.
`; + + const tabObrasci = ` + + ${(d.obrasci||[]).map(o=>``).join('') || ''} +
ObrazacRef.StatusPredano
${esc(o.template_naziv||o.template_code)}${esc(o.reference_no||'')}${esc(o.status)}${fmtDate(o.submitted_at||o.created_at)}📄
Nema obrazaca.
`; + + const tabNagrade = (d.nagrade && d.nagrade.length) ? ` + + ${d.nagrade.map(n=>``).join('')} +
GodinaNatjecanjeRazinaDisciplinaPlasmanKlub
${esc(n.godina)}${esc(n.natjecanje||'—')}${esc(n.razina_natjecanja||'—')}${esc(n.disciplina||'—')}${n.plasman||'—'}${esc(n.klub_naziv||'—')}
` : ''; + + // Modal — širi nego standardno + $('#modal').style.maxWidth = '1100px'; + openModal(` + + + `); +} + +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 = ``; + } else if (isTextarea) { + inputHtml = ``; + } else { + inputHtml = ``; + } + const promptHtml = ` + + `; + $('#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'); diff --git a/static/erp.html b/static/erp.html index b6bc74b..2854151 100644 --- a/static/erp.html +++ b/static/erp.html @@ -53,7 +53,31 @@ label.lbl { font-size:11px; color:var(--text-3); display:block; margin-bottom:4p .grid2 { display:grid; grid-template-columns:1fr 1fr; gap:10px; } .grid3 { display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px; } .grid4 { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; } -@media(max-width:768px) { .app { grid-template-columns:1fr; } .sidebar { display:none; } .grid2,.grid3 { grid-template-columns:1fr; } } +tr.clickable { cursor:pointer; } +tr.clickable:hover { background:var(--bg-3); box-shadow:inset 3px 0 0 var(--accent); } +.modal-bg { position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:100; display:none; align-items:flex-start; justify-content:center; padding:30px; overflow-y:auto; } +.modal-bg.show { display:flex; } +.modal { background:var(--bg-2); border:1px solid var(--border); border-radius:10px; max-width:1100px; width:100%; padding:0; box-shadow:0 12px 48px rgba(0,0,0,.6); } +.modal-h { display:flex; justify-content:space-between; align-items:center; padding:16px 22px; border-bottom:1px solid var(--border); } +.modal-h h3 { color:var(--accent); font-size:16px; } +.modal-h .x { background:transparent; border:0; color:var(--text-2); font-size:22px; cursor:pointer; } +.modal-h .x:hover { color:var(--red); } +.modal-body { padding:18px 22px; max-height:80vh; overflow-y:auto; } +.col2 { display:grid; grid-template-columns:1fr 1fr; gap:18px; } +.kv { display:grid; grid-template-columns:140px 1fr; gap:6px 12px; font-size:13px; } +.kv > div:nth-child(odd) { color:var(--text-3); font-size:11px; text-transform:uppercase; letter-spacing:.5px; align-self:center; } +.kv > div:nth-child(even) { font-family:'JetBrains Mono',monospace; } +.preview-img { max-width:100%; max-height:480px; border:1px solid var(--border); border-radius:6px; background:var(--bg); } +.audit-row { display:grid; grid-template-columns:140px 110px 130px 1fr; gap:8px; padding:6px 0; border-bottom:1px dashed var(--border); font-size:12px; } +.audit-row:last-child { border-bottom:0; } +.audit-row .ts { color:var(--text-3); font-family:'JetBrains Mono',monospace; font-size:11px; } +.audit-row .op { color:var(--accent); font-weight:600; } +.audit-row .who { color:var(--text-2); } +.btn.green { background:var(--green); color:var(--bg); } +.btn.red { background:var(--red); color:#fff; } +.btn.yellow { background:var(--yellow); color:var(--bg); } +.actions-row { display:flex; flex-wrap:wrap; gap:8px; margin-top:14px; padding-top:14px; border-top:1px solid var(--border); } +@media(max-width:768px) { .app { grid-template-columns:1fr; } .sidebar { display:none; } .grid2,.grid3 { grid-template-columns:1fr; } .col2 { grid-template-columns:1fr; } .audit-row { grid-template-columns:1fr; } } @@ -173,6 +197,153 @@ label.lbl { font-size:11px; color:var(--text-3); display:block; margin-bottom:4p
+ + + + + + + + + + + + + + + + + +