Files
damir 8e136351f9 CRISIS FIX: login flow + mobile responsive + token expiry handling
ROOT CAUSE ISOLATED:
Backend POST /api/auth/login, GET/PUT /api/auth/me, POST avatar, POST /logout
all return 200 OK (verified curl). Damirov problem is browser-side:
stale localStorage tokens that don't match current backend → 401 cascade
→ avatar upload appears as 'failed: 401' → profile changes 'lost'.

FIXES:
1. apiAuth() in app.html now:
   - Pre-checks JWT exp claim before request
   - On 401 response: clears localStorage (pgz_access/refresh/user) +
     redirects to /login?reason=unauthorized
   - On JWT expired: redirects to /login?reason=expired

2. login.html displays toast for ?reason=expired/unauthorized

3. Mobile responsive CSS (max-width: 768px):
   - app.html: hamburger menu, sidebar slide-in, full-width drill-down panel
   - sport2.html: KPI grid 2-col, klubovi 1-col, tables horizontal scroll
   - Both: viewport meta + media queries + touch-friendly buttons

4. Mobile menu toggle button + backdrop overlay added

VERIFIED E2E (curl):
- POST /auth/login → 200 + JWT
- GET /auth/me → 200 + telefon persisted
- PUT /auth/me → 200, DB row updated
- POST /auth/me/avatar → 200, file saved + avatar_url returned
- POST /auth/logout → 200, token revoked (next /me returns 401)
2026-05-05 09:14:46 +02:00

288 lines
14 KiB
Python

