Compare commits
18 Commits
add-google
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 26ad6cd650 | |||
| 04fe593d0c | |||
| bbd4e332f4 | |||
| 653cc6cd74 | |||
| 4bfa3cd486 | |||
| 2782c3bea6 | |||
| d4f09bb368 | |||
| 1acaf26a38 | |||
| d55b700502 | |||
| 6e5c898d97 | |||
| c59afa6595 | |||
| 50d029c67e | |||
| 187232a95f | |||
| 5afe297161 | |||
| c571967701 | |||
| ee8fcb231b | |||
| b470cd017e | |||
| 55cd055645 |
@@ -27,6 +27,14 @@ BASE_URL=https://leerdoelen.jouwdomain.be
|
|||||||
MICROSOFT_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
MICROSOFT_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||||
MICROSOFT_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
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
|
# Naam van de scholengroep — verschijnt op de loginpagina
|
||||||
ORG_NAME=GO! Scholengroep 2
|
ORG_NAME=GO! Scholengroep 2
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ def create_app():
|
|||||||
# OAuth2
|
# OAuth2
|
||||||
app.config['MICROSOFT_CLIENT_ID'] = os.environ.get('MICROSOFT_CLIENT_ID')
|
app.config['MICROSOFT_CLIENT_ID'] = os.environ.get('MICROSOFT_CLIENT_ID')
|
||||||
app.config['MICROSOFT_CLIENT_SECRET'] = os.environ.get('MICROSOFT_CLIENT_SECRET')
|
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')
|
app.config['MICROSOFT_TENANT_ID'] = os.environ.get('MICROSOFT_TENANT_ID', 'common')
|
||||||
|
|
||||||
# Session cookie beveiliging
|
# Session cookie beveiliging
|
||||||
|
|||||||
29
backend/migrations/versions/0003_school_google_sso.py
Normal file
29
backend/migrations/versions/0003_school_google_sso.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""schools: voeg Google Workspace SSO credentials toe per school
|
||||||
|
|
||||||
|
Revision ID: 0003
|
||||||
|
Revises: 0002
|
||||||
|
Create Date: 2026-03-03
|
||||||
|
|
||||||
|
Elke school heeft zijn eigen Google Workspace omgeving en dus
|
||||||
|
zijn eigen OAuth2 client_id en client_secret. Deze worden per school
|
||||||
|
opgeslagen en nooit blootgesteld via de API (enkel of ze ingesteld zijn).
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = '0003'
|
||||||
|
down_revision = '0002'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column('schools',
|
||||||
|
sa.Column('google_client_id', sa.String(255), nullable=True))
|
||||||
|
op.add_column('schools',
|
||||||
|
sa.Column('google_client_secret', sa.String(255), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column('schools', 'google_client_secret')
|
||||||
|
op.drop_column('schools', 'google_client_id')
|
||||||
60
backend/migrations/versions/0004_class_based_assessments.py
Normal file
60
backend/migrations/versions/0004_class_based_assessments.py
Normal 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)
|
||||||
|
)
|
||||||
|
""")
|
||||||
@@ -12,6 +12,9 @@ class School(db.Model):
|
|||||||
slug = db.Column(db.String(100), nullable=False, unique=True)
|
slug = db.Column(db.String(100), nullable=False, unique=True)
|
||||||
email_domains = db.Column(db.ARRAY(db.Text), nullable=False, default=list)
|
email_domains = db.Column(db.ARRAY(db.Text), nullable=False, default=list)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
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')
|
users = db.relationship('User', back_populates='school', lazy='dynamic')
|
||||||
school_years = db.relationship('SchoolYear', 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,
|
'name': self.name,
|
||||||
'slug': self.slug,
|
'slug': self.slug,
|
||||||
'email_domains': self.email_domains or [],
|
'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'
|
__tablename__ = 'assessments'
|
||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
# Klasgebonden — geen koppeling aan individuele leerkracht
|
||||||
school_id = db.Column(db.Integer, db.ForeignKey('schools.id', ondelete='CASCADE'), nullable=False)
|
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)
|
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)
|
vak_id = db.Column(db.String(50), nullable=False)
|
||||||
goal_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)
|
opmerking = db.Column(db.String(500), nullable=True)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
user = db.relationship('User')
|
klas = db.relationship('Class')
|
||||||
school = db.relationship('School')
|
|
||||||
school_year = db.relationship('SchoolYear', back_populates='assessments')
|
school_year = db.relationship('SchoolYear', back_populates='assessments')
|
||||||
|
|
||||||
__table_args__ = (
|
__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):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
|
'class_id': self.class_id,
|
||||||
'vak_id': self.vak_id,
|
'vak_id': self.vak_id,
|
||||||
'goal_id': self.goal_id,
|
'goal_id': self.goal_id,
|
||||||
'status': self.status,
|
'status': self.status,
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ def school_ict_required(f):
|
|||||||
return decorated
|
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) ────────────────────────────────────────────────
|
# ── Scholen (scholengroep_ict) ────────────────────────────────────────────────
|
||||||
|
|
||||||
@admin_bp.route('/schools', methods=['GET'])
|
@admin_bp.route('/schools', methods=['GET'])
|
||||||
@@ -102,6 +112,55 @@ def update_school(school_id):
|
|||||||
return jsonify({'school': school.to_dict()})
|
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'])
|
@admin_bp.route('/schools/<int:school_id>', methods=['DELETE'])
|
||||||
@login_required
|
@login_required
|
||||||
@scholengroep_ict_required
|
@scholengroep_ict_required
|
||||||
@@ -179,7 +238,7 @@ def activate_year(year_id):
|
|||||||
|
|
||||||
@admin_bp.route('/schools/<int:school_id>/users', methods=['GET'])
|
@admin_bp.route('/schools/<int:school_id>/users', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
@school_ict_required
|
@director_or_ict_required
|
||||||
def list_school_users(school_id):
|
def list_school_users(school_id):
|
||||||
if not current_user.is_scholengroep_ict and current_user.school_id != 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
|
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'])
|
@admin_bp.route('/schools/<int:school_id>/classes', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
@school_ict_required
|
@director_or_ict_required
|
||||||
def list_classes(school_id):
|
def list_classes(school_id):
|
||||||
if not current_user.is_scholengroep_ict and current_user.school_id != school_id:
|
if not current_user.is_scholengroep_ict and current_user.school_id != school_id:
|
||||||
return jsonify({'error': 'Geen toegang'}), 403
|
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'])
|
@admin_bp.route('/schools/<int:school_id>/classes', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@school_ict_required
|
@director_or_ict_required
|
||||||
def create_class(school_id):
|
def create_class(school_id):
|
||||||
if not current_user.is_scholengroep_ict and current_user.school_id != school_id:
|
if not current_user.is_scholengroep_ict and current_user.school_id != school_id:
|
||||||
return jsonify({'error': 'Geen toegang'}), 403
|
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'])
|
@admin_bp.route('/schools/<int:school_id>/classes/<int:class_id>', methods=['DELETE'])
|
||||||
@login_required
|
@login_required
|
||||||
@school_ict_required
|
@director_or_ict_required
|
||||||
def delete_class(school_id, class_id):
|
def delete_class(school_id, class_id):
|
||||||
if not current_user.is_scholengroep_ict and current_user.school_id != school_id:
|
if not current_user.is_scholengroep_ict and current_user.school_id != school_id:
|
||||||
return jsonify({'error': 'Geen toegang'}), 403
|
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'])
|
@admin_bp.route('/schools/<int:school_id>/classes/<int:class_id>/teachers', methods=['PUT'])
|
||||||
@login_required
|
@login_required
|
||||||
@school_ict_required
|
@director_or_ict_required
|
||||||
def set_class_teachers(school_id, class_id):
|
def set_class_teachers(school_id, class_id):
|
||||||
"""Vervang alle leerkrachten van een klas in één keer."""
|
"""Vervang alle leerkrachten van een klas in één keer."""
|
||||||
if not current_user.is_scholengroep_ict and current_user.school_id != school_id:
|
if not current_user.is_scholengroep_ict and current_user.school_id != school_id:
|
||||||
|
|||||||
@@ -19,18 +19,36 @@ def director_required(f):
|
|||||||
return decorated
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
def get_active_year(school_id=None):
|
def get_active_year():
|
||||||
"""Geeft het globaal actief schooljaar terug (school_id wordt genegeerd)."""
|
"""Geeft het globaal actief schooljaar terug."""
|
||||||
return SchoolYear.query.filter_by(school_id=None, is_active=True).first()
|
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) ─────────────────────────────────────────
|
# ── Doelen (statische JSON bestanden) ─────────────────────────────────────────
|
||||||
|
|
||||||
@api_bp.route('/doelen/index')
|
@api_bp.route('/doelen/index')
|
||||||
@login_required
|
@login_required
|
||||||
def doelen_index():
|
def doelen_index():
|
||||||
data = load_index()
|
data = load_index()
|
||||||
# Altijd een geldig object teruggeven — lege vakkenlijst is geen fout
|
|
||||||
return jsonify(data)
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
@@ -50,16 +68,30 @@ def doelen_vak(vak_id):
|
|||||||
@api_bp.route('/assessments', methods=['GET'])
|
@api_bp.route('/assessments', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
def get_assessments():
|
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': []})
|
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:
|
if not school_year:
|
||||||
return jsonify({'assessments': []})
|
return jsonify({'assessments': []})
|
||||||
|
|
||||||
year_id = request.args.get('year_id', school_year.id)
|
year_id = request.args.get('year_id', school_year.id)
|
||||||
vak_id = request.args.get('vak_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:
|
if vak_id:
|
||||||
query = query.filter_by(vak_id=vak_id)
|
query = query.filter_by(vak_id=vak_id)
|
||||||
|
|
||||||
@@ -68,30 +100,39 @@ def get_assessments():
|
|||||||
|
|
||||||
@api_bp.route('/assessments', methods=['POST'])
|
@api_bp.route('/assessments', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@limiter.limit('120 per minute') # max 2 per seconde per gebruiker
|
@limiter.limit('120 per minute')
|
||||||
def save_assessment():
|
def save_assessment():
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
|
class_id = data.get('class_id')
|
||||||
vak_id = (data.get('vak_id') or '').strip()
|
vak_id = (data.get('vak_id') or '').strip()
|
||||||
goal_id = (data.get('goal_id') or '').strip()
|
goal_id = (data.get('goal_id') or '').strip()
|
||||||
status = (data.get('status') or '').strip()
|
status = (data.get('status') or '').strip()
|
||||||
opmerking = (data.get('opmerking') or '').strip()[:500]
|
opmerking = (data.get('opmerking') or '').strip()[:500]
|
||||||
|
|
||||||
if not vak_id or not goal_id:
|
if not class_id or not vak_id or not goal_id:
|
||||||
return jsonify({'error': 'vak_id en goal_id zijn verplicht'}), 400
|
return jsonify({'error': 'class_id, vak_id en goal_id zijn verplicht'}), 400
|
||||||
if status not in ('groen', 'oranje', 'roze', ''):
|
if status not in ('groen', 'oranje', 'roze', ''):
|
||||||
return jsonify({'error': 'Ongeldige status — gebruik groen, oranje, roze of leeg'}), 400
|
return jsonify({'error': 'Ongeldige status'}), 400
|
||||||
# Sanitiseer input — voorkomt oversized data in DB
|
|
||||||
if len(vak_id) > 100 or len(goal_id) > 50:
|
if len(vak_id) > 100 or len(goal_id) > 50:
|
||||||
return jsonify({'error': 'Ongeldige invoer'}), 400
|
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:
|
if not school_year:
|
||||||
return jsonify({'error': 'Geen actief schooljaar gevonden'}), 400
|
return jsonify({'error': 'Geen actief schooljaar gevonden'}), 400
|
||||||
|
|
||||||
assessment = Assessment.query.filter_by(
|
assessment = Assessment.query.filter_by(
|
||||||
user_id=current_user.id,
|
class_id=class_id,
|
||||||
school_year_id=school_year.id,
|
school_year_id=school_year.id,
|
||||||
vak_id=vak_id,
|
vak_id=vak_id,
|
||||||
goal_id=goal_id,
|
goal_id=goal_id,
|
||||||
@@ -109,8 +150,7 @@ def save_assessment():
|
|||||||
assessment.updated_at = datetime.utcnow()
|
assessment.updated_at = datetime.utcnow()
|
||||||
else:
|
else:
|
||||||
assessment = Assessment(
|
assessment = Assessment(
|
||||||
user_id=current_user.id,
|
class_id=class_id,
|
||||||
school_id=current_user.school_id,
|
|
||||||
school_year_id=school_year.id,
|
school_year_id=school_year.id,
|
||||||
vak_id=vak_id,
|
vak_id=vak_id,
|
||||||
goal_id=goal_id,
|
goal_id=goal_id,
|
||||||
@@ -120,38 +160,44 @@ def save_assessment():
|
|||||||
db.session.add(assessment)
|
db.session.add(assessment)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
# Auditlog enkel bij statuswijziging (niet bij elke klik)
|
|
||||||
audit_log('assessment.save', 'assessment',
|
audit_log('assessment.save', 'assessment',
|
||||||
target_type='goal', target_id=f'{vak_id}:{goal_id}',
|
target_type='class', target_id=str(class_id),
|
||||||
detail={'status': status})
|
detail={'status': status, 'vak': vak_id, 'goal': goal_id})
|
||||||
return jsonify({'assessment': assessment.to_dict()})
|
return jsonify({'assessment': assessment.to_dict()})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route('/assessments/opmerking', methods=['POST'])
|
@api_bp.route('/assessments/opmerking', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@limiter.limit('120 per minute')
|
@limiter.limit('120 per minute')
|
||||||
def save_opmerking():
|
def save_opmerking():
|
||||||
"""Sla enkel een opmerking op bij een bestaand of nieuw assessment record."""
|
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
|
class_id = data.get('class_id')
|
||||||
vak_id = (data.get('vak_id') or '').strip()
|
vak_id = (data.get('vak_id') or '').strip()
|
||||||
goal_id = (data.get('goal_id') or '').strip()
|
goal_id = (data.get('goal_id') or '').strip()
|
||||||
opmerking = (data.get('opmerking') or '').strip()[:500]
|
opmerking = (data.get('opmerking') or '').strip()[:500]
|
||||||
|
|
||||||
if not vak_id or not goal_id:
|
if not class_id or not vak_id or not goal_id:
|
||||||
return jsonify({'error': 'vak_id en goal_id zijn verplicht'}), 400
|
return jsonify({'error': 'class_id, vak_id en goal_id zijn verplicht'}), 400
|
||||||
if len(vak_id) > 100 or len(goal_id) > 50:
|
if len(vak_id) > 100 or len(goal_id) > 50:
|
||||||
return jsonify({'error': 'Ongeldige invoer'}), 400
|
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:
|
if not school_year:
|
||||||
return jsonify({'error': 'Geen actief schooljaar'}), 400
|
return jsonify({'error': 'Geen actief schooljaar'}), 400
|
||||||
|
|
||||||
assessment = Assessment.query.filter_by(
|
assessment = Assessment.query.filter_by(
|
||||||
user_id=current_user.id,
|
class_id=class_id,
|
||||||
school_year_id=school_year.id,
|
school_year_id=school_year.id,
|
||||||
vak_id=vak_id,
|
vak_id=vak_id,
|
||||||
goal_id=goal_id,
|
goal_id=goal_id,
|
||||||
@@ -161,10 +207,8 @@ def save_opmerking():
|
|||||||
assessment.opmerking = opmerking or None
|
assessment.opmerking = opmerking or None
|
||||||
assessment.updated_at = datetime.utcnow()
|
assessment.updated_at = datetime.utcnow()
|
||||||
else:
|
else:
|
||||||
# Maak een record aan zonder status voor de opmerking
|
|
||||||
assessment = Assessment(
|
assessment = Assessment(
|
||||||
user_id=current_user.id,
|
class_id=class_id,
|
||||||
school_id=current_user.school_id,
|
|
||||||
school_year_id=school_year.id,
|
school_year_id=school_year.id,
|
||||||
vak_id=vak_id,
|
vak_id=vak_id,
|
||||||
goal_id=goal_id,
|
goal_id=goal_id,
|
||||||
@@ -176,32 +220,43 @@ def save_opmerking():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({'ok': True})
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route('/assessments/bulk-import', methods=['POST'])
|
@api_bp.route('/assessments/bulk-import', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@limiter.limit('5 per minute')
|
@limiter.limit('5 per minute')
|
||||||
def bulk_import_assessments():
|
def bulk_import_assessments():
|
||||||
"""
|
"""
|
||||||
Importeer beoordelingen vanuit de legacy standalone JSON export.
|
Importeer beoordelingen vanuit legacy standalone JSON export.
|
||||||
Body: { "vakken": { "vak_id": { "goal_id": "status", ... }, ... } }
|
Body: { "class_id": 1, "vakken": { "vak_id": { "goal_id": "status" } } }
|
||||||
of v4 formaat: { "vakken": { "vak_id": { "statussen": { "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 {}
|
data = request.get_json() or {}
|
||||||
|
class_id = data.get('class_id')
|
||||||
vakken = data.get('vakken', {})
|
vakken = data.get('vakken', {})
|
||||||
|
|
||||||
|
if not class_id:
|
||||||
|
return jsonify({'error': 'class_id is verplicht'}), 400
|
||||||
if not vakken:
|
if not vakken:
|
||||||
return jsonify({'error': 'Geen vakken gevonden in payload'}), 400
|
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
|
totaal = 0
|
||||||
fouten = 0
|
fouten = 0
|
||||||
|
|
||||||
for vak_id, vak_data in vakken.items():
|
for vak_id, vak_data in vakken.items():
|
||||||
# Sanitiseer vak_id
|
|
||||||
if not isinstance(vak_id, str) or len(vak_id) > 100:
|
if not isinstance(vak_id, str) or len(vak_id) > 100:
|
||||||
fouten += 1
|
fouten += 1
|
||||||
continue
|
continue
|
||||||
@@ -224,7 +279,7 @@ def bulk_import_assessments():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
assessment = Assessment.query.filter_by(
|
assessment = Assessment.query.filter_by(
|
||||||
user_id=current_user.id,
|
class_id=class_id,
|
||||||
school_year_id=school_year.id,
|
school_year_id=school_year.id,
|
||||||
vak_id=vak_id,
|
vak_id=vak_id,
|
||||||
goal_id=goal_id,
|
goal_id=goal_id,
|
||||||
@@ -235,8 +290,7 @@ def bulk_import_assessments():
|
|||||||
assessment.updated_at = datetime.utcnow()
|
assessment.updated_at = datetime.utcnow()
|
||||||
else:
|
else:
|
||||||
db.session.add(Assessment(
|
db.session.add(Assessment(
|
||||||
user_id=current_user.id,
|
class_id=class_id,
|
||||||
school_id=current_user.school_id,
|
|
||||||
school_year_id=school_year.id,
|
school_year_id=school_year.id,
|
||||||
vak_id=vak_id,
|
vak_id=vak_id,
|
||||||
goal_id=goal_id,
|
goal_id=goal_id,
|
||||||
@@ -249,6 +303,7 @@ def bulk_import_assessments():
|
|||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
audit_log('assessment.bulk_import', 'assessment',
|
audit_log('assessment.bulk_import', 'assessment',
|
||||||
|
target_type='class', target_id=str(class_id),
|
||||||
detail={'totaal': totaal, 'fouten': fouten})
|
detail={'totaal': totaal, 'fouten': fouten})
|
||||||
return jsonify({'totaal': totaal, 'fouten': fouten})
|
return jsonify({'totaal': totaal, 'fouten': fouten})
|
||||||
|
|
||||||
@@ -261,47 +316,44 @@ def bulk_import_assessments():
|
|||||||
def school_overview():
|
def school_overview():
|
||||||
if not current_user.school_id:
|
if not current_user.school_id:
|
||||||
return jsonify({'error': 'Geen school gekoppeld'}), 400
|
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:
|
if not school_year:
|
||||||
return jsonify({'error': 'Geen actief schooljaar'}), 400
|
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')
|
year_id_param = request.args.get('year_id')
|
||||||
if year_id_param and current_user.is_director:
|
if year_id_param:
|
||||||
year_id = int(year_id_param)
|
selected_year = SchoolYear.query.get(int(year_id_param)) or school_year
|
||||||
selected_year = SchoolYear.query.filter_by(
|
|
||||||
id=year_id, school_id=current_user.school_id
|
|
||||||
).first() or school_year
|
|
||||||
else:
|
else:
|
||||||
selected_year = school_year
|
selected_year = school_year
|
||||||
year_id = school_year.id
|
|
||||||
|
|
||||||
vak_id = request.args.get('vak_id')
|
vak_id = request.args.get('vak_id')
|
||||||
|
|
||||||
teachers = User.query.filter_by(
|
# Alle klassen van deze school
|
||||||
school_id=current_user.school_id, role='teacher', is_active=True
|
klassen = Class.query.filter_by(school_id=current_user.school_id)\
|
||||||
).all()
|
.order_by(Class.name).all()
|
||||||
|
class_ids = [k.id for k in klassen]
|
||||||
|
|
||||||
query = Assessment.query.filter_by(
|
query = Assessment.query.filter(
|
||||||
school_id=current_user.school_id, school_year_id=year_id
|
Assessment.class_id.in_(class_ids),
|
||||||
|
Assessment.school_year_id == selected_year.id,
|
||||||
)
|
)
|
||||||
if vak_id:
|
if vak_id:
|
||||||
query = query.filter_by(vak_id=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():
|
for a in query.all():
|
||||||
by_teacher.setdefault(a.user_id, {})
|
by_class[a.class_id].setdefault(a.vak_id, {})[a.goal_id] = a.status
|
||||||
by_teacher[a.user_id].setdefault(a.vak_id, {})
|
|
||||||
by_teacher[a.user_id][a.vak_id][a.goal_id] = a.status
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'school_year': selected_year.to_dict(),
|
'school_year': selected_year.to_dict(),
|
||||||
'teachers': [t.to_dict() for t in teachers],
|
'classes': [k.to_dict() for k in klassen],
|
||||||
'assessments_by_teacher': by_teacher,
|
'assessments_by_class': by_class,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
# ── Gebruikersbeheer (school_ict / directeur) ──────────────────────────────────
|
# ── Gebruikersbeheer (director / school_ict) ───────────────────────────────────
|
||||||
|
|
||||||
@api_bp.route('/users', methods=['GET'])
|
@api_bp.route('/users', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
@@ -347,54 +399,57 @@ def delete_user(user_id):
|
|||||||
return jsonify({'deleted': True})
|
return jsonify({'deleted': True})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Schooljaren ────────────────────────────────────────────────────────────────
|
||||||
# ── Schooljaren (directeur/admin leesbaar) ────────────────────────────────────
|
|
||||||
|
|
||||||
@api_bp.route('/school/years')
|
@api_bp.route('/school/years')
|
||||||
@login_required
|
@login_required
|
||||||
@director_required
|
@director_required
|
||||||
def get_school_years():
|
def get_school_years():
|
||||||
"""Geeft alle globale schooljaren terug (voor jaarselectie in directeur dashboard)."""
|
years = SchoolYear.query.filter_by(school_id=None)\
|
||||||
years = SchoolYear.query.filter_by(school_id=None) .order_by(SchoolYear.label.desc()).all()
|
.order_by(SchoolYear.label.desc()).all()
|
||||||
return jsonify({'years': [y.to_dict() for y in years]})
|
return jsonify({'years': [y.to_dict() for y in years]})
|
||||||
|
|
||||||
|
|
||||||
# ── Huidig ingelogde gebruiker ────────────────────────────────────────────────
|
# ── Huidig ingelogde gebruiker ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@api_bp.route('/me')
|
@api_bp.route('/me')
|
||||||
@login_required
|
@login_required
|
||||||
def me():
|
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({
|
return jsonify({
|
||||||
'user': current_user.to_dict(),
|
'user': current_user.to_dict(),
|
||||||
'school_year': school_year.to_dict() if school_year else None,
|
'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'])
|
@api_bp.route('/my/classes', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
def my_classes():
|
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:
|
if not current_user.school_id:
|
||||||
return jsonify({'all_classes': [], 'my_classes': []})
|
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({
|
return jsonify({
|
||||||
'all_classes': [{'id': c.id, 'name': c.name} for c in all_cls],
|
'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'])
|
@api_bp.route('/my/classes', methods=['PUT'])
|
||||||
@login_required
|
@login_required
|
||||||
def set_my_classes():
|
def set_my_classes():
|
||||||
"""Leerkracht stelt eigen klassen in."""
|
"""Leerkracht stelt zijn eigen klassen in."""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
class_ids = data.get('class_ids', [])
|
class_ids = data.get('class_ids', [])
|
||||||
classes = Class.query.filter(
|
classes = Class.query.filter(
|
||||||
Class.id.in_(class_ids),
|
Class.id.in_(class_ids),
|
||||||
Class.school_id == current_user.school_id
|
Class.school_id == current_user.school_id,
|
||||||
).all()
|
).all()
|
||||||
current_user.classes = classes
|
current_user.classes = classes
|
||||||
audit_log('class.user_assignment', 'class', target_type='user',
|
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')
|
@api_bp.route('/audit-log')
|
||||||
@login_required
|
@login_required
|
||||||
@@ -414,16 +537,13 @@ def get_audit_log():
|
|||||||
return jsonify({'error': 'Geen toegang'}), 403
|
return jsonify({'error': 'Geen toegang'}), 403
|
||||||
|
|
||||||
page = max(1, int(request.args.get('page', 1)))
|
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')
|
category = request.args.get('category')
|
||||||
search = request.args.get('search', '').strip()
|
search = request.args.get('search', '').strip()
|
||||||
|
|
||||||
query = AuditLog.query
|
query = AuditLog.query
|
||||||
|
|
||||||
# School ICT ziet enkel eigen school
|
|
||||||
if not current_user.is_scholengroep_ict:
|
if not current_user.is_scholengroep_ict:
|
||||||
query = query.filter(AuditLog.school_id == current_user.school_id)
|
query = query.filter(AuditLog.school_id == current_user.school_id)
|
||||||
|
|
||||||
if category:
|
if category:
|
||||||
query = query.filter(AuditLog.category == category)
|
query = query.filter(AuditLog.category == category)
|
||||||
if search:
|
if search:
|
||||||
@@ -435,7 +555,8 @@ def get_audit_log():
|
|||||||
)
|
)
|
||||||
|
|
||||||
total = query.count()
|
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({
|
return jsonify({
|
||||||
'total': total,
|
'total': total,
|
||||||
@@ -444,3 +565,36 @@ def get_audit_log():
|
|||||||
'entries': [e.to_dict() for e in entries],
|
'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),
|
||||||
|
})
|
||||||
|
|||||||
@@ -113,7 +113,10 @@ def login():
|
|||||||
return redirect(url_for('pages.dashboard'))
|
return redirect(url_for('pages.dashboard'))
|
||||||
entra_configured = bool(_entra_client_id() and _entra_client_secret())
|
entra_configured = bool(_entra_client_id() and _entra_client_secret())
|
||||||
org_name = current_app.config.get('ORG_NAME', 'GO! Scholengroep')
|
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')
|
@auth_bp.route('/logout')
|
||||||
@@ -239,6 +242,202 @@ def microsoft_callback():
|
|||||||
return redirect(_safe_next_url(request.args.get('next')))
|
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'])
|
@auth_bp.route('/setup', methods=['GET', 'POST'])
|
||||||
@limiter.limit('5 per minute')
|
@limiter.limit('5 per minute')
|
||||||
def setup():
|
def setup():
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ def dashboard():
|
|||||||
return render_template('directeur.html', org_name=org)
|
return render_template('directeur.html', org_name=org)
|
||||||
return render_template('leerkracht.html', org_name=org)
|
return render_template('leerkracht.html', org_name=org)
|
||||||
|
|
||||||
|
|
||||||
@pages_bp.route('/doelen-beheer')
|
@pages_bp.route('/doelen-beheer')
|
||||||
@login_required
|
@login_required
|
||||||
@_beheer_required
|
@_beheer_required
|
||||||
@@ -58,3 +57,23 @@ def doelen_beheer():
|
|||||||
@login_required
|
@login_required
|
||||||
def admin_page():
|
def admin_page():
|
||||||
return redirect(url_for('pages.dashboard'))
|
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
363
backend/templates/directeur_klassen.html
Normal file
363
backend/templates/directeur_klassen.html
Normal 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,''')}"
|
||||||
|
data-teachers="${JSON.stringify(c.teachers?.map(t=>t.id)||[]).replace(/"/g,'"')}">
|
||||||
|
Leerkrachten koppelen
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger btn-sm"
|
||||||
|
data-action="deleteKlas"
|
||||||
|
data-id="${c.id}"
|
||||||
|
data-name="${c.name.replace(/'/g,''')}">
|
||||||
|
×
|
||||||
|
</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(/"/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
@@ -9,250 +9,128 @@
|
|||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
min-height: 100vh;
|
min-height: 100vh; display: flex;
|
||||||
display: flex;
|
align-items: center; justify-content: center; padding: 1rem;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
background: white;
|
background: white; border-radius: 16px; padding: 2.5rem;
|
||||||
border-radius: 16px;
|
width: 100%; max-width: 400px; box-shadow: 0 20px 60px rgba(0,0,0,0.2);
|
||||||
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 { text-align: center; margin-bottom: 2rem; }
|
||||||
.logo .icon { font-size: 3rem; margin-bottom: 0.5rem; }
|
.logo .icon { font-size: 3rem; margin-bottom: 0.5rem; }
|
||||||
.logo h1 { font-size: 1.4rem; color: var(--gray-900, #1f2937); font-weight: 700; }
|
.logo h1 { font-size: 1.4rem; color: #1f2937; font-weight: 700; }
|
||||||
.logo p { color: var(--gray-500, #6b7280); font-size: 0.85rem; margin-top: 0.25rem; }
|
.logo p { color: #6b7280; font-size: 0.85rem; margin-top: 0.25rem; }
|
||||||
|
|
||||||
.btn-microsoft {
|
.btn-microsoft {
|
||||||
width: 100%;
|
width: 100%; padding: 0.85rem; background: #0078d4; color: white;
|
||||||
padding: 0.85rem;
|
border: none; border-radius: 8px; font-size: 1rem; font-weight: 600;
|
||||||
background: #0078d4;
|
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||||
color: white;
|
gap: 0.75rem; text-decoration: none; transition: background 0.2s; margin-bottom: 1rem;
|
||||||
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; }
|
.btn-microsoft:hover { background: #006cbe; }
|
||||||
|
.sso-divider {
|
||||||
.alert {
|
display: flex; align-items: center; gap: 0.75rem;
|
||||||
padding: 0.85rem 1rem;
|
margin: 0.5rem 0 1.25rem; color: #9ca3af; font-size: 0.8rem;
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
}
|
||||||
|
.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-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; }
|
.alert-warning { background: #fffbeb; color: #92400e; border: 1px solid #fde68a; }
|
||||||
|
.alert-success { background: #f0fdf4; color: #16a34a; border: 1px solid #bbf7d0; }
|
||||||
.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 */
|
|
||||||
.superadmin-toggle {
|
.superadmin-toggle {
|
||||||
text-align: center;
|
text-align: center; margin-top: 1.5rem;
|
||||||
margin-top: 1.5rem;
|
padding-top: 1rem; border-top: 1px solid #f3f4f6;
|
||||||
padding-top: 1rem;
|
|
||||||
border-top: 1px solid #f3f4f6;
|
|
||||||
}
|
}
|
||||||
.superadmin-toggle button {
|
.superadmin-toggle button {
|
||||||
background: none; border: none;
|
background: none; border: none; color: #9ca3af;
|
||||||
color: #9ca3af; font-size: 0.75rem;
|
font-size: 0.75rem; cursor: pointer;
|
||||||
cursor: pointer; text-decoration: underline;
|
text-decoration: underline; text-underline-offset: 2px;
|
||||||
text-underline-offset: 2px;
|
|
||||||
}
|
}
|
||||||
.superadmin-toggle button:hover { color: #6b7280; }
|
.superadmin-toggle button:hover { color: #6b7280; }
|
||||||
|
|
||||||
.superadmin-form { display: none; margin-top: 1rem; }
|
.superadmin-form { display: none; margin-top: 1rem; }
|
||||||
.superadmin-form.visible { display: block; }
|
.superadmin-form.visible { display: block; }
|
||||||
.superadmin-form .form-group { margin-bottom: 0.75rem; }
|
.superadmin-form .form-group { margin-bottom: 0.75rem; }
|
||||||
.superadmin-form label {
|
.superadmin-form label { display: block; font-size: 0.8rem; font-weight: 600; color: #374151; margin-bottom: 0.3rem; }
|
||||||
display: block; font-size: 0.8rem;
|
|
||||||
font-weight: 600; color: #374151; margin-bottom: 0.3rem;
|
|
||||||
}
|
|
||||||
.superadmin-form input {
|
.superadmin-form input {
|
||||||
width: 100%; padding: 0.6rem 0.75rem;
|
width: 100%; padding: 0.6rem 0.75rem;
|
||||||
border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem;
|
border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
.superadmin-form input:focus {
|
.superadmin-form input:focus { outline: none; border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79,70,229,0.1); }
|
||||||
outline: none; border-color: #4f46e5;
|
|
||||||
box-shadow: 0 0 0 3px rgba(79,70,229,0.1);
|
|
||||||
}
|
|
||||||
.btn-superadmin {
|
.btn-superadmin {
|
||||||
width: 100%; padding: 0.6rem;
|
width: 100%; padding: 0.6rem; background: #6b7280; color: white;
|
||||||
background: #6b7280; color: white;
|
border: none; border-radius: 6px; font-size: 0.85rem; font-weight: 600; cursor: pointer;
|
||||||
border: none; border-radius: 6px;
|
|
||||||
font-size: 0.85rem; font-weight: 600; cursor: pointer;
|
|
||||||
}
|
}
|
||||||
.btn-superadmin:hover { background: #4b5563; }
|
.btn-superadmin:hover { background: #4b5563; }
|
||||||
#sa-error { color: #dc2626; font-size: 0.8rem; margin-top: 0.5rem; display: none; }
|
#sa-error { color: #dc2626; font-size: 0.8rem; margin-top: 0.5rem; display: none; }
|
||||||
|
.spinner-inline {
|
||||||
@media (prefers-color-scheme: dark) {
|
display: inline-block; width: 14px; height: 14px;
|
||||||
:root {
|
border: 2px solid #d1d5db; border-top-color: #6b7280;
|
||||||
--gray-50: #1a1a2e;
|
border-radius: 50%; animation: spin 0.7s linear infinite;
|
||||||
--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;
|
|
||||||
}
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
body { background: #0f172a; color: #e2e8f0; }
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body { background: linear-gradient(135deg, #1e1b4b, #312e81); }
|
||||||
/* Kaarten en secties */
|
.card { background: #1e293b; color: #e2e8f0; }
|
||||||
.card, .section, .stat-card, .school-card,
|
.logo h1 { color: #f1f5f9; }
|
||||||
.table-container, .filters-container, .legend-container,
|
.logo p { color: #94a3b8; }
|
||||||
.stats-bar .stat-card, .stats-overview, .vak-stats,
|
.sso-divider { color: #475569; }
|
||||||
.import-section, .detail-section, .filters-bar,
|
.sso-divider::before, .sso-divider::after { background: #334155; }
|
||||||
.header:not([class*="gradient"]) {
|
.google-section-title { color: #cbd5e1; }
|
||||||
background: #1e293b !important;
|
.email-input-row input { background: #0f172a; color: #e2e8f0; border-color: #334155; }
|
||||||
border-color: #334155 !important;
|
.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; }
|
||||||
}
|
}
|
||||||
|
</style>
|
||||||
/* 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>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -268,6 +146,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
|
{# Microsoft: één global endpoint — werkt direct voor alle scholen #}
|
||||||
{% if entra_configured %}
|
{% if entra_configured %}
|
||||||
<a href="/auth/microsoft" class="btn-microsoft">
|
<a href="/auth/microsoft" class="btn-microsoft">
|
||||||
<svg width="20" height="20" viewBox="0 0 21 21">
|
<svg width="20" height="20" viewBox="0 0 21 21">
|
||||||
@@ -278,24 +157,36 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Inloggen met Microsoft
|
Inloggen met Microsoft
|
||||||
</a>
|
</a>
|
||||||
<p style="text-align:center; color:#6b7280; font-size:0.8rem;">
|
<div class="sso-divider">of via Google Workspace</div>
|
||||||
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>
|
|
||||||
{% endif %}
|
{% 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">
|
<div class="superadmin-toggle">
|
||||||
<button id="btnToggleSuperadmin">Platformbeheerder</button>
|
<button id="btnToggleSuperadmin">Platformbeheerder</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="superadmin-form" id="superadminForm">
|
<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
|
Platformbeheerder toegang
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -312,49 +203,94 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script nonce="{{ csp_nonce() }}">
|
<script nonce="{{ csp_nonce() }}">
|
||||||
function bind(id, ev, fn) {
|
function bind(id, ev, fn) { const el = document.getElementById(id); if (el) el.addEventListener(ev, fn); }
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (el) el.addEventListener(ev, fn);
|
const GOOGLE_SVG = `<svg width="20" height="20" viewBox="0 0 48 48">
|
||||||
|
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/>
|
||||||
|
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/>
|
||||||
|
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/>
|
||||||
|
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.18 1.48-4.97 2.31-8.16 2.31-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
let busy = false;
|
||||||
|
|
||||||
|
async function doGoogleLookup() {
|
||||||
|
if (busy) return;
|
||||||
|
const email = document.getElementById('googleEmail').value.trim();
|
||||||
|
const errEl = document.getElementById('lookupError');
|
||||||
|
const warnEl = document.getElementById('lookupWarning');
|
||||||
|
const resultEl = document.getElementById('googleResult');
|
||||||
|
const btn = document.getElementById('btnLookup');
|
||||||
|
|
||||||
|
errEl.style.display = 'none';
|
||||||
|
warnEl.style.display = 'none';
|
||||||
|
resultEl.style.display = 'none';
|
||||||
|
resultEl.innerHTML = '';
|
||||||
|
|
||||||
|
if (!email || !email.includes('@')) {
|
||||||
|
errEl.textContent = 'Vul een geldig e-mailadres in.';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
busy = true; btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-inline"></span>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sso-lookup?email=' + encodeURIComponent(email));
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!data.found) {
|
||||||
|
warnEl.innerHTML = 'Geen school gevonden voor <strong>' + email.split('@')[1] + '</strong>. '
|
||||||
|
+ 'Controleer uw e-mailadres of contacteer uw ICT-beheerder.';
|
||||||
|
warnEl.style.display = 'block';
|
||||||
|
} else if (!data.google) {
|
||||||
|
warnEl.innerHTML = '<strong>' + data.school_name + '</strong> heeft Google Workspace SSO '
|
||||||
|
+ 'nog niet ingesteld. Contacteer uw school ICT-beheerder.';
|
||||||
|
warnEl.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
resultEl.innerHTML = '<a href="/auth/google?school_id=' + data.school_id + '" class="btn-google">'
|
||||||
|
+ GOOGLE_SVG + 'Doorgaan met Google — ' + data.school_name + '</a>'
|
||||||
|
+ '<p class="school-found-label">School herkend via ' + email.split('@')[1] + '</p>';
|
||||||
|
resultEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errEl.textContent = 'Verbindingsfout. Probeer opnieuw.';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
} finally {
|
||||||
|
busy = false; btn.disabled = false; btn.textContent = 'Verder \u2192';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let saVisible = false;
|
let saVisible = false;
|
||||||
|
function toggleSuperadmin() {
|
||||||
function toggleSuperadmin() {
|
|
||||||
saVisible = !saVisible;
|
saVisible = !saVisible;
|
||||||
document.getElementById('superadminForm').classList.toggle('visible', saVisible);
|
document.getElementById('superadminForm').classList.toggle('visible', saVisible);
|
||||||
if (saVisible) document.getElementById('saPassword').focus();
|
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', {
|
const res = await fetch('/auth/superadmin-login', {
|
||||||
method: 'POST',
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email: document.getElementById('saEmail').value,
|
email: document.getElementById('saEmail').value,
|
||||||
password: document.getElementById('saPassword').value,
|
password: document.getElementById('saPassword').value,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) { window.location.href = data.redirect || '/dashboard'; }
|
||||||
window.location.href = data.redirect || '/dashboard';
|
else { errEl.textContent = data.error || 'Inloggen mislukt'; errEl.style.display = 'block'; }
|
||||||
} else {
|
}
|
||||||
errorEl.textContent = data.error || 'Inloggen mislukt';
|
|
||||||
errorEl.style.display = 'block';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter' && saVisible) superadminLogin();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
bind('btnToggleSuperadmin', 'click', () => { toggleSuperadmin() });
|
bind('btnLookup', 'click', doGoogleLookup);
|
||||||
bind('btnSuperadminLogin', 'click', () => { superadminLogin() });
|
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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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;}
|
.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 */
|
/* Gebruikers-per-school tabel */
|
||||||
.schools-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:1rem;}
|
.schools-user-table{width:100%;border-collapse:collapse;}
|
||||||
.school-card{border:1px solid var(--gray-200);border-radius:10px;overflow:hidden;}
|
.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-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-row td{padding:.7rem .85rem;border-bottom:1px solid var(--gray-100);vertical-align:middle;font-size:.875rem;}
|
||||||
.school-card-header h3{font-size:.95rem;}
|
.school-row{cursor:pointer;transition:background .12s;}
|
||||||
.school-card-body{padding:.75rem 1.1rem;}
|
.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{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-row:last-child{border-bottom:none;}
|
||||||
.user-info{flex:1;min-width:0;}
|
.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{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);}
|
.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;}
|
.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 */
|
||||||
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;align-items:center;justify-content:center;}
|
.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;}
|
th{background:#1e293b !important;color:#94a3b8 !important;border-color:#334155 !important;}
|
||||||
td{border-color:#1e293b !important;color:#e2e8f0;}
|
td{border-color:#1e293b !important;color:#e2e8f0;}
|
||||||
tr:hover td{background:#263548 !important;}
|
tr:hover td{background:#263548 !important;}
|
||||||
.school-card{border-color:#334155 !important;}
|
.school-row.expanded td{background:#1a2744 !important;}
|
||||||
.school-card-header{background:#162032 !important;border-color:#334155 !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;}
|
.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;}
|
.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;}
|
.btn-secondary{background:#334155 !important;color:#e2e8f0 !important;}
|
||||||
@@ -151,7 +165,7 @@
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>📅 Schooljaren</h2>
|
<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>
|
</div>
|
||||||
<p class="section-hint">
|
<p class="section-hint">
|
||||||
Het actieve schooljaar geldt voor alle scholen tegelijk.
|
Het actieve schooljaar geldt voor alle scholen tegelijk.
|
||||||
@@ -184,13 +198,32 @@ toevoegen</button>
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scholen & gebruikers detail -->
|
<!-- Gebruikers per school — lazy tabel -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>👥 Gebruikers per school</h2>
|
<h2>👥 Gebruikers per school</h2>
|
||||||
<button id="btnAddUser" class="btn btn-primary btn-sm">+ Gebruiker toevoegen</button>
|
<button id="btnAddUser" class="btn btn-primary btn-sm">+ Gebruiker toevoegen</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Auditlog -->
|
<!-- Auditlog -->
|
||||||
@@ -256,11 +289,36 @@ toevoegen</button>
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="modal-editSchool">
|
<div class="modal-overlay" id="modal-editSchool">
|
||||||
<div class="modal">
|
<div class="modal" style="max-width:520px;">
|
||||||
<h2>School bewerken</h2>
|
<h2>School bewerken</h2>
|
||||||
<input type="hidden" id="editSchoolId">
|
<input type="hidden" id="editSchoolId">
|
||||||
<div class="form-group"><label>Naam</label><input type="text" id="editSchoolName"></div>
|
<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>
|
<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="form-error" id="edit-school-error"></div>
|
||||||
<div class="modal-buttons">
|
<div class="modal-buttons">
|
||||||
<button class="btn btn-secondary" id="btnCancelEditSch">Annuleren</button>
|
<button class="btn btn-secondary" id="btnCancelEditSch">Annuleren</button>
|
||||||
@@ -338,7 +396,7 @@ function bind(id, ev, fn) {
|
|||||||
if (el) el.addEventListener(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 = [];
|
let schools = [];
|
||||||
|
|
||||||
const SCHOOL_ROLLEN = [
|
const SCHOOL_ROLLEN = [
|
||||||
@@ -357,12 +415,22 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
bind('auditSearch', 'input', loadAuditLog);
|
bind('auditSearch', 'input', loadAuditLog);
|
||||||
document.getElementById('btnCancelSchool') && bind('btnCancelSchool', 'click', closeModal);
|
document.getElementById('btnCancelSchool') && bind('btnCancelSchool', 'click', closeModal);
|
||||||
document.getElementById('btnSaveSchool') && bind('btnSaveSchool', 'click', addSchool);
|
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('btnCancelSgIct') && bind('btnCancelSgIct', 'click', closeModal);
|
||||||
document.getElementById('btnSaveSgIct') && bind('btnSaveSgIct', 'click', addSgIct);
|
document.getElementById('btnSaveSgIct') && bind('btnSaveSgIct', 'click', addSgIct);
|
||||||
document.getElementById('btnCancelJaar') && bind('btnCancelJaar', 'click', closeModal);
|
document.getElementById('btnCancelJaar') && bind('btnCancelJaar', 'click', closeModal);
|
||||||
document.getElementById('btnSaveJaar') && bind('btnSaveJaar', 'click', addJaar);
|
document.getElementById('btnSaveJaar') && bind('btnSaveJaar', 'click', addJaar);
|
||||||
document.getElementById('btnCancelUser') && bind('btnCancelUser', 'click', closeModal);
|
document.getElementById('btnCancelUser') && bind('btnCancelUser', 'click', closeModal);
|
||||||
document.getElementById('btnSaveUser') && bind('btnSaveUser', 'click', addUser);
|
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()];
|
const tasks = [loadStats(), loadSchoolsTable(), loadSchoolsGrid(), loadSgIct()];
|
||||||
await Promise.all(tasks);
|
await Promise.all(tasks);
|
||||||
await loadJaren();
|
await loadJaren();
|
||||||
@@ -433,7 +501,10 @@ async function loadSchoolsTable() {
|
|||||||
tbody.innerHTML = schools.map(s => `
|
tbody.innerHTML = schools.map(s => `
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>${s.name}</strong></td>
|
<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="color:var(--gray-500);">${s.user_count}</td>
|
||||||
<td style="display:flex;gap:.35rem;">
|
<td style="display:flex;gap:.35rem;">
|
||||||
<button class="btn btn-secondary btn-sm" data-action="editSchool" data-id="${s.id}" data-name="${s.name.replace(/'/g,''')}" data-domains="${(s.email_domains||[]).join(', ')}">Bewerken</button>
|
<button class="btn btn-secondary btn-sm" data-action="editSchool" data-id="${s.id}" data-name="${s.name.replace(/'/g,''')}" data-domains="${(s.email_domains||[]).join(', ')}">Bewerken</button>
|
||||||
@@ -456,6 +527,7 @@ async function addSchool() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) { err.textContent = data.error; err.style.display = 'block'; return; }
|
if (!res.ok) { err.textContent = data.error; err.style.display = 'block'; return; }
|
||||||
closeModal(); notify('School aangemaakt', 'success');
|
closeModal(); notify('School aangemaakt', 'success');
|
||||||
|
delete loadedUsers[data.school?.id]; // forceer herlaad als al gecached
|
||||||
await Promise.all([loadSchoolsTable(), loadSchoolsGrid(), loadStats()]);
|
await Promise.all([loadSchoolsTable(), loadSchoolsGrid(), loadStats()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,6 +535,18 @@ function editSchool(id, name, domainsStr) {
|
|||||||
document.getElementById('editSchoolId').value = id;
|
document.getElementById('editSchoolId').value = id;
|
||||||
document.getElementById('editSchoolName').value = name;
|
document.getElementById('editSchoolName').value = name;
|
||||||
document.getElementById('editSchoolDomains').value = domainsStr;
|
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');
|
openModal('editSchool');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,13 +554,49 @@ async function saveSchool() {
|
|||||||
const err = document.getElementById('edit-school-error');
|
const err = document.getElementById('edit-school-error');
|
||||||
err.style.display = 'none';
|
err.style.display = 'none';
|
||||||
const id = document.getElementById('editSchoolId').value;
|
const id = document.getElementById('editSchoolId').value;
|
||||||
|
|
||||||
|
// 1. Sla naam en domeinen op (bestaand endpoint — scholengroep_ict)
|
||||||
const res = await fetch(`/admin/schools/${id}`, {
|
const res = await fetch(`/admin/schools/${id}`, {
|
||||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
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();
|
const data = await res.json();
|
||||||
if (!res.ok) { err.textContent = data.error; err.style.display = 'block'; return; }
|
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()]);
|
await Promise.all([loadSchoolsTable(), loadSchoolsGrid()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,114 +605,239 @@ async function deleteSchool(id, name) {
|
|||||||
const res = await fetch(`/admin/schools/${id}`, { method: 'DELETE' });
|
const res = await fetch(`/admin/schools/${id}`, { method: 'DELETE' });
|
||||||
if (!res.ok) { notify('Verwijderen mislukt', 'error'); return; }
|
if (!res.ok) { notify('Verwijderen mislukt', 'error'); return; }
|
||||||
notify('School verwijderd', 'success');
|
notify('School verwijderd', 'success');
|
||||||
|
expandedSchools.delete(parseInt(id));
|
||||||
|
delete loadedUsers[parseInt(id)];
|
||||||
await Promise.all([loadSchoolsTable(), loadSchoolsGrid(), loadStats()]);
|
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() {
|
async function loadSchoolsGrid() {
|
||||||
const res = await fetch('/admin/schools');
|
const res = await fetch('/admin/schools');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
schools = data.schools || [];
|
schools = data.schools || [];
|
||||||
const grid = document.getElementById('schoolsGrid');
|
renderSchoolsUserTable();
|
||||||
if (!schools.length) {
|
updateSchoolCountBadge();
|
||||||
grid.innerHTML = '<p style="color:var(--gray-500);font-style:italic;padding:.5rem 0;">Nog geen scholen aangemaakt.</p>';
|
|
||||||
|
// 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;
|
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) {
|
function buildSchoolRows(school, term) {
|
||||||
const res = await fetch(`/admin/schools/${school.id}/users`);
|
const isOpen = expandedSchools.has(school.id);
|
||||||
const data = await res.json();
|
const isLoading = loadingSchools.has(school.id);
|
||||||
const users = data.users || [];
|
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,''')}"
|
||||||
|
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,''')}">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 = {
|
const byRole = {
|
||||||
school_ict: users.filter(u => u.role === 'school_ict'),
|
school_ict: filtered.filter(u => u.role === 'school_ict'),
|
||||||
director: users.filter(u => u.role === 'director'),
|
director: filtered.filter(u => u.role === 'director'),
|
||||||
teacher: users.filter(u => u.role === 'teacher'),
|
teacher: filtered.filter(u => u.role === 'teacher'),
|
||||||
};
|
};
|
||||||
const card = document.createElement('div');
|
const total = filtered.length;
|
||||||
card.className = 'school-card';
|
|
||||||
card.id = `school-card-${school.id}`;
|
if (!total) return `<div style="color:var(--gray-500);font-size:.85rem;padding:.25rem 0;">Geen gebruikers gevonden${term ? ` voor "${term}"` : ''}.</div>`;
|
||||||
card.innerHTML = `
|
|
||||||
<div class="school-card-header">
|
return [
|
||||||
<div>
|
renderUserGroup(schoolId, 'School ICT', byRole.school_ict),
|
||||||
<h3>${school.name}</h3>
|
renderUserGroup(schoolId, 'Directeurs', byRole.director),
|
||||||
<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>
|
renderUserGroup(schoolId, 'Leerkrachten', byRole.teacher),
|
||||||
</div>
|
].join('');
|
||||||
<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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderUserGroup(schoolId, label, users, maxShow=99) {
|
function renderUserGroup(schoolId, label, users) {
|
||||||
if (!users.length) return '';
|
if (!users.length) return '';
|
||||||
const shown = users.slice(0, maxShow);
|
|
||||||
const hidden = users.length - shown.length;
|
|
||||||
return `
|
return `
|
||||||
<div class="group-label"><span>${label}</span><span style="font-weight:400;text-transform:none;letter-spacing:0;">${users.length}</span></div>
|
<div class="group-label">
|
||||||
${shown.map(u => `
|
<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-row">
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<div class="user-name">${u.full_name}</div>
|
<div class="user-name">${u.full_name}</div>
|
||||||
<div class="user-email">${u.email}</div>
|
<div class="user-email">${u.email}</div>
|
||||||
<div class="user-email" style="color:var(--gray-400);font-size:.7rem;">
|
<div class="user-email" style="color:var(--gray-400);font-size:.7rem;">
|
||||||
${u.last_login
|
${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'}
|
: 'Nog niet ingelogd'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-actions">
|
<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,''')}">
|
<select class="role-select" data-action="changeRole"
|
||||||
|
data-school-id="${schoolId}" data-user-id="${u.id}"
|
||||||
|
data-name="${u.full_name.replace(/'/g,''')}">
|
||||||
${SCHOOL_ROLLEN.map(r=>`<option value="${r.value}" ${r.value===u.role?'selected':''}>${r.label}</option>`).join('')}
|
${SCHOOL_ROLLEN.map(r=>`<option value="${r.value}" ${r.value===u.role?'selected':''}>${r.label}</option>`).join('')}
|
||||||
</select>
|
</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,''')}">×</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,''')}">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`).join('')}
|
</div>`).join('')}`;
|
||||||
${hidden > 0 ? `<div style="color:var(--gray-500);font-size:.8rem;padding:.3rem 0 0;">+ ${hidden} meer...</div>` : ''}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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`, {
|
const res = await fetch(`/admin/schools/${schoolId}/users/${userId}/role`, {
|
||||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({ role: newRole })
|
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');
|
notify(`${naam} is nu ${SCHOOL_ROLLEN.find(r=>r.value===newRole)?.label}`, 'success');
|
||||||
await refreshCard(schoolId);
|
await refreshSchool(schoolId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addUser() {
|
async function addUser() {
|
||||||
const err = document.getElementById('addUser-error');
|
const err = document.getElementById('addUser-error');
|
||||||
err.style.display = 'none';
|
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`, {
|
const res = await fetch(`/admin/schools/${schoolId}/users`, {
|
||||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
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();
|
const data = await res.json();
|
||||||
if (!res.ok) { err.textContent = data.error; err.style.display = 'block'; return; }
|
if (!res.ok) { err.textContent = data.error; err.style.display = 'block'; return; }
|
||||||
closeModal(); notify(data.linked ? 'Account heractiveerd en bijgewerkt' : 'Gebruiker toegevoegd', 'success');
|
closeModal();
|
||||||
await Promise.all([refreshCard(parseInt(schoolId)), loadStats()]);
|
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) {
|
async function removeUser(schoolId, userId, naam) {
|
||||||
if (!confirm(`${naam} verwijderen?`)) return;
|
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; }
|
if (!res.ok) { notify('Verwijderen mislukt', 'error'); return; }
|
||||||
notify(`${naam} verwijderd`, 'success');
|
notify(`${naam} verwijderd`, 'success');
|
||||||
await Promise.all([refreshCard(schoolId), loadStats()]);
|
delete loadedUsers[parseInt(schoolId)]; // forceer herlaad
|
||||||
}
|
await Promise.all([refreshSchool(parseInt(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')); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Schooljaren ───────────────────────────────────────────────────────────────
|
// ── Schooljaren ───────────────────────────────────────────────────────────────
|
||||||
@@ -722,6 +967,12 @@ function notify(msg, type='success') {
|
|||||||
|
|
||||||
// ── Event delegation voor dynamisch gegenereerde elementen ────────────────────
|
// ── Event delegation voor dynamisch gegenereerde elementen ────────────────────
|
||||||
document.addEventListener('click', function(e) {
|
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]');
|
const btn = e.target.closest('[data-action]');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const action = btn.dataset.action;
|
const action = btn.dataset.action;
|
||||||
@@ -734,7 +985,7 @@ document.addEventListener('click', function(e) {
|
|||||||
});
|
});
|
||||||
document.addEventListener('change', function(e) {
|
document.addEventListener('change', function(e) {
|
||||||
const sel = e.target.closest('[data-action="changeRole"]');
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -248,6 +248,75 @@
|
|||||||
<div id="klassenList">Laden...</div>
|
<div id="klassenList">Laden...</div>
|
||||||
</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 -->
|
<!-- Auditlog -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
@@ -340,11 +409,18 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
document.getElementById('btnConfirmUser') && bind('btnConfirmUser', 'click', addUser);
|
document.getElementById('btnConfirmUser') && bind('btnConfirmUser', 'click', addUser);
|
||||||
document.getElementById('auditCategory') && bind('auditCategory', 'change', loadAuditLog);
|
document.getElementById('auditCategory') && bind('auditCategory', 'change', loadAuditLog);
|
||||||
document.getElementById('auditSearch') && bind('auditSearch', 'input', 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());
|
const me = await fetch('/api/me').then(r => r.json());
|
||||||
mySchoolId = me.user?.school_id;
|
mySchoolId = me.user?.school_id;
|
||||||
document.getElementById('schoolName').textContent = me.user?.school_name || '';
|
document.getElementById('schoolName').textContent = me.user?.school_name || '';
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
await loadKlassen();
|
await loadKlassen();
|
||||||
|
loadSsoStatus(me.user?.school); // geef school direct mee — geen extra API call nodig
|
||||||
await loadAuditLog();
|
await loadAuditLog();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -592,6 +668,73 @@ async function loadAuditLog(page = 1) {
|
|||||||
</button>`).join('');
|
</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 ────────────────────
|
// ── Event delegation voor dynamisch gegenereerde elementen ────────────────────
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
const btn = e.target.closest('[data-action]');
|
const btn = e.target.closest('[data-action]');
|
||||||
|
|||||||
BIN
docs/Handleiding_Google_SSO.docx
Normal file
BIN
docs/Handleiding_Google_SSO.docx
Normal file
Binary file not shown.
Reference in New Issue
Block a user