Files
Pivot/templates/ipblokkade.html
T
2026-10-14 13:34:37 +02:00

366 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}IP-blokkades{% endblock %}
{% block page_title %}IP-blokkades{% endblock %}
{% block extra_css %}
<style>
.ip-form { display: grid; grid-template-columns: 1.2fr 1fr 2fr auto; gap: 10px; align-items: end; }
.ip-form.met-school { grid-template-columns: 1.2fr 1fr 1.4fr 2fr auto; }
.ip-form .field { margin: 0; }
.ip-form .school-veld[hidden] { display: none; }
/* Keuzelijst met vinkjes voor één of meer scholen */
.schoolkiezer { position: relative; }
.schoolkiezer summary { list-style: none; cursor: pointer; padding: 9px 30px 9px 12px; border: 1px solid var(--border);
border-radius: var(--radius); background: var(--surface, #fff); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
position: relative; min-height: 20px; }
.schoolkiezer summary::-webkit-details-marker { display: none; }
.schoolkiezer summary::after { content: "▾"; position: absolute; right: 11px; top: 50%; transform: translateY(-50%); opacity: .6; }
.schoolkiezer[open] summary { border-color: var(--primary-color); }
.schoolkiezer .sk-lijst { position: absolute; z-index: 30; left: 0; top: calc(100% + 4px); min-width: 100%; width: max-content;
max-width: 380px; max-height: 300px; overflow: auto; background: var(--surface, #fff); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: 0 8px 24px rgba(15,23,42,.14); padding: 6px; }
.schoolkiezer .sk-zoek { width: 100%; margin-bottom: 6px; }
.schoolkiezer label { display: flex; gap: 8px; align-items: center; padding: 6px 8px; border-radius: 6px; cursor: pointer;
font-weight: 400; margin: 0; white-space: nowrap; }
.schoolkiezer label:hover { background: var(--surface-muted); }
.schoolkiezer label input { width: auto; margin: 0; }
.schoolkiezer .sk-acties { display: flex; justify-content: space-between; gap: 8px; padding: 6px 4px 2px; border-top: 1px solid var(--border); margin-top: 4px; }
.schoolkiezer .sk-acties button { all: unset; cursor: pointer; color: var(--primary-color); font-size: .88em; }
.chip { display: inline-block; padding: 2px 9px; margin: 2px 4px 2px 0; border-radius: 999px; background: var(--surface-muted);
border: 1px solid var(--border); font-size: .88em; white-space: nowrap; }
/* Bewerken in de tabel "Altijd toestaan" */
tr .bew { display: none; }
tr.bewerken .bew { display: block; }
tr.bewerken .toon { display: none; }
tr.bewerken td { background: var(--surface-muted); vertical-align: top; }
tr.bewerken td.actions .bew { display: flex; flex-direction: column; gap: 6px; align-items: stretch; }
tr.bewerken td.actions .bew button { width: 100%; white-space: nowrap; }
tr .bew input[type=text] { width: 100%; min-width: 120px; }
tr.bewerken .schoolkiezer { min-width: 200px; max-width: 280px; }
tr.bewerken .schoolkiezer label { white-space: normal; }
tr.bewerken .schoolkiezer .sk-lijst { position: static; box-shadow: none; max-width: none; width: auto; margin-top: 4px; }
.ip-tabel[data-sleutel="toe"] tbody tr:not(.bewerken):not(.empty-row) td:not(.actions) { cursor: text; }
.acties-rij { display: inline-flex; gap: 6px; }
@media (max-width: 760px) { .ip-form { grid-template-columns: 1fr; } }
.mijn-ip { display: inline-flex; gap: 8px; align-items: center; padding: 6px 12px; border-radius: 999px;
background: var(--surface-muted); border: 1px solid var(--border); font-size: .92em; }
td.mono, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.test-form { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.test-form input { flex: 1 1 220px; max-width: 320px; }
.modus { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; justify-content: space-between;
padding: 14px 16px; border: 1px solid var(--border); border-radius: var(--radius); margin-top: 16px; }
.modus.aan { border-color: var(--danger-text); background: var(--danger-bg); }
.modus strong { display: block; }
.schakelaar { display: inline-flex; align-items: center; gap: 10px; cursor: pointer; font-weight: 600; }
.schakelaar .spoor { width: 44px; height: 24px; border-radius: 999px; background: #cbd5e1; position: relative; transition: background .15s; flex-shrink: 0; }
.schakelaar .spoor::after { content: ""; position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%;
background: #fff; transition: transform .15s; box-shadow: 0 1px 2px rgba(0,0,0,.25); }
.schakelaar.aan .spoor { background: var(--danger-text); }
.schakelaar.aan .spoor::after { transform: translateX(20px); }
.schakelaar button { all: unset; display: inline-flex; align-items: center; gap: 10px; cursor: pointer; }
.schakelaar button:focus-visible .spoor { outline: 2px solid var(--primary-color); outline-offset: 2px; }
.tabel-zoek { margin-bottom: 10px; max-width: 360px; }
th.sorteer { cursor: pointer; user-select: none; white-space: nowrap; }
th.sorteer:hover { color: var(--primary-color); }
th.sorteer .pijl::after { content: "↕"; opacity: .35; margin-left: 2px; }
th.sorteer.op .pijl::after { content: "▲"; opacity: 1; }
th.sorteer.af .pijl::after { content: "▼"; opacity: 1; }
.tabel-voet { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px; margin-top: 12px; }
.paginering { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
</style>
{% endblock %}
{% macro schoolkiezer(gekozen=[], formid='') %}
<details class="schoolkiezer">
<summary class="sk-tekst" data-leeg="— geen school —">— geen school —</summary>
<div class="sk-lijst">
{% if scholen|length > 8 %}<input type="search" class="sk-zoek" placeholder="Zoek een school..." aria-label="Zoek een school">{% endif %}
{% for s in scholen %}
<label><input type="checkbox" name="scholen" value="{{ s.code }}" data-naam="{{ s.naam }}"{% if formid %} form="{{ formid }}"{% endif %}{{ ' checked' if s.code in gekozen }}>
{{ s.naam }}{% if not s.actief %} <span class="muted small">(inactief)</span>{% endif %}</label>
{% else %}
<div class="muted small" style="padding:6px 8px">Er zijn nog geen scholen.</div>
{% endfor %}
{% if scholen %}<div class="sk-acties"><button type="button" class="sk-alles">Alles aanvinken</button><button type="button" class="sk-geen">Niets</button></div>{% endif %}
</div>
</details>
{% endmacro %}
{% block content %}
<div class="card">
<div class="page-head">
<h2>🚫 IP-blokkades</h2>
<span class="mijn-ip">Jouw IP-adres: <strong class="mono">{{ mijn_ip }}</strong></span>
</div>
<p class="field-hint" style="margin-top:-6px">Geldt voor alle scholen. Een geblokkeerd adres krijgt bij elke aanvraag
"Toegang geweigerd", ook op de loginpagina. Een adres bij <em>Altijd toestaan</em> mag altijd, ook als het in een
geblokkeerde reeks valt.</p>
<form method="POST" action="{{ url_for('ipblokkade.toevoegen') }}" class="ip-form">
<div class="field">
<label for="reeks">IP-adres of reeks</label>
<input type="text" id="reeks" name="reeks" required class="mono" placeholder="bv. 85. of 85.12.3.4 of 85.0.0.0/8">
</div>
<div class="field">
<label for="soort">Soort</label>
<select id="soort" name="soort">
<option value="blokkeren">🚫 Blokkeren</option>
<option value="toestaan">✅ Altijd toestaan</option>
</select>
</div>
<div class="field school-veld" hidden>
<label>School(s)</label>
{{ schoolkiezer() }}
</div>
<div class="field">
<label for="opmerking">Opmerking</label>
<input type="text" id="opmerking" name="opmerking" maxlength="255" placeholder="Waarom? (optioneel)">
</div>
<button type="submit">➕ Toevoegen</button>
</form>
{% if lokaal %}
<div class="alert alert-warning" style="margin:16px 0 0">PIVOT ziet jouw adres als <strong class="mono">{{ mijn_ip }}</strong>.
Waarschijnlijk draait PIVOT achter een reverse proxy, en dan lijkt elke bezoeker van dat adres te komen. Zet in
<code>config.ini › [algemeen] proxy_aantal = 1</code> en herstart PIVOT, anders werken de IP-regels niet zoals bedoeld.</div>
{% endif %}
<div class="modus {{ 'aan' if enkel_toegestaan }}">
<div>
<strong>Enkel "Altijd toestaan" binnenlaten</strong>
<span class="muted small">
{% if enkel_toegestaan %}Staat <b>aan</b>: elk adres dat niet bij Altijd toestaan staat, wordt geweigerd
({{ modus_geweigerd }} keer sinds de teller begon){% if modus and modus.Door %} · aangezet door {{ modus.Door }}{% endif %}.
{% else %}Staat uit: iedereen mag binnen, behalve de geblokkeerde adressen.{% endif %}
</span>
</div>
<form method="POST" action="{{ url_for('ipblokkade.modus') }}" style="margin:0"
onsubmit="return confirm({{ ("Iedereen weer binnenlaten (behalve de geblokkeerde adressen)?" if enkel_toegestaan else "Enkel nog adressen uit 'Altijd toestaan' binnenlaten? Iedereen anders krijgt 'Toegang geweigerd', ook op de loginpagina.")|tojson|forceescape }})">
<input type="hidden" name="aan" value="{{ '0' if enkel_toegestaan else '1' }}">
<span class="schakelaar {{ 'aan' if enkel_toegestaan }}">
<button type="submit" role="switch" aria-checked="{{ 'true' if enkel_toegestaan else 'false' }}">
<span class="spoor"></span> {{ 'Aan' if enkel_toegestaan else 'Uit' }}
</button>
</span>
</form>
</div>
</div>
{% for titel, lijst, leeg, sleutel in (('🚫 Geblokkeerd', blokkeren, 'Er is nog niets geblokkeerd.', 'blok'),
('✅ Altijd toestaan', toestaan, 'Geen uitzonderingen.', 'toe')) %}
<div class="card ip-tabel" data-sleutel="{{ sleutel }}">
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;">
<h2 class="card-title">{{ titel }} <span class="muted small">({{ lijst|length }})</span></h2>
{% if sleutel == 'blok' and lijst %}
<form method="post" action="{{ url_for('ipblokkade.leegmaken') }}" style="margin:0;"
onsubmit="return confirm({{ ('Alle ' ~ (lijst|length) ~ ' geblokkeerde regel' ~ ('s' if lijst|length != 1 else '') ~ ' verwijderen? De lijst \'Altijd toestaan\' blijft ongewijzigd.')|tojson|forceescape }})">
<button type="submit" class="btn-sm btn-danger">🗑️ Bloklijst leegmaken</button>
</form>
{% endif %}
</div>
{% if lijst|length > 10 %}<input type="search" class="tabel-zoek" placeholder="Zoek een reeks{{ ', school' if sleutel == 'toe' }} of opmerking..." aria-label="Zoeken in {{ titel }}">{% endif %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sorteer" data-tekst>Reeks <span class="pijl"></span></th>
{% if sleutel == 'toe' %}<th class="sorteer" data-tekst>School <span class="pijl"></span></th>{% endif %}
<th class="sorteer" data-tekst>Opmerking <span class="pijl"></span></th>
<th class="sorteer">Toegevoegd <span class="pijl"></span></th>
{% if lijst is sameas blokkeren %}<th class="sorteer text-right">Geweigerd <span class="pijl"></span></th>
<th class="sorteer">Laatst geweigerd <span class="pijl"></span></th>{% endif %}
<th class="actions"></th>
</tr>
</thead>
<tbody>
{% for r in lijst %}
{% set bf = 'bew-' ~ r.ID %}
<tr data-id="{{ r.ID }}">
{% if sleutel == 'toe' %}
<td class="mono" data-sort="{{ r.Sorteer }}"><span class="toon"><strong>{{ r.Reeks }}</strong></span>
<span class="bew"><input type="text" name="reeks" form="{{ bf }}" value="{{ r.Reeks }}" required class="mono" aria-label="Reeks"></span></td>
<td data-sort="{{ r.Scholen_namen|join(', ') }}"><span class="toon">{% for n in r.Scholen_namen %}<span class="chip">{{ n }}</span>{% else %}<span class="muted">–</span>{% endfor %}</span>
<span class="bew">{{ schoolkiezer(r.Scholen_codes, bf) }}</span></td>
<td data-sort="{{ r.Opmerking or '' }}"><span class="toon">{{ r.Opmerking or '' }}</span>
<span class="bew"><input type="text" name="opmerking" form="{{ bf }}" value="{{ r.Opmerking or '' }}" maxlength="255" placeholder="Opmerking" aria-label="Opmerking"></span></td>
{% else %}
<td class="mono" data-sort="{{ r.Sorteer }}"><strong>{{ r.Reeks }}</strong></td>
<td data-sort="{{ r.Opmerking or '' }}">{{ r.Opmerking or '' }}</td>
{% endif %}
<td class="muted small" data-sort="{{ r.Aangemaakt.isoformat() }}">{{ r.Aangemaakt.strftime('%d-%m-%Y %H:%M') }}{% if r.Door %} · {{ r.Door }}{% endif %}</td>
{% if lijst is sameas blokkeren %}
<td class="text-right" data-sort="{{ '%010d'|format(r.Aantal_geweigerd) }}">{{ r.Aantal_geweigerd }}</td>
<td class="muted small" data-sort="{{ r.Laatst_geweigerd.isoformat() if r.Laatst_geweigerd else '' }}">{{ r.Laatst_geweigerd.strftime('%d-%m-%Y %H:%M') if r.Laatst_geweigerd else '–' }}</td>
{% endif %}
<td class="actions">
<span class="toon acties-rij">
{% if sleutel == 'toe' %}<button type="button" class="btn-sm btn-ghost start-bew">✏️ Bewerken</button>{% endif %}
<form method="POST" action="{{ url_for('ipblokkade.verwijderen', regel_id=r.ID) }}" style="margin:0"
onsubmit="return confirm({{ (r.Reeks ~ ' uit de lijst verwijderen?')|tojson|forceescape }})">
<button type="submit" class="btn-sm btn-ghost">🗑️ Verwijderen</button>
</form>
</span>
{% if sleutel == 'toe' %}
<span class="bew">
<form id="{{ bf }}" method="POST" action="{{ url_for('ipblokkade.bewerken', regel_id=r.ID) }}" style="margin:0">
<button type="submit" class="btn-sm">💾 Opslaan</button>
</form>
<button type="button" class="btn-sm btn-light stop-bew">Annuleren</button>
</span>
{% endif %}
</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="6">{{ leeg }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if lijst %}
<div class="tabel-voet">
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
<div class="muted small teller"></div>
<span class="per-pagina-plek"></span>
</div>
<nav class="paginering" aria-label="Pagina's"></nav>
</div>
{% endif %}
</div>
{% endfor %}
<div class="card">
<h2 class="card-title">🔎 Een adres testen</h2>
<form method="GET" action="{{ url_for('ipblokkade.overzicht') }}" class="test-form">
<input type="text" name="test" class="mono" placeholder="bv. 85.12.3.4" value="{{ test.ip if test else '' }}">
<button type="submit" class="btn-secondary">Testen</button>
{% if test %}
{% if test.geweigerd %}
<span class="badge badge-danger">🚫 Geweigerd door {{ test.regel.Reeks if test.regel else 'een regel' }}</span>
{% else %}
<span class="badge badge-success">✅ Toegelaten</span>
{% endif %}
{% endif %}
</form>
</div>
{% endblock %}
{% block scripts %}
<script>
// Keuzelijst met vinkjes: tekst bovenaan = gekozen scholen
function skTekst(sk) {
const namen = Array.from(sk.querySelectorAll('input[type=checkbox]:checked')).map(c => c.dataset.naam);
const t = sk.querySelector('.sk-tekst');
t.textContent = !namen.length ? t.dataset.leeg : namen.length <= 2 ? namen.join(', ') : `${namen.length} scholen`;
t.title = namen.join('\n');
}
document.querySelectorAll('.schoolkiezer').forEach(sk => {
skTekst(sk);
sk.addEventListener('change', () => skTekst(sk));
const zoek = sk.querySelector('.sk-zoek');
if (zoek) zoek.addEventListener('keydown', e => { if (e.key === 'Enter') e.preventDefault(); });
if (zoek) zoek.addEventListener('input', () => {
const q = zoek.value.trim().toLowerCase();
sk.querySelectorAll('label').forEach(l => l.style.display = l.textContent.toLowerCase().includes(q) ? '' : 'none');
});
sk.querySelector('.sk-alles')?.addEventListener('click', () => {
sk.querySelectorAll('label').forEach(l => { if (l.style.display !== 'none') l.querySelector('input').checked = true; });
skTekst(sk);
});
sk.querySelector('.sk-geen')?.addEventListener('click', () => {
sk.querySelectorAll('input[type=checkbox]').forEach(c => c.checked = false); skTekst(sk);
});
sk.addEventListener('toggle', () => { if (sk.open && zoek) zoek.focus(); });
});
// Klik buiten een open keuzelijst = dichtklappen
document.addEventListener('click', e => {
document.querySelectorAll('.schoolkiezer[open]').forEach(sk => { if (!sk.contains(e.target)) sk.open = false; });
});
// Tabel "Altijd toestaan" bewerken: ✏️ of dubbelklik op een rij; Esc = annuleren, Enter = opslaan
function stopBewerken(tr) {
const f = document.getElementById('bew-' + tr.dataset.id);
if (f) f.reset();
tr.classList.remove('bewerken');
tr.querySelectorAll('.schoolkiezer').forEach(sk => { sk.open = false; skTekst(sk); });
}
function startBewerken(tr) {
document.querySelectorAll('tr.bewerken').forEach(stopBewerken);
tr.classList.add('bewerken');
const veld = tr.querySelector('.bew input[name=reeks]');
if (veld) { veld.focus(); veld.select(); }
}
document.querySelectorAll('.ip-tabel[data-sleutel="toe"] tbody tr[data-id]').forEach(tr => {
tr.querySelector('.start-bew')?.addEventListener('click', () => startBewerken(tr));
tr.querySelector('.stop-bew')?.addEventListener('click', () => stopBewerken(tr));
tr.addEventListener('dblclick', e => {
if (!tr.classList.contains('bewerken') && !e.target.closest('td.actions')) startBewerken(tr);
});
tr.addEventListener('keydown', e => { if (e.key === 'Escape') stopBewerken(tr); });
});
// Schoolkeuze enkel tonen bij "Altijd toestaan"
(function () {
const soort = document.getElementById('soort'), veld = document.querySelector('.school-veld');
if (!soort || !veld) return;
const pas = () => {
const toe = soort.value === 'toestaan';
veld.hidden = !toe;
veld.closest('form').classList.toggle('met-school', toe);
if (!toe) veld.querySelectorAll('input[type=checkbox]').forEach(c => c.checked = false);
veld.querySelectorAll('.schoolkiezer').forEach(skTekst);
};
soort.addEventListener('change', pas); pas();
})();
// Zoeken en pagineren per tabel (Geblokkeerd / Altijd toestaan)
document.querySelectorAll('.ip-tabel').forEach(kaart => {
const body = kaart.querySelector('tbody');
if (!body || body.querySelector('.empty-row')) return;
const sleutel = 'ip-' + kaart.dataset.sleutel;
let PER_PAGINA = perPagina(sleutel), pagina = 1, zoek = '';
const zoekveld = kaart.querySelector('.tabel-zoek');
function toon() {
const tekst = tr => Array.from(tr.cells).map(td => (td.querySelector('.toon') || td).textContent).join(' ').toLowerCase();
const rijen = Array.from(body.rows).filter(tr => !zoek || tekst(tr).includes(zoek));
const grootte = PER_PAGINA || Infinity, paginas = Math.max(1, Math.ceil(rijen.length / grootte));
pagina = Math.min(Math.max(1, pagina), paginas);
const van = PER_PAGINA ? (pagina - 1) * PER_PAGINA : 0, tot = van + grootte;
Array.from(body.rows).forEach(tr => tr.style.display = 'none');
rijen.forEach((tr, i) => tr.style.display = (i >= van && i < tot) ? '' : 'none');
kaart.querySelector('.teller').textContent = rijen.length
? `${van + 1}–${Math.min(tot, rijen.length)} van ${rijen.length}` : 'Niets gevonden';
const nav = kaart.querySelector('.paginering');
nav.innerHTML = '';
if (paginas <= 1) return;
const knop = (label, p, actief) => {
const b = document.createElement('button');
b.type = 'button'; b.textContent = label; b.className = 'btn-sm ' + (actief ? 'btn-secondary' : 'btn-light');
b.onclick = () => { pagina = p; toon(); };
nav.appendChild(b);
};
if (pagina > 1) { knop('«', 1); knop('‹ Vorige', pagina - 1); }
for (let p = Math.max(1, pagina - 2); p <= Math.min(paginas, pagina + 2); p++) knop(String(p), p, p === pagina);
if (pagina < paginas) { knop('Volgende ›', pagina + 1); knop('»', paginas); }
}
if (zoekveld) zoekveld.addEventListener('input', () => { zoek = zoekveld.value.trim().toLowerCase(); pagina = 1; toon(); });
// Sorteren: klik op een kolomkop (nog eens klikken = omgekeerd). Aantallen en datums: eerst hoog/nieuw.
let kol = null, op = true;
kaart.querySelectorAll('th.sorteer').forEach(th => th.addEventListener('click', () => {
const k = th.cellIndex;
op = kol === k ? !op : 'tekst' in th.dataset;
kol = k;
kaart.querySelectorAll('th.sorteer').forEach(x => x.classList.remove('op', 'af'));
th.classList.add(op ? 'op' : 'af');
Array.from(body.rows).sort((a, b) => {
const x = a.cells[k].dataset.sort || '', y = b.cells[k].dataset.sort || '';
if (!x !== !y) return x ? -1 : 1; // lege waarden altijd onderaan
const c = x.localeCompare(y, 'nl', { numeric: true, sensitivity: 'base' });
return op ? c : -c;
}).forEach(tr => body.appendChild(tr));
pagina = 1; toon();
}));
perPaginaKiezer(kaart.querySelector('.per-pagina-plek'), sleutel, n => { PER_PAGINA = n; pagina = 1; toon(); });
toon();
});
</script>
{% endblock %}