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

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

View File

@@ -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():