PIVOT 1.1.0

This commit is contained in:
marijn committed 2026-10-14 13:34:37 +02:00
commit 8a53234eb6
161 files changed
+31109

No files matched your search

+354
View File
@@ -0,0 +1,354 @@
{% extends "base.html" %}
{% block title %}2FA-hulp{% endblock %}
{% block page_title %}2FA-hulp{% endblock %}
{% block main_class %}{{ '' if tab == 'lijst' else 'medium' }}{% endblock %}
{% block extra_css %}
<style>
.user-head { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin-bottom: 4px; }
.avatar { width: 46px; height: 46px; border-radius: 50%; display: grid; place-items: center; flex-shrink: 0;
background: var(--primary-light); color: var(--primary-color); font-weight: 800; font-size: 18px; }
.user-head .who { flex: 1; min-width: 220px; }
.user-head .who strong { display: block; font-size: 1.1rem; }
.badges { display: flex; flex-wrap: wrap; gap: 6px; }
.actions-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; margin-top: 18px; }
@media (max-width: 760px) { .actions-grid { grid-template-columns: 1fr; } }
.action-box { border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; background: var(--surface-muted); }
.action-box h3 { margin: 0 0 4px; font-size: 1rem; }
.action-box p { font-size: 14px; color: var(--text-muted); margin: 0 0 14px; }
.action-box .btn-row { display: flex; flex-wrap: wrap; gap: 8px; }
.code-panel { margin-top: 18px; border: 2px solid var(--success); border-radius: var(--radius-lg); background: var(--success-bg); padding: 20px; text-align: center; }
.big-code { font-family: ui-monospace, Consolas, monospace; font-size: 40px; font-weight: 800; letter-spacing: 6px; color: var(--success-text); margin: 6px 0 4px; }
.code-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 6px; margin-top: 12px; }
.code-grid span { font-family: ui-monospace, Consolas, monospace; background: #fff; border: 1px solid var(--border); border-radius: 6px; padding: 6px; font-size: 15px; }
details summary { cursor: pointer; color: var(--text-muted); font-size: 14px; margin-top: 12px; }
.exception-active { margin-top: 14px; }
.pick-list { list-style: none; margin: 10px 0 0; padding: 0; }
.pick-list a { display: flex; justify-content: space-between; gap: 12px; padding: 10px 12px; border-radius: var(--radius-sm); text-decoration: none; color: var(--text); }
.pick-list a:hover { background: var(--primary-light); }
.pick-list .ou { color: var(--text-muted); font-size: 13px; }
.setup-hint { font-size: 13px; }
/* Afdrukken: enkel het codebriefje */
@media print {
body.print-slip * { visibility: hidden; }
body.print-slip #codeSlip, body.print-slip #codeSlip * { visibility: visible; }
body.print-slip #codeSlip { position: absolute; left: 0; top: 0; width: 100%; border: 1px dashed #999; background: #fff; }
body.print-slip #codeSlip .no-print { display: none; }
}
</style>
{% endblock %}
{% block content %}
<div class="tabs">
<a class="tab-link {{ 'active' if tab != 'lijst' else '' }}" href="{{ url_for('tweestaps.overzicht') }}" style="text-decoration:none">🆘 Hulp bij inloggen</a>
<a class="tab-link {{ 'active' if tab == 'lijst' else '' }}" href="{{ url_for('tweestaps.overzicht', tab='lijst') }}" style="text-decoration:none" onclick="showLoader('Gebruikers ophalen bij Google...')">⚠️ Nog geen 2FA ingesteld</a>
</div>
{% if tab != 'lijst' %}
<section class="tab-content card active">
{# ---------- Zoeken ---------- #}
<form class="search-bar" method="GET" action="{{ url_for('tweestaps.overzicht') }}" onsubmit="showLoader()" style="margin:0">
<input type="text" name="zoek" placeholder="Naam of e-mailadres van de leerkracht" value="{{ zoek or (gebruiker.email if gebruiker else '') }}" autofocus>
<button type="submit">🔍 Zoeken</button>
</form>
{% if zoekresultaten %}
<ul class="pick-list">
{% for u in zoekresultaten %}
<li><a href="{{ url_for('tweestaps.overzicht', email=u.email) }}" onclick="showLoader()">
<span><strong>{{ u.naam }}</strong> · {{ u.email }}</span><span class="ou">{{ u.ou }}</span></a></li>
{% endfor %}
</ul>
{% endif %}
{% if fout %}<div class="alert alert-danger" style="margin-top:14px">{{ fout }}</div>{% endif %}
{# ---------- Gekozen gebruiker ---------- #}
{% if gebruiker %}
<div style="margin-top: 22px; padding-top: 20px; border-top: 1px solid var(--border);">
<div class="user-head">
<span class="avatar">{{ (gebruiker.naam or gebruiker.email)[:1]|upper }}</span>
<div class="who">
<strong>{{ gebruiker.naam }}</strong>
<span class="muted">{{ gebruiker.email }} · {{ gebruiker.ou }}</span>
</div>
<div class="badges">
{% if gebruiker.ingesteld %}<span class="badge badge-success">✓ 2FA ingesteld</span>
{% else %}<span class="badge badge-warning">2FA niet ingesteld</span>{% endif %}
{% if uitzondering %}<span class="badge badge-info">⏳ Uitzondering tot {{ uitzondering.Tot.strftime('%H:%M') }}</span>
{% elif gebruiker.afgedwongen %}<span class="badge">Verplicht</span>{% endif %}
{% if gebruiker.geschorst %}<span class="badge badge-danger">Geschorst</span>{% endif %}
</div>
</div>
{# ---------- Codes tonen ---------- #}
{% if codes %}
<div class="code-panel" id="codeSlip">
<div class="muted small">Back-upcode voor <strong>{{ gebruiker.email }}</strong></div>
<div class="big-code">{{ codes[0][:4] }} {{ codes[0][4:] }}</div>
<div class="small">Typ deze code bij "Probeer een andere manier" › "Voer een van je back-upcodes van 8 cijfers in". Elke code werkt maar één keer.</div>
<details class="no-print">
<summary>Alle {{ codes|length }} geldige codes tonen</summary>
<div class="code-grid">{% for c in codes %}<span>{{ c[:4] }} {{ c[4:] }}</span>{% endfor %}</div>
</details>
<div class="btn-row no-print" style="justify-content:center; margin-top:14px">
<button type="button" class="btn-light btn-sm" onclick="printSlip()">🖨️ Afdrukken</button>
</div>
{% if code_actie == 'nieuw' %}<div class="small muted no-print" style="margin-top:8px">Er is een nieuwe set aangemaakt; oudere codes van deze gebruiker werken niet meer.</div>{% endif %}
</div>
{% endif %}
<div class="actions-grid">
{% if mag('tweestaps_codes') %}
<div class="action-box">
<h3>📱 Telefoon vergeten</h3>
<p>Geef een back-upcode. 2FA blijft gewoon aan; de code werkt één keer.</p>
<form method="POST" action="{{ url_for('tweestaps.codes') }}" class="btn-row" onsubmit="showLoader()">
<input type="hidden" name="email" value="{{ gebruiker.email }}">
<button type="submit" name="actie" value="tonen">🔑 Back-upcode geven</button>
<button type="submit" name="actie" value="nieuw" class="btn-light"
onclick="return confirm('Een nieuwe set maken? Alle oudere back-upcodes van deze gebruiker werken dan niet meer.')">Nieuwe set</button>
<button type="submit" name="actie" value="ongeldig" class="btn-ghost"
onclick="return confirm('Alle back-upcodes van deze gebruiker ongeldig maken?')">Codes intrekken</button>
</form>
</div>
{% endif %}
{% if mag('tweestaps_uitzetten') and reset_wacht %}
<div class="action-box">
<h3>⏳ 2FA-reset wacht op Google</h3>
<p>Gevraagd om {{ reset_wacht.Gevraagd_op.strftime('%H:%M') }} door {{ reset_wacht.Door or '?' }}.
{{ gebruiker.email }} staat al in de uitzonderingsgroep, maar Google past dat pas na enkele minuten toe.
PIVOT probeert elke minuut opnieuw (al {{ reset_wacht.Pogingen }} poging(en)), tot {{ reset_wacht.Tot.strftime('%H:%M') }}.
Je hoeft niets te doen.</p>
<p>Google meldt nu: 2FA afgedwongen voor deze gebruiker: <strong>{{ 'ja (uitzondering nog niet toegepast)' if gebruiker.afgedwongen else 'nee, de reset volgt bij de volgende poging' }}</strong>.
{% if reset_wacht.Laatste_fout %}<br><span class="muted">Laatste antwoord van Google: {{ reset_wacht.Laatste_fout }}</span>{% endif %}</p>
<p class="field-hint">Moet de gebruiker nu meteen binnen? Toon hierboven een <strong>back-upcode</strong>: daarmee logt hij in en stelt hij zijn nieuwe telefoon in via myaccount.google.com › Beveiliging › Authenticatie in 2 stappen. Dat werkt meteen, ook als 2FA afgedwongen wordt.</p>
<a class="btn-light" href="{{ url_for('tweestaps.overzicht', email=gebruiker.email) }}">🔄 Status vernieuwen</a>
</div>
{% elif mag('tweestaps_uitzetten') and gebruiker.ingesteld %}
<div class="action-box">
<h3>📵 Nieuwe telefoon: 2FA resetten</h3>
<p class="field-hint">💡 Snelste weg bij een nieuwe telefoon: geef de gebruiker een <strong>back-upcode</strong> (hierboven). Hij logt daarmee in en koppelt zelf zijn nieuwe telefoon. Resetten is pas nodig als dat niet lukt.</p>
<p>Zet 2FA uit zodat de gebruiker het opnieuw kan instellen met een nieuw toestel. Oude back-upcodes en het oude toestel werken daarna niet meer.
{% if gebruiker.afgedwongen %}Omdat 2FA verplicht is, krijgt hij ook een tijdelijke uitzondering tot vandaag {{ einde.strftime('%H:%M') }} om opnieuw in te stellen.{% endif %}</p>
<form method="POST" action="{{ url_for('tweestaps.reset') }}" onsubmit="return confirm('2FA resetten voor {{ gebruiker.email }}? De gebruiker moet 2FA daarna opnieuw instellen.') && (showLoader(), true)">
<input type="hidden" name="email" value="{{ gebruiker.email }}">
<div class="field" style="margin-bottom:10px">
<input type="text" name="reden" maxlength="255" placeholder="Reden (optioneel), bv. nieuwe telefoon">
</div>
<button type="submit" class="btn-danger">📵 2FA resetten</button>
</form>
</div>
{% endif %}
{% if mag('tweestaps_uitzetten') %}
<div class="action-box">
<h3>⏳ Tijdelijk zonder 2FA</h3>
{% if uitzondering %}
<p>Deze gebruiker kan tot <strong>{{ uitzondering.Tot.strftime('%d-%m %H:%M') }}</strong> zonder 2FA inloggen. Daarna wordt 2FA automatisch terug verplicht.</p>
<div class="btn-row">
{% if uitzondering.Tot < einde %}
<form method="POST" action="{{ url_for('tweestaps.uitzondering') }}" style="margin:0" onsubmit="showLoader()">
<input type="hidden" name="email" value="{{ gebruiker.email }}">
<button type="submit" class="btn-light">Verlengen tot {{ einde.strftime('%H:%M') }}</button>
</form>
{% endif %}
<form method="POST" action="{{ url_for('tweestaps.stop_uitzondering', uitz_id=uitzondering.ID) }}" style="margin:0" onsubmit="showLoader()">
<input type="hidden" name="terug" value="{{ gebruiker.email }}">
<button type="submit" class="btn-danger">Nu beëindigen</button>
</form>
</div>
{% elif not groep %}
<p>Nog niet ingesteld: vul de <strong>Uitzonderingsgroep</strong> in bij
{% if mag('koppelingen') %}<a href="{{ url_for('scholen.mijn_school') }}">🔌 Koppelingen van deze school</a>{% else %}🔌 Koppelingen van deze school{% endif %}
› 🛡️ 2FA-hulp (een Google-groep waarvoor 2FA niet afgedwongen wordt).</p>
{% else %}
<p>Voor wie 2FA nog moet instellen, of als een back-upcode niet lukt. De uitzondering loopt tot
<strong>vandaag {{ einde.strftime('%H:%M') }}</strong>; daarna zet PIVOT 2FA automatisch terug aan.</p>
<form method="POST" action="{{ url_for('tweestaps.uitzondering') }}" onsubmit="showLoader()">
<input type="hidden" name="email" value="{{ gebruiker.email }}">
<div class="field" style="margin-bottom:10px">
<input type="text" name="reden" maxlength="255" placeholder="Reden (optioneel), bv. nieuwe telefoon">
</div>
<button type="submit" class="btn-warning">⏳ Uitzondering geven tot {{ einde.strftime('%H:%M') }}</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endif %}
</section>
{# ---------- Lopende uitzonderingen ---------- #}
<div class="card">
<h3 class="card-title">⏳ Lopende uitzonderingen</h3>
{% if actief %}
<div class="table-wrap">
<table>
<thead><tr><th>Gebruiker</th><th>Tot</th><th>Gegeven door</th><th>Reden</th><th class="actions">Actie</th></tr></thead>
<tbody>
{% for u in actief %}
<tr>
<td><a href="{{ url_for('tweestaps.overzicht', email=u.Email) }}"><strong>{{ u.Naam or u.Email }}</strong></a><div class="muted small">{{ u.Email }}</div></td>
<td class="nowrap">
{{ u.Tot.strftime('%H:%M') if u.Tot.date() == nu.date() else u.Tot.strftime('%d-%m %H:%M') }}
{% if u.Tot <= nu %}<span class="badge badge-warning">wordt beëindigd</span>{% endif %}
</td>
<td>{{ u.Door or '?' }}</td>
<td class="muted">{{ u.Reden or '' }}</td>
<td class="actions">
{% if mag('tweestaps_uitzetten') %}
<form method="POST" action="{{ url_for('tweestaps.stop_uitzondering', uitz_id=u.ID) }}" onsubmit="showLoader()">
<button type="submit" class="btn-sm btn-danger">Nu beëindigen</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="muted" style="margin:0">Geen lopende uitzonderingen. Iedereen moet 2FA gebruiken.</p>
{% endif %}
</div>
{# ---------- Logboek ---------- #}
{% if logboek %}
<div class="card" id="logboek">
<div class="page-head" style="margin-bottom:10px">
<h3 class="card-title" style="margin:0">🕑 Recente acties</h3>
<span class="muted small">{{ log_totaal }} actie(s)</span>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Tijdstip</th><th>Door</th><th>Actie</th><th>Gebruiker</th><th>Details</th></tr></thead>
<tbody>
{% for l in logboek %}
<tr>
<td class="nowrap">{{ l.Tijdstip.strftime('%d-%m %H:%M') }}</td>
<td>{{ l.Door or '?' }}</td>
<td>{{ l.Actie }}</td>
<td>{{ l.Email }}</td>
<td class="muted">{{ l.Details or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% set extra = {'email': gebruiker.email} if gebruiker else {} %}
<div style="display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:10px;margin-top:12px">
<form method="GET" action="{{ url_for('tweestaps.overzicht') }}#logboek" style="margin:0">
{% if gebruiker %}<input type="hidden" name="email" value="{{ gebruiker.email }}">{% endif %}
<label class="per-pagina">Toon
<select name="logper" onchange="this.form.submit()" aria-label="Aantal per pagina">
{% for n in log_keuzes %}<option value="{{ n }}" {% if n == log_per %}selected{% endif %}>{{ n or 'alles' }}</option>{% endfor %}
</select> per pagina</label>
</form>
{% if log_paginas > 1 %}
<nav class="paginering" aria-label="Pagina's logboek" style="margin-top:0">
{% if log_pagina > 1 %}
<a class="btn btn-sm btn-light" href="{{ url_for('tweestaps.overzicht', logpagina=1, **extra) }}#logboek" title="Eerste pagina">«</a>
<a class="btn btn-sm btn-light" href="{{ url_for('tweestaps.overzicht', logpagina=log_pagina - 1, **extra) }}#logboek">‹ Vorige</a>
{% endif %}
{% for p in range([1, log_pagina - 2]|max, [log_paginas, log_pagina + 2]|min + 1) %}
<a class="btn btn-sm {{ 'btn-secondary' if p == log_pagina else 'btn-light' }}"
href="{{ url_for('tweestaps.overzicht', logpagina=p, **extra) }}#logboek" {% if p == log_pagina %}aria-current="page"{% endif %}>{{ p }}</a>
{% endfor %}
{% if log_pagina < log_paginas %}
<a class="btn btn-sm btn-light" href="{{ url_for('tweestaps.overzicht', logpagina=log_pagina + 1, **extra) }}#logboek">Volgende ›</a>
<a class="btn btn-sm btn-light" href="{{ url_for('tweestaps.overzicht', logpagina=log_paginas, **extra) }}#logboek" title="Laatste pagina">»</a>
{% endif %}
<span class="muted small" style="margin-left:6px">pagina {{ log_pagina }} van {{ log_paginas }}</span>
</nav>
{% endif %}
</div>
</div>
{% endif %}
{% else %}
{# ---------- Tab: nog geen 2FA ingesteld ---------- #}
<section class="tab-content card active">
<div class="page-head">
<div>
<h2 style="margin:0">Nog geen 2FA ingesteld</h2>
<div class="muted small">Actieve accounts in {{ ous }} zonder 2FA</div>
</div>
<div class="actions">
<a class="btn btn-light" href="{{ url_for('tweestaps.overzicht', tab='lijst', vernieuw=1) }}" onclick="showLoader('Gebruikers ophalen bij Google...')">🔄 Vernieuwen</a>
</div>
</div>
{% if lijst_fout %}<div class="alert alert-danger">{{ lijst_fout }}</div>{% endif %}
{% if lijst is not none %}
<div class="table-wrap">
<table>
<thead><tr><th class="sorteer" data-kol="0" data-type="tekst" title="Sorteren op naam">Naam <span class="pijl"></span></th>
<th class="sorteer" data-kol="1" data-type="tekst" title="Sorteren op e-mail">E-mail <span class="pijl"></span></th><th>OU</th>
<th class="sorteer" data-kol="3" data-type="datum" title="Sorteren op laatste login">Laatste login <span class="pijl"></span></th>
<th class="sorteer" data-kol="4" data-type="datum" title="Sorteren op status">Status <span class="pijl"></span></th><th class="actions"></th></tr></thead>
<tbody id="lijstBody">
{% set uitz = actief|map(attribute='Email')|list %}
{% for u in lijst %}
<tr>
<td data-sort="{{ u.naam|lower }}"><strong>{{ u.naam }}</strong></td>
<td data-sort="{{ u.email|lower }}">{{ u.email }}</td>
<td class="muted">{{ u.ou }}</td>
<td class="nowrap" data-sort="{{ u.laatste_login or '' }}">{{ u.laatste_login or 'Nooit' }}</td>
{# volgorde bij sorteren: kan niet inloggen → uitzondering → nog niet verplicht; daarbinnen op naam #}
<td data-sort="{{ '2' if u.email in uitz else ('1' if u.afgedwongen else '3') }} {{ u.naam|lower }}">
{% if u.email in uitz %}<span class="badge badge-info">Uitzondering</span>
{% elif u.afgedwongen %}<span class="badge badge-danger">Kan niet inloggen</span>
{% else %}<span class="badge badge-warning">Nog niet verplicht</span>{% endif %}
</td>
<td class="actions"><a class="btn btn-sm btn-light" href="{{ url_for('tweestaps.overzicht', email=u.email) }}">Helpen</a></td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="6">🎉 Iedereen heeft 2FA ingesteld.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if lijst %}<p class="field-hint">{{ lijst|length }} account(s). "Kan niet inloggen": voor deze mensen wordt 2FA afgedwongen maar ze hebben het nog niet ingesteld. Geef ze een tijdelijke uitzondering zodat ze het kunnen instellen.</p>{% endif %}
{% endif %}
</section>
{% endif %}
{% endblock %}
{% block scripts %}
<style>
.paginering { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 12px; }
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; }
</style>
<script>
// Sorteren via de kolomkop (Naam, Laatste login). "Nooit" telt als oudste.
document.querySelectorAll('th.sorteer').forEach(function (th) {
th.addEventListener('click', function () {
var body = document.getElementById('lijstBody');
if (!body) return;
var kol = +th.dataset.kol, op = !th.classList.contains('op');
document.querySelectorAll('th.sorteer').forEach(function (x) { x.classList.remove('op', 'af'); });
th.classList.add(op ? 'op' : 'af');
var rijen = Array.prototype.filter.call(body.rows, function (r) { return !r.classList.contains('empty-row'); });
rijen.sort(function (a, b) {
var x = a.cells[kol].dataset.sort || '', y = b.cells[kol].dataset.sort || '';
var c = th.dataset.type === 'tekst' ? x.localeCompare(y, 'nl') : (x < y ? -1 : x > y ? 1 : 0);
return op ? c : -c;
});
rijen.forEach(function (r) { body.appendChild(r); });
});
});
function printSlip() {
document.body.classList.add('print-slip');
window.print();
setTimeout(() => document.body.classList.remove('print-slip'), 500);
}
</script>
{% endblock %}