feat: add Google Workspace SSO configuration per school
All checks were successful
Build & Push / Build & Push image (push) Successful in 39s
All checks were successful
Build & Push / Build & Push image (push) Successful in 39s
- Implemented Google SSO management in the school settings, allowing schools to configure their own OAuth2 credentials. - Added fields for Client ID and Client Secret in the edit school modal and school detail page. - Introduced functionality to save and clear Google SSO settings via API. - Updated UI to display current SSO status and instructions for setting up Google OAuth2. - Created a new database migration to add `google_client_id` and `google_client_secret` columns to the schools table.
This commit is contained in:
29
backend/migrations/versions/0003_school_google_sso.py
Normal file
29
backend/migrations/versions/0003_school_google_sso.py
Normal file
@@ -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')
|
||||
@@ -12,6 +12,9 @@ class School(db.Model):
|
||||
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')
|
||||
@@ -23,6 +26,9 @@ class School(db.Model):
|
||||
'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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -102,6 +102,55 @@ def update_school(school_id):
|
||||
return jsonify({'school': school.to_dict()})
|
||||
|
||||
|
||||
@admin_bp.route('/schools/<int:school_id>/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/<int:school_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
@scholengroep_ict_required
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -112,10 +112,11 @@ 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())
|
||||
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=<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_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'))
|
||||
|
||||
@@ -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;
|
||||
.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;
|
||||
}
|
||||
|
||||
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;
|
||||
@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; }
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
@@ -287,6 +146,7 @@
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
{# Microsoft: één global endpoint — werkt direct voor alle scholen #}
|
||||
{% if entra_configured %}
|
||||
<a href="/auth/microsoft" class="btn-microsoft">
|
||||
<svg width="20" height="20" viewBox="0 0 21 21">
|
||||
@@ -297,46 +157,36 @@
|
||||
</svg>
|
||||
Inloggen met Microsoft
|
||||
</a>
|
||||
<div class="sso-divider">of via Google Workspace</div>
|
||||
{% endif %}
|
||||
|
||||
{% if entra_configured and google_configured %}
|
||||
<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">
|
||||
{# Google: email-first — domein bepaalt welke school-credentials gebruikt worden #}
|
||||
<div class="google-section">
|
||||
<div class="google-section-title">
|
||||
<svg width="15" height="15" 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">
|
||||
<strong>SSO login niet geconfigureerd</strong><br><br>
|
||||
Stel <code>MICROSOFT_CLIENT_ID</code> of <code>GOOGLE_CLIENT_ID</code>
|
||||
in de <code>.env</code> in om SSO login te activeren.
|
||||
Inloggen met Google Workspace
|
||||
</div>
|
||||
<div class="email-input-row">
|
||||
<input type="email" id="googleEmail"
|
||||
placeholder="uw.naam@school.be"
|
||||
autocomplete="email" autocapitalize="none" spellcheck="false">
|
||||
<button class="btn-lookup" id="btnLookup">Verder →</button>
|
||||
</div>
|
||||
<div class="lookup-msg error" id="lookupError"></div>
|
||||
<div class="lookup-msg warning" id="lookupWarning"></div>
|
||||
<div id="googleResult" style="display:none; margin-top:0.85rem;"></div>
|
||||
</div>
|
||||
{% 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 -->
|
||||
<div class="superadmin-toggle">
|
||||
<button id="btnToggleSuperadmin">Platformbeheerder</button>
|
||||
</div>
|
||||
|
||||
<div class="superadmin-form" id="superadminForm">
|
||||
<div style="font-size:0.8rem; color:#6b7280; margin-bottom:0.75rem; text-align:center;">
|
||||
<div style="font-size:0.8rem;color:#6b7280;margin-bottom:.75rem;text-align:center;">
|
||||
Platformbeheerder toegang
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -353,49 +203,94 @@
|
||||
</div>
|
||||
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
function bind(id, ev, fn) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.addEventListener(ev, fn);
|
||||
function bind(id, ev, fn) { const el = document.getElementById(id); if (el) el.addEventListener(ev, fn); }
|
||||
|
||||
const GOOGLE_SVG = `<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"/>
|
||||
</svg>`;
|
||||
|
||||
let busy = false;
|
||||
|
||||
async function doGoogleLookup() {
|
||||
if (busy) return;
|
||||
const email = document.getElementById('googleEmail').value.trim();
|
||||
const errEl = document.getElementById('lookupError');
|
||||
const warnEl = document.getElementById('lookupWarning');
|
||||
const resultEl = document.getElementById('googleResult');
|
||||
const btn = document.getElementById('btnLookup');
|
||||
|
||||
errEl.style.display = 'none';
|
||||
warnEl.style.display = 'none';
|
||||
resultEl.style.display = 'none';
|
||||
resultEl.innerHTML = '';
|
||||
|
||||
if (!email || !email.includes('@')) {
|
||||
errEl.textContent = 'Vul een geldig e-mailadres in.';
|
||||
errEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
busy = true; btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-inline"></span>';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/sso-lookup?email=' + encodeURIComponent(email));
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.found) {
|
||||
warnEl.innerHTML = 'Geen school gevonden voor <strong>' + email.split('@')[1] + '</strong>. '
|
||||
+ 'Controleer uw e-mailadres of contacteer uw ICT-beheerder.';
|
||||
warnEl.style.display = 'block';
|
||||
} else if (!data.google) {
|
||||
warnEl.innerHTML = '<strong>' + data.school_name + '</strong> heeft Google Workspace SSO '
|
||||
+ 'nog niet ingesteld. Contacteer uw school ICT-beheerder.';
|
||||
warnEl.style.display = 'block';
|
||||
} else {
|
||||
resultEl.innerHTML = '<a href="/auth/google?school_id=' + data.school_id + '" class="btn-google">'
|
||||
+ GOOGLE_SVG + 'Doorgaan met Google — ' + data.school_name + '</a>'
|
||||
+ '<p class="school-found-label">School herkend via ' + email.split('@')[1] + '</p>';
|
||||
resultEl.style.display = 'block';
|
||||
}
|
||||
} catch {
|
||||
errEl.textContent = 'Verbindingsfout. Probeer opnieuw.';
|
||||
errEl.style.display = 'block';
|
||||
} finally {
|
||||
busy = false; btn.disabled = false; btn.textContent = 'Verder \u2192';
|
||||
}
|
||||
}
|
||||
|
||||
let saVisible = false;
|
||||
|
||||
function toggleSuperadmin() {
|
||||
let saVisible = false;
|
||||
function toggleSuperadmin() {
|
||||
saVisible = !saVisible;
|
||||
document.getElementById('superadminForm').classList.toggle('visible', saVisible);
|
||||
if (saVisible) document.getElementById('saPassword').focus();
|
||||
}
|
||||
|
||||
async function superadminLogin() {
|
||||
const errorEl = document.getElementById('sa-error');
|
||||
errorEl.style.display = 'none';
|
||||
}
|
||||
|
||||
async function superadminLogin() {
|
||||
const errEl = document.getElementById('sa-error');
|
||||
errEl.style.display = 'none';
|
||||
const res = await fetch('/auth/superadmin-login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
email: document.getElementById('saEmail').value,
|
||||
password: document.getElementById('saPassword').value,
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
window.location.href = data.redirect || '/dashboard';
|
||||
} else {
|
||||
errorEl.textContent = data.error || 'Inloggen mislukt';
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && saVisible) superadminLogin();
|
||||
});
|
||||
if (res.ok) { window.location.href = data.redirect || '/dashboard'; }
|
||||
else { errEl.textContent = data.error || 'Inloggen mislukt'; errEl.style.display = 'block'; }
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
bind('btnToggleSuperadmin', 'click', () => { toggleSuperadmin() });
|
||||
bind('btnSuperadminLogin', 'click', () => { superadminLogin() });
|
||||
bind('btnLookup', 'click', doGoogleLookup);
|
||||
bind('btnToggleSuperadmin', 'click', toggleSuperadmin);
|
||||
bind('btnSuperadminLogin', 'click', superadminLogin);
|
||||
document.getElementById('googleEmail').addEventListener('keydown', e => { if (e.key === 'Enter') doGoogleLookup(); });
|
||||
document.getElementById('saPassword').addEventListener('keydown', e => { if (e.key === 'Enter') superadminLogin(); });
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -256,11 +256,36 @@ toevoegen</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="modal-editSchool">
|
||||
<div class="modal">
|
||||
<div class="modal" style="max-width:520px;">
|
||||
<h2>School bewerken</h2>
|
||||
<input type="hidden" id="editSchoolId">
|
||||
<div class="form-group"><label>Naam</label><input type="text" id="editSchoolName"></div>
|
||||
<div class="form-group"><label>E-maildomeinen</label><input type="text" id="editSchoolDomains"><div class="form-hint">Komma-gescheiden.</div></div>
|
||||
<hr style="border:none;border-top:1px solid var(--gray-200);margin:1rem 0;">
|
||||
<div style="font-size:.82rem;font-weight:700;color:var(--gray-600);margin-bottom:.65rem;">
|
||||
<svg width="13" height="13" viewBox="0 0 48 48" style="vertical-align:middle;margin-right:.3rem;">
|
||||
<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"/>
|
||||
</svg>
|
||||
Google Workspace SSO
|
||||
</div>
|
||||
<div id="editSsoStatus" style="margin-bottom:.75rem;font-size:.8rem;"></div>
|
||||
<div class="form-group">
|
||||
<label>Client ID</label>
|
||||
<input type="text" id="editGoogleClientId" placeholder="...apps.googleusercontent.com" style="font-family:monospace;font-size:.82rem;">
|
||||
<div class="form-hint">Leeg laten = huidige waarde behouden. Vul in om te wijzigen.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Client Secret</label>
|
||||
<input type="password" id="editGoogleClientSecret" placeholder="GOCSPX-..." style="font-family:monospace;font-size:.82rem;">
|
||||
</div>
|
||||
<div style="margin-bottom:.75rem;">
|
||||
<label style="display:flex;align-items:center;gap:.4rem;font-size:.8rem;cursor:pointer;color:var(--danger);">
|
||||
<input type="checkbox" id="editGoogleClear"> Google SSO verwijderen voor deze school
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-error" id="edit-school-error"></div>
|
||||
<div class="modal-buttons">
|
||||
<button class="btn btn-secondary" id="btnCancelEditSch">Annuleren</button>
|
||||
@@ -435,7 +460,10 @@ async function loadSchoolsTable() {
|
||||
tbody.innerHTML = schools.map(s => `
|
||||
<tr>
|
||||
<td><strong>${s.name}</strong></td>
|
||||
<td>${(s.email_domains||[]).map(d=>`<span class="domain-chip">${d}</span>`).join('') || '<em style="color:var(--gray-400)">geen</em>'}</td>
|
||||
<td>${(s.email_domains||[]).map(d=>`<span class="domain-chip">${d}</span>`).join('') || '<em style="color:var(--gray-400)">geen</em>'}
|
||||
${s.google_sso_configured
|
||||
? '<span style="margin-left:.4rem;font-size:.7rem;background:#d1fae5;color:#065f46;padding:.15rem .4rem;border-radius:4px;font-weight:600;">G SSO ✓</span>'
|
||||
: ''}</td>
|
||||
<td style="color:var(--gray-500);">${s.user_count}</td>
|
||||
<td style="display:flex;gap:.35rem;">
|
||||
<button class="btn btn-secondary btn-sm" data-action="editSchool" data-id="${s.id}" data-name="${s.name.replace(/'/g,''')}" data-domains="${(s.email_domains||[]).join(', ')}">Bewerken</button>
|
||||
@@ -465,6 +493,18 @@ function editSchool(id, name, domainsStr) {
|
||||
document.getElementById('editSchoolId').value = id;
|
||||
document.getElementById('editSchoolName').value = name;
|
||||
document.getElementById('editSchoolDomains').value = domainsStr;
|
||||
// Reset Google velden
|
||||
document.getElementById('editGoogleClientId').value = '';
|
||||
document.getElementById('editGoogleClientSecret').value = '';
|
||||
document.getElementById('editGoogleClear').checked = false;
|
||||
// Toon huidige SSO status
|
||||
const school = schools.find(s => s.id == id);
|
||||
const ssoStatusEl = document.getElementById('editSsoStatus');
|
||||
if (ssoStatusEl && school) {
|
||||
ssoStatusEl.innerHTML = school.google_sso_configured
|
||||
? `<span style="color:#065f46;background:#d1fae5;padding:.25rem .5rem;border-radius:4px;">✅ Google SSO actief — Client ID: ${school.google_client_id}</span>`
|
||||
: `<span style="color:#92400e;background:#fef3c7;padding:.25rem .5rem;border-radius:4px;">⚠️ Google SSO nog niet ingesteld</span>`;
|
||||
}
|
||||
openModal('editSchool');
|
||||
}
|
||||
|
||||
@@ -472,13 +512,47 @@ async function saveSchool() {
|
||||
const err = document.getElementById('edit-school-error');
|
||||
err.style.display = 'none';
|
||||
const id = document.getElementById('editSchoolId').value;
|
||||
|
||||
// 1. Sla naam en domeinen op (bestaand endpoint — scholengroep_ict)
|
||||
const res = await fetch(`/admin/schools/${id}`, {
|
||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ name: document.getElementById('editSchoolName').value, email_domains: document.getElementById('editSchoolDomains').value.split(',').map(d=>d.trim()).filter(Boolean) })
|
||||
body: JSON.stringify({
|
||||
name: document.getElementById('editSchoolName').value,
|
||||
email_domains: document.getElementById('editSchoolDomains').value.split(',').map(d=>d.trim()).filter(Boolean)
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { err.textContent = data.error; err.style.display = 'block'; return; }
|
||||
closeModal(); notify('School opgeslagen', 'success');
|
||||
|
||||
// 2. Verwerk Google SSO velden (apart endpoint — ondersteunt ook school_ict)
|
||||
const clearSso = document.getElementById('editGoogleClear').checked;
|
||||
const clientId = document.getElementById('editGoogleClientId').value.trim();
|
||||
const clientSec = document.getElementById('editGoogleClientSecret').value.trim();
|
||||
|
||||
if (clearSso) {
|
||||
const ssoRes = await fetch(`/admin/schools/${id}/google-sso`, {
|
||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ clear: true })
|
||||
});
|
||||
if (!ssoRes.ok) {
|
||||
const ssoData = await ssoRes.json();
|
||||
err.textContent = 'SSO: ' + (ssoData.error || 'Verwijderen mislukt');
|
||||
err.style.display = 'block'; return;
|
||||
}
|
||||
} else if (clientId || clientSec) {
|
||||
const ssoRes = await fetch(`/admin/schools/${id}/google-sso`, {
|
||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ google_client_id: clientId, google_client_secret: clientSec })
|
||||
});
|
||||
if (!ssoRes.ok) {
|
||||
const ssoData = await ssoRes.json();
|
||||
err.textContent = 'SSO: ' + (ssoData.error || 'Opslaan mislukt');
|
||||
err.style.display = 'block'; return;
|
||||
}
|
||||
}
|
||||
|
||||
closeModal();
|
||||
notify('School opgeslagen', 'success');
|
||||
await Promise.all([loadSchoolsTable(), loadSchoolsGrid()]);
|
||||
}
|
||||
|
||||
|
||||
@@ -248,6 +248,75 @@
|
||||
<div id="klassenList">Laden...</div>
|
||||
</div>
|
||||
|
||||
<!-- Google Workspace SSO -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h2>🔑 Google Workspace SSO</h2>
|
||||
</div>
|
||||
<p style="color:var(--gray-500);font-size:.85rem;margin-bottom:1.25rem;line-height:1.6;">
|
||||
Leerkrachten en directeurs kunnen inloggen met hun Google Workspace account van deze school.
|
||||
Maak hiervoor een OAuth2-app aan in de
|
||||
<a href="https://console.cloud.google.com/apis/credentials" target="_blank" rel="noopener"
|
||||
style="color:var(--primary);">Google Cloud Console</a>
|
||||
en vul de gegevens hieronder in.
|
||||
</p>
|
||||
|
||||
<!-- Status badge -->
|
||||
<div id="ssoStatus" style="margin-bottom:1.25rem;"></div>
|
||||
|
||||
<div style="display:grid;gap:.85rem;max-width:520px;">
|
||||
<div>
|
||||
<label style="display:block;font-size:.82rem;font-weight:600;color:var(--gray-600);margin-bottom:.35rem;">
|
||||
Client ID
|
||||
</label>
|
||||
<input type="text" id="ssoClientId"
|
||||
placeholder="1234567890-abc123.apps.googleusercontent.com"
|
||||
style="width:100%;padding:.6rem .75rem;border:1px solid var(--gray-300);border-radius:6px;font-size:.85rem;font-family:monospace;">
|
||||
<div style="font-size:.75rem;color:var(--gray-500);margin-top:.3rem;">
|
||||
Eindigt altijd op <code>.apps.googleusercontent.com</code>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:.82rem;font-weight:600;color:var(--gray-600);margin-bottom:.35rem;">
|
||||
Client Secret
|
||||
</label>
|
||||
<input type="password" id="ssoClientSecret"
|
||||
placeholder="GOCSPX-..."
|
||||
style="width:100%;padding:.6rem .75rem;border:1px solid var(--gray-300);border-radius:6px;font-size:.85rem;font-family:monospace;">
|
||||
<div style="font-size:.75rem;color:var(--gray-500);margin-top:.3rem;">
|
||||
Het secret is nooit zichtbaar na opslaan — vul het opnieuw in om te wijzigen.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:1rem;display:flex;gap:.5rem;flex-wrap:wrap;">
|
||||
<button class="btn btn-primary btn-sm" id="btnSaveSso">💾 Opslaan</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btnClearSso"
|
||||
style="color:var(--danger);">🗑 SSO verwijderen</button>
|
||||
</div>
|
||||
<div id="ssoError" style="color:var(--danger);font-size:.82rem;margin-top:.5rem;display:none;"></div>
|
||||
|
||||
<!-- Instructies -->
|
||||
<details style="margin-top:1.5rem;border:1px solid var(--gray-200);border-radius:8px;padding:.85rem 1rem;">
|
||||
<summary style="cursor:pointer;font-weight:600;font-size:.85rem;color:var(--gray-700);">
|
||||
📋 Hoe stel ik een Google OAuth2-app in?
|
||||
</summary>
|
||||
<ol style="margin-top:.85rem;padding-left:1.25rem;font-size:.83rem;color:var(--gray-600);line-height:2;">
|
||||
<li>Ga naar <strong>console.cloud.google.com</strong> → maak een project aan voor uw school</li>
|
||||
<li>Ga naar <strong>API's en services → Inlogscherm OAuth</strong> → kies "Intern" (enkel uw Workspace)</li>
|
||||
<li>Ga naar <strong>Credentials → Create Credentials → OAuth client ID</strong></li>
|
||||
<li>Type: <strong>Webapplicatie</strong></li>
|
||||
<li>Voeg als Redirect URI toe:
|
||||
<code id="redirectUriDisplay"
|
||||
style="display:block;margin-top:.25rem;padding:.35rem .5rem;background:var(--gray-100);border-radius:4px;font-size:.8rem;word-break:break-all;user-select:all;">
|
||||
Laden...
|
||||
</code>
|
||||
</li>
|
||||
<li>Kopieer de <strong>Client ID</strong> en het <strong>Client Secret</strong> en plak ze hierboven</li>
|
||||
</ol>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Auditlog -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
@@ -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) {
|
||||
</button>`).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 = `
|
||||
<div style="display:inline-flex;align-items:center;gap:.5rem;
|
||||
padding:.5rem .85rem;background:#d1fae5;color:#065f46;
|
||||
border-radius:6px;font-size:.83rem;font-weight:600;">
|
||||
✅ Google SSO is actief
|
||||
<span style="font-weight:400;opacity:.8;">— Client ID: ${school.google_client_id}</span>
|
||||
</div>`;
|
||||
} else {
|
||||
statusEl.innerHTML = `
|
||||
<div style="display:inline-flex;align-items:center;gap:.5rem;
|
||||
padding:.5rem .85rem;background:#fef3c7;color:#92400e;
|
||||
border-radius:6px;font-size:.83rem;">
|
||||
⚠️ Google SSO is nog niet ingesteld
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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]');
|
||||
|
||||
Reference in New Issue
Block a user