Refactor school year button and enhance JavaScript bindings
All checks were successful
Build & Push / Build & Push image (push) Successful in 44s

- Updated the button for adding a new school year to have an ID for easier access.
- Changed the way IS_SUPERADMIN is defined to use JSON for better compatibility.
- Added event bindings for canceling and saving edits for schools in the JavaScript code.
- Introduced a new document for Google SSO instructions.
This commit is contained in:
2026-03-03 20:33:49 +01:00
parent 28c05edb0b
commit 55cd055645
8 changed files with 336 additions and 27 deletions

View File

@@ -27,6 +27,14 @@ BASE_URL=https://leerdoelen.jouwdomain.be
MICROSOFT_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx MICROSOFT_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
MICROSOFT_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx MICROSOFT_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# ── Google Workspace SSO ───────────────────────────────────────
# Aanmaken via: https://console.cloud.google.com
# → APIs & Services → Credentials → OAuth 2.0 Client ID
# Redirect URI: https://jouwdomain.be/auth/google/callback
# Zie handleiding: docs/Handleiding_Google_SSO.md
GOOGLE_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Naam van de scholengroep — verschijnt op de loginpagina # Naam van de scholengroep — verschijnt op de loginpagina
ORG_NAME=GO! Scholengroep 2 ORG_NAME=GO! Scholengroep 2

View File

@@ -43,6 +43,8 @@ def create_app():
# OAuth2 # OAuth2
app.config['MICROSOFT_CLIENT_ID'] = os.environ.get('MICROSOFT_CLIENT_ID') app.config['MICROSOFT_CLIENT_ID'] = os.environ.get('MICROSOFT_CLIENT_ID')
app.config['MICROSOFT_CLIENT_SECRET'] = os.environ.get('MICROSOFT_CLIENT_SECRET') app.config['MICROSOFT_CLIENT_SECRET'] = os.environ.get('MICROSOFT_CLIENT_SECRET')
app.config['GOOGLE_CLIENT_ID'] = os.environ.get('GOOGLE_CLIENT_ID')
app.config['GOOGLE_CLIENT_SECRET'] = os.environ.get('GOOGLE_CLIENT_SECRET')
app.config['MICROSOFT_TENANT_ID'] = os.environ.get('MICROSOFT_TENANT_ID', 'common') app.config['MICROSOFT_TENANT_ID'] = os.environ.get('MICROSOFT_TENANT_ID', 'common')
# Session cookie beveiliging # Session cookie beveiliging

View File

