Compare commits

18 Commits

Author SHA1 Message Date
26ad6cd650 fix: update age range in filters for consistency across templates
All checks were successful
Build & Push / Build & Push image (push) Successful in 39s
2026-03-06 12:11:20 +01:00
04fe593d0c feat: implement modal functionality for linking teachers and adding new classes with improved UI
All checks were successful
Build & Push / Build & Push image (push) Successful in 41s
2026-03-06 10:07:32 +01:00
bbd4e332f4 feat: add CRUD functionality for classes with API endpoints and UI integration
All checks were successful
Build & Push / Build & Push image (push) Successful in 41s
2026-03-06 09:12:32 +01:00
653cc6cd74 fix: update class teacher assignment and enhance UI for teacher selection
All checks were successful
Build & Push / Build & Push image (push) Successful in 40s
2026-03-05 23:41:05 +01:00
4bfa3cd486 feat: add functionality for linking teachers to classes with a new UI tab and API endpoint
All checks were successful
Build & Push / Build & Push image (push) Successful in 40s
2026-03-05 23:30:13 +01:00
2782c3bea6 feat: enhance class access logic for directors in my_classes endpoint
All checks were successful
Build & Push / Build & Push image (push) Successful in 40s
2026-03-05 23:13:50 +01:00
d4f09bb368 fix: update status indicator symbols for better clarity
All checks were successful
Build & Push / Build & Push image (push) Successful in 40s
2026-03-05 23:05:35 +01:00
1acaf26a38 Migrate assessments to class-based model
All checks were successful
Build & Push / Build & Push image (push) Successful in 42s
- Dropped the existing assessments table to remove user_id association.
- Created a new assessments table linked to classes (class_id) allowing multiple teachers to share assessments.
- Added necessary indexes for performance optimization.
- Downgrade functionality to revert back to user-based assessments if needed.
2026-03-05 22:36:36 +01:00
d55b700502 feat: enhance Google SSO management by adding save and clear buttons
All checks were successful
Build & Push / Build & Push image (push) Successful in 40s
2026-03-05 12:46:34 +01:00
6e5c898d97 revert c59afa6595
All checks were successful
Build & Push / Build & Push image (push) Successful in 40s
revert fix: change file permissions for entrypoint.sh and migrate.sh
2026-03-05 11:24:28 +00:00
c59afa6595 fix: change file permissions for entrypoint.sh and migrate.sh
All checks were successful
Build & Push / Build & Push image (push) Successful in 42s
2026-03-05 11:58:28 +01:00
Sam
50d029c67e feat: add leerkracht-view route and update templates for director mode
All checks were successful
Build & Push / Build & Push image (push) Successful in 39s
2026-03-04 14:31:29 +01:00
Sam
187232a95f feat: update user access control to allow directors to list school users
All checks were successful
Build & Push / Build & Push image (push) Successful in 40s
2026-03-04 14:14:28 +01:00
Sam
5afe297161 feat: add class management page for directors and enhance access control
All checks were successful
Build & Push / Build & Push image (push) Successful in 40s
2026-03-04 11:45:45 +01:00
Sam
c571967701 feat: display redirect URI for Google SSO in user interface
All checks were successful
Build & Push / Build & Push image (push) Successful in 41s
2026-03-04 10:06:02 +01:00
Sam
ee8fcb231b feat: enhance user management table with search functionality and improved layout
All checks were successful
Build & Push / Build & Push image (push) Successful in 40s
2026-03-03 23:10:31 +01:00
Sam
b470cd017e feat: add Google Workspace SSO configuration per school
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.
2026-03-03 22:40:14 +01:00
Sam
55cd055645 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.
2026-03-03 20:33:49 +01:00
16 changed files with 2734 additions and 2155 deletions

View File

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

View File

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

View 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')

View File