#!/usr/bin/env python3
# gdpr.py — GDPR endpoints: export, erasure, consent, audit (M10)
# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04
"""
GET /api/gdpr/export (current user — Art. 20 portability)
POST /api/gdpr/erase (current user — Art. 17 erasure request)
POST /api/gdpr/consent (cookie / processing consent log)
GET /api/gdpr/consent
GET /api/gdpr/policy (returns text URL/markdown)
GET /api/admin/gdpr/erasure-requests (PGŽ admin)
POST /api/admin/gdpr/erasure-requests/{id}/process
"""
import json
from datetime import datetime
from typing import Optional, Dict, List
from fastapi import APIRouter, HTTPException, Depends, Request, Body
from pydantic import BaseModel
from fastapi.responses import JSONResponse
from fastapi.encoders import jsonable_encoder
from .auth_v2 import (
db_query, db_one, db_exec,
require_user, audit, _client,
)
from .admin_users import _is_pgz_admin
router = APIRouter(prefix="/api/gdpr", tags=["gdpr"])
admin_router = APIRouter(prefix="/api/admin/gdpr", tags=["gdpr_admin"])
me_router = APIRouter(prefix="/api/users/me", tags=["users_me_gdpr"])
# Ensure GDPR tables exist (idempotent)
def _ensure_tables():
try:
db_exec("""CREATE TABLE IF NOT EXISTS pgz_sport.gdpr_consent (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER REFERENCES pgz_sport.users(id) ON DELETE CASCADE,
session_id TEXT,
ip TEXT,
necessary BOOLEAN DEFAULT true,
analytics BOOLEAN DEFAULT false,
marketing BOOLEAN DEFAULT false,
consent_at TIMESTAMPTZ DEFAULT now(),
policy_version TEXT DEFAULT 'v1',
user_agent TEXT
)""")
db_exec("""CREATE INDEX IF NOT EXISTS idx_gdpr_consent_user ON pgz_sport.gdpr_consent(user_id)""")
db_exec("""CREATE TABLE IF NOT EXISTS pgz_sport.gdpr_erasure_requests (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER REFERENCES pgz_sport.users(id) ON DELETE CASCADE,
email TEXT,
requested_at TIMESTAMPTZ DEFAULT now(),
reason TEXT,
status TEXT DEFAULT 'pending', -- pending|approved|denied|completed
processed_by INTEGER REFERENCES pgz_sport.users(id),
processed_at TIMESTAMPTZ,
note TEXT
)""")
db_exec("""ALTER TABLE pgz_sport.users
ADD COLUMN IF NOT EXISTS gdpr_consent_at TIMESTAMPTZ""")
except Exception as e:
print(f"[GDPR migration WARN] {e}")
_ensure_tables()
POLICY_VERSION = "v1"
# ─────────────────────────── Cookie / consent ───────────────────────────
class ConsentReq(BaseModel):
necessary: bool = True
analytics: bool = False
marketing: bool = False
session_id: Optional[str] = None
policy_version: Optional[str] = None
@router.post("/consent")
def post_consent(req: ConsentReq, request: Request):
"""Record a consent event. Works for anonymous (session_id only) or logged-in users."""
user = None
auth = request.headers.get("authorization")
if auth:
from .auth_v2 import get_current_user
user = get_current_user(authorization=auth)
ip, ua = _client(request)
uid = user["id"] if user else None
db_exec("""INSERT INTO pgz_sport.gdpr_consent
(user_id, session_id, ip, necessary, analytics, marketing, policy_version, user_agent)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)""",
(uid, req.session_id, ip, req.necessary, req.analytics, req.marketing,
req.policy_version or POLICY_VERSION, ua))
if uid:
db_exec("UPDATE pgz_sport.users SET gdpr_consent_at=now() WHERE id=%s", (uid,))
audit(uid, "gdpr.consent", meta={
"necessary": req.necessary, "analytics": req.analytics,
"marketing": req.marketing, "session_id": req.session_id}, ip=ip, ua=ua)
return {"status": "ok", "policy_version": POLICY_VERSION}
@router.get("/consent")
def get_consent(user = Depends(require_user)):
rows = db_query("""SELECT necessary, analytics, marketing, consent_at,
policy_version, ip, session_id
FROM pgz_sport.gdpr_consent WHERE user_id=%s
ORDER BY consent_at DESC LIMIT 50""", (user["id"],))
return {"current": rows[0] if rows else None, "history": rows}
@router.get("/policy")
def get_policy():
return {
"version": POLICY_VERSION,
"url": "https://api.rinet.one/sport/static/privacy.html",
"rights": [
"Art. 15 — Pravo na pristup",
"Art. 16 — Pravo na ispravak",
"Art. 17 — Pravo na brisanje",
"Art. 18 — Pravo na ograničenje obrade",
"Art. 20 — Pravo na prenosivost podataka",
"Art. 21 — Pravo na prigovor",
],
"controller": "Primorsko-goranska županija — Odjel za sport",
"contact": "gdpr@pgz.hr",
"dpo": "Damir Radulić (damir@rinet.one)",
}
# ─────────────────────────── Article 20 — data export ───────────────────────────
@router.get("/export")
def export_my_data(user = Depends(require_user)):
"""Return all data we hold about the calling user — JSON dump."""
uid = user["id"]
profile = db_one("""SELECT id, email, full_name, ime, prezime, oib, telefon, phone,
user_type, klub_id, savez_id, status, aktivan, last_login, created_at,
preferred_language, gdpr_consent_at
FROM pgz_sport.users WHERE id=%s""", (uid,))
sessions = db_query("""SELECT id, device_info, ip_address::text AS ip,
created_at, expires_at, revoked
FROM pgz_sport.user_sessions WHERE user_id=%s ORDER BY created_at DESC""", (uid,))
audit_rows = db_query("""SELECT id, action, resource_type, resource_id,
ts AS created_at, ip_address::text AS ip, user_agent, meta
FROM pgz_sport.audit_events WHERE user_id=%s ORDER BY ts DESC LIMIT 1000""", (uid,))
consent = db_query("""SELECT necessary, analytics, marketing, consent_at,
policy_version FROM pgz_sport.gdpr_consent WHERE user_id=%s
ORDER BY consent_at DESC""", (uid,))
klub_links = db_query("""SELECT klub_id, savez_id, link_type, role,
primary_klub, granted_at, od_datuma, do_datuma
FROM pgz_sport.user_klub_links WHERE user_id=%s""", (uid,))
roles = db_query("""SELECT r.code, r.naziv, ur.scope_type, ur.scope_id,
ur.granted_at, ur.expires_at, ur.active
FROM pgz_sport.user_roles ur
JOIN pgz_sport.roles r ON r.id=ur.role_id
WHERE ur.user_id=%s""", (uid,))
audit(uid, "gdpr.export")
return {
"exported_at": datetime.utcnow().isoformat() + "Z",
"policy_version": POLICY_VERSION,
"subject": profile,
"sessions": sessions,
"audit_events": audit_rows,
"consent_history": consent,
"klub_links": klub_links,
"roles": roles,
}
# ─────────────────────────── Article 17 — erasure request ───────────────────────────
class EraseReq(BaseModel):
reason: Optional[str] = None
confirm_email: Optional[str] = None
@router.post("/erase")
def request_erasure(req: EraseReq, request: Request, user = Depends(require_user)):
if req.confirm_email and req.confirm_email.lower().strip() != user["email"].lower():
raise HTTPException(400, "confirm_email se ne poklapa")
ip, ua = _client(request)
new_id = db_one("""INSERT INTO pgz_sport.gdpr_erasure_requests
(user_id, email, reason, status) VALUES (%s,%s,%s,'pending') RETURNING id""",
(user["id"], user["email"], req.reason))["id"]
audit(user["id"], "gdpr.erasure.request", "user", user["id"],
{"reason": req.reason}, ip, ua)
return {"status": "ok", "request_id": new_id,
"message": "Vaš zahtjev je zaprimljen i bit će obrađen unutar 30 dana."}
# ─────────────────────────── Admin: erasure queue ───────────────────────────
# ─────────────────────────── /api/users/me alias (R4 #4) ───────────────────────────
@me_router.get("/gdpr-export")
@me_router.post("/gdpr-export")
def me_gdpr_export(user = Depends(require_user)):
"""GDPR Art. 20 — JSON export of all data we hold about the caller.
Same payload as GET /api/gdpr/export, exposed at user-friendly path.
Returns Content-Disposition: attachment so browsers offer a download."""
payload = export_my_data(user=user)
fn = f"pgz_data_export_{user['id']}_{int(datetime.utcnow().timestamp())}.json"
return JSONResponse(jsonable_encoder(payload),
headers={"Content-Disposition": f'attachment; filename="{fn}"'})
@me_router.post("/gdpr-erase")
def me_gdpr_erase(req: 'EraseReq', request: Request, user = Depends(require_user)):
return request_erasure(req=req, request=request, user=user)
@me_router.post("/request-deletion")
def me_request_deletion(req: EraseReq, request: Request, user = Depends(require_user)):
"""Frontend alias for /gdpr-erase (R7 — Art. 17 erasure request)."""
return request_erasure(req=req, request=request, user=user)
@me_router.get("/gdpr-consent")
def me_gdpr_consent(user = Depends(require_user)):
rows = db_query("""SELECT necessary, analytics, marketing, consent_at,
policy_version, ip, session_id
FROM pgz_sport.gdpr_consent WHERE user_id=%s
ORDER BY consent_at DESC LIMIT 50""", (user["id"],))
return {"current": rows[0] if rows else None, "history": rows}
# ─────────────────────────── Article 7 — withdraw consent ───────────────────────────
# GDPR Art. 7(3): "the data subject shall have the right to withdraw his or
# her consent at any time. The withdrawal of consent shall be as easy as to
# give consent."
@me_router.post("/withdraw-consent")
@me_router.delete("/gdpr-consent")
def me_withdraw_consent(request: Request, user = Depends(require_user)):
"""Withdraw all non-necessary consent (analytics + marketing).
Records a fresh consent row with everything but `necessary` = false and
clears users.gdpr_consent_at so the cookie banner shows again on next
login. Necessary cookies (session, CSRF) remain — they're legitimate
interest, not consent-based."""
ip, ua = _client(request)
db_exec("""INSERT INTO pgz_sport.gdpr_consent
(user_id, session_id, ip, necessary, analytics, marketing, policy_version, user_agent)
VALUES (%s, NULL, %s, true, false, false, %s, %s)""",
(user["id"], ip, POLICY_VERSION, ua))
db_exec("UPDATE pgz_sport.users SET gdpr_consent_at=NULL WHERE id=%s",
(user["id"],))
audit(user["id"], "gdpr.consent.withdraw",
meta={"reason": "user_requested"}, ip=ip, ua=ua)
return {"status": "ok",
"message": "Pristanak za neobvezne kolačiće povučen. Nužni kolačići i dalje vrijede temeljem legitimnog interesa.",
"policy_version": POLICY_VERSION}
# ─────────────────────────── Admin: erasure queue ───────────────────────────
@admin_router.get("/erasure-requests")
def list_erasure_requests(status: Optional[str] = None,
actor = Depends(require_user)):
if not _is_pgz_admin(actor):
raise HTTPException(403, "PGŽ admin only")
where, args = ["1=1"], []
if status: where.append("er.status=%s"); args.append(status)
rows = db_query(f"""SELECT er.id, er.user_id, er.email, er.requested_at,
er.reason, er.status, er.processed_by, er.processed_at, er.note,
u.full_name
FROM pgz_sport.gdpr_erasure_requests er
LEFT JOIN pgz_sport.users u ON u.id=er.user_id
WHERE {' AND '.join(where)}
ORDER BY er.requested_at DESC""", tuple(args))
return {"count": len(rows), "results": rows}
class ProcessEraseReq(BaseModel):
decision: str # 'approve' | 'deny'
note: Optional[str] = None
anonymize: bool = True
@admin_router.post("/erasure-requests/{rid}/process")
def process_erasure(rid: int, req: ProcessEraseReq, request: Request,
actor = Depends(require_user)):
if not _is_pgz_admin(actor):
raise HTTPException(403, "PGŽ admin only")
er = db_one("SELECT * FROM pgz_sport.gdpr_erasure_requests WHERE id=%s", (rid,))
if not er: raise HTTPException(404, "Request not found")
if er["status"] != "pending":
raise HTTPException(400, f"Already {er['status']}")
if req.decision == "approve":
if req.anonymize and er["user_id"]:
db_exec("""UPDATE pgz_sport.users SET
email = CONCAT('erased-', id, '@anonymous.gdpr'),
full_name = 'Erased',
ime = NULL, prezime = NULL, oib = NULL,
telefon = NULL, phone = NULL, password_hash = NULL,
aktivan = false, status = 'erased',
google_sub = NULL, google_picture = NULL,
updated_at = now()
WHERE id=%s""", (er["user_id"],))
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s",
(er["user_id"],))
new_status = "completed"
else:
new_status = "denied"
db_exec("""UPDATE pgz_sport.gdpr_erasure_requests
SET status=%s, processed_by=%s, processed_at=now(), note=%s
WHERE id=%s""", (new_status, actor["id"], req.note, rid))
ip, ua = _client(request)
audit(actor["id"], "gdpr.erasure.process", "user", er["user_id"] or 0,
{"request_id": rid, "decision": req.decision, "note": req.note}, ip, ua)
return {"status": new_status, "id": rid}