diff --git a/.env.example b/.env.example index 5d1740a..ea0d24c 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,14 @@ BASE_URL=https://leerdoelen.jouwdomain.be MICROSOFT_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 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 ORG_NAME=GO! Scholengroep 2 diff --git a/backend/app.py b/backend/app.py index 63ea9f7..7d7e429 100644 --- a/backend/app.py +++ b/backend/app.py @@ -43,6 +43,8 @@ def create_app(): # OAuth2 app.config['MICROSOFT_CLIENT_ID'] = os.environ.get('MICROSOFT_CLIENT_ID') 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') # Session cookie beveiliging diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 9496324..12a019a 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -111,9 +111,11 @@ def superadmin_page(): def login(): if current_user.is_authenticated: 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') - 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') @@ -239,6 +241,173 @@ def microsoft_callback(): 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']) @limiter.limit('5 per minute') def setup(): diff --git a/backend/templates/directeur.html b/backend/templates/directeur.html index bf6b24b..03d412b 100644 --- a/backend/templates/directeur.html +++ b/backend/templates/directeur.html @@ -9,7 +9,7 @@ --primary: #4f46e5; --primary-dark: #4338ca; --success: #10b981; --warning: #f59e0b; --danger: #ef4444; --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; --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 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; } + + /* 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) { :root { @@ -167,18 +207,20 @@ body { background: #0f172a; color: #e2e8f0; } - /* Kaarten en secties */ + /* Kaarten en secties - NIET de gradient header! */ .card, .section, .stat-card, .school-card, .table-container, .filters-container, .legend-container, .stats-bar .stat-card, .stats-overview, .vak-stats, .import-section, .detail-section, .filters-bar, - .header:not([class*="gradient"]) { + .teachers-section { background: #1e293b !important; border-color: #334155 !important; } - /* Header kaart in leerkracht.html */ - .header { background: #1e293b !important; } + /* Tab knoppen */ + .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 */ 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-stats { color: #94a3b8 !important; } - /* Progress bars achtergrond */ - .progress-bar { background: #334155 !important; } + /* Klas progress bars achtergrond */ + .progress-bar, .klas-progress-bar, .vergelijk-bar-wrap { background: #334155 !important; } /* Vak card */ .vak-card { background: #162032 !important; } @@ -414,14 +456,14 @@ -
+
-
+
@@ -534,14 +576,14 @@
-