diff --git a/backend/migrations/versions/0003_school_google_sso.py b/backend/migrations/versions/0003_school_google_sso.py new file mode 100644 index 0000000..c993b0e --- /dev/null +++ b/backend/migrations/versions/0003_school_google_sso.py @@ -0,0 +1,29 @@ +"""schools: voeg Google Workspace SSO credentials toe per school + +Revision ID: 0003 +Revises: 0002 +Create Date: 2026-03-03 + +Elke school heeft zijn eigen Google Workspace omgeving en dus +zijn eigen OAuth2 client_id en client_secret. Deze worden per school +opgeslagen en nooit blootgesteld via de API (enkel of ze ingesteld zijn). +""" +from alembic import op +import sqlalchemy as sa + +revision = '0003' +down_revision = '0002' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('schools', + sa.Column('google_client_id', sa.String(255), nullable=True)) + op.add_column('schools', + sa.Column('google_client_secret', sa.String(255), nullable=True)) + + +def downgrade(): + op.drop_column('schools', 'google_client_secret') + op.drop_column('schools', 'google_client_id') diff --git a/backend/models.py b/backend/models.py index 424fa09..72f7cbb 100644 --- a/backend/models.py +++ b/backend/models.py @@ -7,11 +7,14 @@ from app import db class School(db.Model): __tablename__ = 'schools' - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(255), nullable=False) - slug = db.Column(db.String(100), nullable=False, unique=True) - email_domains = db.Column(db.ARRAY(db.Text), nullable=False, default=list) - created_at = db.Column(db.DateTime, default=datetime.utcnow) + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(255), nullable=False) + slug = db.Column(db.String(100), nullable=False, unique=True) + email_domains = db.Column(db.ARRAY(db.Text), nullable=False, default=list) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + # Google Workspace SSO — per school eigen OAuth2 credentials + google_client_id = db.Column(db.String(255), nullable=True) + google_client_secret = db.Column(db.String(255), nullable=True) users = db.relationship('User', back_populates='school', lazy='dynamic') school_years = db.relationship('SchoolYear', back_populates='school', lazy='dynamic') @@ -19,10 +22,13 @@ class School(db.Model): def to_dict(self): return { - 'id': self.id, - 'name': self.name, - 'slug': self.slug, - 'email_domains': self.email_domains or [], + 'id': self.id, + 'name': self.name, + 'slug': self.slug, + 'email_domains': self.email_domains or [], + 'google_client_id': self.google_client_id or '', + # Secret nooit teruggeven — enkel of het ingesteld is + 'google_sso_configured': bool(self.google_client_id and self.google_client_secret), } diff --git a/backend/routes/admin.py b/backend/routes/admin.py index c04a924..a3b04f2 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -102,6 +102,55 @@ def update_school(school_id): return jsonify({'school': school.to_dict()}) +@admin_bp.route('/schools//google-sso', methods=['PUT']) +@login_required +@school_ict_required +def update_school_google_sso(school_id): + """ + Sla Google Workspace OAuth2 credentials op voor een school. + Toegankelijk voor scholengroep_ict (alle scholen) én school_ict + (enkel hun eigen school). + + Body: + google_client_id: string (verplicht om in te stellen) + google_client_secret: string (verplicht om in te stellen) + clear: boolean (optioneel — verwijdert de credentials) + """ + # School ICT mag enkel zijn eigen school aanpassen + if not current_user.is_scholengroep_ict and current_user.school_id != school_id: + return jsonify({'error': 'Geen toegang tot deze school'}), 403 + + school = School.query.get_or_404(school_id) + data = request.get_json() or {} + + if data.get('clear'): + school.google_client_id = None + school.google_client_secret = None + audit_log('school.google_sso_removed', 'school', + target_type='school', target_id=str(school_id), + detail={'name': school.name}, school_id=school_id) + db.session.commit() + return jsonify({'school': school.to_dict(), 'message': 'Google SSO verwijderd'}) + + client_id = (data.get('google_client_id') or '').strip() + client_secret = (data.get('google_client_secret') or '').strip() + + if not client_id or not client_secret: + return jsonify({'error': 'Zowel Client ID als Client Secret zijn verplicht'}), 400 + + # Basis validatie: Google client IDs eindigen op .apps.googleusercontent.com + if not client_id.endswith('.apps.googleusercontent.com'): + return jsonify({'error': 'Ongeldig Client ID — moet eindigen op .apps.googleusercontent.com'}), 400 + + school.google_client_id = client_id + school.google_client_secret = client_secret + audit_log('school.google_sso_configured', 'school', + target_type='school', target_id=str(school_id), + detail={'name': school.name}, school_id=school_id) + db.session.commit() + return jsonify({'school': school.to_dict(), 'message': 'Google SSO ingesteld'}) + + @admin_bp.route('/schools/', methods=['DELETE']) @login_required @scholengroep_ict_required diff --git a/backend/routes/api.py b/backend/routes/api.py index 4b5336d..6201dba 100644 --- a/backend/routes/api.py +++ b/backend/routes/api.py @@ -444,3 +444,46 @@ def get_audit_log(): 'entries': [e.to_dict() for e in entries], }) + +# ── SSO-lookup: welke loginmethodes heeft dit e-maildomein? ────────────────── + +@api_bp.route('/sso-lookup') +def sso_lookup(): + """ + Publieke endpoint — geen auth vereist. + Geeft aan welke SSO-methodes beschikbaar zijn voor een e-maildomein. + Legt NOOIT credentials bloot — enkel of Google geconfigureerd is. + """ + from flask import current_app + from app import limiter + + email = request.args.get('email', '').lower().strip() + if not email or '@' not in email: + return jsonify({'error': 'Ongeldig e-mailadres'}), 400 + + domain = email.split('@')[-1] + schools = School.query.all() + school = next( + (s for s in schools if s.email_domains and domain in [d.lower() for d in s.email_domains]), + None + ) + + microsoft_available = bool( + current_app.config.get('MICROSOFT_CLIENT_ID') and + current_app.config.get('MICROSOFT_CLIENT_SECRET') + ) + + if not school: + return jsonify({ + 'found': False, + 'microsoft': microsoft_available, + 'google': False, + }) + + return jsonify({ + 'found': True, + 'school_id': school.id, + 'school_name': school.name, + 'microsoft': microsoft_available, + 'google': bool(school.google_client_id and school.google_client_secret), + }) diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 12a019a..b1c2367 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -111,11 +111,12 @@ 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()) - google_configured = bool(_google_client_id() and _google_client_secret()) + entra_configured = bool(_entra_client_id() and _entra_client_secret()) org_name = current_app.config.get('ORG_NAME', 'GO! Scholengroep') + # Google SSO is per school — we tonen altijd de Google-sectie + # zodat gebruikers hun e-mail kunnen invullen voor de lookup return render_template('login.html', entra_configured=entra_configured, - google_configured=google_configured, org_name=org_name) + google_configured=True, org_name=org_name) @auth_bp.route('/logout') @@ -242,25 +243,44 @@ def microsoft_callback(): -# ── Google OAuth2 ───────────────────────────────────────────────────────────── +# ── Google OAuth2 (multi-tenant: per school eigen credentials) ──────────────── +# +# Waarom per school? Microsoft heeft één "common" endpoint dat voor alle +# tenants werkt — één globale client_id volstaat. Google heeft dit NIET: +# elke Google Workspace organisatie is een aparte OAuth2-app. We slaan +# google_client_id + google_client_secret daarom per school op in de DB. +# De beheerder (scholengroep ICT of school ICT) vult die in via de web UI. +# +# Login flow: +# 1. Gebruiker typt e-mailadres op de loginpagina +# 2. JS roept /api/sso-lookup?email=... aan +# 3. Backend zoekt school via e-maildomein → geeft school_id + google_sso terug +# 4. JS stuurt door naar /auth/google?school_id= +# 5. We laden credentials uit DB, starten OAuth, bewaren school_id in sessie +# 6. Callback leest school_id uit sessie → gebruikt zelfde credentials +# om de autorisatiecode in te wisselen + 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_school_google_creds(school_id: int): + """Haal Google credentials op voor een specifieke school. Geeft (id, secret) of (None, None).""" + school = School.query.get(school_id) + if school and school.google_client_id and school.google_client_secret: + return school.google_client_id, school.google_client_secret + return None, None + + 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) + """Zoek gebruiker op Google sub, dan e-mail, maak aan als nieuw.""" user = User.query.filter_by(oauth_provider='google', oauth_id=google_sub).first() if user: user.first_name = first_name or user.first_name @@ -269,7 +289,6 @@ def _get_or_create_google_user(email, first_name, last_name, google_sub): 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' @@ -279,7 +298,6 @@ def _get_or_create_google_user(email, first_name, last_name, google_sub): 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, @@ -295,24 +313,29 @@ def _get_or_create_google_user(email, first_name, last_name, google_sub): @auth_bp.route('/google') @limiter.limit('20 per minute') def google_login(): - if not _google_client_id(): - flash('Google login is niet geconfigureerd.', 'error') + school_id = request.args.get('school_id', type=int) + if not school_id: + flash('Geen school gevonden voor dit e-mailadres. Contacteer uw ICT-beheerder.', 'error') + return redirect(url_for('auth.login')) + + client_id, _ = _get_school_google_creds(school_id) + if not client_id: + flash('Google login is niet geconfigureerd voor deze school. ' + 'Contacteer uw ICT-beheerder.', '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 + session['google_oauth_state'] = state + session['google_oauth_school'] = school_id # bewaren voor de callback params = { - 'client_id': _google_client_id(), + 'client_id': client_id, 'response_type': 'code', 'redirect_uri': _google_callback_url(), 'scope': GOOGLE_SCOPES, 'state': state, - 'access_type': 'online', # geen refresh token nodig + 'access_type': 'online', '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)}") @@ -328,21 +351,31 @@ def google_callback(): state = request.args.get('state', '') expected_state = session.pop('google_oauth_state', None) + school_id = session.pop('google_oauth_school', 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')) + if not school_id: + flash('Sessie verlopen. Probeer opnieuw in te loggen.', 'error') + return redirect(url_for('auth.login')) + + client_id, client_secret = _get_school_google_creds(school_id) + if not client_id: + flash('Google login is niet (meer) geconfigureerd voor deze school.', '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(), + 'client_id': client_id, + 'client_secret': client_secret, 'code': code, 'redirect_uri': _google_callback_url(), 'grant_type': 'authorization_code', @@ -359,7 +392,6 @@ def google_callback(): 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, @@ -376,14 +408,12 @@ def google_callback(): 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 + google_sub = profile.get('sub', '') - # 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')) diff --git a/backend/templates/login.html b/backend/templates/login.html index eec9d5c..2d34c03 100644 --- a/backend/templates/login.html +++ b/backend/templates/login.html @@ -9,269 +9,128 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; + min-height: 100vh; display: flex; + align-items: center; justify-content: center; padding: 1rem; } .card { - background: white; - border-radius: 16px; - padding: 2.5rem; - width: 100%; - max-width: 400px; - box-shadow: 0 20px 60px rgba(0,0,0,0.2); + background: white; border-radius: 16px; padding: 2.5rem; + width: 100%; max-width: 400px; box-shadow: 0 20px 60px rgba(0,0,0,0.2); } .logo { text-align: center; margin-bottom: 2rem; } .logo .icon { font-size: 3rem; margin-bottom: 0.5rem; } - .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; } - - .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; - } - + .logo h1 { font-size: 1.4rem; color: #1f2937; font-weight: 700; } + .logo p { color: #6b7280; font-size: 0.85rem; margin-top: 0.25rem; } .btn-microsoft { - width: 100%; - padding: 0.85rem; - background: #0078d4; - color: white; - border: none; - border-radius: 8px; - font-size: 1rem; - font-weight: 600; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - gap: 0.75rem; - text-decoration: none; - transition: background 0.2s; - margin-bottom: 1rem; + width: 100%; padding: 0.85rem; background: #0078d4; color: white; + border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; + cursor: pointer; display: flex; align-items: center; justify-content: center; + gap: 0.75rem; text-decoration: none; transition: background 0.2s; margin-bottom: 1rem; } .btn-microsoft:hover { background: #006cbe; } - - .alert { - padding: 0.85rem 1rem; - border-radius: 8px; - margin-bottom: 1.25rem; - font-size: 0.875rem; - line-height: 1.5; + .sso-divider { + display: flex; align-items: center; gap: 0.75rem; + margin: 0.5rem 0 1.25rem; color: #9ca3af; font-size: 0.8rem; } + .sso-divider::before, .sso-divider::after { content: ''; flex: 1; height: 1px; background: #e5e7eb; } + .google-section-title { + font-size: 0.82rem; font-weight: 600; color: #374151; + margin-bottom: 0.6rem; display: flex; align-items: center; gap: 0.4rem; + } + .email-input-row { display: flex; gap: 0.5rem; } + .email-input-row input { + flex: 1; padding: 0.65rem 0.75rem; + border: 1px solid #d1d5db; border-radius: 8px; font-size: 0.95rem; + transition: border-color 0.15s, box-shadow 0.15s; + } + .email-input-row input:focus { + outline: none; border-color: #4285f4; + box-shadow: 0 0 0 3px rgba(66,133,244,0.15); + } + .btn-lookup { + padding: 0.65rem 1rem; background: #f3f4f6; color: #374151; + border: 1px solid #d1d5db; border-radius: 8px; + font-size: 0.9rem; font-weight: 500; cursor: pointer; + transition: background 0.15s; white-space: nowrap; + } + .btn-lookup:hover { background: #e5e7eb; } + .btn-lookup:disabled { opacity: 0.5; cursor: not-allowed; } + .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; cursor: pointer; + transition: background 0.15s, box-shadow 0.15s; + } + .btn-google:hover { background: #f9fafb; box-shadow: 0 1px 4px rgba(0,0,0,0.1); } + .school-found-label { font-size: 0.78rem; color: #6b7280; text-align: center; margin-top: 0.5rem; } + .lookup-msg { + margin-top: 0.5rem; padding: 0.6rem 0.75rem; border-radius: 6px; + font-size: 0.82rem; display: none; + } + .lookup-msg.error { background: #fef2f2; color: #dc2626; border: 1px solid #fecaca; } + .lookup-msg.warning { background: #fffbeb; color: #92400e; border: 1px solid #fde68a; } + .alert { padding: 0.85rem 1rem; border-radius: 8px; margin-bottom: 1.25rem; font-size: 0.875rem; } .alert-error { background: #fef2f2; color: #dc2626; border: 1px solid #fecaca; } - .alert-success { background: #f0fdf4; color: #16a34a; border: 1px solid #bbf7d0; } .alert-warning { background: #fffbeb; color: #92400e; border: 1px solid #fde68a; } - - .not-configured { - text-align: center; padding: 1.5rem; background: #f9fafb; - border-radius: 8px; color: #6b7280; font-size: 0.9rem; - border: 1px dashed #d1d5db; - } - .not-configured code { - background: #e5e7eb; padding: 0.15rem 0.4rem; - border-radius: 4px; font-size: 0.8rem; - } - - /* Superadmin fallback */ + .alert-success { background: #f0fdf4; color: #16a34a; border: 1px solid #bbf7d0; } .superadmin-toggle { - text-align: center; - margin-top: 1.5rem; - padding-top: 1rem; - border-top: 1px solid #f3f4f6; + text-align: center; margin-top: 1.5rem; + padding-top: 1rem; border-top: 1px solid #f3f4f6; } .superadmin-toggle button { - background: none; border: none; - color: #9ca3af; font-size: 0.75rem; - cursor: pointer; text-decoration: underline; - text-underline-offset: 2px; + background: none; border: none; color: #9ca3af; + font-size: 0.75rem; cursor: pointer; + text-decoration: underline; text-underline-offset: 2px; } .superadmin-toggle button:hover { color: #6b7280; } - .superadmin-form { display: none; margin-top: 1rem; } .superadmin-form.visible { display: block; } .superadmin-form .form-group { margin-bottom: 0.75rem; } - .superadmin-form label { - display: block; font-size: 0.8rem; - font-weight: 600; color: #374151; margin-bottom: 0.3rem; - } + .superadmin-form label { display: block; font-size: 0.8rem; font-weight: 600; color: #374151; margin-bottom: 0.3rem; } .superadmin-form input { width: 100%; padding: 0.6rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; } - .superadmin-form input:focus { - outline: none; border-color: #4f46e5; - box-shadow: 0 0 0 3px rgba(79,70,229,0.1); - } + .superadmin-form input:focus { outline: none; border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79,70,229,0.1); } .btn-superadmin { - width: 100%; padding: 0.6rem; - background: #6b7280; color: white; - border: none; border-radius: 6px; - font-size: 0.85rem; font-weight: 600; cursor: pointer; + width: 100%; padding: 0.6rem; background: #6b7280; color: white; + border: none; border-radius: 6px; font-size: 0.85rem; font-weight: 600; cursor: pointer; } .btn-superadmin:hover { background: #4b5563; } #sa-error { color: #dc2626; font-size: 0.8rem; margin-top: 0.5rem; display: none; } - -@media (prefers-color-scheme: dark) { - :root { - --gray-50: #1a1a2e; - --gray-100: #16213e; - --gray-200: #0f3460; - --gray-300: #1a1a3e; - --gray-400: #6b7280; - --gray-500: #9ca3af; - --gray-600: #d1d5db; - --gray-700: #e5e7eb; - --gray-800: #f3f4f6; - --gray-900: #f9fafb; - } - - body { background: #0f172a; color: #e2e8f0; } - - /* Kaarten en secties */ - .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"]) { - background: #1e293b !important; - border-color: #334155 !important; - } - - /* Header kaart in leerkracht.html */ - .header { background: #1e293b !important; } - - /* Tabellen */ - thead, th { background: #1e293b !important; color: #94a3b8 !important; border-color: #334155 !important; } - td { border-color: #1e293b !important; color: #e2e8f0; } - tr:hover td, tr:hover { background: #263548 !important; } - tr.status-groen { background: #064e3b !important; } - tr.status-groen:hover { background: #065f46 !important; } - tr.status-oranje { background: #451a03 !important; } - tr.status-oranje:hover { background: #78350f !important; } - tr.status-roze { background: #500724 !important; } - tr.status-roze:hover { background: #701a35 !important; } - - /* Inputs en selects */ - input, select, textarea { - background: #0f172a !important; - color: #e2e8f0 !important; - border-color: #334155 !important; - } - input::placeholder { color: #64748b !important; } - input:focus, select:focus, textarea:focus { - border-color: #6366f1 !important; - box-shadow: 0 0 0 3px rgba(99,102,241,0.2) !important; - } - - /* Role select inline */ - .role-select { - background: #1e293b !important; - color: #e2e8f0 !important; - border-color: #334155 !important; - } - - /* Modals */ - .modal { background: #1e293b !important; color: #e2e8f0; } - .modal h2 { color: #f1f5f9; } - - /* Knoppen */ - .btn-secondary { background: #334155 !important; color: #e2e8f0 !important; } - .btn-secondary:hover { background: #475569 !important; } - - /* Status selector knoppen (leerkracht tabel) */ - .status-selector.status-none { background: #1e293b !important; border-color: #475569 !important; color: #64748b !important; } - - /* Stat cards */ - .stat-card { background: #1e293b !important; } - - /* School card header */ - .school-card-header { background: #162032 !important; border-color: #334155 !important; } - .school-card { border-color: #334155 !important; } - - /* Drop zone */ - .drop-zone { background: #162032 !important; border-color: #334155 !important; } - .drop-zone:hover, .drop-zone.over { background: #1a2744 !important; border-color: #6366f1 !important; } - - /* Domain chips */ - .domain-chip { background: #1e3a5f !important; border-color: #2563eb !important; color: #93c5fd !important; } - - /* Badges */ - .leeftijd-badge { background: #334155 !important; color: #94a3b8 !important; } - .ebg-begrijpen { color: #1f2937 !important; } - - /* MIA container */ - .mia-container { background: #162032 !important; } - .mia-item { background: #1e293b !important; border-color: #334155 !important; color: #e2e8f0 !important; } - .mia-item.niet-aanklikbaar { background: #162032 !important; color: #64748b !important; } - - /* Not configured box */ - .not-configured { background: #162032 !important; border-color: #334155 !important; color: #94a3b8 !important; } - .not-configured code { background: #0f172a !important; color: #a5b4fc !important; } - - /* Profile section */ - .profile-section { background: #162032 !important; } - - /* Leeftijd checkboxes */ - .leeftijd-checkbox { border-color: #334155 !important; color: #e2e8f0; } - .leeftijd-checkbox:has(input:checked) { background: var(--primary) !important; } - - /* Vak indicator */ - .vak-indicator { /* gradient blijft, ziet er goed uit */ } - - /* Progress bars achtergrond */ - .progress-bar { background: #334155 !important; } - - /* Vak card */ - .vak-card { background: #162032 !important; } - - /* Upload results */ - .upload-ok { background: #064e3b !important; border-color: #065f46 !important; } - .upload-err { background: #450a0a !important; border-color: #7f1d1d !important; } - - /* Alert boxes */ - .alert-error { background: #450a0a !important; border-color: #7f1d1d !important; color: #fca5a5 !important; } - .alert-success { background: #064e3b !important; border-color: #065f46 !important; color: #6ee7b7 !important; } - .alert-warning { background: #451a03 !important; border-color: #78350f !important; color: #fcd34d !important; } - .alert-info { background: #1e3a5f !important; border-color: #1d4ed8 !important; color: #93c5fd !important; } - - /* Error text */ - .form-error, #sa-error, #addUser-error { color: #f87171 !important; } - .form-hint { color: #64748b !important; } - - /* Superadmin toggle */ - .superadmin-toggle { border-color: #334155 !important; } - .superadmin-toggle button { color: #475569 !important; } - .superadmin-toggle button:hover { color: #94a3b8 !important; } - - /* Superadmin form inputs */ - .superadmin-form label { color: #94a3b8 !important; } - - /* Footer */ - .footer { color: #64748b !important; } - .status-legend { background: #162032 !important; border-color: #334155 !important; color: #94a3b8 !important; } - - /* Scrollbar (webkit) */ - ::-webkit-scrollbar { width: 8px; height: 8px; } - ::-webkit-scrollbar-track { background: #0f172a; } - ::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; } - ::-webkit-scrollbar-thumb:hover { background: #475569; } -} - + .spinner-inline { + display: inline-block; width: 14px; height: 14px; + border: 2px solid #d1d5db; border-top-color: #6b7280; + border-radius: 50%; animation: spin 0.7s linear infinite; + } + @keyframes spin { to { transform: rotate(360deg); } } + @media (prefers-color-scheme: dark) { + body { background: linear-gradient(135deg, #1e1b4b, #312e81); } + .card { background: #1e293b; color: #e2e8f0; } + .logo h1 { color: #f1f5f9; } + .logo p { color: #94a3b8; } + .sso-divider { color: #475569; } + .sso-divider::before, .sso-divider::after { background: #334155; } + .google-section-title { color: #cbd5e1; } + .email-input-row input { background: #0f172a; color: #e2e8f0; border-color: #334155; } + .email-input-row input::placeholder { color: #475569; } + .email-input-row input:focus { border-color: #4285f4; } + .btn-lookup { background: #334155; color: #e2e8f0; border-color: #475569; } + .btn-lookup:hover { background: #475569; } + .btn-google { background: #0f172a; color: #e2e8f0; border-color: #334155; } + .btn-google:hover { background: #1e293b; } + .school-found-label { color: #94a3b8; } + .lookup-msg.error { background: #450a0a; border-color: #7f1d1d; color: #fca5a5; } + .lookup-msg.warning { background: #451a03; border-color: #78350f; color: #fcd34d; } + .superadmin-toggle { border-color: #334155; } + .superadmin-toggle button { color: #475569; } + .superadmin-toggle button:hover { color: #94a3b8; } + .superadmin-form label { color: #94a3b8; } + .superadmin-form input { background: #0f172a; color: #e2e8f0; border-color: #334155; } + .alert-error { background: #450a0a; border-color: #7f1d1d; color: #fca5a5; } + .alert-warning { background: #451a03; border-color: #78350f; color: #fcd34d; } + } +
@@ -287,6 +146,7 @@ {% endfor %} {% endwith %} + {# Microsoft: één global endpoint — werkt direct voor alle scholen #} {% if entra_configured %} @@ -297,46 +157,36 @@ Inloggen met Microsoft +
of via Google Workspace
{% endif %} - {% if entra_configured and google_configured %} -
of
- {% endif %} - - {% if google_configured %} - - + {# Google: email-first — domein bepaalt welke school-credentials gebruikt worden #} +
+
+ - - Inloggen met Google - - {% endif %} - - {% if not entra_configured and not google_configured %} -
- SSO login niet geconfigureerd

- Stel MICROSOFT_CLIENT_ID of GOOGLE_CLIENT_ID - in de .env in om SSO login te activeren. + Inloggen met Google Workspace
- {% endif %} + +
+
+ +
- {% if entra_configured or google_configured %} -

- Log in met uw school account -

- {% endif %} - -
-
-
+
Platformbeheerder toegang
@@ -353,49 +203,94 @@
+ diff --git a/backend/templates/scholengroep_ict.html b/backend/templates/scholengroep_ict.html index 3623df5..e845c57 100644 --- a/backend/templates/scholengroep_ict.html +++ b/backend/templates/scholengroep_ict.html @@ -256,15 +256,40 @@ toevoegen
+ +
+
+

🔑 Google Workspace SSO

+
+

+ Leerkrachten en directeurs kunnen inloggen met hun Google Workspace account van deze school. + Maak hiervoor een OAuth2-app aan in de + Google Cloud Console + en vul de gegevens hieronder in. +

+ + +
+ +
+
+ + +
+ Eindigt altijd op .apps.googleusercontent.com +
+
+
+ + +
+ Het secret is nooit zichtbaar na opslaan — vul het opnieuw in om te wijzigen. +
+
+
+ +
+ + +
+ + + +
+ + 📋 Hoe stel ik een Google OAuth2-app in? + +
    +
  1. Ga naar console.cloud.google.com → maak een project aan voor uw school
  2. +
  3. Ga naar API's en services → Inlogscherm OAuth → kies "Intern" (enkel uw Workspace)
  4. +
  5. Ga naar Credentials → Create Credentials → OAuth client ID
  6. +
  7. Type: Webapplicatie
  8. +
  9. Voeg als Redirect URI toe: + + Laden... + +
  10. +
  11. Kopieer de Client ID en het Client Secret en plak ze hierboven
  12. +
+
+
+
@@ -345,6 +414,7 @@ document.addEventListener('DOMContentLoaded', async () => { document.getElementById('schoolName').textContent = me.user?.school_name || ''; await loadUsers(); await loadKlassen(); + await loadSsoStatus(); await loadAuditLog(); }); @@ -592,6 +662,74 @@ async function loadAuditLog(page = 1) { `).join(''); } +// ── Google SSO beheer ───────────────────────────────────────────────────────── +async function loadSsoStatus() { + const res = await fetch('/admin/schools'); + if (!res.ok) return; + const data = await res.json(); + const school = (data.schools || []).find(s => s.id === mySchoolId); + + const statusEl = document.getElementById('ssoStatus'); + if (!statusEl || !school) return; + + // Toon de redirect URI in de instructies + const redirectEl = document.getElementById('redirectUriDisplay'); + if (redirectEl) redirectEl.textContent = window.location.origin + '/auth/google/callback'; + + if (school.google_sso_configured) { + statusEl.innerHTML = ` +
+ ✅ Google SSO is actief + — Client ID: ${school.google_client_id} +
`; + } else { + statusEl.innerHTML = ` +
+ ⚠️ Google SSO is nog niet ingesteld +
`; + } +} + +async function saveSso() { + const errEl = document.getElementById('ssoError'); + const clientId = document.getElementById('ssoClientId').value.trim(); + const clientSecret = document.getElementById('ssoClientSecret').value.trim(); + errEl.style.display = 'none'; + + if (!clientId || !clientSecret) { + errEl.textContent = 'Vul zowel het Client ID als het Client Secret in.'; + errEl.style.display = 'block'; + return; + } + + const res = await fetch(`/admin/schools/${mySchoolId}/google-sso`, { + method: 'PUT', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ google_client_id: clientId, google_client_secret: clientSecret }) + }); + const data = await res.json(); + if (!res.ok) { errEl.textContent = data.error; errEl.style.display = 'block'; return; } + + document.getElementById('ssoClientId').value = ''; + document.getElementById('ssoClientSecret').value = ''; + notify('Google SSO ingesteld ✅', 'success'); + await loadSsoStatus(); +} + +async function clearSso() { + if (!confirm('Google SSO verwijderen? Leerkrachten kunnen dan niet meer inloggen via Google.')) return; + const res = await fetch(`/admin/schools/${mySchoolId}/google-sso`, { + method: 'PUT', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ clear: true }) + }); + if (!res.ok) { notify('Verwijderen mislukt', 'error'); return; } + notify('Google SSO verwijderd', 'success'); + await loadSsoStatus(); +} + // ── Event delegation voor dynamisch gegenereerde elementen ──────────────────── document.addEventListener('click', function(e) { const btn = e.target.closest('[data-action]');