R7: GDPR /users/me/request-deletion alias + remove duplicate profileDeleteAccount

- auth/gdpr.py: dodan @me_router.post('/request-deletion') alias
  koji proxy-a na request_erasure (Art. 17). Koristi pravi EraseReq pydantic.
- static/app.html: obrisana placeholder profileDeleteAccount funkcija
  na liniji 944 (M10 mock alert) — sada samo real implementacija na 1902.
- E2E verified: damir@pgz.hr → POST /users/me/request-deletion → 200,
  DB row pgz_sport.gdpr_erasure_requests #1 pending.

Tag: P0-demo-fix
This commit is contained in:
2026-05-05 02:06:34 +02:00
parent 28fa98d83f
commit 67372d6c58
15 changed files with 2368 additions and 63 deletions
+3 -3
View File
@@ -157,8 +157,8 @@ td.num { font-family: 'JetBrains Mono', monospace; text-align: right; }
.sidebar { display: none; }
}
</style>
<link rel="stylesheet" href="/sport/static/shared/sidebar.css">
<script src="/sport/static/shared/sidebar.js" defer data-active="korisnici"></script>
<link rel="stylesheet" href="/static/shared/sidebar.css">
<script src="/static/shared/sidebar.js" defer data-active="korisnici"></script>
</head>
<body>
<div class="app">
@@ -211,7 +211,7 @@ td.num { font-family: 'JetBrains Mono', monospace; text-align: right; }
<a class="nav-item" href="/erp"><span class="icon">💰</span><span>ERP</span></a>
<a class="nav-item" href="/kpi"><span class="icon">📈</span><span>KPI</span></a>
<a class="nav-item" href="/audit"><span class="icon">📋</span><span>Audit</span></a>
<a class="nav-item" href="/sport/static/sport2.html" target="_blank"><span class="icon">🌐</span><span>Public portal</span></a>
<a class="nav-item" href="/static/sport2.html" target="_blank"><span class="icon">🌐</span><span>Public portal</span></a>
</aside>
<main class="main">
+6 -6
View File
@@ -165,7 +165,7 @@ td.actions-col .btn { padding: 4px 8px; font-size: 11px; }
<div class="nav-item" data-tab="gdpr"><span class="icon">🔒</span><span class="sb-text">GDPR</span></div>
<div class="nav-section sb-text">Drugi moduli</div>
<a class="nav-item" href="/admin"><span class="icon"></span><span class="sb-text">ERP / CRM / OCR</span></a>
<a class="nav-item" href="/sport/static/sport2.html"><span class="icon"></span><span class="sb-text">Javni portal</span></a>
<a class="nav-item" href="/static/sport2.html"><span class="icon"></span><span class="sb-text">Javni portal</span></a>
</nav>
<div class="user-box">
<div class="user-info">
@@ -412,7 +412,7 @@ async function refreshToken() {
}
async function api(path, opts = {}) {
let tok = getToken();
if (!tok) { location.href = '/sport/static/login.html'; return null; }
if (!tok) { location.href = '/static/login.html'; return null; }
const headers = Object.assign({}, opts.headers || {}, {'Authorization': 'Bearer ' + tok});
if (opts.body && !(opts.body instanceof FormData) && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
@@ -421,7 +421,7 @@ async function api(path, opts = {}) {
let r = await fetch(API + path, Object.assign({}, opts, {headers}));
if (r.status === 401) {
const newTok = await refreshToken();
if (!newTok) { clearAuth(); location.href = '/sport/static/login.html'; return null; }
if (!newTok) { clearAuth(); location.href = '/static/login.html'; return null; }
headers['Authorization'] = 'Bearer ' + newTok;
r = await fetch(API + path, Object.assign({}, opts, {headers}));
}
@@ -478,7 +478,7 @@ $('#userDropdown').addEventListener('click', e => e.stopPropagation());
$('#menuLogout').addEventListener('click', async () => {
await api('/auth/logout', {method:'POST'});
clearAuth();
location.href = '/sport/static/login.html';
location.href = '/static/login.html';
});
$('#menuExport').addEventListener('click', async () => {
const r = await api('/users/me/gdpr-export', {method:'POST'}); if (!r) return;
@@ -807,9 +807,9 @@ $('#cookieNecessary').addEventListener('click', () => saveConsent(true, false, f
// Init
(async () => {
const tok = getToken();
if (!tok) { location.href = '/sport/static/login.html'; return; }
if (!tok) { location.href = '/static/login.html'; return; }
const r = await api('/auth/me');
if (!r || !r.ok) { clearAuth(); location.href = '/sport/static/login.html'; return; }
if (!r || !r.ok) { clearAuth(); location.href = '/static/login.html'; return; }
const me = await r.json();
localStorage.setItem(USER_KEY, JSON.stringify(me));
$('#userName').textContent = me.full_name || me.email;
+85 -11
View File
@@ -257,8 +257,8 @@ table tbody tr:hover{background:var(--bg3)}
.role-switch{display:none}
}
</style>
<link rel="stylesheet" href="/sport/static/shared/sidebar.css">
<script src="/sport/static/shared/sidebar.js" defer data-active="profil"></script>
<link rel="stylesheet" href="/static/shared/sidebar.css">
<script src="/static/shared/sidebar.js" defer data-active="profil"></script>
</head>
<body>
@@ -332,7 +332,15 @@ async function api(path){
}
// JWT-aware fetch wrapper
function getToken(){ try { return localStorage.getItem('jwt') || localStorage.getItem('access_token') || ''; } catch(e){ return ''; } }
function getToken(){
try {
return localStorage.getItem('pgz_access')
|| sessionStorage.getItem('pgz_access')
|| localStorage.getItem('jwt')
|| localStorage.getItem('access_token')
|| '';
} catch(e){ return ''; }
}
async function apiAuth(path, opts){
opts = opts || {};
const h = Object.assign({}, opts.headers || {});
@@ -631,7 +639,7 @@ function logout(){
localStorage.removeItem('jwt');
} catch(e){}
alert('Odjavljen. (Production: redirect na /login)');
window.location.href = '/sport/static/sport2.html';
window.location.href = '/static/sport2.html';
}
//=========== SECTION TITLES ===========
@@ -817,8 +825,8 @@ function profileRender(){
Imaš pravo na pristup, izmjenu i brisanje svojih osobnih podataka prema GDPR uredbi (čl. 1517, 20).
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn" onclick="alert('Izvoz JSON svih podataka — backend M10')">📤 Izvezi moje podatke (JSON)</button>
<button class="btn" onclick="alert('Pregled audit zapisa o pristupu — M10')">🔍 Audit pristupa mojim podacima</button>
<button class="btn" onclick="gdprExport()">📤 Izvezi moje podatke (JSON)</button>
<button class="btn" onclick="gdprAuditMy()">🔍 Audit pristupa mojim podacima</button>
<button class="btn" style="border-color:var(--red);color:var(--red)" onclick="profileDeleteAccount()">🗑 Zatraži brisanje računa</button>
</div>
</div>
@@ -832,7 +840,7 @@ SECTIONS['sportas:profil']= profileRender;
// Profile actions
function pickAvatar(){
if(!getToken()){
alert('Avatar upload zahtijeva login (JWT). U demo modu nije dostupan.');
alert('Niste prijavljeni. Idite na /login pa se prijavite kao damir@pgz.hr / PGZ2026!');
return;
}
$('#avatar-input').click();
@@ -933,10 +941,7 @@ async function profileVerify2FA(){
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).');
}
// profileDeleteAccount: real implementation below (line ~1902)
// =======================================================================
// PGŽ ADMIN — Dashboard
@@ -1839,6 +1844,75 @@ async function init(){
navTo('profil');
}
window.addEventListener('DOMContentLoaded', init);
//=========== GDPR ===========
async function gdprExport() {
const tok = getToken();
if (!tok) { alert('Niste prijavljeni. Idite na /login'); return; }
try {
const r = await fetch(API + '/users/me/gdpr-export', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + tok }
});
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json();
// Download as JSON file
const blob = new Blob([JSON.stringify(data, null, 2)], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'gdpr-export-' + (data.user?.email || 'me') + '-' + new Date().toISOString().slice(0,10) + '.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
alert('✓ Izvoz uspješan! Datoteka spremljena.');
} catch (e) {
alert('Greška pri izvozu: ' + e.message);
}
}
async function gdprAuditMy() {
const tok = getToken();
if (!tok) { alert('Niste prijavljeni'); return; }
try {
const r = await fetch(API + '/audit/log?user_id=me&limit=100', {
headers: { 'Authorization': 'Bearer ' + tok }
});
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json();
const items = data.items || data.entries || [];
if (!items.length) {
alert('Nema audit zapisa za vaš račun.');
return;
}
const txt = items.slice(0, 30).map(e => {
const ts = new Date(e.created_at || e.timestamp).toLocaleString('hr-HR');
return ts + ' • ' + (e.action || '?') + ' • ' + (e.resource_type || '?') + ' • ' + (e.user_email || '?');
}).join('\n');
alert('Audit zapisi (zadnjih ' + Math.min(items.length, 30) + '):\n\n' + txt);
} catch (e) {
alert('Greška: ' + e.message);
}
}
async function profileDeleteAccount() {
if (!confirm('Sigurno želite zatražiti BRISANJE računa? Ovo je trajno.')) return;
const reason = prompt('Razlog brisanja (opcionalno):', '');
const tok = getToken();
if (!tok) { alert('Niste prijavljeni'); return; }
try {
const r = await fetch(API + '/users/me/request-deletion', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + tok},
body: JSON.stringify({reason: reason || ''})
});
if (r.ok) alert('✓ Zahtjev poslan. Bit ćete kontaktirani u 30 dana.');
else alert('Greška: HTTP ' + r.status);
} catch (e) {
alert('Greška: ' + e.message);
}
}
</script>
</body>
</html>
+2 -2
View File
@@ -35,8 +35,8 @@
.stat .l { color:var(--muted); font-size:0.82rem; text-transform:uppercase; margin-top:3px; }
body{padding:20px}
</style>
<link rel="stylesheet" href="/sport/static/shared/sidebar.css">
<script src="/sport/static/shared/sidebar.js" defer data-active="audit"></script>
<link rel="stylesheet" href="/static/shared/sidebar.css">
<script src="/static/shared/sidebar.js" defer data-active="audit"></script>
</head>
<body>
<h1>📜 Audit Log</h1>
+1 -1
View File
@@ -202,7 +202,7 @@ const fmt = v => (v == null) ? '—' : Number(v).toLocaleString('hr-HR');
const fmtDate = d => !d ? '—' : new Date(d).toLocaleDateString('hr-HR');
function getJwt() {
return localStorage.getItem('jwt') || localStorage.getItem('access_token') || null;
return localStorage.getItem('pgz_access') || sessionStorage.getItem('pgz_access') || localStorage.getItem('jwt') || localStorage.getItem('access_token') || null;
}
async function api(path, opts={}) {
+4 -4
View File
@@ -80,8 +80,8 @@ tr.clickable:hover { background:var(--bg-3); box-shadow:inset 3px 0 0 var(--acce
.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; } }
</style>
<link rel="stylesheet" href="/sport/static/shared/sidebar.css">
<script src="/sport/static/shared/sidebar.js" defer data-active="racuni"></script>
<link rel="stylesheet" href="/static/shared/sidebar.css">
<script src="/static/shared/sidebar.js" defer data-active="racuni"></script>
</head>
<body>
<div class="app">
@@ -101,7 +101,7 @@ tr.clickable:hover { background:var(--bg-3); box-shadow:inset 3px 0 0 var(--acce
<a class="nav-item active" href="/erp" style="text-decoration:none"><span>💰</span><span>ERP</span></a>
<a class="nav-item" href="/kpi" style="text-decoration:none"><span>📈</span><span>KPI</span></a>
<a class="nav-item" href="/audit" style="text-decoration:none"><span>📋</span><span>Audit</span></a>
<a class="nav-item" href="/sport/static/sport2.html" target="_blank" style="text-decoration:none"><span>🌐</span><span>Public portal</span></a>
<a class="nav-item" href="/static/sport2.html" target="_blank" style="text-decoration:none"><span>🌐</span><span>Public portal</span></a>
</aside>
<main class="main">
<div class="header">
@@ -698,7 +698,7 @@ async function loadPutni() {
function AUTH_HDR(extra) {
const h = Object.assign({}, extra || {});
let t = null;
try { t = localStorage.getItem('jwt') || sessionStorage.getItem('jwt'); } catch(e){}
try { t = localStorage.getItem('pgz_access') || sessionStorage.getItem('pgz_access') || localStorage.getItem('jwt') || sessionStorage.getItem('jwt'); } catch(e){}
if (!t) t = 'admin-pgz-2026';
h['Authorization'] = 'Bearer ' + t;
return h;
+2 -2
View File
@@ -23,8 +23,8 @@
.refresh { background: #4af; color: #fff; border: none; padding: 4px 12px; border-radius: 4px; cursor: pointer; }
body{padding:20px}
</style>
<link rel="stylesheet" href="/sport/static/shared/sidebar.css">
<script src="/sport/static/shared/sidebar.js" defer data-active="kpi"></script>
<link rel="stylesheet" href="/static/shared/sidebar.css">
<script src="/static/shared/sidebar.js" defer data-active="kpi"></script>
</head>
<body>
<h1>RINET KPI Dashboard <span class="updated" id="updated"></span> <button class="refresh" onclick="load()"></button></h1>
+2 -2
View File
@@ -310,8 +310,8 @@ body {
.cookie-actions button:hover { color: var(--text); border-color: var(--accent); }
.cookie a { color: var(--accent); text-decoration: none; }
</style>
<link rel="stylesheet" href="/sport/static/shared/sidebar.css">
<script src="/sport/static/shared/sidebar.js" defer data-active="login"></script>
<link rel="stylesheet" href="/static/shared/sidebar.css">
<script src="/static/shared/sidebar.js" defer data-active="login"></script>
</head>
<body>
+32 -32
View File
@@ -3,7 +3,7 @@
* Reference: app.rinet.one/klasik/dabi
*
* Usage:
* <link rel="stylesheet" href="/sport/static/shared/sidebar.css">
* <link rel="stylesheet" href="/static/shared/sidebar.css">
* <script src="/sport/static/shared/sidebar.js" defer
* data-active="dashboard" // active item id
* data-portal="portal"></script> // active portal hint (optional)
@@ -16,47 +16,47 @@
// Sectioned menu (DABI-style).
// href can be:
// "/sport/<page>" → cross-portal navigation (full page load)
// "/<page>" → cross-portal navigation (full page load)
// "/sport/<page>#<hash>" → cross-portal + intent on that page
// "#<id>" → in-page anchor (handled by host page on hashchange)
const SIDEBAR_SECTIONS = [
{title:'PORTAL', items: [
{id:'dashboard', ic:'\u{1F4CA}', label:'Dashboard', href:'/sport/static/sport2.html#dashboard'},
{id:'savezi', ic:'\u{1F3C5}', label:'Savezi', href:'/sport/static/sport2.html#savezi'},
{id:'klubovi', ic:'⬢', label:'Klubovi', href:'/sport/static/sport2.html#klubovi'},
{id:'sportasi', ic:'\u{1F464}', label:'Sportaši', href:'/sport/static/sport2.html#sportasi'},
{id:'manifestacije', ic:'\u{1F4C5}', label:'Manifestacije', href:'/sport/static/sport2.html#manifestacije'}
{id:'dashboard', ic:'\u{1F4CA}', label:'Dashboard', href:'/static/sport2.html#dashboard'},
{id:'savezi', ic:'\u{1F3C5}', label:'Savezi', href:'/static/sport2.html#savezi'},
{id:'klubovi', ic:'⬢', label:'Klubovi', href:'/static/sport2.html#klubovi'},
{id:'sportasi', ic:'\u{1F464}', label:'Sportaši', href:'/static/sport2.html#sportasi'},
{id:'manifestacije', ic:'\u{1F4C5}', label:'Manifestacije', href:'/static/sport2.html#manifestacije'}
]},
{title:'OPERATIVA', items: [
{id:'profil', ic:'\u{1F464}', label:'Moj profil', href:'/sport/app#profil'},
{id:'app', ic:'\u{1F4F1}', label:'Aplikacija', href:'/sport/app'},
{id:'kalendar', ic:'\u{1F4C5}', label:'Kalendar', href:'/sport/app#kalendar'},
{id:'notif', ic:'\u{1F514}', label:'Notifikacije', href:'/sport/app#notif'}
{id:'profil', ic:'\u{1F464}', label:'Moj profil', href:'/app#profil'},
{id:'app', ic:'\u{1F4F1}', label:'Aplikacija', href:'/app'},
{id:'kalendar', ic:'\u{1F4C5}', label:'Kalendar', href:'/app#kalendar'},
{id:'notif', ic:'\u{1F514}', label:'Notifikacije', href:'/app#notif'}
]},
{title:'CRM', items: [
{id:'clanarine', ic:'\u{1F4B3}', label:'Članarine', href:'/sport/crm#clanarine'},
{id:'lijecnicki',ic:'⚕', label:'Liječnički', href:'/sport/crm#lijecnicki'},
{id:'obrasci', ic:'\u{1F4CB}', label:'Obrasci', href:'/sport/crm#obrasci'},
{id:'dokumenti', ic:'\u{1F4C4}', label:'Dokumenti', href:'/sport/crm#dokumenti'}
{id:'clanarine', ic:'\u{1F4B3}', label:'Članarine', href:'/crm#clanarine'},
{id:'lijecnicki',ic:'⚕', label:'Liječnički', href:'/crm#lijecnicki'},
{id:'obrasci', ic:'\u{1F4CB}', label:'Obrasci', href:'/crm#obrasci'},
{id:'dokumenti', ic:'\u{1F4C4}', label:'Dokumenti', href:'/crm#dokumenti'}
]},
{title:'ERP', items: [
{id:'racuni', ic:'\u{1F9FE}', label:'Računi (OCR)', href:'/sport/erp#racuni'},
{id:'putni', ic:'✈', label:'Putni nalozi', href:'/sport/erp#putni'},
{id:'placanja', ic:'\u{1F4B0}', label:'Plaćanja', href:'/sport/erp#placanja'},
{id:'xlsx', ic:'\u{1F4C8}', label:'XLSX export', href:'/sport/erp#xlsx'}
{id:'racuni', ic:'\u{1F9FE}', label:'Računi (OCR)', href:'/erp#racuni'},
{id:'putni', ic:'✈', label:'Putni nalozi', href:'/erp#putni'},
{id:'placanja', ic:'\u{1F4B0}', label:'Plaćanja', href:'/erp#placanja'},
{id:'xlsx', ic:'\u{1F4C8}', label:'XLSX export', href:'/erp#xlsx'}
]},
{title:'ANALITIKA', items: [
{id:'kpi', ic:'\u{1F4C8}', label:'KPI Dashboard', href:'/sport/kpi'},
{id:'financije', ic:'€', label:'Financije', href:'/sport/static/sport2.html#financije'},
{id:'mreza', ic:'\u{1F578}', label:'Mreža (graf)', href:'/sport/static/sport2.html#mreza'},
{id:'forenzika', ic:'⚠', label:'Forenzika', href:'/sport/static/sport2.html#forenzika'},
{id:'audit', ic:'\u{1F512}', label:'Audit log', href:'/sport/audit'}
{id:'kpi', ic:'\u{1F4C8}', label:'KPI Dashboard', href:'/kpi'},
{id:'financije', ic:'€', label:'Financije', href:'/static/sport2.html#financije'},
{id:'mreza', ic:'\u{1F578}', label:'Mreža (graf)', href:'/static/sport2.html#mreza'},
{id:'forenzika', ic:'⚠', label:'Forenzika', href:'/static/sport2.html#forenzika'},
{id:'audit', ic:'\u{1F512}', label:'Audit log', href:'/audit'}
]},
{title:'ADMIN', requireRole:['pgz_admin','super_admin'], items: [
{id:'korisnici', ic:'\u{1F465}', label:'Korisnici', href:'/sport/admin#korisnici'},
{id:'tenanti', ic:'\u{1F3E2}', label:'Tenanti', href:'/sport/admin#tenanti'},
{id:'sigurnost', ic:'\u{1F6E1}', label:'Sigurnost', href:'/sport/admin#sigurnost'},
{id:'sustav', ic:'⚙', label:'Sustav', href:'/sport/admin#sustav'}
{id:'korisnici', ic:'\u{1F465}', label:'Korisnici', href:'/admin#korisnici'},
{id:'tenanti', ic:'\u{1F3E2}', label:'Tenanti', href:'/admin#tenanti'},
{id:'sigurnost', ic:'\u{1F6E1}', label:'Sigurnost', href:'/admin#sigurnost'},
{id:'sustav', ic:'⚙', label:'Sustav', href:'/admin#sustav'}
]}
];
@@ -144,11 +144,11 @@
</div>
<div class="caret">▾</div>
<div class="pgz-user-menu" id="pgz-user-menu" onclick="event.stopPropagation()">
<a href="/sport/app#profil"><span>👤</span><span>Moj profil</span></a>
<a href="/sport/app#postavke"><span>⚙</span><span>Postavke</span></a>
<a href="/sport/static/sport2.html"><span>🌐</span><span>Public portal</span></a>
<a href="/app#profil"><span>👤</span><span>Moj profil</span></a>
<a href="/app#postavke"><span>⚙</span><span>Postavke</span></a>
<a href="/static/sport2.html"><span>🌐</span><span>Public portal</span></a>
<div class="sep"></div>
<a href="/sport/login" id="pgz-menu-login"><span>🔑</span><span>Prijava</span></a>
<a href="/login" id="pgz-menu-login"><span>🔑</span><span>Prijava</span></a>
<a class="danger" id="pgz-menu-logout" onclick="PGZSidebar.logout()" style="display:none"><span>⎋</span><span>Odjava</span></a>
</div>
</div>