@@ -111,9 +111,11 @@ def superadmin_page():
def login(): def login():
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for('pages.dashboard')) return redirect(url_for('pages.dashboard'))
entra_configured = bool(_entra_client_id() and _entra_client_secret()) entra_configured = bool(_entra_client_id() and _entra_client_secret())
google_configured = bool(_google_client_id() and _google_client_secret())
org_name = current_app.config.get('ORG_NAME', 'GO! Scholengroep') org_name = current_app.config.get('ORG_NAME', 'GO! Scholengroep')
return render_template('login.html', entra_configured=entra_configured, org_name=org_name) return render_template('login.html', entra_configured=entra_configured,
google_configured=google_configured, org_name=org_name)
@auth_bp.route('/logout') @auth_bp.route('/logout')
@@ -239,6 +241,173 @@ def microsoft_callback():
return redirect(_safe_next_url(request.args.get('next'))) return redirect(_safe_next_url(request.args.get('next')))
# ── Google OAuth2 ─────────────────────────────────────────────────────────────
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"
GOOGLE_SCOPES = "openid email profile"
def _google_client_id():
return current_app.config.get('GOOGLE_CLIENT_ID')
def _google_client_secret():
return current_app.config.get('GOOGLE_CLIENT_SECRET')
def _google_callback_url():
base = current_app.config.get('BASE_URL', 'http://localhost').rstrip('/')
return f"{base}/auth/google/callback"
def _get_or_create_google_user(email, first_name, last_name, google_sub):
"""Zelfde logica als Microsoft — zoek op google sub, dan email, dan maak aan."""
# 1. Zoek op Google sub (stabielste identifier)
user = User.query.filter_by(oauth_provider='google', oauth_id=google_sub).first()
if user:
user.first_name = first_name or user.first_name
user.last_name = last_name or user.last_name
user.email = email
db.session.commit()
return user, False
# 2. Zoek op email — koppel bestaand account aan Google
user = User.query.filter_by(email=email).first()
if user:
user.oauth_provider = 'google'
user.oauth_id = google_sub
user.first_name = first_name or user.first_name
user.last_name = last_name or user.last_name
db.session.commit()
return user, False
# 3. Nieuw account — koppel aan school via emaildomein
school = _find_school_for_email(email)
user = User(
email=email, first_name=first_name, last_name=last_name,
role='teacher', school_id=school.id if school else None,
oauth_provider='google', oauth_id=google_sub,
is_active=True,
)
db.session.add(user)
db.session.commit()
return user, True
@auth_bp.route('/google')
@limiter.limit('20 per minute')
def google_login():
if not _google_client_id():
flash('Google login is niet geconfigureerd.', 'error')
return redirect(url_for('auth.login'))
# Aparte state-sleutel voor Google om verwarring met Microsoft te vermijden
state = secrets.token_urlsafe(32)
session['google_oauth_state'] = state
params = {
'client_id': _google_client_id(),
'response_type': 'code',
'redirect_uri': _google_callback_url(),
'scope': GOOGLE_SCOPES,
'state': state,
'access_type': 'online', # geen refresh token nodig
'prompt': 'select_account',
# hd-parameter beperkt NIET — we valideren zelf via emaildomein
# zodat scholen met meerdere domeinen correct werken
}
return redirect(f"{GOOGLE_AUTH_URL}?{urlencode(params)}")
@auth_bp.route('/google/callback')
@limiter.limit('20 per minute')
def google_callback():
error = request.args.get('error')
if error:
logger.warning(f"Google OAuth fout: {error}")
flash('Inloggen via Google mislukt. Probeer opnieuw.', 'error')
return redirect(url_for('auth.login'))
state = request.args.get('state', '')
expected_state = session.pop('google_oauth_state', None)
if not expected_state or state != expected_state:
logger.warning("Google OAuth2 state mismatch")
flash('Ongeldige sessie. Probeer opnieuw in te loggen.', 'error')
return redirect(url_for('auth.login'))
code = request.args.get('code')
if not code:
flash('Geen autorisatiecode ontvangen van Google.', 'error')
return redirect(url_for('auth.login'))
# Wissel code in voor tokens
try:
token_resp = requests.post(GOOGLE_TOKEN_URL, data={
'client_id': _google_client_id(),
'client_secret': _google_client_secret(),
'code': code,
'redirect_uri': _google_callback_url(),
'grant_type': 'authorization_code',
}, timeout=15)
token_resp.raise_for_status()
tokens = token_resp.json()
except requests.RequestException as e:
logger.error(f"Google token uitwisseling mislukt: {e}")
flash('Kon niet communiceren met Google. Probeer opnieuw.', 'error')
return redirect(url_for('auth.login'))
access_token = tokens.get('access_token')
if not access_token:
flash('Geen access token ontvangen van Google.', 'error')
return redirect(url_for('auth.login'))
# Haal gebruikersprofiel op
try:
userinfo_resp = requests.get(
GOOGLE_USERINFO_URL,
headers={'Authorization': f'Bearer {access_token}'},
timeout=10
)
userinfo_resp.raise_for_status()
profile = userinfo_resp.json()
except requests.RequestException as e:
logger.error(f"Google userinfo mislukt: {e}")
flash('Kon gebruikersgegevens niet ophalen bij Google.', 'error')
return redirect(url_for('auth.login'))
email = profile.get('email', '').lower().strip()
first_name = profile.get('given_name', '')
last_name = profile.get('family_name', '')
google_sub = profile.get('sub', '') # stabiele unieke Google user ID
# Vereiste velden
if not email or not google_sub:
flash('Onvoldoende profielgegevens ontvangen van Google.', 'error')
return redirect(url_for('auth.login'))
# Google geeft 'email_verified' mee — weiger onverifieerde adressen
if not profile.get('email_verified', False):
flash('Uw Google e-mailadres is nog niet geverifieerd.', 'error')
return redirect(url_for('auth.login'))
user, is_new = _get_or_create_google_user(email, first_name, last_name, google_sub)
if not user.is_active:
flash('Uw account is gedeactiveerd. Contacteer uw ICT-beheerder.', 'error')
return redirect(url_for('auth.login'))
if not user.school_id and not user.is_scholengroep_ict and not user.is_superadmin:
flash(
'Uw account is aangemaakt maar nog niet gekoppeld aan een school. '
'Contacteer uw ICT-beheerder.', 'warning'
)
login_user(user, remember=True)
user.last_login = datetime.utcnow()
audit_log('login.success', 'auth', detail={'provider': 'google', 'new_user': is_new})
db.session.commit()
logger.info(f"Google login: {email} (nieuw: {is_new}, school_id: {user.school_id})")
return redirect(_safe_next_url(request.args.get('next')))
@auth_bp.route('/setup', methods=['GET', 'POST']) @auth_bp.route('/setup', methods=['GET', 'POST'])
@limiter.limit('5 per minute') @limiter.limit('5 per minute')
def setup(): def setup():