@@ -0,0 +1,60 @@
"""assessments: herstructureer naar klasgebonden model
Revision ID: 0004
Revises: 0003
Create Date: 2026-03-05
Wijziging: assessments zijn niet langer gekoppeld aan een individuele
leerkracht (user_id) maar aan een klas (class_id). Meerdere leerkrachten
van dezelfde klas delen één set beoordelingen.
OPGELET: dit dropt de bestaande assessments tabel — testdata gaat verloren.
"""
from alembic import op
import sqlalchemy as sa
revision = '0004'
down_revision = '0003'
branch_labels = None
depends_on = None
def upgrade():
# Drop oude tabel volledig (testomgeving — geen productiedata)
op.execute("DROP TABLE IF EXISTS assessments CASCADE")
# Nieuwe tabel: klasgebonden, geen user_id
op.execute("""
CREATE TABLE assessments (
id SERIAL PRIMARY KEY,
class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
school_year_id INTEGER NOT NULL REFERENCES school_years(id) ON DELETE CASCADE,
vak_id VARCHAR(50) NOT NULL,
goal_id VARCHAR(50) NOT NULL,
status VARCHAR(10) NOT NULL,
opmerking VARCHAR(500),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(class_id, school_year_id, vak_id, goal_id)
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_assessments_class_year ON assessments(class_id, school_year_id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_assessments_vak ON assessments(vak_id)")
def downgrade():
op.execute("DROP TABLE IF EXISTS assessments CASCADE")
# Zet terug naar user-gebaseerde tabel (zonder data)
op.execute("""
CREATE TABLE assessments (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
school_id INTEGER NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
school_year_id INTEGER NOT NULL REFERENCES school_years(id) ON DELETE CASCADE,
vak_id VARCHAR(50) NOT NULL,
goal_id VARCHAR(50) NOT NULL,
status VARCHAR(10) NOT NULL,
opmerking VARCHAR(500),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, school_year_id, vak_id, goal_id)
)
""")

View File

@@ -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),
}
@@ -145,8 +151,8 @@ class Assessment(db.Model):
__tablename__ = 'assessments'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
school_id = db.Column(db.Integer, db.ForeignKey('schools.id', ondelete='CASCADE'), nullable=False)
# Klasgebonden — geen koppeling aan individuele leerkracht
class_id = db.Column(db.Integer, db.ForeignKey('classes.id', ondelete='CASCADE'), nullable=False)
school_year_id = db.Column(db.Integer, db.ForeignKey('school_years.id', ondelete='CASCADE'), nullable=False)
vak_id = db.Column(db.String(50), nullable=False)
goal_id = db.Column(db.String(50), nullable=False)
@@ -154,17 +160,18 @@ class Assessment(db.Model):
opmerking = db.Column(db.String(500), nullable=True)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = db.relationship('User')
school = db.relationship('School')
klas = db.relationship('Class')
school_year = db.relationship('SchoolYear', back_populates='assessments')
__table_args__ = (
db.UniqueConstraint('user_id', 'school_year_id', 'vak_id', 'goal_id'),
db.UniqueConstraint('class_id', 'school_year_id', 'vak_id', 'goal_id',
name='uq_assessment_class_year_vak_goal'),
)
def to_dict(self):
return {
'id': self.id,
'class_id': self.class_id,
'vak_id': self.vak_id,
'goal_id': self.goal_id,
'status': self.status,

View File

@@ -48,6 +48,16 @@ def school_ict_required(f):
return decorated
def director_or_ict_required(f):
"""Decorator: school_ict én director mogen door (binnen eigen school)."""
@wraps(f)
def decorated(*args, **kwargs):
if not current_user.is_director: # is_director omvat ook school_ict en hoger
return jsonify({'error': 'Geen toegang'}), 403
return f(*args, **kwargs)
return decorated
# ── Scholen (scholengroep_ict) ────────────────────────────────────────────────
@admin_bp.route('/schools', methods=['GET'])
@@ -102,6 +112,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
@@ -179,7 +238,7 @@ def activate_year(year_id):
@admin_bp.route('/schools/<int:school_id>/users', methods=['GET'])
@login_required
@school_ict_required
@director_or_ict_required
def list_school_users(school_id):
if not current_user.is_scholengroep_ict and current_user.school_id != school_id:
return jsonify({'error': 'Geen toegang tot deze school'}), 403
@@ -528,7 +587,7 @@ def global_stats():
@admin_bp.route('/schools/<int:school_id>/classes', methods=['GET'])
@login_required
@school_ict_required
@director_or_ict_required
def list_classes(school_id):
if not current_user.is_scholengroep_ict and current_user.school_id != school_id:
return jsonify({'error': 'Geen toegang'}), 403
@@ -538,7 +597,7 @@ def list_classes(school_id):
@admin_bp.route('/schools/<int:school_id>/classes', methods=['POST'])
@login_required
@school_ict_required
@director_or_ict_required
def create_class(school_id):
if not current_user.is_scholengroep_ict and current_user.school_id != school_id:
return jsonify({'error': 'Geen toegang'}), 403
@@ -560,7 +619,7 @@ def create_class(school_id):
@admin_bp.route('/schools/<int:school_id>/classes/<int:class_id>', methods=['DELETE'])
@login_required
@school_ict_required
@director_or_ict_required
def delete_class(school_id, class_id):
if not current_user.is_scholengroep_ict and current_user.school_id != school_id:
return jsonify({'error': 'Geen toegang'}), 403
@@ -574,7 +633,7 @@ def delete_class(school_id, class_id):
@admin_bp.route('/schools/<int:school_id>/classes/<int:class_id>/teachers', methods=['PUT'])
@login_required
@school_ict_required
@director_or_ict_required
def set_class_teachers(school_id, class_id):
"""Vervang alle leerkrachten van een klas in één keer."""
if not current_user.is_scholengroep_ict and current_user.school_id != school_id:

View File

@@ -19,18 +19,36 @@ def director_required(f):
return decorated
def get_active_year(school_id=None):
"""Geeft het globaal actief schooljaar terug (school_id wordt genegeerd)."""
def get_active_year():
"""Geeft het globaal actief schooljaar terug."""
return SchoolYear.query.filter_by(school_id=None, is_active=True).first()
def check_class_access(class_id):
"""
Geeft de klas terug als de huidige gebruiker er toegang toe heeft.
- Leerkrachten: enkel hun eigen klassen (via teacher_classes).
- Directeur en hoger: alle klassen van hun school.
- Geeft False terug als de klas niet bestaat.
- Geeft None terug als de gebruiker geen toegang heeft.
"""
klas = Class.query.filter_by(id=class_id).first()
if not klas:
return False
if klas.school_id != current_user.school_id:
return None
if current_user.is_teacher:
if not any(c.id == class_id for c in current_user.classes):
return None
return klas
# ── Doelen (statische JSON bestanden) ─────────────────────────────────────────
@api_bp.route('/doelen/index')
@login_required
def doelen_index():
data = load_index()
# Altijd een geldig object teruggeven — lege vakkenlijst is geen fout
return jsonify(data)
@@ -50,16 +68,30 @@ def doelen_vak(vak_id):
@api_bp.route('/assessments', methods=['GET'])
@login_required
def get_assessments():
if not current_user.school_id:
"""Haal beoordelingen op voor een klas (en optioneel een vak)."""
class_id_str = request.args.get('class_id')
if not class_id_str:
return jsonify({'assessments': []})
school_year = get_active_year(current_user.school_id)
try:
class_id = int(class_id_str)
except ValueError:
return jsonify({'error': 'Ongeldig class_id'}), 400
klas = check_class_access(class_id)
if klas is False:
return jsonify({'error': 'Klas niet gevonden'}), 404
if klas is None:
return jsonify({'error': 'Geen toegang tot deze klas'}), 403
school_year = get_active_year()
if not school_year:
return jsonify({'assessments': []})
year_id = request.args.get('year_id', school_year.id)
vak_id = request.args.get('vak_id')
query = Assessment.query.filter_by(user_id=current_user.id, school_year_id=year_id)
query = Assessment.query.filter_by(class_id=class_id, school_year_id=year_id)
if vak_id:
query = query.filter_by(vak_id=vak_id)
@@ -68,30 +100,39 @@ def get_assessments():
@api_bp.route('/assessments', methods=['POST'])
@login_required
@limiter.limit('120 per minute') # max 2 per seconde per gebruiker
@limiter.limit('120 per minute')
def save_assessment():
data = request.get_json() or {}
class_id = data.get('class_id')
vak_id = (data.get('vak_id') or '').strip()
goal_id = (data.get('goal_id') or '').strip()
status = (data.get('status') or '').strip()
opmerking = (data.get('opmerking') or '').strip()[:500]
if not vak_id or not goal_id:
return jsonify({'error': 'vak_id en goal_id zijn verplicht'}), 400
if not class_id or not vak_id or not goal_id:
return jsonify({'error': 'class_id, vak_id en goal_id zijn verplicht'}), 400
if status not in ('groen', 'oranje', 'roze', ''):
return jsonify({'error': 'Ongeldige status — gebruik groen, oranje, roze of leeg'}), 400
# Sanitiseer input — voorkomt oversized data in DB
return jsonify({'error': 'Ongeldige status'}), 400
if len(vak_id) > 100 or len(goal_id) > 50:
return jsonify({'error': 'Ongeldige invoer'}), 400
if not current_user.school_id:
return jsonify({'error': 'Account is nog niet gekoppeld aan een school'}), 400
school_year = get_active_year(current_user.school_id)
try:
class_id = int(class_id)
except (ValueError, TypeError):
return jsonify({'error': 'Ongeldig class_id'}), 400
klas = check_class_access(class_id)
if klas is False:
return jsonify({'error': 'Klas niet gevonden'}), 404
if klas is None:
return jsonify({'error': 'Geen toegang tot deze klas'}), 403
school_year = get_active_year()
if not school_year:
return jsonify({'error': 'Geen actief schooljaar gevonden'}), 400
assessment = Assessment.query.filter_by(
user_id=current_user.id,
class_id=class_id,
school_year_id=school_year.id,
vak_id=vak_id,
goal_id=goal_id,
@@ -109,8 +150,7 @@ def save_assessment():
assessment.updated_at = datetime.utcnow()
else:
assessment = Assessment(
user_id=current_user.id,
school_id=current_user.school_id,
class_id=class_id,
school_year_id=school_year.id,
vak_id=vak_id,
goal_id=goal_id,
@@ -120,38 +160,44 @@ def save_assessment():
db.session.add(assessment)
db.session.commit()
# Auditlog enkel bij statuswijziging (niet bij elke klik)
audit_log('assessment.save', 'assessment',
target_type='goal', target_id=f'{vak_id}:{goal_id}',
detail={'status': status})
target_type='class', target_id=str(class_id),
detail={'status': status, 'vak': vak_id, 'goal': goal_id})
return jsonify({'assessment': assessment.to_dict()})
@api_bp.route('/assessments/opmerking', methods=['POST'])
@login_required
@limiter.limit('120 per minute')
def save_opmerking():
"""Sla enkel een opmerking op bij een bestaand of nieuw assessment record."""
data = request.get_json() or {}
class_id = data.get('class_id')
vak_id = (data.get('vak_id') or '').strip()
goal_id = (data.get('goal_id') or '').strip()
opmerking = (data.get('opmerking') or '').strip()[:500]
if not vak_id or not goal_id:
return jsonify({'error': 'vak_id en goal_id zijn verplicht'}), 400
if not class_id or not vak_id or not goal_id:
return jsonify({'error': 'class_id, vak_id en goal_id zijn verplicht'}), 400
if len(vak_id) > 100 or len(goal_id) > 50:
return jsonify({'error': 'Ongeldige invoer'}), 400
if not current_user.school_id:
return jsonify({'error': 'Account niet gekoppeld aan een school'}), 400
school_year = get_active_year(current_user.school_id)
try:
class_id = int(class_id)
except (ValueError, TypeError):
return jsonify({'error': 'Ongeldig class_id'}), 400
klas = check_class_access(class_id)
if klas is False:
return jsonify({'error': 'Klas niet gevonden'}), 404
if klas is None:
return jsonify({'error': 'Geen toegang tot deze klas'}), 403
school_year = get_active_year()
if not school_year:
return jsonify({'error': 'Geen actief schooljaar'}), 400
assessment = Assessment.query.filter_by(
user_id=current_user.id,
class_id=class_id,
school_year_id=school_year.id,
vak_id=vak_id,
goal_id=goal_id,
@@ -161,10 +207,8 @@ def save_opmerking():
assessment.opmerking = opmerking or None
assessment.updated_at = datetime.utcnow()
else:
# Maak een record aan zonder status voor de opmerking
assessment = Assessment(
user_id=current_user.id,
school_id=current_user.school_id,
class_id=class_id,
school_year_id=school_year.id,
vak_id=vak_id,
goal_id=goal_id,
@@ -176,32 +220,43 @@ def save_opmerking():
db.session.commit()
return jsonify({'ok': True})
@api_bp.route('/assessments/bulk-import', methods=['POST'])
@login_required
@limiter.limit('5 per minute')
def bulk_import_assessments():
"""
Importeer beoordelingen vanuit de legacy standalone JSON export.
Body: { "vakken": { "vak_id": { "goal_id": "status", ... }, ... } }
of v4 formaat: { "vakken": { "vak_id": { "statussen": { "goal_id": "status" } } } }
Importeer beoordelingen vanuit legacy standalone JSON export.
Body: { "class_id": 1, "vakken": { "vak_id": { "goal_id": "status" } } }
"""
if not current_user.school_id:
return jsonify({'error': 'Account niet gekoppeld aan een school'}), 400
school_year = get_active_year(current_user.school_id)
if not school_year:
return jsonify({'error': 'Geen actief schooljaar'}), 400
data = request.get_json() or {}
class_id = data.get('class_id')
vakken = data.get('vakken', {})
if not class_id:
return jsonify({'error': 'class_id is verplicht'}), 400
if not vakken:
return jsonify({'error': 'Geen vakken gevonden in payload'}), 400
try:
class_id = int(class_id)
except (ValueError, TypeError):
return jsonify({'error': 'Ongeldig class_id'}), 400
klas = check_class_access(class_id)
if klas is False:
return jsonify({'error': 'Klas niet gevonden'}), 404
if klas is None:
return jsonify({'error': 'Geen toegang tot deze klas'}), 403
school_year = get_active_year()
if not school_year:
return jsonify({'error': 'Geen actief schooljaar'}), 400
totaal = 0
fouten = 0
for vak_id, vak_data in vakken.items():
# Sanitiseer vak_id
if not isinstance(vak_id, str) or len(vak_id) > 100:
fouten += 1
continue
@@ -224,7 +279,7 @@ def bulk_import_assessments():
try:
assessment = Assessment.query.filter_by(
user_id=current_user.id,
class_id=class_id,
school_year_id=school_year.id,
vak_id=vak_id,
goal_id=goal_id,
@@ -235,8 +290,7 @@ def bulk_import_assessments():
assessment.updated_at = datetime.utcnow()
else:
db.session.add(Assessment(
user_id=current_user.id,
school_id=current_user.school_id,
class_id=class_id,
school_year_id=school_year.id,
vak_id=vak_id,
goal_id=goal_id,
@@ -249,6 +303,7 @@ def bulk_import_assessments():
db.session.commit()
audit_log('assessment.bulk_import', 'assessment',
target_type='class', target_id=str(class_id),
detail={'totaal': totaal, 'fouten': fouten})
return jsonify({'totaal': totaal, 'fouten': fouten})
@@ -261,47 +316,44 @@ def bulk_import_assessments():
def school_overview():
if not current_user.school_id:
return jsonify({'error': 'Geen school gekoppeld'}), 400
school_year = get_active_year(current_user.school_id)
school_year = get_active_year()
if not school_year:
return jsonify({'error': 'Geen actief schooljaar'}), 400
# year_id param: directeur/admin kan wisselen, leerkracht zit vast aan actief jaar
year_id_param = request.args.get('year_id')
if year_id_param and current_user.is_director:
year_id = int(year_id_param)
selected_year = SchoolYear.query.filter_by(
id=year_id, school_id=current_user.school_id
).first() or school_year
if year_id_param:
selected_year = SchoolYear.query.get(int(year_id_param)) or school_year
else:
selected_year = school_year
year_id = school_year.id
vak_id = request.args.get('vak_id')
teachers = User.query.filter_by(
school_id=current_user.school_id, role='teacher', is_active=True
).all()
# Alle klassen van deze school
klassen = Class.query.filter_by(school_id=current_user.school_id)\
.order_by(Class.name).all()
class_ids = [k.id for k in klassen]
query = Assessment.query.filter_by(
school_id=current_user.school_id, school_year_id=year_id
query = Assessment.query.filter(
Assessment.class_id.in_(class_ids),
Assessment.school_year_id == selected_year.id,
)
if vak_id:
query = query.filter_by(vak_id=vak_id)
by_teacher = {t.id: {} for t in teachers}
# Groepeer per klas → vak → goal
by_class = {k.id: {} for k in klassen}
for a in query.all():
by_teacher.setdefault(a.user_id, {})
by_teacher[a.user_id].setdefault(a.vak_id, {})
by_teacher[a.user_id][a.vak_id][a.goal_id] = a.status
by_class[a.class_id].setdefault(a.vak_id, {})[a.goal_id] = a.status
return jsonify({
'school_year': selected_year.to_dict(),
'teachers': [t.to_dict() for t in teachers],
'assessments_by_teacher': by_teacher,
'classes': [k.to_dict() for k in klassen],
'assessments_by_class': by_class,
})
# ── Gebruikersbeheer (school_ict / directeur) ──────────────────────────────────
# ── Gebruikersbeheer (director / school_ict) ──────────────────────────────────
@api_bp.route('/users', methods=['GET'])
@login_required
@@ -347,54 +399,57 @@ def delete_user(user_id):
return jsonify({'deleted': True})
# ── Schooljaren (directeur/admin leesbaar) ────────────────────────────────────
# ── Schooljaren ────────────────────────────────────────────────────────────────
@api_bp.route('/school/years')
@login_required
@director_required
def get_school_years():
"""Geeft alle globale schooljaren terug (voor jaarselectie in directeur dashboard)."""
years = SchoolYear.query.filter_by(school_id=None) .order_by(SchoolYear.label.desc()).all()
years = SchoolYear.query.filter_by(school_id=None)\
.order_by(SchoolYear.label.desc()).all()
return jsonify({'years': [y.to_dict() for y in years]})
# ── Huidig ingelogde gebruiker ────────────────────────────────────────────────
# ── Huidig ingelogde gebruiker ────────────────────────────────────────────────
@api_bp.route('/me')
@login_required
def me():
school_year = get_active_year(current_user.school_id) if current_user.school_id else None
school_year = get_active_year() if current_user.school_id else None
return jsonify({
'user': current_user.to_dict(),
'school_year': school_year.to_dict() if school_year else None,
})
# ── Klassen voor leerkracht (zelf instellen) ──────────────────────────────────
# ── Klassen voor leerkracht ────────────────────────────────────────────────────
@api_bp.route('/my/classes', methods=['GET'])
@login_required
def my_classes():
"""Geeft alle beschikbare klassen en eigen klassen terug."""
"""Geeft alle klassen van de school en de eigen klassen van de leerkracht.
Directeurs en hoger zien automatisch alle klassen als my_classes."""
if not current_user.school_id:
return jsonify({'all_classes': [], 'my_classes': []})
all_cls = Class.query.filter_by(school_id=current_user.school_id) .order_by(Class.name).all()
all_cls = Class.query.filter_by(school_id=current_user.school_id)\
.order_by(Class.name).all()
# Directeurs en hoger hebben toegang tot alle klassen zonder expliciete koppeling
my_cls = all_cls if current_user.is_director else current_user.classes
return jsonify({
'all_classes': [{'id': c.id, 'name': c.name} for c in all_cls],
'my_classes': [{'id': c.id, 'name': c.name} for c in current_user.classes],
'my_classes': [{'id': c.id, 'name': c.name} for c in my_cls],
})
@api_bp.route('/my/classes', methods=['PUT'])
@login_required
def set_my_classes():
"""Leerkracht stelt eigen klassen in."""
"""Leerkracht stelt zijn eigen klassen in."""
data = request.get_json() or {}
class_ids = data.get('class_ids', [])
classes = Class.query.filter(
Class.id.in_(class_ids),
Class.school_id == current_user.school_id
Class.school_id == current_user.school_id,
).all()
current_user.classes = classes
audit_log('class.user_assignment', 'class', target_type='user',
@@ -405,7 +460,75 @@ def set_my_classes():
# ── Auditlog ──────────────────────────────────────────────────────────────────
# ── Klassen CRUD (directeur) ───────────────────────────────────────────────────
@api_bp.route('/classes', methods=['GET'])
@login_required
@director_required
def list_classes():
"""Alle klassen van de school."""
classes = Class.query.filter_by(school_id=current_user.school_id) .order_by(Class.name).all()
return jsonify({'classes': [
{'id': c.id, 'name': c.name,
'teachers': [{'id': t.id, 'full_name': t.full_name} for t in c.teachers]}
for c in classes
]})
@api_bp.route('/classes', methods=['POST'])
@login_required
@director_required
def create_class():
"""Nieuwe klas aanmaken."""
data = request.get_json() or {}
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'Naam is verplicht'}), 400
if Class.query.filter_by(school_id=current_user.school_id, name=name).first():
return jsonify({'error': 'Een klas met deze naam bestaat al'}), 409
klas = Class(name=name, school_id=current_user.school_id)
db.session.add(klas)
audit_log('class.create', 'class', detail={'name': name})
db.session.commit()
return jsonify({'class': {'id': klas.id, 'name': klas.name, 'teachers': []}}), 201
@api_bp.route('/classes/<int:class_id>', methods=['DELETE'])
@login_required
@director_required
def delete_class(class_id):
"""Klas verwijderen (enkel eigen school)."""
klas = Class.query.filter_by(id=class_id, school_id=current_user.school_id).first_or_404()
name = klas.name
db.session.delete(klas)
audit_log('class.delete', 'class', target_id=str(class_id), detail={'name': name})
db.session.commit()
return jsonify({'deleted': class_id})
# ── Klas-leerkracht koppeling (directeur) ──────────────────────────────────────
@api_bp.route('/classes/<int:class_id>/teachers', methods=['PUT'])
@login_required
@director_required
def set_class_teachers(class_id):
"""Directeur koppelt leerkrachten aan een klas."""
klas = Class.query.filter_by(id=class_id, school_id=current_user.school_id).first_or_404()
data = request.get_json() or {}
user_ids = data.get('teacher_ids', [])
teachers = User.query.filter(
User.id.in_(user_ids),
User.school_id == current_user.school_id,
User.is_active == True,
).all()
klas.teachers = teachers
audit_log('class.user_assignment', 'class', target_id=str(class_id),
detail={'class_name': klas.name, 'teacher_ids': user_ids,
'teacher_names': [t.full_name for t in teachers]})
db.session.commit()
return jsonify({'teachers': [{'id': t.id, 'full_name': t.full_name} for t in teachers]})
# ── Auditlog ───────────────────────────────────────────────────────────────────
@api_bp.route('/audit-log')
@login_required
@@ -414,16 +537,13 @@ def get_audit_log():
return jsonify({'error': 'Geen toegang'}), 403
page = max(1, int(request.args.get('page', 1)))
per_page = min(100, max(1, int(request.args.get('per_page', 50)))) # max 100 per pagina
per_page = min(100, max(1, int(request.args.get('per_page', 50))))
category = request.args.get('category')
search = request.args.get('search', '').strip()
query = AuditLog.query
# School ICT ziet enkel eigen school
if not current_user.is_scholengroep_ict:
query = query.filter(AuditLog.school_id == current_user.school_id)
if category:
query = query.filter(AuditLog.category == category)
if search:
@@ -435,7 +555,8 @@ def get_audit_log():
)
total = query.count()
entries = query.order_by(AuditLog.timestamp.desc()) .offset((page - 1) * per_page).limit(per_page).all()
entries = query.order_by(AuditLog.timestamp.desc())\
.offset((page - 1) * per_page).limit(per_page).all()
return jsonify({
'total': total,
@@ -444,3 +565,36 @@ def get_audit_log():
'entries': [e.to_dict() for e in entries],
})
# ── SSO-lookup ─────────────────────────────────────────────────────────────────
@api_bp.route('/sso-lookup')
def sso_lookup():
from flask import current_app
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),
})

View File

@@ -113,7 +113,10 @@ def login():
return redirect(url_for('pages.dashboard'))
entra_configured = bool(_entra_client_id() and _entra_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)
# 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=True, org_name=org_name)
@auth_bp.route('/logout')
@@ -239,6 +242,202 @@ def microsoft_callback():
return redirect(_safe_next_url(request.args.get('next')))
# ── 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_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):
"""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
user.last_name = last_name or user.last_name
user.email = email
db.session.commit()
return user, False
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
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():
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'))
state = secrets.token_urlsafe(32)
session['google_oauth_state'] = state
session['google_oauth_school'] = school_id # bewaren voor de callback
params = {
'client_id': client_id,
'response_type': 'code',
'redirect_uri': _google_callback_url(),
'scope': GOOGLE_SCOPES,
'state': state,
'access_type': 'online',
'prompt': 'select_account',
}
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)
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'))
try:
token_resp = requests.post(GOOGLE_TOKEN_URL, data={
'client_id': client_id,
'client_secret': 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'))
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', '')
if not email or not google_sub:
flash('Onvoldoende profielgegevens ontvangen van Google.', 'error')
return redirect(url_for('auth.login'))
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():

View File

@@ -43,7 +43,6 @@ def dashboard():
return render_template('directeur.html', org_name=org)
return render_template('leerkracht.html', org_name=org)
@pages_bp.route('/doelen-beheer')
@login_required
@_beheer_required
@@ -58,3 +57,23 @@ def doelen_beheer():
@login_required
def admin_page():
return redirect(url_for('pages.dashboard'))
@pages_bp.route('/klassen')
@login_required
def klassen_beheer():
"""Klassenbeheer voor directeurs (en school_ict)."""
if not current_user.is_director:
from flask import abort
abort(403)
return render_template('directeur_klassen.html', org_name=_org_name())
@pages_bp.route('/leerkracht-view')
@login_required
def leerkracht_view():
"""Directeur bekijkt de tracker als leerkracht."""
if not current_user.is_director:
from flask import abort
abort(403)
return render_template('leerkracht.html', org_name=_org_name(), director_mode=True)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,363 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Klassenbeheer — {{ org_name }}</title>
<style>
:root {
--primary: #4f46e5; --primary-dark: #4338ca;
--success: #10b981; --danger: #ef4444; --warning: #f59e0b;
--gray-50: #f9fafb; --gray-100: #f3f4f6; --gray-200: #e5e7eb;
--gray-300: #d1d5db; --gray-400: #9ca3af; --gray-500: #6b7280;
--gray-600: #4b5563; --gray-700: #374151; --gray-800: #1f2937;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--gray-100); color: var(--gray-800); line-height: 1.5; }
.container { max-width: 900px; margin: 0 auto; padding: 1.25rem; }
/* Header */
.header { background: white; border-radius: 12px; padding: 1.25rem 1.5rem;
margin-bottom: 1.25rem; box-shadow: 0 1px 3px rgba(0,0,0,.1);
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .75rem; }
.header-left { display: flex; align-items: center; gap: .75rem; }
.back-link { display: inline-flex; align-items: center; gap: .35rem; padding: .4rem .75rem;
border: 1px solid var(--gray-300); border-radius: 6px; font-size: .85rem;
color: var(--gray-600); text-decoration: none; transition: all .15s; }
.back-link:hover { background: var(--gray-100); border-color: var(--gray-400); }
.header h1 { font-size: 1.25rem; color: var(--gray-900);
display: flex; align-items: center; gap: .5rem; }
.school-badge { font-size: .75rem; background: var(--primary); color: white;
padding: .2rem .55rem; border-radius: 9999px; font-weight: 500; }
/* Section */
.section { background: white; border-radius: 12px; padding: 1.5rem;
margin-bottom: 1.25rem; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
.section-header { display: flex; align-items: center; justify-content: space-between;
margin-bottom: 1.1rem; flex-wrap: wrap; gap: .5rem; }
.section-header h2 { font-size: 1rem; color: var(--gray-700);
display: flex; align-items: center; gap: .4rem; }
/* Buttons */
.btn { display: inline-flex; align-items: center; gap: .35rem; padding: .45rem .9rem;
border: none; border-radius: 6px; font-size: .85rem; font-weight: 500;
cursor: pointer; transition: all .15s; }
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-dark); }
.btn-secondary { background: var(--gray-200); color: var(--gray-700); }
.btn-secondary:hover { background: var(--gray-300); }
.btn-danger { background: var(--danger); color: white; }
.btn-danger:hover { background: #dc2626; }
.btn-sm { padding: .3rem .65rem; font-size: .8rem; }
/* Tabel */
table { width: 100%; border-collapse: collapse; font-size: .875rem; }
thead th { padding: .6rem .85rem; text-align: left; font-size: .75rem; font-weight: 700;
text-transform: uppercase; letter-spacing: .04em; color: var(--gray-500);
border-bottom: 2px solid var(--gray-200); background: var(--gray-50); }
tbody td { padding: .7rem .85rem; border-bottom: 1px solid var(--gray-100); vertical-align: middle; }
tbody tr:hover td { background: var(--gray-50); }
tbody tr:last-child td { border-bottom: none; }
.teacher-names { font-size: .82rem; color: var(--gray-500); }
.no-teacher { font-style: italic; color: var(--gray-400); font-size: .82rem; }
td:last-child { white-space: nowrap; text-align: right; }
/* Empty state */
.empty { text-align: center; padding: 2.5rem; color: var(--gray-500); font-style: italic; }
/* Assign modal (dynamisch ingeladen) */
.assign-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.5);
z-index: 1000; display: flex; align-items: center; justify-content: center; }
.assign-modal { background: white; border-radius: 12px; padding: 1.5rem;
max-width: 420px; width: 90%; max-height: 80vh; overflow-y: auto; }
.assign-modal h3 { font-size: 1rem; margin-bottom: 1rem; }
.checkbox-list { max-height: 240px; overflow-y: auto; display: flex;
flex-direction: column; gap: .4rem; margin-bottom: 1.1rem; }
.checkbox-list label { display: flex; align-items: center; gap: .5rem;
cursor: pointer; font-size: .875rem; padding: .25rem 0; }
.checkbox-list label:hover { color: var(--primary); }
.modal-buttons { display: flex; gap: .5rem; justify-content: flex-end; }
.form-error { color: var(--danger); font-size: .82rem; margin-top: .4rem; display: none; }
/* Notification */
.notification { position: fixed; bottom: 1rem; right: 1rem; padding: .85rem 1.25rem;
border-radius: 8px; color: white; font-weight: 500;
transform: translateY(100px); opacity: 0; transition: all .3s; z-index: 2000; }
.notification.show { transform: translateY(0); opacity: 1; }
.notification.success { background: var(--success); }
.notification.error { background: var(--danger); }
.notification.warning { background: var(--warning); }
/* Dark mode */
@media (prefers-color-scheme: dark) {
:root { --gray-50:#1a1a2e; --gray-100:#16213e; --gray-200:#0f3460;
--gray-300:#1a1a3e; --gray-500:#9ca3af; --gray-600:#d1d5db;
--gray-700:#e5e7eb; --gray-800:#f3f4f6; }
body { background: #0f172a; color: #e2e8f0; }
.header, .section, .assign-modal { background: #1e293b !important; }
.back-link { border-color: #334155; color: #94a3b8; }
.back-link:hover { background: #263548; }
thead th { background: #1e293b !important; border-color: #334155 !important; }
tbody td { border-color: #1e293b !important; }
tbody tr:hover td { background: #263548 !important; }
.btn-secondary { background: #334155 !important; color: #e2e8f0 !important; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-left">
<a href="/dashboard" class="back-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
Terug naar dashboard
</a>
<h1>
🏫 Klassenbeheer
<span class="school-badge" id="schoolBadge">Laden...</span>
</h1>
</div>
<a href="/auth/logout" class="btn btn-secondary btn-sm">Uitloggen</a>
</div>
<div class="section">
<div class="section-header">
<h2>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/>
</svg>
Klassen
</h2>
<button class="btn btn-primary btn-sm" id="btnAddKlas">+ Klas toevoegen</button>
</div>
<div id="klassenList">
<div class="empty">Laden...</div>
</div>
</div>
<div class="section">
<div class="section-header">
<h2>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
Leerkrachten van deze school
</h2>
</div>
<div id="teachersList">
<div class="empty">Laden...</div>
</div>
</div>
</div>
<div class="notification" id="notification"></div>
<script nonce="{{ csp_nonce() }}">
let mySchoolId = null;
let allTeachers = [];
// ── Init ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('btnAddKlas').addEventListener('click', openAddKlas);
const me = await fetch('/api/me').then(r => r.json());
mySchoolId = me.user?.school_id;
document.getElementById('schoolBadge').textContent = me.user?.school_name || 'Mijn school';
await Promise.all([loadKlassen(), loadTeachers()]);
});
// ── Klassen laden & renderen ──────────────────────────────────────────────────
async function loadKlassen() {
if (!mySchoolId) return;
const res = await fetch(`/admin/schools/${mySchoolId}/classes`);
const data = await res.json();
const klassen = data.classes || [];
const container = document.getElementById('klassenList');
if (!klassen.length) {
container.innerHTML = '<div class="empty">Nog geen klassen aangemaakt. Klik op "+ Klas toevoegen" om te starten.</div>';
return;
}
container.innerHTML = `
<table>
<thead>
<tr>
<th>Klas</th>
<th>Leerkrachten</th>
<th></th>
</tr>
</thead>
<tbody>
${klassen.map(c => `
<tr>
<td><strong>${c.name}</strong></td>
<td>
${c.teachers?.length
? `<span class="teacher-names">${c.teachers.map(t => t.full_name).join(', ')}</span>`
: '<span class="no-teacher">Geen leerkracht gekoppeld</span>'}
</td>
<td>
<button class="btn btn-secondary btn-sm"
data-action="assignTeachers"
data-id="${c.id}"
data-name="${c.name.replace(/'/g,'&#39;')}"
data-teachers="${JSON.stringify(c.teachers?.map(t=>t.id)||[]).replace(/"/g,'&quot;')}">
Leerkrachten koppelen
</button>
<button class="btn btn-danger btn-sm"
data-action="deleteKlas"
data-id="${c.id}"
data-name="${c.name.replace(/'/g,'&#39;')}">
×
</button>
</td>
</tr>`).join('')}
</tbody>
</table>`;
}
// ── Leerkrachtenlijst ─────────────────────────────────────────────────────────
async function loadTeachers() {
if (!mySchoolId) return;
const res = await fetch(`/admin/schools/${mySchoolId}/users`);
const data = await res.json();
allTeachers = (data.users || []).filter(u => u.role === 'teacher');
const container = document.getElementById('teachersList');
if (!allTeachers.length) {
container.innerHTML = '<div class="empty">Nog geen leerkrachten geregistreerd via SSO.</div>';
return;
}
container.innerHTML = `
<table>
<thead>
<tr>
<th>Naam</th>
<th>E-mail</th>
<th>Laatste login</th>
</tr>
</thead>
<tbody>
${allTeachers.map(u => `
<tr>
<td><strong>${u.full_name}</strong></td>
<td style="color:var(--gray-500);font-size:.85rem;">${u.email}</td>
<td style="color:var(--gray-500);font-size:.82rem;">
${u.last_login
? new Date(u.last_login).toLocaleString('nl-BE',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})
: '<em>Nog niet ingelogd</em>'}
</td>
</tr>`).join('')}
</tbody>
</table>`;
}
// ── Klas toevoegen ────────────────────────────────────────────────────────────
async function openAddKlas() {
const name = prompt('Naam van de nieuwe klas (bv. 3A):');
if (!name?.trim()) return;
const res = await fetch(`/admin/schools/${mySchoolId}/classes`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ name: name.trim() })
});
const data = await res.json();
if (!res.ok) { notify(data.error || 'Aanmaken mislukt', 'error'); return; }
notify(`Klas "${name.trim()}" aangemaakt`, 'success');
await loadKlassen();
}
// ── Klas verwijderen ──────────────────────────────────────────────────────────
async function deleteKlas(classId, name) {
if (!confirm(`Klas "${name}" verwijderen? Alle leerkrachtkoppelingen worden ook verwijderd.`)) return;
const res = await fetch(`/admin/schools/${mySchoolId}/classes/${classId}`, { method: 'DELETE' });
if (!res.ok) { notify('Verwijderen mislukt', 'error'); return; }
notify(`Klas "${name}" verwijderd`, 'success');
await loadKlassen();
}
// ── Leerkrachten koppelen ─────────────────────────────────────────────────────
let assignClassId = null;
async function openAssignTeachers(classId, className, currentTeacherIds) {
assignClassId = classId;
// Haal verse leerkrachtenlijst op als nog niet geladen
if (!allTeachers.length) await loadTeachers();
const html = `
<div class="assign-overlay" id="assignModal">
<div class="assign-modal">
<h3>Leerkrachten koppelen aan <strong>${className}</strong></h3>
<div class="checkbox-list">
${allTeachers.length
? allTeachers.map(t => `
<label>
<input type="checkbox" value="${t.id}"
${currentTeacherIds.includes(t.id) ? 'checked' : ''}>
<span>${t.full_name}</span>
<span style="font-size:.75rem;color:var(--gray-400);">(${t.email})</span>
</label>`).join('')
: '<em style="color:var(--gray-400);">Geen leerkrachten beschikbaar.<br>Leerkrachten verschijnen hier zodra ze voor het eerst inloggen via SSO.</em>'}
</div>
<div class="modal-buttons">
<button class="btn btn-secondary" id="btnCancelAssign">Annuleren</button>
<button class="btn btn-primary" id="btnSaveAssign">Opslaan</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', html);
document.getElementById('btnCancelAssign').addEventListener('click', () => document.getElementById('assignModal')?.remove());
document.getElementById('btnSaveAssign').addEventListener('click', saveAssignTeachers);
// Klik buiten modal sluit ook
document.getElementById('assignModal').addEventListener('click', e => {
if (e.target.id === 'assignModal') document.getElementById('assignModal').remove();
});
}
async function saveAssignTeachers() {
const ids = [...document.querySelectorAll('#assignModal input:checked')].map(i => parseInt(i.value));
const res = await fetch(`/admin/schools/${mySchoolId}/classes/${assignClassId}/teachers`, {
method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ user_ids: ids })
});
document.getElementById('assignModal')?.remove();
if (!res.ok) { notify('Opslaan mislukt', 'error'); return; }
notify('Leerkrachten bijgewerkt', 'success');
await loadKlassen();
}
// ── Event delegation ──────────────────────────────────────────────────────────
document.addEventListener('click', e => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'deleteKlas') {
deleteKlas(btn.dataset.id, btn.dataset.name);
}
if (action === 'assignTeachers') {
const ids = JSON.parse(btn.dataset.teachers.replace(/&quot;/g, '"'));
openAssignTeachers(btn.dataset.id, btn.dataset.name, ids);
}
});
// ── Notificaties ──────────────────────────────────────────────────────────────
function notify(msg, type = 'success') {
const el = document.getElementById('notification');
el.textContent = msg;
el.className = `notification ${type} show`;
setTimeout(() => el.classList.remove('show'), 3500);
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -9,250 +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; }
.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">
@@ -268,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">
@@ -278,24 +157,36 @@
</svg>
Inloggen met Microsoft
</a>
<p style="text-align:center; color:#6b7280; font-size:0.8rem;">
Log in met uw school Microsoft account
</p>
{% else %}
<div class="not-configured">
<strong>Microsoft login niet geconfigureerd</strong><br><br>
Stel <code>MICROSOFT_CLIENT_ID</code> en <code>MICROSOFT_CLIENT_SECRET</code>
in de <code>.env</code> in om Entra login te activeren.
</div>
<div class="sso-divider">of via Google Workspace</div>
{% endif %}
<!-- Superadmin fallback — zichtbaar maar discreet -->
{# 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"/>
</svg>
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>
<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">
@@ -312,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 &mdash; ' + 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>

View File

@@ -49,12 +49,20 @@
.domain-chip{display:inline-block;padding:.15rem .45rem;background:#eff6ff;color:#1d4ed8;border-radius:4px;font-size:.72rem;margin:.1rem;border:1px solid #bfdbfe;}
/* Scholen grid */
.schools-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:1rem;}
.school-card{border:1px solid var(--gray-200);border-radius:10px;overflow:hidden;}
.school-card-header{padding:.9rem 1.1rem;background:var(--gray-50);border-bottom:1px solid var(--gray-200);display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem;}
.school-card-header h3{font-size:.95rem;}
.school-card-body{padding:.75rem 1.1rem;}
/* Gebruikers-per-school tabel */
.schools-user-table{width:100%;border-collapse:collapse;}
.schools-user-table th{padding:.6rem .85rem;text-align:left;font-size:.78rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--gray-500);border-bottom:2px solid var(--gray-200);background:var(--gray-50);}
.school-row td{padding:.7rem .85rem;border-bottom:1px solid var(--gray-100);vertical-align:middle;font-size:.875rem;}
.school-row{cursor:pointer;transition:background .12s;}
.school-row:hover td{background:var(--gray-50);}
.school-row.expanded td{background:#f0f9ff;border-bottom:none;}
.expand-icon{display:inline-block;transition:transform .2s;font-style:normal;width:18px;text-align:center;}
.school-row.expanded .expand-icon{transform:rotate(90deg);}
.users-panel-row td{padding:0;border-bottom:1px solid var(--gray-200);}
.users-panel{padding:.75rem 1.1rem 1rem;background:#f8fafc;display:none;}
.users-panel.open{display:block;}
.users-search{width:100%;max-width:280px;padding:.4rem .65rem;border:1px solid var(--gray-300);border-radius:6px;font-size:.82rem;margin-bottom:.75rem;}
.users-search:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 2px rgba(79,70,229,.1);}
.user-row{display:flex;align-items:center;justify-content:space-between;padding:.35rem 0;border-bottom:1px solid var(--gray-100);font-size:.84rem;gap:.5rem;}
.user-row:last-child{border-bottom:none;}
.user-info{flex:1;min-width:0;}
@@ -64,6 +72,10 @@
.role-select{padding:.2rem .4rem;border:1px solid var(--gray-300);border-radius:4px;font-size:.75rem;cursor:pointer;background:white;color:var(--gray-700);}
.role-select:focus{outline:none;border-color:var(--primary);}
.group-label{font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--gray-400);margin:.6rem 0 .3rem;display:flex;justify-content:space-between;}
.school-search-bar{display:flex;align-items:center;gap:.5rem;margin-bottom:.85rem;}
.school-search-input{flex:1;max-width:320px;padding:.5rem .75rem;border:1px solid var(--gray-300);border-radius:6px;font-size:.875rem;}
.school-search-input:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 3px rgba(79,70,229,.1);}
.school-count-badge{font-size:.75rem;color:var(--gray-500);}
/* Modal */
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;align-items:center;justify-content:center;}
@@ -91,8 +103,10 @@
th{background:#1e293b !important;color:#94a3b8 !important;border-color:#334155 !important;}
td{border-color:#1e293b !important;color:#e2e8f0;}
tr:hover td{background:#263548 !important;}
.school-card{border-color:#334155 !important;}
.school-card-header{background:#162032 !important;border-color:#334155 !important;}
.school-row.expanded td{background:#1a2744 !important;}
.users-panel{background:#162032 !important;}
.users-search{background:#0f172a !important;color:#e2e8f0 !important;border-color:#334155 !important;}
.school-search-input{background:#0f172a !important;color:#e2e8f0 !important;border-color:#334155 !important;}
.domain-chip{background:#1e3a5f !important;border-color:#2563eb !important;color:#93c5fd !important;}
.role-select,.form-group input,.form-group select{background:#0f172a !important;color:#e2e8f0 !important;border-color:#334155 !important;}
.btn-secondary{background:#334155 !important;color:#e2e8f0 !important;}
@@ -151,7 +165,7 @@
<div class="section">
<div class="section-header">
<h2>📅 Schooljaren</h2>
<button class="btn btn-primary btn-sm">+ Nieuw schooljaar</button>
<button id="btnAddJaar" class="btn btn-primary btn-sm">+ Nieuw schooljaar</button>
</div>
<p class="section-hint">
Het actieve schooljaar geldt voor alle scholen tegelijk.
@@ -184,13 +198,32 @@ toevoegen</button>
</table>
</div>
<!-- Scholen & gebruikers detail -->
<!-- Gebruikers per school — lazy tabel -->
<div class="section">
<div class="section-header">
<h2>👥 Gebruikers per school</h2>
<button id="btnAddUser" class="btn btn-primary btn-sm">+ Gebruiker toevoegen</button>
</div>
<div class="schools-grid" id="schoolsGrid">Laden...</div>
<div class="school-search-bar">
<input type="text" id="schoolSearchInput" class="school-search-input"
placeholder="Zoek op schoolnaam of gebruikersnaam...">
<span class="school-count-badge" id="schoolCountBadge"></span>
</div>
<div style="overflow-x:auto;">
<table class="schools-user-table">
<thead>
<tr>
<th style="width:32px;"></th>
<th>School</th>
<th>Domeinen</th>
<th>SSO</th>
<th style="text-align:center;">Gebruikers</th>
<th style="text-align:right;">Acties</th>
</tr>
</thead>
<tbody id="schoolsUserTbody"></tbody>
</table>
</div>
</div>
<!-- Auditlog -->
@@ -256,11 +289,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>
@@ -338,7 +396,7 @@ function bind(id, ev, fn) {
if (el) el.addEventListener(ev, fn);
}
const IS_SUPERADMIN = {{ 'true' if is_superadmin else 'false' }};
const IS_SUPERADMIN = {{ is_superadmin | tojson }};
let schools = [];
const SCHOOL_ROLLEN = [
@@ -357,12 +415,22 @@ document.addEventListener('DOMContentLoaded', async () => {
bind('auditSearch', 'input', loadAuditLog);
document.getElementById('btnCancelSchool') && bind('btnCancelSchool', 'click', closeModal);
document.getElementById('btnSaveSchool') && bind('btnSaveSchool', 'click', addSchool);
document.getElementById('btnCancelEditSch') && bind('btnCancelEditSch', 'click', closeModal);
document.getElementById('btnSaveEditSch') && bind('btnSaveEditSch', 'click', saveSchool);
document.getElementById('btnCancelSgIct') && bind('btnCancelSgIct', 'click', closeModal);
document.getElementById('btnSaveSgIct') && bind('btnSaveSgIct', 'click', addSgIct);
document.getElementById('btnCancelJaar') && bind('btnCancelJaar', 'click', closeModal);
document.getElementById('btnSaveJaar') && bind('btnSaveJaar', 'click', addJaar);
document.getElementById('btnCancelUser') && bind('btnCancelUser', 'click', closeModal);
document.getElementById('btnSaveUser') && bind('btnSaveUser', 'click', addUser);
// Zoekbalk scholen/gebruikers
const schoolSearchEl = document.getElementById('schoolSearchInput');
if (schoolSearchEl) {
schoolSearchEl.addEventListener('input', () => {
schoolFilter = schoolSearchEl.value.trim();
renderSchoolsUserTable();
});
}
const tasks = [loadStats(), loadSchoolsTable(), loadSchoolsGrid(), loadSgIct()];
await Promise.all(tasks);
await loadJaren();
@@ -433,7 +501,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,'&#39;')}" data-domains="${(s.email_domains||[]).join(', ')}">Bewerken</button>
@@ -456,6 +527,7 @@ async function addSchool() {
const data = await res.json();
if (!res.ok) { err.textContent = data.error; err.style.display = 'block'; return; }
closeModal(); notify('School aangemaakt', 'success');
delete loadedUsers[data.school?.id]; // forceer herlaad als al gecached
await Promise.all([loadSchoolsTable(), loadSchoolsGrid(), loadStats()]);
}
@@ -463,6 +535,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');
}
@@ -470,13 +554,49 @@ 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');
// Gebruikerscache wissen zodat heropenen verse data toont
delete loadedUsers[parseInt(id)];
await Promise.all([loadSchoolsTable(), loadSchoolsGrid()]);
}
@@ -485,114 +605,239 @@ async function deleteSchool(id, name) {
const res = await fetch(`/admin/schools/${id}`, { method: 'DELETE' });
if (!res.ok) { notify('Verwijderen mislukt', 'error'); return; }
notify('School verwijderd', 'success');
expandedSchools.delete(parseInt(id));
delete loadedUsers[parseInt(id)];
await Promise.all([loadSchoolsTable(), loadSchoolsGrid(), loadStats()]);
}
// ── Gebruikers grid ───────────────────────────────────────────────────────────
// ── Gebruikers-per-school: lazy tabel ────────────────────────────────────────
// State
const expandedSchools = new Set(); // welke school-rijen zijn open
const loadedUsers = {}; // cache: school_id -> users array
const loadingSchools = new Set(); // bezig met laden
let schoolFilter = ''; // huidig zoekterm
// Laad de scholenlijst (zonder gebruikers) en render de tabel
async function loadSchoolsGrid() {
const res = await fetch('/admin/schools');
const data = await res.json();
schools = data.schools || [];
const grid = document.getElementById('schoolsGrid');
if (!schools.length) {
grid.innerHTML = '<p style="color:var(--gray-500);font-style:italic;padding:.5rem 0;">Nog geen scholen aangemaakt.</p>';
renderSchoolsUserTable();
updateSchoolCountBadge();
// Vul ook de school select in het "gebruiker toevoegen" modal
const sel = document.getElementById('addUserSchool');
if (sel) sel.innerHTML = schools.map(s => `<option value="${s.id}">${s.name}</option>`).join('');
}
// Teken (of herteken) de volledige tabel op basis van huidige filter + expanded state
function renderSchoolsUserTable() {
const tbody = document.getElementById('schoolsUserTbody');
const term = schoolFilter.toLowerCase();
// Filter: schoolnaam OF (als school al geladen) gebruikersnaam
const filtered = schools.filter(s => {
if (s.name.toLowerCase().includes(term)) return true;
const users = loadedUsers[s.id];
if (users && term) return users.some(u => u.full_name.toLowerCase().includes(term));
return !term;
});
updateSchoolCountBadge(filtered.length);
if (!filtered.length) {
tbody.innerHTML = `<tr class="empty-row"><td colspan="6">Geen scholen gevonden voor "${schoolFilter}"</td></tr>`;
return;
}
grid.innerHTML = '';
await Promise.all(schools.map(s => renderSchoolCard(s, grid)));
const rows = filtered.map(s => buildSchoolRows(s, term)).join('');
tbody.innerHTML = rows;
}
async function renderSchoolCard(school, container) {
const res = await fetch(`/admin/schools/${school.id}/users`);
const data = await res.json();
const users = data.users || [];
function buildSchoolRows(school, term) {
const isOpen = expandedSchools.has(school.id);
const isLoading = loadingSchools.has(school.id);
const ssoIcon = school.google_sso_configured
? '<span style="font-size:.7rem;background:#d1fae5;color:#065f46;padding:.15rem .4rem;border-radius:4px;font-weight:600;">G ✓</span>'
: '<span style="font-size:.7rem;color:var(--gray-400);">—</span>';
const domainHtml = (school.email_domains||[]).map(d=>`<span class="domain-chip">${d}</span>`).join('')
|| '<em style="color:var(--gray-400);font-size:.75rem;">geen</em>';
// Hoofdrij
const mainRow = `
<tr class="school-row${isOpen ? ' expanded' : ''}" data-action="toggleSchool" data-id="${school.id}">
<td><i class="expand-icon">▶</i></td>
<td><strong>${school.name}</strong></td>
<td>${domainHtml}</td>
<td>${ssoIcon}</td>
<td style="text-align:center;color:var(--gray-500);">${school.user_count}</td>
<td style="text-align:right;white-space:nowrap;">
<button class="btn btn-secondary btn-sm" data-action="editSchool"
data-id="${school.id}"
data-name="${school.name.replace(/'/g,'&#39;')}"
data-domains="${(school.email_domains||[]).join(', ')}">Bewerken</button>
<button class="btn btn-danger btn-sm" data-action="deleteSchool"
data-id="${school.id}"
data-name="${school.name.replace(/'/g,'&#39;')}">Verwijderen</button>
</td>
</tr>`;
// Uitklapbare gebruikerspaneel rij
const panelRow = `
<tr class="users-panel-row" id="panel-row-${school.id}">
<td colspan="6">
<div class="users-panel${isOpen ? ' open' : ''}" id="users-panel-${school.id}">
${isLoading
? '<div style="padding:.5rem;color:var(--gray-500);font-size:.85rem;">⏳ Laden...</div>'
: renderUsersPanel(school.id, term)}
</div>
</td>
</tr>`;
return mainRow + panelRow;
}
function renderUsersPanel(schoolId, term) {
const users = loadedUsers[schoolId];
if (!users) return ''; // nog niet geladen
// Filter op gebruikersnaam als er een zoekterm is
const filtered = term
? users.filter(u => u.full_name.toLowerCase().includes(term) || u.email.toLowerCase().includes(term))
: users;
const byRole = {
school_ict: users.filter(u => u.role === 'school_ict'),
director: users.filter(u => u.role === 'director'),
teacher: users.filter(u => u.role === 'teacher'),
school_ict: filtered.filter(u => u.role === 'school_ict'),
director: filtered.filter(u => u.role === 'director'),
teacher: filtered.filter(u => u.role === 'teacher'),
};
const card = document.createElement('div');
card.className = 'school-card';
card.id = `school-card-${school.id}`;
card.innerHTML = `
<div class="school-card-header">
<div>
<h3>${school.name}</h3>
<div style="margin-top:.35rem;">${(school.email_domains||[]).map(d=>`<span class="domain-chip">${d}</span>`).join('') || '<span style="color:var(--gray-400);font-size:.75rem;">geen domeinen</span>'}</div>
</div>
<span style="color:var(--gray-500);font-size:.8rem;white-space:nowrap;">${users.length} gebruikers</span>
</div>
<div class="school-card-body">
${renderUserGroup(school.id,'School ICT',byRole.school_ict)}
${renderUserGroup(school.id,'Directeurs',byRole.director)}
${renderUserGroup(school.id,'Leerkrachten',byRole.teacher,5)}
</div>`;
container.appendChild(card);
const total = filtered.length;
if (!total) return `<div style="color:var(--gray-500);font-size:.85rem;padding:.25rem 0;">Geen gebruikers gevonden${term ? ` voor "${term}"` : ''}.</div>`;
return [
renderUserGroup(schoolId, 'School ICT', byRole.school_ict),
renderUserGroup(schoolId, 'Directeurs', byRole.director),
renderUserGroup(schoolId, 'Leerkrachten', byRole.teacher),
].join('');
}
function renderUserGroup(schoolId, label, users, maxShow=99) {
function renderUserGroup(schoolId, label, users) {
if (!users.length) return '';
const shown = users.slice(0, maxShow);
const hidden = users.length - shown.length;
return `
<div class="group-label"><span>${label}</span><span style="font-weight:400;text-transform:none;letter-spacing:0;">${users.length}</span></div>
${shown.map(u => `
<div class="group-label">
<span>${label}</span>
<span style="font-weight:400;text-transform:none;letter-spacing:0;">${users.length}</span>
</div>
${users.map(u => `
<div class="user-row">
<div class="user-info">
<div class="user-name">${u.full_name}</div>
<div class="user-email">${u.email}</div>
<div class="user-email" style="color:var(--gray-400);font-size:.7rem;">
${u.last_login
? '↩ ' + new Date(u.last_login).toLocaleString('nl-BE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})
? '↩ ' + new Date(u.last_login).toLocaleString('nl-BE',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})
: 'Nog niet ingelogd'}
</div>
</div>
<div class="user-actions">
<select class="role-select" data-action="changeRole" data-school-id="${schoolId}" data-user-id="${u.id}" data-name="${u.full_name.replace(/'/g,'&#39;')}">
<select class="role-select" data-action="changeRole"
data-school-id="${schoolId}" data-user-id="${u.id}"
data-name="${u.full_name.replace(/'/g,'&#39;')}">
${SCHOOL_ROLLEN.map(r=>`<option value="${r.value}" ${r.value===u.role?'selected':''}>${r.label}</option>`).join('')}
</select>
<button class="btn btn-danger btn-sm" data-action="removeUser" data-school-id="${schoolId}" data-user-id="${u.id}" data-name="${u.full_name.replace(/'/g,'&#39;')}">×</button>
<button class="btn btn-danger btn-sm" data-action="removeUser"
data-school-id="${schoolId}" data-user-id="${u.id}"
data-name="${u.full_name.replace(/'/g,'&#39;')}">×</button>
</div>
</div>`).join('')}
${hidden > 0 ? `<div style="color:var(--gray-500);font-size:.8rem;padding:.3rem 0 0;">+ ${hidden} meer...</div>` : ''}`;
</div>`).join('')}`;
}
async function changeRole(schoolId, userId, newRole, naam, selectEl) {
function updateSchoolCountBadge(visibleCount) {
const el = document.getElementById('schoolCountBadge');
if (!el) return;
const total = schools.length;
if (visibleCount === undefined || visibleCount === total) {
el.textContent = `${total} school${total !== 1 ? 'en' : ''}`;
} else {
el.textContent = `${visibleCount} van ${total} scholen`;
}
}
// Toggle een school open/dicht; laad gebruikers als dat nog niet is gebeurd
async function toggleSchool(schoolId) {
if (expandedSchools.has(schoolId)) {
expandedSchools.delete(schoolId);
renderSchoolsUserTable();
return;
}
expandedSchools.add(schoolId);
if (!loadedUsers[schoolId]) {
// Toon laad-indicator via hertekenen (loading state)
loadingSchools.add(schoolId);
renderSchoolsUserTable();
const res = await fetch(`/admin/schools/${schoolId}/users`);
const data = await res.json();
loadedUsers[schoolId] = data.users || [];
loadingSchools.delete(schoolId);
}
renderSchoolsUserTable();
}
// Herlaad de gebruikers van één school (na wijziging) en herteken
async function refreshSchool(schoolId) {
const res = await fetch(`/admin/schools/${schoolId}/users`);
const data = await res.json();
loadedUsers[schoolId] = data.users || [];
// Bijwerken user_count in schools array
const school = schools.find(s => s.id === schoolId);
if (school) school.user_count = (data.users || []).length;
renderSchoolsUserTable();
}
async function changeRole(schoolId, userId, newRole, naam) {
const res = await fetch(`/admin/schools/${schoolId}/users/${userId}/role`, {
method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ role: newRole })
});
if (!res.ok) { notify((await res.json()).error || 'Wijzigen mislukt', 'error'); await refreshCard(schoolId); return; }
if (!res.ok) { notify((await res.json()).error || 'Wijzigen mislukt', 'error'); await refreshSchool(schoolId); return; }
notify(`${naam} is nu ${SCHOOL_ROLLEN.find(r=>r.value===newRole)?.label}`, 'success');
await refreshCard(schoolId);
await refreshSchool(schoolId);
}
async function addUser() {
const err = document.getElementById('addUser-error');
err.style.display = 'none';
const schoolId = document.getElementById('addUserSchool').value;
const schoolId = parseInt(document.getElementById('addUserSchool').value);
const res = await fetch(`/admin/schools/${schoolId}/users`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ email: document.getElementById('addUserEmail').value, first_name: document.getElementById('addUserFirst').value, last_name: document.getElementById('addUserLast').value, role: document.getElementById('addUserRole').value })
body: JSON.stringify({
email: document.getElementById('addUserEmail').value,
first_name: document.getElementById('addUserFirst').value,
last_name: document.getElementById('addUserLast').value,
role: document.getElementById('addUserRole').value,
})
});
const data = await res.json();
if (!res.ok) { err.textContent = data.error; err.style.display = 'block'; return; }
closeModal(); notify(data.linked ? 'Account heractiveerd en bijgewerkt' : 'Gebruiker toegevoegd', 'success');
await Promise.all([refreshCard(parseInt(schoolId)), loadStats()]);
closeModal();
notify(data.linked ? 'Account heractiveerd en bijgewerkt' : 'Gebruiker toegevoegd', 'success');
// Zorg dat de school open staat zodat de nieuwe gebruiker zichtbaar is
expandedSchools.add(schoolId);
delete loadedUsers[schoolId]; // forceer herlaad
await Promise.all([refreshSchool(schoolId), loadStats()]);
}
async function removeUser(schoolId, userId, naam) {
if (!confirm(`${naam} verwijderen?`)) return;
const res = await fetch(`/admin/schools/${schoolId}/users/${userId}`, { method: 'DELETE' });
const res = await fetch(`/admin/schools/${parseInt(schoolId)}/users/${userId}`, { method: 'DELETE' });
if (!res.ok) { notify('Verwijderen mislukt', 'error'); return; }
notify(`${naam} verwijderd`, 'success');
await Promise.all([refreshCard(schoolId), loadStats()]);
}
async function refreshCard(schoolId) {
const card = document.getElementById(`school-card-${schoolId}`);
const school = schools.find(s => s.id === schoolId);
if (card && school) { card.remove(); await renderSchoolCard(school, document.getElementById('schoolsGrid')); }
delete loadedUsers[parseInt(schoolId)]; // forceer herlaad
await Promise.all([refreshSchool(parseInt(schoolId)), loadStats()]);
}
// ── Schooljaren ───────────────────────────────────────────────────────────────
@@ -722,6 +967,12 @@ function notify(msg, type='success') {
// ── Event delegation voor dynamisch gegenereerde elementen ────────────────────
document.addEventListener('click', function(e) {
// toggleSchool: klik op de rij zelf, maar NIET op knoppen erin
const schoolRow = e.target.closest('.school-row[data-action="toggleSchool"]');
if (schoolRow && !e.target.closest('button') && !e.target.closest('select')) {
toggleSchool(parseInt(schoolRow.dataset.id));
return;
}
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
@@ -734,7 +985,7 @@ document.addEventListener('click', function(e) {
});
document.addEventListener('change', function(e) {
const sel = e.target.closest('[data-action="changeRole"]');
if (sel) { changeRole(sel.dataset.schoolId, sel.dataset.userId, sel.value, sel.dataset.name, sel); }
if (sel) { changeRole(sel.dataset.schoolId, sel.dataset.userId, sel.value, sel.dataset.name); }
});
</script>
</body>

View File

@@ -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">
@@ -340,11 +409,18 @@ document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('btnConfirmUser') && bind('btnConfirmUser', 'click', addUser);
document.getElementById('auditCategory') && bind('auditCategory', 'change', loadAuditLog);
document.getElementById('auditSearch') && bind('auditSearch', 'input', loadAuditLog);
document.getElementById('btnSaveSso') && bind('btnSaveSso', 'click', saveSso);
document.getElementById('btnClearSso') && bind('btnClearSso', 'click', clearSso);
// Redirect URI is altijd bekend — vul meteen in zodat het niet "Laden..." blijft
const redirectEl = document.getElementById('redirectUriDisplay');
if (redirectEl) redirectEl.textContent = window.location.origin + '/auth/google/callback';
const me = await fetch('/api/me').then(r => r.json());
mySchoolId = me.user?.school_id;
document.getElementById('schoolName').textContent = me.user?.school_name || '';
await loadUsers();
await loadKlassen();
loadSsoStatus(me.user?.school); // geef school direct mee — geen extra API call nodig
await loadAuditLog();
});
@@ -592,6 +668,73 @@ async function loadAuditLog(page = 1) {
</button>`).join('');
}
// ── Google SSO beheer ─────────────────────────────────────────────────────────
async function loadSsoStatus(school) {
// Als geen school meegegeven: haal op via /api/me (school_ict heeft geen toegang tot /admin/schools)
if (!school) {
const res = await fetch('/api/me');
if (!res.ok) return;
const data = await res.json();
school = data.user?.school;
}
const statusEl = document.getElementById('ssoStatus');
if (!statusEl || !school) return;
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(); // herlaadt via /api/me
}
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]');

Binary file not shown.