View File

@@ -9,7 +9,7 @@
--primary: #4f46e5; --primary-dark: #4338ca; --primary: #4f46e5; --primary-dark: #4338ca;
--success: #10b981; --warning: #f59e0b; --danger: #ef4444; --success: #10b981; --warning: #f59e0b; --danger: #ef4444;
--gray-50: #f9fafb; --gray-100: #f3f4f6; --gray-200: #e5e7eb; --gray-50: #f9fafb; --gray-100: #f3f4f6; --gray-200: #e5e7eb;
--gray-300: #d1d5db; --gray-500: #6b7280; --gray-600: #4b5563; --gray-300: #d1d5db; --gray-400: #9ca3af; --gray-500: #6b7280; --gray-600: #4b5563;
--gray-700: #374151; --gray-800: #1f2937; --gray-700: #374151; --gray-800: #1f2937;
--status-groen: #10b981; --status-oranje: #f59e0b; --status-roze: #ec4899; --status-groen: #10b981; --status-oranje: #f59e0b; --status-roze: #ec4899;
} }
@@ -150,6 +150,46 @@
.form-group label { display: block; font-size: 0.85rem; font-weight: 600; color: var(--gray-700); margin-bottom: 0.35rem; } .form-group label { display: block; font-size: 0.85rem; font-weight: 600; color: var(--gray-700); margin-bottom: 0.35rem; }
.form-group input { width: 100%; padding: 0.6rem 0.75rem; border: 1px solid var(--gray-300); border-radius: 6px; font-size: 0.9rem; } .form-group input { width: 100%; padding: 0.6rem 0.75rem; border: 1px solid var(--gray-300); border-radius: 6px; font-size: 0.9rem; }
.modal-buttons { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; } .modal-buttons { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
/* Tab navigatie */
.tab-btn {
padding: 0.5rem 1rem; border: none; border-radius: 8px 8px 0 0;
background: var(--gray-100); color: var(--gray-600);
font-size: 0.875rem; font-weight: 500; cursor: pointer;
transition: all 0.15s; border-bottom: 2px solid transparent;
}
.tab-btn:hover { background: var(--gray-200); color: var(--gray-800); }
.tab-btn.active {
background: white; color: var(--primary);
border-bottom: 2px solid var(--primary);
box-shadow: 0 -2px 8px rgba(79,70,229,0.08);
}
/* Version badge */
.version-badge {
font-size: 0.7rem; background: rgba(255,255,255,0.25);
padding: 0.15rem 0.5rem; border-radius: 9999px; font-weight: 500;
vertical-align: middle;
}
/* Klasoverzicht progress */
.klas-progress-row {
display: flex; align-items: center; gap: 0.75rem;
margin-bottom: 0.5rem;
}
.klas-label { font-size: 0.82rem; color: var(--gray-600); min-width: 80px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.klas-progress-bar {
flex: 1; height: 10px; background: var(--gray-200);
border-radius: 5px; overflow: hidden; display: flex;
}
.klas-pct { font-size: 0.78rem; color: var(--gray-500); min-width: 36px; text-align: right; }
/* Vergelijking progress bars */
.vergelijk-bar-wrap {
height: 12px; background: var(--gray-200);
border-radius: 4px; overflow: hidden;
margin-bottom: 0.2rem;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
@@ -167,18 +207,20 @@
body { background: #0f172a; color: #e2e8f0; } body { background: #0f172a; color: #e2e8f0; }
/* Kaarten en secties */ /* Kaarten en secties - NIET de gradient header! */
.card, .section, .stat-card, .school-card, .card, .section, .stat-card, .school-card,
.table-container, .filters-container, .legend-container, .table-container, .filters-container, .legend-container,
.stats-bar .stat-card, .stats-overview, .vak-stats, .stats-bar .stat-card, .stats-overview, .vak-stats,
.import-section, .detail-section, .filters-bar, .import-section, .detail-section, .filters-bar,
.header:not([class*="gradient"]) { .teachers-section {
background: #1e293b !important; background: #1e293b !important;
border-color: #334155 !important; border-color: #334155 !important;
} }
/* Header kaart in leerkracht.html */ /* Tab knoppen */
.header { background: #1e293b !important; } .tab-btn { background: #162032 !important; color: #94a3b8 !important; }
.tab-btn:hover { background: #1e293b !important; color: #e2e8f0 !important; }
.tab-btn.active { background: #1e293b !important; color: #a5b4fc !important; border-bottom-color: #6366f1 !important; }
/* Tabellen */ /* Tabellen */
thead, th { background: #1e293b !important; color: #94a3b8 !important; border-color: #334155 !important; } thead, th { background: #1e293b !important; color: #94a3b8 !important; border-color: #334155 !important; }
@@ -276,8 +318,8 @@
.vak-card-header h3 { color: #e2e8f0 !important; } .vak-card-header h3 { color: #e2e8f0 !important; }
.vak-card-stats { color: #94a3b8 !important; } .vak-card-stats { color: #94a3b8 !important; }
/* Progress bars achtergrond */ /* Klas progress bars achtergrond */
.progress-bar { background: #334155 !important; } .progress-bar, .klas-progress-bar, .vergelijk-bar-wrap { background: #334155 !important; }
/* Vak card */ /* Vak card */
.vak-card { background: #162032 !important; } .vak-card { background: #162032 !important; }
@@ -414,14 +456,14 @@
</div> </div>
<!-- Tab navigatie --> <!-- Tab navigatie -->
<div style="display:flex;gap:.25rem;margin-bottom:.5rem;"> <div style="display:flex;gap:.25rem;margin-bottom:0;">
<button class="tab-btn active" id="tab-doelen">📋 Doelen</button> <button class="tab-btn active" id="tab-doelen">📋 Doelen</button>
<button class="tab-btn" id="tab-klassen">🏫 Klasoverzicht</button> <button class="tab-btn" id="tab-klassen">🏫 Klasoverzicht</button>
<button class="tab-btn" id="tab-vergelijk">⚖️ Klasvergelijking</button> <button class="tab-btn" id="tab-vergelijk">⚖️ Klasvergelijking</button>
</div> </div>
<!-- Tab: Doelen --> <!-- Tab: Doelen -->
<div id="panel-doelen" class="section"> <div id="panel-doelen" class="section" style="border-radius:0 12px 12px 12px;">
<!-- Legenda --> <!-- Legenda -->
<div class="legend-container"> <div class="legend-container">
@@ -534,14 +576,14 @@
</div><!-- /panel-doelen --> </div><!-- /panel-doelen -->
<!-- Tab: Klasoverzicht --> <!-- Tab: Klasoverzicht -->
<div id="panel-klassen" class="section" style="display:none;"> <div id="panel-klassen" class="section" style="display:none;border-radius:0 12px 12px 12px;">
<div id="klasOverzichtContent"> <div id="klasOverzichtContent">
<p style="color:var(--gray-400);font-style:italic;">Laden...</p> <p style="color:var(--gray-400);font-style:italic;">Laden...</p>
</div> </div>
</div> </div>
<!-- Tab: Klasvergelijking --> <!-- Tab: Klasvergelijking -->
<div id="panel-vergelijk" class="section" style="display:none;"> <div id="panel-vergelijk" class="section" style="display:none;border-radius:0 12px 12px 12px;">
<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem;"> <div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem;">
<div class="filter-group"> <div class="filter-group">
<label>Klas A</label> <label>Klas A</label>
@@ -577,6 +619,33 @@
<!-- Modal: leerkracht toevoegen --> <!-- Modal: leerkracht toevoegen -->
<div class="modal-overlay" id="addTeacherModal"> <div class="modal-overlay" id="addTeacherModal">
<div class="modal"> <div class="modal">
<h2>👤 Leerkracht toevoegen</h2>
<div class="form-group">
<label>Voornaam</label>
<input type="text" id="newFirstName" placeholder="Voornaam...">
</div>
<div class="form-group">
<label>Achternaam</label>
<input type="text" id="newLastName" placeholder="Achternaam...">
</div>
<div class="form-group">
<label>E-mailadres</label>
<input type="email" id="newEmail" placeholder="naam@school.be">
</div>
<div class="form-group">
<label>Wachtwoord (tijdelijk)</label>
<input type="password" id="newPassword" placeholder="Minimaal 8 tekens...">
</div>
<div id="addTeacherError" style="display:none;color:var(--danger);font-size:0.85rem;margin-top:0.5rem;"></div>
<div class="modal-buttons">
<button id="btnCancelTeacher" class="btn btn-secondary">Annuleren</button>
<button id="btnConfirmTeacher" class="btn btn-primary">Toevoegen</button>
</div>
</div>
</div>
<div class="notification" id="notification"></div>
<script nonce="{{ csp_nonce() }}"> <script nonce="{{ csp_nonce() }}">
function bind(id, ev, fn) { function bind(id, ev, fn) {
const el = document.getElementById(id); const el = document.getElementById(id);
@@ -607,6 +676,10 @@ document.addEventListener('DOMContentLoaded', async () => {
bind('filterSectie', 'change', applyFilters); bind('filterSectie', 'change', applyFilters);
bind('filterSearch', 'input', applyFilters); bind('filterSearch', 'input', applyFilters);
document.querySelectorAll('.leeftijd-checkbox input').forEach(cb => cb.addEventListener('change', applyFilters)); document.querySelectorAll('.leeftijd-checkbox input').forEach(cb => cb.addEventListener('change', applyFilters));
// Vergelijk tab selects
bind('vergelijkKlasA', 'change', renderVergelijking);
bind('vergelijkKlasB', 'change', renderVergelijking);
bind('vergelijkVak', 'change', renderVergelijking);
await loadUser(); await loadUser();
await loadJaren(); await loadJaren();
await loadTeachers(); await loadTeachers();
@@ -856,15 +929,23 @@ function applyFilters() {
if (!overviewData) return; if (!overviewData) return;
const vakFilter = document.getElementById('filterVak').value; const vakFilter = document.getElementById('filterVak').value;
const teacherFilter = document.getElementById('filterTeacher').value; const teacherFilter = document.getElementById('filterTeacher').value;
const klasFilter = document.getElementById('filterKlas')?.value || 'all';
const statusFilter = document.getElementById('filterStatus').value; const statusFilter = document.getElementById('filterStatus').value;
const sectieFilter = document.getElementById('filterSectie')?.value || 'all'; const sectieFilter = document.getElementById('filterSectie')?.value || 'all';
const search = document.getElementById('filterSearch').value.toLowerCase(); const search = document.getElementById('filterSearch').value.toLowerCase();
const leeftijdFilter = [...document.querySelectorAll('.leeftijd-checkbox input:checked')].map(cb => cb.value); const leeftijdFilter = [...document.querySelectorAll('.leeftijd-checkbox input:checked')].map(cb => cb.value);
const shownTeachers = teacherFilter === 'all' // Filter leerkrachten op geselecteerde klas én op leerkrachtfilter
let shownTeachers = teacherFilter === 'all'
? overviewData.teachers ? overviewData.teachers
: overviewData.teachers.filter(t => t.id == teacherFilter); : overviewData.teachers.filter(t => t.id == teacherFilter);
if (klasFilter !== 'all') {
shownTeachers = shownTeachers.filter(t =>
(t.classes || []).some(c => c.name === klasFilter)
);
}
const shownVakken = vakFilter === 'all' ? Object.keys(allGoals) : [vakFilter]; const shownVakken = vakFilter === 'all' ? Object.keys(allGoals) : [vakFilter];
// Build header // Build header

View File

@@ -54,9 +54,12 @@
th { padding: 0.75rem 1rem; text-align: left; font-weight: 600; color: var(--gray-600); border-bottom: 2px solid var(--gray-200); white-space: nowrap; } th { padding: 0.75rem 1rem; text-align: left; font-weight: 600; color: var(--gray-600); border-bottom: 2px solid var(--gray-200); white-space: nowrap; }
td { padding: 0.75rem 1rem; border-bottom: 1px solid var(--gray-100); vertical-align: top; } td { padding: 0.75rem 1rem; border-bottom: 1px solid var(--gray-100); vertical-align: top; }
tr:hover { background: var(--gray-50); } tr:hover { background: var(--gray-50); }
tr.status-groen { background: var(--status-groen-bg); } tr.status-groen { background: var(--status-groen-bg); }
tr.status-groen:hover { background: #a7f3d0; }
tr.status-oranje { background: var(--status-oranje-bg); } tr.status-oranje { background: var(--status-oranje-bg); }
tr.status-roze { background: var(--status-roze-bg); } tr.status-oranje:hover { background: #fde68a; }
tr.status-roze { background: var(--status-roze-bg); }
tr.status-roze:hover { background: #fbcfe8; }
.status-selector { width: 32px; height: 32px; border-radius: 6px; border: 2px solid var(--gray-300); background: white; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; font-size: 1rem; font-weight: bold; transition: all 0.15s; } .status-selector { width: 32px; height: 32px; border-radius: 6px; border: 2px solid var(--gray-300); background: white; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; font-size: 1rem; font-weight: bold; transition: all 0.15s; }
.status-selector:hover { transform: scale(1.1); } .status-selector:hover { transform: scale(1.1); }
.status-selector.status-none { color: var(--gray-400); } .status-selector.status-none { color: var(--gray-400); }
@@ -170,7 +173,8 @@
/* Modals */ /* Modals */
.modal { background: #1e293b !important; color: #e2e8f0; } .modal { background: #1e293b !important; color: #e2e8f0; }
.modal h2 { color: #f1f5f9; } .modal-inner-box { background: #1e293b !important; color: #e2e8f0 !important; }
.modal h2, .modal-inner-box h2 { color: #f1f5f9; }
/* Knoppen */ /* Knoppen */
.btn-secondary { background: #334155 !important; color: #e2e8f0 !important; } .btn-secondary { background: #334155 !important; color: #e2e8f0 !important; }
@@ -269,6 +273,8 @@
::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; } ::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #475569; } ::-webkit-scrollbar-thumb:hover { background: #475569; }
} }
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-dark); }
.btn-import { background: var(--warning); color: white; } .btn-import { background: var(--warning); color: white; }
.btn-import:hover { background: #d97706; } .btn-import:hover { background: #d97706; }
</style> </style>
@@ -420,7 +426,7 @@
<!-- Klassen modal --> <!-- Klassen modal -->
<div id="klasModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;align-items:center;justify-content:center;"> <div id="klasModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;align-items:center;justify-content:center;">
<div style="background:var(--gray-800, white);border-radius:12px;padding:1.5rem;max-width:400px;width:90%;box-shadow:0 20px 60px rgba(0,0,0,.3);"> <div style="background:white;border-radius:12px;padding:1.5rem;max-width:400px;width:90%;box-shadow:0 20px 60px rgba(0,0,0,.3);" class="modal-inner-box">
<h2 style="font-size:1.1rem;margin-bottom:.5rem;">📚 Mijn klassen instellen</h2> <h2 style="font-size:1.1rem;margin-bottom:.5rem;">📚 Mijn klassen instellen</h2>
<p style="font-size:.85rem;color:var(--gray-400);margin-bottom:1rem;"> <p style="font-size:.85rem;color:var(--gray-400);margin-bottom:1rem;">
Selecteer de klassen waarvoor jij beoordelingen invult. Selecteer de klassen waarvoor jij beoordelingen invult.

View File

@@ -27,6 +27,25 @@
.logo h1 { font-size: 1.4rem; color: var(--gray-900, #1f2937); font-weight: 700; } .logo h1 { font-size: 1.4rem; color: var(--gray-900, #1f2937); font-weight: 700; }
.logo p { color: var(--gray-500, #6b7280); font-size: 0.85rem; margin-top: 0.25rem; } .logo p { color: var(--gray-500, #6b7280); font-size: 0.85rem; margin-top: 0.25rem; }
.btn-google {
display: flex; align-items: center; justify-content: center; gap: 0.75rem;
width: 100%; padding: 0.8rem 1rem;
background: white; color: #1f2937;
border: 1px solid #d1d5db; border-radius: 8px;
font-size: 0.95rem; font-weight: 500;
text-decoration: none; transition: background 0.15s, box-shadow 0.15s;
margin-bottom: 0.75rem;
}
.btn-google:hover { background: #f9fafb; box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
.btn-google svg { flex-shrink: 0; }
.sso-divider {
display: flex; align-items: center; gap: 0.75rem;
margin: 0.5rem 0 0.75rem; color: #9ca3af; font-size: 0.8rem;
}
.sso-divider::before, .sso-divider::after {
content: ''; flex: 1; height: 1px; background: #e5e7eb;
}
.btn-microsoft { .btn-microsoft {
width: 100%; width: 100%;
padding: 0.85rem; padding: 0.85rem;
@@ -278,17 +297,39 @@
</svg> </svg>
Inloggen met Microsoft Inloggen met Microsoft
</a> </a>
<p style="text-align:center; color:#6b7280; font-size:0.8rem;"> {% endif %}
Log in met uw school Microsoft account
</p> {% if entra_configured and google_configured %}
{% else %} <div class="sso-divider">of</div>
{% endif %}
{% if google_configured %}
<a href="/auth/google" class="btn-google">
<svg width="20" height="20" viewBox="0 0 48 48">
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/>
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/>
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/>
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.18 1.48-4.97 2.31-8.16 2.31-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/>
<path fill="none" d="M0 0h48v48H0z"/>
</svg>
Inloggen met Google
</a>
{% endif %}
{% if not entra_configured and not google_configured %}
<div class="not-configured"> <div class="not-configured">
<strong>Microsoft login niet geconfigureerd</strong><br><br> <strong>SSO login niet geconfigureerd</strong><br><br>
Stel <code>MICROSOFT_CLIENT_ID</code> en <code>MICROSOFT_CLIENT_SECRET</code> Stel <code>MICROSOFT_CLIENT_ID</code> of <code>GOOGLE_CLIENT_ID</code>
in de <code>.env</code> in om Entra login te activeren. in de <code>.env</code> in om SSO login te activeren.
</div> </div>
{% endif %} {% endif %}
{% if entra_configured or google_configured %}
<p style="text-align:center; color:#6b7280; font-size:0.8rem; margin-top:0.25rem;">
Log in met uw school account
</p>
{% endif %}
<!-- Superadmin fallback — zichtbaar maar discreet --> <!-- Superadmin fallback — zichtbaar maar discreet -->
<div class="superadmin-toggle"> <div class="superadmin-toggle">
<button id="btnToggleSuperadmin">Platformbeheerder</button> <button id="btnToggleSuperadmin">Platformbeheerder</button>

View File

@@ -151,7 +151,7 @@
<div class="section"> <div class="section">
<div class="section-header"> <div class="section-header">
<h2>📅 Schooljaren</h2> <h2>📅 Schooljaren</h2>
<button class="btn btn-primary btn-sm">+ Nieuw schooljaar</button> <button id="btnAddJaar" class="btn btn-primary btn-sm">+ Nieuw schooljaar</button>
</div> </div>
<p class="section-hint"> <p class="section-hint">
Het actieve schooljaar geldt voor alle scholen tegelijk. Het actieve schooljaar geldt voor alle scholen tegelijk.
@@ -338,7 +338,7 @@ function bind(id, ev, fn) {
if (el) el.addEventListener(ev, fn); if (el) el.addEventListener(ev, fn);
} }
const IS_SUPERADMIN = {{ 'true' if is_superadmin else 'false' }}; const IS_SUPERADMIN = {{ is_superadmin | tojson }};
let schools = []; let schools = [];
const SCHOOL_ROLLEN = [ const SCHOOL_ROLLEN = [
@@ -357,6 +357,8 @@ document.addEventListener('DOMContentLoaded', async () => {
bind('auditSearch', 'input', loadAuditLog); bind('auditSearch', 'input', loadAuditLog);
document.getElementById('btnCancelSchool') && bind('btnCancelSchool', 'click', closeModal); document.getElementById('btnCancelSchool') && bind('btnCancelSchool', 'click', closeModal);
document.getElementById('btnSaveSchool') && bind('btnSaveSchool', 'click', addSchool); document.getElementById('btnSaveSchool') && bind('btnSaveSchool', 'click', addSchool);
document.getElementById('btnCancelEditSch') && bind('btnCancelEditSch', 'click', closeModal);
document.getElementById('btnSaveEditSch') && bind('btnSaveEditSch', 'click', saveSchool);
document.getElementById('btnCancelSgIct') && bind('btnCancelSgIct', 'click', closeModal); document.getElementById('btnCancelSgIct') && bind('btnCancelSgIct', 'click', closeModal);
document.getElementById('btnSaveSgIct') && bind('btnSaveSgIct', 'click', addSgIct); document.getElementById('btnSaveSgIct') && bind('btnSaveSgIct', 'click', addSgIct);
document.getElementById('btnCancelJaar') && bind('btnCancelJaar', 'click', closeModal); document.getElementById('btnCancelJaar') && bind('btnCancelJaar', 'click', closeModal);

Binary file not shown.