diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2f08c01 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + +jobs: + backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + env: + DJANGO_DEBUG: "1" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m pip install --upgrade pip + - run: pip install -r requirements.txt + - run: python manage.py check + - run: python manage.py makemigrations --check --dry-run + - run: python manage.py test core.tests + + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci + - run: npm audit --audit-level=moderate + - run: npm run build diff --git a/backend/config/settings.py b/backend/config/settings.py index 5e5485b..7344d98 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -79,6 +79,9 @@ MODULE_APPS = [ "modules.personenwizard", # Personen-wizard & import "modules.autorooster", # Automatisch rooster "modules.huisstijl", # Schoollogo / huisstijl + "modules.importexport", # Import/export + "modules.vervanging", # Vervangingen bij afwezigheid + "modules.portal", # Leerling/ouder-portaal ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + CORE_APPS + MODULE_APPS @@ -162,6 +165,8 @@ MEDIA_ROOT = os.environ.get("DJANGO_MEDIA_ROOT", str(BASE_DIR / "media")) # een andere gebruiker draait) ze niet serveren → gebroken afbeeldingen. FILE_UPLOAD_PERMISSIONS = 0o644 FILE_UPLOAD_DIRECTORY_PERMISSIONS = 0o755 +FILE_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get("DJANGO_FILE_UPLOAD_MAX_MEMORY_SIZE", str(3 * 1024 * 1024))) +DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get("DJANGO_DATA_UPLOAD_MAX_MEMORY_SIZE", str(5 * 1024 * 1024))) # E-mail (voor o.a. wachtwoordreset). Zonder SMTP-config gaat e-mail naar de # console-log (handig voor test); zet DJANGO_EMAIL_BACKEND/EMAIL_* in productie. @@ -248,5 +253,6 @@ LOGGING = { "loggers": { "django.request": {"level": "WARNING"}, "roosterwijs": {"level": "INFO"}, + "roosterwijs.audit": {"handlers": ["console"], "level": "INFO", "propagate": False}, }, } diff --git a/backend/config/urls.py b/backend/config/urls.py index f5df69f..d27c228 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -18,6 +18,9 @@ urlpatterns = [ path("api/", include("modules.autorooster.urls")), path("api/", include("modules.accounts.urls")), path("api/", include("modules.huisstijl.urls")), + path("api/", include("modules.importexport.urls")), + path("api/", include("modules.vervanging.urls")), + path("api/", include("modules.portal.urls")), ] if settings.DEBUG: diff --git a/backend/core/audit.py b/backend/core/audit.py new file mode 100644 index 0000000..2bfdef5 --- /dev/null +++ b/backend/core/audit.py @@ -0,0 +1,58 @@ +"""Kleine auditlog-helper voor gevoelige acties.""" +import logging + +from rest_framework import viewsets + +logger = logging.getLogger("roosterwijs.audit") + + +def audit_log(request, action, target="", extra=None): + """Log wie wat deed, zonder request bodies of wachtwoorden te loggen.""" + user = getattr(request, "user", None) + username = user.get_username() if user and user.is_authenticated else "anonymous" + ip = None + if request is not None: + forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "") + ip = (forwarded.split(",", 1)[0].strip() or request.META.get("REMOTE_ADDR") or None) + logger.info( + "actor=%r action=%s target=%s extra=%s", + username, + action, + target or "-", + extra or {}, + ) + try: + from plugins.models import AuditEvent + + AuditEvent.objects.create( + actor=username, + action=action, + target=target or "", + ip_address=ip, + extra=extra or {}, + ) + except Exception: + logger.exception("Kon audit-event niet opslaan") + + +class AuditedModelViewSet(viewsets.ModelViewSet): + """ModelViewSet die create/update/delete-acties auditbaar maakt.""" + + audit_name = None + + def _audit_target(self, obj): + name = self.audit_name or obj.__class__.__name__ + return f"{name}:{getattr(obj, 'pk', '-')}" + + def perform_create(self, serializer): + obj = serializer.save() + audit_log(self.request, "create", self._audit_target(obj)) + + def perform_update(self, serializer): + obj = serializer.save() + audit_log(self.request, "update", self._audit_target(obj)) + + def perform_destroy(self, instance): + target = self._audit_target(instance) + super().perform_destroy(instance) + audit_log(self.request, "delete", target) diff --git a/backend/core/permissions.py b/backend/core/permissions.py new file mode 100644 index 0000000..0b13e81 --- /dev/null +++ b/backend/core/permissions.py @@ -0,0 +1,139 @@ +"""Gedeelde permissies voor roltoegang en module-gating.""" +from rest_framework.permissions import BasePermission, SAFE_METHODS + + +def _profile(user): + return getattr(user, "gebruikersprofiel", None) + + +def user_role(user): + profile = _profile(user) + return getattr(profile, "rol", None) + + +def linked_person(user): + profile = _profile(user) + return getattr(profile, "persoon", None) + + +def is_admin_user(user): + return bool( + user + and user.is_authenticated + and (user.is_staff or user_role(user) == "beheerder") + ) + + +INTERNAL_READ_ROLES = { + "medewerker", + "roostermaker", + "directie", + "zorgcoordinator", + "stagebegeleider", + "kijker", +} + + +def has_view_write_role(user, view): + write_roles = set(getattr(view, "write_roles", ())) + return user_role(user) in write_roles + + +class CoreDataPermission(BasePermission): + """Kerngegevens: schrijven per rol, lezen rolbewust.""" + + message = "Je hebt geen rechten voor deze roostergegevens." + + def has_permission(self, request, view): + if not request.user or not request.user.is_authenticated: + return False + if is_admin_user(request.user): + return True + if request.method in SAFE_METHODS: + return True + return has_view_write_role(request.user, view) + + def has_object_permission(self, request, view, obj): + if is_admin_user(request.user): + return True + if request.method not in SAFE_METHODS: + return has_view_write_role(request.user, view) + role = user_role(request.user) + person = linked_person(request.user) + if role in ("leerling", "ouder") and person is not None: + if obj.__class__.__name__ == "Persoon": + return obj.pk == person.pk + if hasattr(obj, "persoon_id"): + return obj.persoon_id == person.pk + return role in INTERNAL_READ_ROLES + + +class StaffDataPermission(BasePermission): + """Rapportages: interne rollen mogen lezen, beheerders/rolhouders schrijven.""" + + message = "Je hebt geen rechten voor deze rapportage." + + def has_permission(self, request, view): + if not request.user or not request.user.is_authenticated: + return False + if is_admin_user(request.user): + return True + if request.method in SAFE_METHODS: + return user_role(request.user) in INTERNAL_READ_ROLES + return has_view_write_role(request.user, view) + + +class AdminRolePermission(BasePermission): + """Beheerder via Django staff of via de applicatierol beheerder.""" + + message = "Je hebt beheerdersrechten nodig." + + def has_permission(self, request, view): + return is_admin_user(request.user) + + +def restrict_core_queryset(request, queryset): + """Filter gevoelige lijsten voor leerling/ouderaccounts met gekoppelde persoon.""" + user = request.user + if is_admin_user(user) or user_role(user) in INTERNAL_READ_ROLES: + return queryset + person = linked_person(user) + if person is None: + return queryset.none() + model_name = queryset.model.__name__ + if model_name == "Groep": + return queryset.filter(pk=person.groep_id) if getattr(person, "groep_id", None) else queryset.none() + if model_name == "Subgroep": + return queryset.filter(leden=person).distinct() + if model_name == "Persoon": + return queryset.filter(pk=person.pk) + if model_name == "Afwezigheid": + return queryset.filter(persoon=person) + if model_name == "Roosterblok": + qs = queryset.filter(leerling=person) + if getattr(person, "groep_id", None): + qs = qs | queryset.filter(groep_id=person.groep_id) + qs = qs | queryset.filter(subgroep__leden=person) + return qs.distinct() + if model_name == "BlokUitzondering": + qs = queryset.filter(roosterblok__leerling=person) + if getattr(person, "groep_id", None): + qs = qs | queryset.filter(roosterblok__groep_id=person.groep_id) + qs = qs | queryset.filter(roosterblok__subgroep__leden=person) + return qs.distinct() + return queryset + + +def module_permission_class(module_key): + """Maak een DRF-permissieklasse die een module-aan/uit vlag afdwingt.""" + + class IsModuleEnabled(BasePermission): + message = f"Module '{module_key}' is uitgeschakeld." + + def has_permission(self, request, view): + from plugins import services + + return services.is_enabled(module_key) + + IsModuleEnabled.__name__ = f"IsModuleEnabled_{module_key}" + return IsModuleEnabled diff --git a/backend/core/serializers.py b/backend/core/serializers.py index f510f68..44212a6 100644 --- a/backend/core/serializers.py +++ b/backend/core/serializers.py @@ -7,6 +7,10 @@ from .models import ( Subgroep, Tijdslot, ) +MAX_IMAGE_BYTES = 3 * 1024 * 1024 +MAX_IMAGE_PIXELS = 4000 * 4000 +ALLOWED_IMAGE_FORMATS = {"PNG", "JPEG", "GIF", "WEBP"} + class FunctieSerializer(serializers.ModelSerializer): aantal_personen = serializers.IntegerField(source="personen.count", read_only=True) @@ -201,6 +205,31 @@ class ActiviteitSerializer(serializers.ModelSerializer): model = Activiteit fields = ["id", "naam", "kleur", "icoon", "pictogram", "omschrijving", "theorievak", "actief"] + def validate_pictogram(self, value): + if value is None: + return value + if getattr(value, "size", 0) > MAX_IMAGE_BYTES: + raise serializers.ValidationError("Pictogram is te groot; maximaal 3 MB.") + try: + from PIL import Image + + pos = value.tell() if hasattr(value, "tell") else None + image = Image.open(value) + image.verify() + if pos is not None: + value.seek(pos) + image = Image.open(value) + except Exception as exc: + raise serializers.ValidationError("Pictogram is geen leesbare afbeelding.") from exc + if image.format not in ALLOWED_IMAGE_FORMATS: + raise serializers.ValidationError("Gebruik PNG, JPG, GIF of WebP.") + width, height = image.size + if width * height > MAX_IMAGE_PIXELS: + raise serializers.ValidationError("Pictogram is te groot in pixels; maximaal 4000 x 4000.") + if hasattr(value, "seek"): + value.seek(0) + return value + class LocatieSerializer(serializers.ModelSerializer): class Meta: diff --git a/backend/core/tests/__init__.py b/backend/core/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/core/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/core/tests/test_permissions.py b/backend/core/tests/test_permissions.py new file mode 100644 index 0000000..20825fe --- /dev/null +++ b/backend/core/tests/test_permissions.py @@ -0,0 +1,78 @@ +from datetime import date, time + +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIClient + +from core.models import Activiteit, Groep, Schooljaar, Tijdslot +from modules.accounts.models import Gebruikersprofiel +from plugins.models import AuditEvent, ModuleState + + +User = get_user_model() + + +class RolePermissionTests(TestCase): + def setUp(self): + self.client = APIClient() + self.schooljaar = Schooljaar.objects.create( + naam="2026-2027", + start_datum=date(2026, 8, 1), + eind_datum=date(2027, 7, 31), + ) + self.groep = Groep.objects.create(naam="Groep A") + self.tijdslot = Tijdslot.objects.create( + naam="Blok 1", + dag=0, + start_tijd=time(8, 30), + eind_tijd=time(9, 15), + volgorde=1, + ) + self.activiteit = Activiteit.objects.create(naam="Rekenen") + + def user_met_rol(self, username, rol, is_staff=False): + user = User.objects.create_user(username=username, password="SterkWachtwoord123!", is_staff=is_staff) + Gebruikersprofiel.objects.create(user=user, rol=rol) + return user + + def test_roostermaker_mag_roosterblok_maken_maar_geen_persoon(self): + user = self.user_met_rol("roostermaker", "roostermaker") + self.client.force_authenticate(user=user) + + rooster_response = self.client.post( + "/api/roosterblokken/", + { + "schooljaar": self.schooljaar.id, + "tijdslot": self.tijdslot.id, + "activiteit": self.activiteit.id, + "groep": self.groep.id, + "begeleiders": [], + }, + format="json", + ) + self.assertEqual(rooster_response.status_code, 201, rooster_response.data) + + persoon_response = self.client.post( + "/api/personen/", + {"voornaam": "Ada", "achternaam": "Lovelace", "rol": "leerling"}, + format="json", + ) + self.assertEqual(persoon_response.status_code, 403) + + def test_module_gating_blokkeert_uitgeschakelde_importexport(self): + user = self.user_met_rol("beheer", "beheerder", is_staff=True) + self.client.force_authenticate(user=user) + ModuleState.objects.update_or_create(key="importexport", defaults={"enabled": False}) + + response = self.client.get("/api/export/personen/") + + self.assertEqual(response.status_code, 403) + + def test_audit_event_wordt_opgeslagen_bij_mutatie(self): + user = self.user_met_rol("beheer", "beheerder", is_staff=True) + self.client.force_authenticate(user=user) + + response = self.client.post("/api/groepen/", {"naam": "Auditgroep"}, format="json") + + self.assertEqual(response.status_code, 201, response.data) + self.assertTrue(AuditEvent.objects.filter(actor="beheer", action="create", target__startswith="Groep:").exists()) diff --git a/backend/core/views.py b/backend/core/views.py index f741af3..543750b 100644 --- a/backend/core/views.py +++ b/backend/core/views.py @@ -1,15 +1,16 @@ -from rest_framework import viewsets from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import AllowAny from rest_framework.response import Response from plugins import services +from .audit import AuditedModelViewSet, audit_log from .models import ( Activiteit, Afwezigheid, BlokUitzondering, Functie, Groep, KalenderDag, Locatie, Lokaal, Lokaalinstelling, Periode, Persoon, Roosterblok, Schooljaar, Subgroep, Tijdslot, ) +from .permissions import CoreDataPermission, restrict_core_queryset from .serializers import ( ActiviteitSerializer, AfwezigheidSerializer, BlokUitzonderingSerializer, FunctieSerializer, GroepSerializer, KalenderDagSerializer, LocatieSerializer, @@ -18,6 +19,10 @@ from .serializers import ( SubgroepSerializer, TijdslotSerializer, ) +SCHEDULE_WRITE_ROLES = ("roostermaker", "directie") +ORGANIZATION_WRITE_ROLES = ("directie", "zorgcoordinator") +FACILITY_WRITE_ROLES = ("roostermaker", "directie") + @api_view(["GET"]) @@ -42,71 +47,89 @@ def info(request): ) -class GroepViewSet(viewsets.ModelViewSet): +class CoreModelViewSet(AuditedModelViewSet): + permission_classes = [CoreDataPermission] + + def get_queryset(self): + return restrict_core_queryset(self.request, super().get_queryset()) + + +class GroepViewSet(CoreModelViewSet): queryset = Groep.objects.all() serializer_class = GroepSerializer + write_roles = ORGANIZATION_WRITE_ROLES -class PersoonViewSet(viewsets.ModelViewSet): +class PersoonViewSet(CoreModelViewSet): serializer_class = PersoonSerializer + write_roles = ORGANIZATION_WRITE_ROLES def get_queryset(self): qs = Persoon.objects.select_related("groep", "stamgroep").prefetch_related("functies", "vakken").all() rol = self.request.query_params.get("rol") if rol: qs = qs.filter(rol=rol) - return qs + return restrict_core_queryset(self.request, qs) -class SubgroepViewSet(viewsets.ModelViewSet): +class SubgroepViewSet(CoreModelViewSet): queryset = Subgroep.objects.prefetch_related("leden").all() serializer_class = SubgroepSerializer + write_roles = ORGANIZATION_WRITE_ROLES -class FunctieViewSet(viewsets.ModelViewSet): +class FunctieViewSet(CoreModelViewSet): queryset = Functie.objects.all() serializer_class = FunctieSerializer + write_roles = ORGANIZATION_WRITE_ROLES # --- Fase 2 --------------------------------------------------------------- -class SchooljaarViewSet(viewsets.ModelViewSet): +class SchooljaarViewSet(CoreModelViewSet): queryset = Schooljaar.objects.all() serializer_class = SchooljaarSerializer + write_roles = SCHEDULE_WRITE_ROLES -class KalenderDagViewSet(viewsets.ModelViewSet): +class KalenderDagViewSet(CoreModelViewSet): serializer_class = KalenderDagSerializer + write_roles = SCHEDULE_WRITE_ROLES def get_queryset(self): qs = KalenderDag.objects.all() schooljaar = self.request.query_params.get("schooljaar") if schooljaar: qs = qs.filter(schooljaar_id=schooljaar) - return qs + return restrict_core_queryset(self.request, qs) -class TijdslotViewSet(viewsets.ModelViewSet): +class TijdslotViewSet(CoreModelViewSet): queryset = Tijdslot.objects.all() serializer_class = TijdslotSerializer + write_roles = SCHEDULE_WRITE_ROLES -class ActiviteitViewSet(viewsets.ModelViewSet): +class ActiviteitViewSet(CoreModelViewSet): queryset = Activiteit.objects.all() serializer_class = ActiviteitSerializer + write_roles = SCHEDULE_WRITE_ROLES -class LocatieViewSet(viewsets.ModelViewSet): +class LocatieViewSet(CoreModelViewSet): queryset = Locatie.objects.all() serializer_class = LocatieSerializer + write_roles = FACILITY_WRITE_ROLES -class LokaalViewSet(viewsets.ModelViewSet): +class LokaalViewSet(CoreModelViewSet): queryset = Lokaal.objects.all() serializer_class = LokaalSerializer + write_roles = FACILITY_WRITE_ROLES @api_view(["GET", "PUT"]) +@permission_classes([CoreDataPermission]) def lokaalinstelling(request): """Lees of wijzig de (per-school) instelling voor dubbele lokaalboekingen.""" obj = Lokaalinstelling.actueel() @@ -114,12 +137,14 @@ def lokaalinstelling(request): ser = LokaalinstellingSerializer(obj, data=request.data, partial=True) ser.is_valid(raise_exception=True) ser.save() + audit_log(request, "update", "Lokaalinstelling:1") return Response(ser.data) return Response(LokaalinstellingSerializer(obj).data) -class RoosterblokViewSet(viewsets.ModelViewSet): +class RoosterblokViewSet(CoreModelViewSet): serializer_class = RoosterblokSerializer + write_roles = SCHEDULE_WRITE_ROLES def get_queryset(self): qs = ( @@ -139,22 +164,24 @@ class RoosterblokViewSet(viewsets.ModelViewSet): qs = qs.filter(sjabloon=True) else: qs = qs.filter(sjabloon=False) - return qs + return restrict_core_queryset(self.request, qs) -class PeriodeViewSet(viewsets.ModelViewSet): +class PeriodeViewSet(CoreModelViewSet): serializer_class = PeriodeSerializer + write_roles = SCHEDULE_WRITE_ROLES def get_queryset(self): qs = Periode.objects.select_related("schooljaar").all() schooljaar = self.request.query_params.get("schooljaar") if schooljaar: qs = qs.filter(schooljaar_id=schooljaar) - return qs + return restrict_core_queryset(self.request, qs) -class AfwezigheidViewSet(viewsets.ModelViewSet): +class AfwezigheidViewSet(CoreModelViewSet): serializer_class = AfwezigheidSerializer + write_roles = SCHEDULE_WRITE_ROLES + ORGANIZATION_WRITE_ROLES def get_queryset(self): qs = Afwezigheid.objects.select_related("persoon").all() @@ -168,11 +195,12 @@ class AfwezigheidViewSet(viewsets.ModelViewSet): qs = qs.filter(eind_datum__gte=van) if tot: qs = qs.filter(start_datum__lte=tot) - return qs + return restrict_core_queryset(self.request, qs) -class BlokUitzonderingViewSet(viewsets.ModelViewSet): +class BlokUitzonderingViewSet(CoreModelViewSet): serializer_class = BlokUitzonderingSerializer + write_roles = SCHEDULE_WRITE_ROLES def get_queryset(self): qs = BlokUitzondering.objects.select_related("vervanger", "roosterblok").all() @@ -188,7 +216,7 @@ class BlokUitzonderingViewSet(viewsets.ModelViewSet): schooljaar = self.request.query_params.get("schooljaar") if schooljaar: qs = qs.filter(roosterblok__schooljaar_id=schooljaar) - return qs + return restrict_core_queryset(self.request, qs) def _leerlingen_van_blok(blok): @@ -203,6 +231,7 @@ def _leerlingen_van_blok(blok): @api_view(["GET"]) +@permission_classes([CoreDataPermission]) def conflicten(request): """Signaleer roosterconflicten binnen een schooljaar. diff --git a/backend/modules/accounts/apps.py b/backend/modules/accounts/apps.py index e149c03..ac69e24 100644 --- a/backend/modules/accounts/apps.py +++ b/backend/modules/accounts/apps.py @@ -25,6 +25,7 @@ class AccountsConfig(AppConfig): default_enabled=True, menu_items=( MenuItem("Gebruikers", "/gebruikers", icon="key", order=300, group="Instellingen"), + MenuItem("Auditlog", "/auditlog", icon="audit", order=302, group="Instellingen"), ), ) ) diff --git a/backend/modules/accounts/migrations/0003_alter_gebruikersprofiel_rol.py b/backend/modules/accounts/migrations/0003_alter_gebruikersprofiel_rol.py new file mode 100644 index 0000000..a66d807 --- /dev/null +++ b/backend/modules/accounts/migrations/0003_alter_gebruikersprofiel_rol.py @@ -0,0 +1,31 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("module_accounts", "0002_alter_gebruikersprofiel_persoon"), + ] + + operations = [ + migrations.AlterField( + model_name="gebruikersprofiel", + name="rol", + field=models.CharField( + choices=[ + ("beheerder", "Beheerder"), + ("medewerker", "Medewerker"), + ("roostermaker", "Roostermaker"), + ("directie", "Directie"), + ("zorgcoordinator", "Zorgcoordinator"), + ("stagebegeleider", "Stagebegeleider"), + ("kijker", "Alleen lezen"), + ("ouder", "Ouder"), + ("leerling", "Leerling"), + ], + default="medewerker", + max_length=16, + verbose_name="rol", + ), + ), + ] diff --git a/backend/modules/accounts/models.py b/backend/modules/accounts/models.py index 5581017..718fcad 100644 --- a/backend/modules/accounts/models.py +++ b/backend/modules/accounts/models.py @@ -11,6 +11,11 @@ from django.db import models class Gebruikersrol(models.TextChoices): BEHEERDER = "beheerder", "Beheerder" MEDEWERKER = "medewerker", "Medewerker" + ROOSTERMAKER = "roostermaker", "Roostermaker" + DIRECTIE = "directie", "Directie" + ZORGCOORDINATOR = "zorgcoordinator", "Zorgcoordinator" + STAGEBEGELEIDER = "stagebegeleider", "Stagebegeleider" + KIJKER = "kijker", "Alleen lezen" OUDER = "ouder", "Ouder" LEERLING = "leerling", "Leerling" diff --git a/backend/modules/accounts/serializers.py b/backend/modules/accounts/serializers.py index 87393ff..4d456e2 100644 --- a/backend/modules/accounts/serializers.py +++ b/backend/modules/accounts/serializers.py @@ -1,4 +1,6 @@ from django.contrib.auth import get_user_model +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import ValidationError as DjangoValidationError from rest_framework import serializers from core.models import Persoon @@ -48,6 +50,18 @@ class GebruikerSerializer(serializers.ModelSerializer): def validate(self, data): if not self.instance and not data.get("password"): raise serializers.ValidationError({"password": "Een wachtwoord is verplicht voor een nieuwe gebruiker."}) + password = data.get("password") + if password: + user = self.instance or User( + username=data.get("username", ""), + first_name=data.get("first_name", ""), + last_name=data.get("last_name", ""), + email=data.get("email", ""), + ) + try: + validate_password(password, user) + except DjangoValidationError as exc: + raise serializers.ValidationError({"password": list(exc.messages)}) from exc return data def create(self, validated_data): diff --git a/backend/modules/accounts/views.py b/backend/modules/accounts/views.py index 38a419f..72efd9c 100644 --- a/backend/modules/accounts/views.py +++ b/backend/modules/accounts/views.py @@ -10,29 +10,49 @@ import unicodedata from django.contrib.auth import authenticate, get_user_model from django.contrib.auth import login as auth_login from django.contrib.auth import logout as auth_logout +from django.contrib.auth.password_validation import validate_password from django.contrib.auth.tokens import default_token_generator +from django.core.exceptions import ValidationError as DjangoValidationError from django.core.mail import send_mail from django.db.models import Q from django.utils.encoding import force_bytes, force_str from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode from django.views.decorators.csrf import ensure_csrf_cookie -from rest_framework import status, viewsets +from rest_framework import status from rest_framework.decorators import api_view, permission_classes -from rest_framework.permissions import AllowAny, IsAdminUser +from rest_framework.permissions import AllowAny from rest_framework.response import Response +from core.audit import AuditedModelViewSet, audit_log +from core.permissions import AdminRolePermission, module_permission_class + from .models import Gebruikersprofiel, Gebruikersrol from .serializers import GebruikerSerializer logger = logging.getLogger("roosterwijs.auth") User = get_user_model() +AccountsModuleEnabled = module_permission_class("accounts") + + +def _password_errors(password, user): + try: + validate_password(password, user) + except DjangoValidationError as exc: + return list(exc.messages) + return None def _user_payload(user): + profiel = getattr(user, "gebruikersprofiel", None) + persoon = getattr(profiel, "persoon", None) if profiel else None return { "authenticated": True, "username": user.get_username(), "is_staff": user.is_staff, + "role": profiel.rol if profiel else None, + "role_label": profiel.get_rol_display() if profiel else None, + "person": persoon.id if persoon else None, + "person_name": persoon.volledige_naam if persoon else None, } @@ -117,24 +137,28 @@ def wachtwoord_reset(request): user = None if not user or not default_token_generator.check_token(user, token): return Response({"detail": "Deze resetlink is ongeldig of verlopen."}, status=400) + fouten = _password_errors(nieuw, user) + if fouten: + return Response({"detail": fouten}, status=400) user.set_password(nieuw) user.save() logger.info("Wachtwoord gereset voor %r", user.get_username()) + audit_log(request, "password_reset", f"User:{user.pk}") return Response({"detail": "Je wachtwoord is aangepast. Je kunt nu inloggen."}) -class GebruikerViewSet(viewsets.ModelViewSet): +class GebruikerViewSet(AuditedModelViewSet): """Gebruikersbeheer — alleen voor beheerders (is_staff).""" serializer_class = GebruikerSerializer - permission_classes = [IsAdminUser] + permission_classes = [AdminRolePermission, AccountsModuleEnabled] def get_queryset(self): return User.objects.select_related("gebruikersprofiel", "gebruikersprofiel__persoon").order_by("username") @api_view(["POST"]) -@permission_classes([IsAdminUser]) +@permission_classes([AdminRolePermission, AccountsModuleEnabled]) def wachtwoord_zet(request, pk): """Beheerder zet/genereert een (tijdelijk) wachtwoord; geeft het terug.""" try: @@ -144,9 +168,13 @@ def wachtwoord_zet(request, pk): nieuw = str(request.data.get("wachtwoord", "")).strip() or secrets.token_urlsafe(9) if len(nieuw) < 8: return Response({"detail": "Het wachtwoord moet minstens 8 tekens zijn."}, status=400) + fouten = _password_errors(nieuw, user) + if fouten: + return Response({"detail": fouten}, status=400) user.set_password(nieuw) user.save() logger.info("Beheerder %r zette nieuw wachtwoord voor %r", request.user.get_username(), user.get_username()) + audit_log(request, "password_set", f"User:{user.pk}") return Response({"wachtwoord": nieuw, "gebruiker": user.get_username()}) @@ -175,7 +203,7 @@ def _uniek(basis: str, gebruikt: set) -> str: @api_view(["POST"]) -@permission_classes([IsAdminUser]) +@permission_classes([AdminRolePermission, AccountsModuleEnabled]) def gebruikers_import(request): """Bulk: maak meerdere gebruikers met automatisch gegenereerd wachtwoord. @@ -201,6 +229,9 @@ def gebruikers_import(request): gn = _uniek(gn, gebruikt) wachtwoord = _genereer_wachtwoord() user = User(username=gn, email=email, first_name=vn, last_name=an) + fouten = _password_errors(wachtwoord, user) + if fouten: + return Response({"detail": fouten, "gebruiker": gn}, status=400) user.set_password(wachtwoord) user.save() persoon = None @@ -219,4 +250,5 @@ def gebruikers_import(request): "rol": rol, "persoon_naam": persoon.volledige_naam if persoon else None, }) + audit_log(request, "import", "User", {"aangemaakt": len(resultaat)}) return Response({"aangemaakt": len(resultaat), "gebruikers": resultaat}) diff --git a/backend/modules/autorooster/views.py b/backend/modules/autorooster/views.py index 71ccb17..9b5ecee 100644 --- a/backend/modules/autorooster/views.py +++ b/backend/modules/autorooster/views.py @@ -4,10 +4,14 @@ Werkt voor meerdere klassen tegelijk. Een wens (vak) kan voor meerdere klassen gelden; begeleiders en lokalen worden over álle klassen heen niet dubbel geboekt. Voorkeuren: vak liever ochtend/middag, dagen vrijhouden, max. blokken per dag. """ -from rest_framework.decorators import api_view +from rest_framework.decorators import api_view, permission_classes from rest_framework.response import Response +from core.audit import audit_log from core.models import Activiteit, Groep, Lokaal, Persoon, Roosterblok, Schooljaar, Tijdslot +from core.permissions import CoreDataPermission, module_permission_class + +AutoroosterModuleEnabled = module_permission_class("autorooster") def _is_ochtend(slot): @@ -15,6 +19,7 @@ def _is_ochtend(slot): @api_view(["POST"]) +@permission_classes([CoreDataPermission, AutoroosterModuleEnabled]) def voorstel(request): sjid = request.data.get("schooljaar") # Meerdere klassen; val terug op het oude enkele 'groep'-veld. @@ -146,4 +151,5 @@ def voorstel(request): niet.append({"activiteit_naam": act_naam.get(act_id, "?"), "groep_naam": groepen[gid].naam, "tekort": aantal - geplaatst}) voorstel_lijst.sort(key=lambda x: (x["groep_naam"], x["dag"], x["tijdslot_label"])) + audit_log(request, "generate", "AutoroosterVoorstel", {"schooljaar": sj.pk, "groepen": ordered_gids}) return Response({"voorstel": voorstel_lijst, "niet_geplaatst": niet}) diff --git a/backend/modules/huisstijl/views.py b/backend/modules/huisstijl/views.py index fddbc66..7051ef7 100644 --- a/backend/modules/huisstijl/views.py +++ b/backend/modules/huisstijl/views.py @@ -5,15 +5,20 @@ PUT /api/huisstijl/ -> upload logo (alleen beheerders); voert een kwaliteitscheck uit en geeft waarschuwingen terug. DELETE /api/huisstijl/ -> logo verwijderen (alleen beheerders). """ -from rest_framework.decorators import api_view, parser_classes +from rest_framework.decorators import api_view, parser_classes, permission_classes from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.response import Response +from core.audit import audit_log +from core.permissions import CoreDataPermission, module_permission_class + from .models import Huisstijl from .serializers import HuisstijlSerializer -TOEGESTAAN = {"png", "jpg", "jpeg", "gif", "webp", "svg"} +TOEGESTAAN = {"png", "jpg", "jpeg", "gif", "webp"} MAX_BYTES = 3 * 1024 * 1024 # 3 MB +MAX_PIXELS = 4000 * 4000 +HuisstijlModuleEnabled = module_permission_class("huisstijl") def _hex(rgb): @@ -31,14 +36,11 @@ def analyseer_logo(f): ext = naam.rsplit(".", 1)[-1] if "." in naam else "" fouten, waarsch = [], [] if ext not in TOEGESTAAN: - fouten.append(f"Bestandstype '.{ext}' wordt niet ondersteund. Gebruik PNG, SVG, JPG, GIF of WebP.") + fouten.append(f"Bestandstype '.{ext}' wordt niet ondersteund. Gebruik PNG, JPG, GIF of WebP.") return {"ok": False, "fouten": fouten, "waarschuwingen": waarsch, "info": {}} if getattr(f, "size", 0) and f.size > MAX_BYTES: - waarsch.append(f"Het bestand is groot ({f.size // 1024} KB); overweeg een kleiner logo.") - - if ext == "svg": - return {"ok": True, "fouten": [], "waarschuwingen": waarsch, - "info": {"formaat": "SVG", "transparant": True, "breedte": None, "hoogte": None, "achtergrond": None}} + fouten.append(f"Het bestand is te groot ({f.size // 1024} KB); maximaal 3 MB.") + return {"ok": False, "fouten": fouten, "waarschuwingen": waarsch, "info": {}} try: from PIL import Image @@ -54,6 +56,9 @@ def analyseer_logo(f): return {"ok": False, "fouten": ["Kan de afbeelding niet lezen of het bestand is beschadigd."], "waarschuwingen": [], "info": {}} breedte, hoogte = img.size + if breedte * hoogte > MAX_PIXELS: + fouten.append("Afbeelding is te groot in pixels; maximaal 4000 x 4000.") + return {"ok": False, "fouten": fouten, "waarschuwingen": waarsch, "info": {}} transparant = False achtergrond = None heeft_alpha = img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info) @@ -97,6 +102,7 @@ def analyseer_logo(f): @api_view(["GET", "PUT", "POST", "DELETE"]) @parser_classes([MultiPartParser, FormParser, JSONParser]) +@permission_classes([CoreDataPermission, HuisstijlModuleEnabled]) def huisstijl(request): obj = Huisstijl.actueel() schrijven = request.method in ("PUT", "POST", "DELETE") @@ -106,6 +112,7 @@ def huisstijl(request): if request.method == "DELETE": if obj.logo: obj.logo.delete(save=True) + audit_log(request, "delete", "Huisstijl.logo") return Response(HuisstijlSerializer(obj, context={"request": request}).data) if request.method in ("PUT", "POST"): @@ -134,6 +141,7 @@ def huisstijl(request): obj.save() except Exception as e: # noqa: BLE001 return Response({"detail": f"Opslaan mislukte: {e}"}, status=400) + audit_log(request, "update", "Huisstijl:1", {"logo": bool(f)}) data = HuisstijlSerializer(obj, context={"request": request}).data if analyse is not None: data["kwaliteit"] = analyse diff --git a/backend/modules/importexport/__init__.py b/backend/modules/importexport/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/modules/importexport/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/modules/importexport/apps.py b/backend/modules/importexport/apps.py new file mode 100644 index 0000000..e8c686a --- /dev/null +++ b/backend/modules/importexport/apps.py @@ -0,0 +1,29 @@ +from django.apps import AppConfig + + +class ImportExportConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "modules.importexport" + label = "module_importexport" + verbose_name = "Module: Import/export" + + def ready(self): + from plugins.registry import MenuItem, ModuleSpec, registry + + registry.register( + ModuleSpec( + key="importexport", + name="Import/export", + description=( + "Exporteer kerngegevens naar CSV en importeer personen in bulk. " + "Handig voor migraties, controles en schooljaarovergangen." + ), + version="0.1.0", + category="Hulpmiddelen", + core=False, + default_enabled=False, + menu_items=( + MenuItem("Import/export", "/import-export", icon="printer", order=90, group="Hulpmiddelen"), + ), + ) + ) diff --git a/backend/modules/importexport/urls.py b/backend/modules/importexport/urls.py new file mode 100644 index 0000000..ab83b29 --- /dev/null +++ b/backend/modules/importexport/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("export//", views.export_csv, name="export-csv"), + path("import/personen/", views.import_personen, name="import-personen"), +] diff --git a/backend/modules/importexport/views.py b/backend/modules/importexport/views.py new file mode 100644 index 0000000..879c864 --- /dev/null +++ b/backend/modules/importexport/views.py @@ -0,0 +1,101 @@ +import csv +import io + +from django.http import HttpResponse +from rest_framework.decorators import api_view, permission_classes +from rest_framework.response import Response + +from core.audit import audit_log +from core.models import Afwezigheid, Groep, Persoon, Roosterblok +from core.permissions import CoreDataPermission, StaffDataPermission, module_permission_class + +ImportExportModuleEnabled = module_permission_class("importexport") + + +def _doel_naam(blok): + if blok.groep_id: + return blok.groep.naam + if blok.subgroep_id: + return blok.subgroep.naam + if blok.leerling_id: + return blok.leerling.volledige_naam + return "" + + +EXPORTS = { + "personen": ( + ["id", "voornaam", "achternaam", "rol", "groep"], + lambda: Persoon.objects.select_related("groep").all(), + lambda p: [p.id, p.voornaam, p.achternaam, p.rol, p.groep.naam if p.groep_id else ""], + ), + "groepen": ( + ["id", "naam", "actief"], + lambda: Groep.objects.all(), + lambda g: [g.id, g.naam, g.actief], + ), + "roosterblokken": ( + ["id", "schooljaar", "tijdslot", "activiteit", "doel_type", "doel", "begeleiders"], + lambda: Roosterblok.objects.select_related( + "schooljaar", "tijdslot", "activiteit", "groep", "subgroep", "leerling" + ).prefetch_related("begeleiders"), + lambda b: [ + b.id, + b.schooljaar.naam, + str(b.tijdslot), + b.activiteit.naam, + b.doel_type, + _doel_naam(b), + ", ".join(p.volledige_naam for p in b.begeleiders.all()), + ], + ), + "afwezigheden": ( + ["id", "persoon", "start_datum", "eind_datum", "reden"], + lambda: Afwezigheid.objects.select_related("persoon").all(), + lambda a: [a.id, a.persoon.volledige_naam, a.start_datum, a.eind_datum, a.reden], + ), +} + + +@api_view(["GET"]) +@permission_classes([StaffDataPermission, ImportExportModuleEnabled]) +def export_csv(request, resource): + if resource not in EXPORTS: + return Response({"detail": "Onbekende export."}, status=404) + headers, queryset_factory, row_factory = EXPORTS[resource] + response = HttpResponse(content_type="text/csv; charset=utf-8") + response["Content-Disposition"] = f'attachment; filename="{resource}.csv"' + response.write("\ufeff") + writer = csv.writer(response) + writer.writerow(headers) + for obj in queryset_factory(): + writer.writerow(row_factory(obj)) + audit_log(request, "export", f"CSV:{resource}") + return response + + +@api_view(["POST"]) +@permission_classes([CoreDataPermission, ImportExportModuleEnabled]) +def import_personen(request): + tekst = request.data.get("csv") or "" + if not tekst.strip(): + return Response({"detail": "CSV-inhoud ontbreekt."}, status=400) + reader = csv.DictReader(io.StringIO(tekst)) + aangemaakt = 0 + meldingen = [] + for nummer, row in enumerate(reader, start=2): + voornaam = (row.get("voornaam") or row.get("Voornaam") or "").strip() + achternaam = (row.get("achternaam") or row.get("Achternaam") or "").strip() + rol = (row.get("rol") or row.get("Rol") or "leerling").strip() + groep_naam = (row.get("groep") or row.get("Groep") or "").strip() + if not voornaam and not achternaam: + continue + groep = None + if groep_naam: + groep, _ = Groep.objects.get_or_create(naam=groep_naam) + try: + Persoon.objects.create(voornaam=voornaam, achternaam=achternaam, rol=rol, groep=groep) + aangemaakt += 1 + except Exception as exc: + meldingen.append(f"Rij {nummer}: {exc}") + audit_log(request, "import", "Persoon", {"aangemaakt": aangemaakt, "meldingen": len(meldingen)}) + return Response({"aangemaakt": aangemaakt, "meldingen": meldingen}) diff --git a/backend/modules/leerplein/views.py b/backend/modules/leerplein/views.py index 7fdb1bf..9687228 100644 --- a/backend/modules/leerplein/views.py +++ b/backend/modules/leerplein/views.py @@ -1,16 +1,32 @@ -from rest_framework import viewsets - +from core.audit import AuditedModelViewSet +from core.permissions import CoreDataPermission, linked_person, module_permission_class, user_role from .models import Leerplein, SubgroepGroep from .serializers import LeerpleinSerializer, SubgroepGroepSerializer +LeerpleinModuleEnabled = module_permission_class("leerplein") -class LeerpleinViewSet(viewsets.ModelViewSet): - queryset = Leerplein.objects.prefetch_related("groepen", "subgroepen", "leerlingen").all() + +class LeerpleinViewSet(AuditedModelViewSet): serializer_class = LeerpleinSerializer + permission_classes = [CoreDataPermission, LeerpleinModuleEnabled] + + def get_queryset(self): + qs = Leerplein.objects.prefetch_related("groepen", "subgroepen", "leerlingen").all() + if user_role(self.request.user) in ("leerling", "ouder"): + person = linked_person(self.request.user) + if person is None: + return qs.none() + qs = qs.filter(leerlingen=person) + if getattr(person, "groep_id", None): + qs = qs | Leerplein.objects.filter(groepen=person.groep_id) + qs = qs | Leerplein.objects.filter(subgroepen__leden=person) + return qs.distinct() + return qs -class SubgroepGroepViewSet(viewsets.ModelViewSet): +class SubgroepGroepViewSet(AuditedModelViewSet): serializer_class = SubgroepGroepSerializer + permission_classes = [CoreDataPermission, LeerpleinModuleEnabled] def get_queryset(self): qs = SubgroepGroep.objects.select_related("groep", "subgroep").all() diff --git a/backend/modules/personenwizard/views.py b/backend/modules/personenwizard/views.py index 91f7a45..e61544c 100644 --- a/backend/modules/personenwizard/views.py +++ b/backend/modules/personenwizard/views.py @@ -1,13 +1,17 @@ """Bulk-import van personen (leerlingen/medewerkers). Geen eigen model.""" -from rest_framework.decorators import api_view +from rest_framework.decorators import api_view, permission_classes from rest_framework.response import Response +from core.audit import audit_log from core.models import Groep, Persoon, Rol +from core.permissions import CoreDataPermission, module_permission_class GELDIGE_ROLLEN = {r.value for r in Rol} +PersonenwizardModuleEnabled = module_permission_class("personenwizard") @api_view(["POST"]) +@permission_classes([CoreDataPermission, PersonenwizardModuleEnabled]) def personen_import(request): rol = request.data.get("rol") personen = request.data.get("personen") or [] @@ -42,4 +46,5 @@ def personen_import(request): meldingen.append({"regel": i + 1, "melding": f"groep '{gnaam}' niet gevonden — leerling zonder groep aangemaakt"}) obj.save() aangemaakt += 1 + audit_log(request, "import", "Persoon", {"aangemaakt": aangemaakt, "rol": rol}) return Response({"aangemaakt": aangemaakt, "meldingen": meldingen}) diff --git a/backend/modules/portal/__init__.py b/backend/modules/portal/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/modules/portal/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/modules/portal/apps.py b/backend/modules/portal/apps.py new file mode 100644 index 0000000..f3c042b --- /dev/null +++ b/backend/modules/portal/apps.py @@ -0,0 +1,29 @@ +from django.apps import AppConfig + + +class PortalConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "modules.portal" + label = "module_portal" + verbose_name = "Module: Portal" + + def ready(self): + from plugins.registry import MenuItem, ModuleSpec, registry + + registry.register( + ModuleSpec( + key="portal", + name="Leerling/ouder-portaal", + description=( + "Een rustige startpagina voor leerlingen en ouders met hun " + "gekoppelde persoon en snelle toegang tot het eigen rooster." + ), + version="0.1.0", + category="Portaal", + core=False, + default_enabled=True, + menu_items=( + MenuItem("Portaal", "/portaal", icon="book", order=8, group="Rooster"), + ), + ) + ) diff --git a/backend/modules/portal/urls.py b/backend/modules/portal/urls.py new file mode 100644 index 0000000..3d1d636 --- /dev/null +++ b/backend/modules/portal/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("portal/me/", views.portal_me, name="portal-me"), +] diff --git a/backend/modules/portal/views.py b/backend/modules/portal/views.py new file mode 100644 index 0000000..fa48e27 --- /dev/null +++ b/backend/modules/portal/views.py @@ -0,0 +1,33 @@ +from rest_framework.decorators import api_view, permission_classes +from rest_framework.response import Response + +from core.models import Afwezigheid +from core.permissions import module_permission_class + +PortalModuleEnabled = module_permission_class("portal") + + +@api_view(["GET"]) +@permission_classes([PortalModuleEnabled]) +def portal_me(request): + profiel = getattr(request.user, "gebruikersprofiel", None) + persoon = getattr(profiel, "persoon", None) if profiel else None + afwezigheden = [] + if persoon: + afwezigheden = [ + { + "id": a.id, + "start_datum": a.start_datum, + "eind_datum": a.eind_datum, + "reden": a.reden, + } + for a in Afwezigheid.objects.filter(persoon=persoon)[:5] + ] + return Response({ + "username": request.user.get_username(), + "role": profiel.rol if profiel else None, + "role_label": profiel.get_rol_display() if profiel else None, + "person": persoon.id if persoon else None, + "person_name": persoon.volledige_naam if persoon else None, + "afwezigheden": afwezigheden, + }) diff --git a/backend/modules/stage/views.py b/backend/modules/stage/views.py index f374244..55d1ee9 100644 --- a/backend/modules/stage/views.py +++ b/backend/modules/stage/views.py @@ -1,13 +1,18 @@ -from rest_framework import viewsets -from rest_framework.decorators import api_view +from rest_framework.decorators import api_view, permission_classes from rest_framework.response import Response +from core.audit import AuditedModelViewSet +from core.permissions import CoreDataPermission, linked_person, module_permission_class, user_role + from .models import Stage from .serializers import StageSerializer, _theorievak_botsing, _lokaal_bezet_door_ander +StageModuleEnabled = module_permission_class("stage") -class StageViewSet(viewsets.ModelViewSet): + +class StageViewSet(AuditedModelViewSet): serializer_class = StageSerializer + permission_classes = [CoreDataPermission, StageModuleEnabled] def get_queryset(self): qs = Stage.objects.select_related( @@ -22,14 +27,22 @@ class StageViewSet(viewsets.ModelViewSet): actief = self.request.query_params.get("actief") if actief in ("1", "true", "True"): qs = qs.filter(actief=True) + if user_role(self.request.user) in ("leerling", "ouder"): + person = linked_person(self.request.user) + qs = qs.filter(leerling=person) if person is not None else qs.none() return qs @api_view(["GET"]) +@permission_classes([CoreDataPermission, StageModuleEnabled]) def stage_conflicten(request): """Signaleer stage-conflicten: begeleider op theorievak + dubbele lokaalboeking.""" resultaat = [] - for stage in Stage.objects.filter(actief=True).select_related("leerling", "begeleider", "lokaal"): + qs = Stage.objects.filter(actief=True).select_related("leerling", "begeleider", "lokaal") + if user_role(request.user) in ("leerling", "ouder"): + person = linked_person(request.user) + qs = qs.filter(leerling=person) if person is not None else qs.none() + for stage in qs: botsing = _theorievak_botsing( stage.begeleider, stage.omvang, stage.dag, stage.dagdeel, stage.tijdslot ) diff --git a/backend/modules/uren/views.py b/backend/modules/uren/views.py index b9ed700..1f51567 100644 --- a/backend/modules/uren/views.py +++ b/backend/modules/uren/views.py @@ -7,13 +7,17 @@ geldigheidsperiode (geldig_van/geldig_tot) telt alleen binnen dat bereik mee. """ from datetime import timedelta -from rest_framework.decorators import api_view +from rest_framework.decorators import api_view, permission_classes from rest_framework.response import Response +from core.audit import audit_log from core.models import KalenderDag, Persoon, Roosterblok, Rol, Schooljaar, Subgroep, Tijdslot +from core.permissions import StaffDataPermission, module_permission_class from .models import UrenMomentopname +UrenModuleEnabled = module_permission_class("uren") + def _duur_uren(ts): s, e = ts.start_tijd, ts.eind_tijd @@ -143,6 +147,7 @@ def bereken_uren(sj): @api_view(["GET"]) +@permission_classes([StaffDataPermission, UrenModuleEnabled]) def urenoverzicht(request): sjid = request.query_params.get("schooljaar") if not sjid: @@ -155,6 +160,7 @@ def urenoverzicht(request): @api_view(["GET", "POST"]) +@permission_classes([StaffDataPermission, UrenModuleEnabled]) def momentopnames(request): """GET: lijst van momentopnames (samenvatting). POST: bewaar huidige stand.""" sjid = request.query_params.get("schooljaar") or request.data.get("schooljaar") @@ -168,6 +174,7 @@ def momentopnames(request): if request.method == "POST": payload = bereken_uren(sj) opname = UrenMomentopname.objects.create(schooljaar=sj, payload=payload) + audit_log(request, "create", f"UrenMomentopname:{opname.pk}", {"schooljaar": sj.pk}) return Response({"id": opname.id, "gemaakt_op": opname.gemaakt_op}, status=201) opnames = UrenMomentopname.objects.filter(schooljaar=sj) diff --git a/backend/modules/vervanging/__init__.py b/backend/modules/vervanging/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/modules/vervanging/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/modules/vervanging/apps.py b/backend/modules/vervanging/apps.py new file mode 100644 index 0000000..c9b0631 --- /dev/null +++ b/backend/modules/vervanging/apps.py @@ -0,0 +1,29 @@ +from django.apps import AppConfig + + +class VervangingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "modules.vervanging" + label = "module_vervanging" + verbose_name = "Module: Vervanging" + + def ready(self): + from plugins.registry import MenuItem, ModuleSpec, registry + + registry.register( + ModuleSpec( + key="vervanging", + name="Vervanging", + description=( + "Zoek beschikbare vervangers bij afwezige medewerkers en leg " + "eenmalige roosterwijzigingen vast." + ), + version="0.1.0", + category="Rooster", + core=False, + default_enabled=False, + menu_items=( + MenuItem("Vervanging", "/vervanging", icon="away", order=18, group="Rooster"), + ), + ) + ) diff --git a/backend/modules/vervanging/urls.py b/backend/modules/vervanging/urls.py new file mode 100644 index 0000000..a4d9c14 --- /dev/null +++ b/backend/modules/vervanging/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("vervangingen/suggesties/", views.suggesties, name="vervanging-suggesties"), + path("vervangingen/toepassen/", views.toepassen, name="vervanging-toepassen"), +] diff --git a/backend/modules/vervanging/views.py b/backend/modules/vervanging/views.py new file mode 100644 index 0000000..f67ce2e --- /dev/null +++ b/backend/modules/vervanging/views.py @@ -0,0 +1,86 @@ +from datetime import timedelta + +from rest_framework.decorators import api_view, permission_classes +from rest_framework.response import Response + +from core.audit import audit_log +from core.models import Afwezigheid, BlokUitzondering, Persoon, Roosterblok +from core.permissions import StaffDataPermission, module_permission_class + +VervangingModuleEnabled = module_permission_class("vervanging") + + +def _datums(start, eind, weekdag): + dag = start + while dag <= eind: + if dag.weekday() == weekdag: + yield dag + dag += timedelta(days=1) + + +def _beschikbare_medewerkers(blok): + bezet = Persoon.objects.filter(begeleider_roosterblokken__tijdslot=blok.tijdslot).values("id") + qs = Persoon.objects.exclude(rol="leerling").filter(actief=True).exclude(id__in=bezet) + return qs.exclude(id__in=blok.begeleiders.values("id")).distinct() + + +@api_view(["GET"]) +@permission_classes([StaffDataPermission, VervangingModuleEnabled]) +def suggesties(request): + afw_id = request.query_params.get("afwezigheid") + try: + afw = Afwezigheid.objects.select_related("persoon").get(pk=afw_id) + except Afwezigheid.DoesNotExist: + return Response({"detail": "Afwezigheid niet gevonden."}, status=404) + + blokken = ( + Roosterblok.objects + .filter(begeleiders=afw.persoon, sjabloon=False) + .select_related("tijdslot", "activiteit", "groep", "subgroep", "leerling") + .prefetch_related("begeleiders") + ) + resultaat = [] + for blok in blokken: + datums = list(_datums(afw.start_datum, afw.eind_datum, blok.tijdslot.dag)) + if not datums: + continue + kandidaten = _beschikbare_medewerkers(blok)[:8] + resultaat.append({ + "roosterblok": blok.id, + "activiteit": blok.activiteit.naam, + "tijdslot": str(blok.tijdslot), + "doel": blok.groep.naam if blok.groep_id else blok.subgroep.naam if blok.subgroep_id else blok.leerling.volledige_naam, + "datums": datums, + "kandidaten": [ + {"id": p.id, "naam": p.volledige_naam, "rol": p.get_rol_display()} + for p in kandidaten + ], + }) + return Response({ + "afwezigheid": afw.id, + "persoon": afw.persoon.volledige_naam, + "suggesties": resultaat, + }) + + +@api_view(["POST"]) +@permission_classes([StaffDataPermission, VervangingModuleEnabled]) +def toepassen(request): + blok_id = request.data.get("roosterblok") + datum = request.data.get("datum") + vervanger_id = request.data.get("vervanger") + if not (blok_id and datum and vervanger_id): + return Response({"detail": "roosterblok, datum en vervanger zijn verplicht."}, status=400) + if not StaffDataPermission().has_permission(request, type("View", (), {"write_roles": ("roostermaker", "directie")})()): + return Response({"detail": "Je hebt geen rechten om vervangingen toe te passen."}, status=403) + obj, _ = BlokUitzondering.objects.update_or_create( + roosterblok_id=blok_id, + datum=datum, + defaults={ + "vervalt": False, + "vervanger_id": vervanger_id, + "opmerking": request.data.get("opmerking", "Vervanging"), + }, + ) + audit_log(request, "replacement_apply", f"BlokUitzondering:{obj.pk}", {"roosterblok": blok_id, "datum": datum}) + return Response({"id": obj.id, "roosterblok": obj.roosterblok_id, "datum": obj.datum, "vervanger": obj.vervanger_id}) diff --git a/backend/plugins/migrations/0002_auditevent.py b/backend/plugins/migrations/0002_auditevent.py new file mode 100644 index 0000000..ae322ab --- /dev/null +++ b/backend/plugins/migrations/0002_auditevent.py @@ -0,0 +1,40 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("plugins", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="AuditEvent", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("actor", models.CharField(blank=True, max_length=150)), + ("action", models.CharField(max_length=80)), + ("target", models.CharField(blank=True, max_length=200)), + ("ip_address", models.GenericIPAddressField(blank=True, null=True)), + ("extra", models.JSONField(blank=True, default=dict)), + ], + options={ + "verbose_name": "audit event", + "verbose_name_plural": "audit events", + "ordering": ["-created_at"], + }, + ), + migrations.AddIndex( + model_name="auditevent", + index=models.Index(fields=["-created_at"], name="plugins_aud_created_46edd2_idx"), + ), + migrations.AddIndex( + model_name="auditevent", + index=models.Index(fields=["actor"], name="plugins_aud_actor_7de72d_idx"), + ), + migrations.AddIndex( + model_name="auditevent", + index=models.Index(fields=["action"], name="plugins_aud_action_d86f22_idx"), + ), + ] diff --git a/backend/plugins/models.py b/backend/plugins/models.py index d56c6bf..1390a59 100644 --- a/backend/plugins/models.py +++ b/backend/plugins/models.py @@ -23,3 +23,27 @@ class ModuleState(models.Model): def __str__(self) -> str: status = "aan" if self.enabled else "uit" return f"{self.key} ({status})" + + +class AuditEvent(models.Model): + """Persistent auditspoor voor gevoelige mutaties en beheerdersacties.""" + + created_at = models.DateTimeField(auto_now_add=True) + actor = models.CharField(max_length=150, blank=True) + action = models.CharField(max_length=80) + target = models.CharField(max_length=200, blank=True) + ip_address = models.GenericIPAddressField(null=True, blank=True) + extra = models.JSONField(default=dict, blank=True) + + class Meta: + verbose_name = "audit event" + verbose_name_plural = "audit events" + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["-created_at"]), + models.Index(fields=["actor"]), + models.Index(fields=["action"]), + ] + + def __str__(self) -> str: + return f"{self.created_at:%Y-%m-%d %H:%M} {self.actor} {self.action} {self.target}".strip() diff --git a/backend/plugins/urls.py b/backend/plugins/urls.py index a0b1889..efc5e63 100644 --- a/backend/plugins/urls.py +++ b/backend/plugins/urls.py @@ -5,4 +5,5 @@ from . import views urlpatterns = [ path("modules/", views.module_list, name="module-list"), path("modules//state/", views.module_set_state, name="module-set-state"), + path("audit-log/", views.audit_log_list, name="audit-log"), ] diff --git a/backend/plugins/views.py b/backend/plugins/views.py index 3dfd3f3..b2a5e77 100644 --- a/backend/plugins/views.py +++ b/backend/plugins/views.py @@ -1,9 +1,12 @@ """API voor het plugin-framework: modules opvragen en aan/uit zetten.""" from rest_framework import status from rest_framework.decorators import api_view, permission_classes -from rest_framework.permissions import IsAdminUser from rest_framework.response import Response +from core.audit import audit_log +from core.permissions import AdminRolePermission + +from .models import AuditEvent from . import services @@ -15,7 +18,7 @@ def module_list(request): @api_view(["POST"]) -@permission_classes([IsAdminUser]) +@permission_classes([AdminRolePermission]) def module_set_state(request, key: str): """Zet één module aan of uit (alleen beheerders). Body: {"enabled": true|false}.""" enabled = request.data.get("enabled") @@ -29,4 +32,33 @@ def module_set_state(request, key: str): services.set_enabled(key, enabled) except services.ModuleError as exc: return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + audit_log(request, "module_set_state", f"Module:{key}", {"enabled": enabled}) return Response(services.describe_all()) + + +@api_view(["GET"]) +@permission_classes([AdminRolePermission]) +def audit_log_list(request): + qs = AuditEvent.objects.all() + action = request.query_params.get("action") + actor = request.query_params.get("actor") + if action: + qs = qs.filter(action__icontains=action) + if actor: + qs = qs.filter(actor__icontains=actor) + try: + limit = min(max(int(request.query_params.get("limit", 100)), 1), 500) + except ValueError: + limit = 100 + return Response([ + { + "id": event.id, + "created_at": event.created_at, + "actor": event.actor, + "action": event.action, + "target": event.target, + "ip_address": event.ip_address, + "extra": event.extra, + } + for event in qs[:limit] + ]) diff --git a/backend/requirements.txt b/backend/requirements.txt index 1777fea..c4cc7a9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,8 +1,8 @@ -Django>=5.2,<6.0 -djangorestframework>=3.15 -django-cors-headers>=4.4 +Django==5.2.15 +djangorestframework==3.17.1 +django-cors-headers==4.9.0 # --- Productie / test-server --------------------------------------------- -gunicorn>=22.0 # WSGI-server die Django draait -psycopg[binary]>=3.2 # PostgreSQL-driver -Pillow>=10.0 # beeldverwerking voor pictogram-upload +gunicorn==26.0.0 # WSGI-server die Django draait +psycopg[binary]==3.3.4 # PostgreSQL-driver +Pillow==12.3.0 # beeldverwerking voor pictogram-upload diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fa90a00..44e24b6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,330 +12,77 @@ "react-dom": "^18.3.1" }, "devDependencies": { - "@vitejs/plugin-react": "^4.3.1", - "vite": "^5.4.2" + "@vitejs/plugin-react": "^6.0.3", + "vite": "^8.1.3" } }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" } }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", - "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6.9.0" + "funding": { + "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", - "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", - "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", "cpu": [ "arm64" ], @@ -346,30 +93,13 @@ "android" ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", "cpu": [ "arm64" ], @@ -380,13 +110,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", "cpu": [ "x64" ], @@ -397,30 +127,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", "cpu": [ "x64" ], @@ -431,13 +144,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", "cpu": [ "arm" ], @@ -448,426 +161,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", - "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", - "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", - "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", - "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", - "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", - "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", - "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", - "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", - "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", "cpu": [ "arm64" ], @@ -879,12 +179,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", - "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", "cpu": [ "arm64" ], @@ -896,46 +199,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", - "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", - "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", - "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", "cpu": [ "ppc64" ], @@ -947,63 +219,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", - "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", - "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", - "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", - "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", "cpu": [ "s390x" ], @@ -1015,12 +239,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", - "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", "cpu": [ "x64" ], @@ -1032,12 +259,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", - "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", "cpu": [ "x64" ], @@ -1049,26 +279,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", - "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", - "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", "cpu": [ "arm64" ], @@ -1077,12 +296,34 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", - "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", "cpu": [ "arm64" ], @@ -1091,26 +332,15 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", - "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", - "cpu": [ - "ia32" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", - "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", "cpu": [ "x64" ], @@ -1119,242 +349,81 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", - "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.35", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz", - "integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001797", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", - "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" }, "peerDependenciesMeta": { - "supports-color": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { "optional": true } } }, - "node_modules/electron-to-chromium": { - "version": "1.5.371", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz", - "integrity": "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w==", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, - "license": "ISC" - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, + "license": "Apache-2.0", "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "node": ">=8" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, "node_modules/fsevents": { @@ -1372,46 +441,283 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" }, "engines": { - "node": ">=6" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=6" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/loose-envify": { @@ -1426,27 +732,10 @@ "loose-envify": "cli.js" } }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, "funding": [ { @@ -1462,16 +751,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1479,10 +758,23 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -1533,59 +825,38 @@ "react": "^18.3.1" } }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/rollup": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", - "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.9" + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.61.1", - "@rollup/rollup-android-arm64": "4.61.1", - "@rollup/rollup-darwin-arm64": "4.61.1", - "@rollup/rollup-darwin-x64": "4.61.1", - "@rollup/rollup-freebsd-arm64": "4.61.1", - "@rollup/rollup-freebsd-x64": "4.61.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", - "@rollup/rollup-linux-arm-musleabihf": "4.61.1", - "@rollup/rollup-linux-arm64-gnu": "4.61.1", - "@rollup/rollup-linux-arm64-musl": "4.61.1", - "@rollup/rollup-linux-loong64-gnu": "4.61.1", - "@rollup/rollup-linux-loong64-musl": "4.61.1", - "@rollup/rollup-linux-ppc64-gnu": "4.61.1", - "@rollup/rollup-linux-ppc64-musl": "4.61.1", - "@rollup/rollup-linux-riscv64-gnu": "4.61.1", - "@rollup/rollup-linux-riscv64-musl": "4.61.1", - "@rollup/rollup-linux-s390x-gnu": "4.61.1", - "@rollup/rollup-linux-x64-gnu": "4.61.1", - "@rollup/rollup-linux-x64-musl": "4.61.1", - "@rollup/rollup-openbsd-x64": "4.61.1", - "@rollup/rollup-openharmony-arm64": "4.61.1", - "@rollup/rollup-win32-arm64-msvc": "4.61.1", - "@rollup/rollup-win32-ia32-msvc": "4.61.1", - "@rollup/rollup-win32-x64-gnu": "4.61.1", - "@rollup/rollup-win32-x64-msvc": "4.61.1", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" } }, "node_modules/scheduler": { @@ -1597,16 +868,6 @@ "loose-envify": "^1.1.0" } }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1617,53 +878,49 @@ "node": ">=0.10.0" } }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, - "bin": { - "update-browserslist-db": "cli.js" + "engines": { + "node": ">=12.0.0" }, - "peerDependencies": { - "browserslist": ">= 4.21.0" + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -1672,23 +929,33 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, - "less": { + "@vitejs/devtools": { "optional": true }, - "lightningcss": { + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { "optional": true }, "sass": { @@ -1705,15 +972,14 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" } } } diff --git a/frontend/package.json b/frontend/package.json index 8c046e0..ee214a7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,7 @@ "react-dom": "^18.3.1" }, "devDependencies": { - "@vitejs/plugin-react": "^4.3.1", - "vite": "^5.4.2" + "@vitejs/plugin-react": "^6.0.3", + "vite": "^8.1.3" } } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d39059c..34d44dd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,13 +8,13 @@ import { AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage, LeerlingRoosterPage, OuderRoosterPage, VakWizardPage, PersonenWizardPage, AutoRoosterPage, WachtwoordResetPage, - HuisstijlPage, + HuisstijlPage, AuditLogPage, ImportExportPage, VervangingPage, PortalPage, } from "./pages.jsx"; const ICONS = { calendar: "📅", users: "👥", layers: "🗂️", printer: "🖨️", key: "🔑", puzzle: "🧩", badge: "🪪", book: "📚", pin: "📍", clock: "🕘", - away: "🌴", gear: "⚙️", door: "🚪", + away: "🌴", gear: "⚙️", door: "🚪", audit: "🧾", }; // Iconen voor de groep-dropdowns in de bovenbalk. @@ -48,6 +48,10 @@ const PAGES = { "/personen-wizard": PersonenWizardPage, "/auto-rooster": AutoRoosterPage, "/vormgeving": HuisstijlPage, + "/auditlog": AuditLogPage, + "/import-export": ImportExportPage, + "/vervanging": VervangingPage, + "/portaal": PortalPage, }; // Thema (vormgeving) app-breed toepassen via CSS-variabelen. @@ -221,6 +225,14 @@ function Shell({ user, onLogout }) { useEffect(() => { load(); }, []); + useEffect(() => { + const onNav = (event) => { + if (event.detail) setActive(normaliseerPad(event.detail)); + }; + window.addEventListener("roosterwijs:navigate", onNav); + return () => window.removeEventListener("roosterwijs:navigate", onNav); + }, []); + async function doLogout() { try { await logout(); @@ -245,7 +257,8 @@ function Shell({ user, onLogout }) { if (!m.enabled) continue; for (const it of m.menu_items) voegToe({ ...it, module: m.key }); } - if (user.is_staff) { + const isBeheerder = user.is_staff || user.role === "beheerder"; + if (isBeheerder) { voegToe({ label: "Modulebeheer", path: "/modules", icon: "gear", order: 9999, group: "Instellingen" }); } const items = [...perPad.values()]; @@ -255,7 +268,7 @@ function Shell({ user, onLogout }) { instellingenTabs: items.filter((i) => i.group === "Instellingen"), alleItems: items, }; - }, [modules, user.is_staff]); + }, [modules, user.is_staff, user.role]); const activePad = normaliseerPad(active); const Page = PAGES[activePad]; @@ -382,7 +395,7 @@ function Shell({ user, onLogout }) { {error &&
⚠ {error}
} {loading ? (

Laden…

- ) : activePad === "/modules" && user.is_staff ? ( + ) : activePad === "/modules" && (user.is_staff || user.role === "beheerder") ? ( ) : Page ? ( diff --git a/frontend/src/api.js b/frontend/src/api.js index 9e0db59..ba3a454 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -73,6 +73,7 @@ export async function uploadLogo(file) { export const getModules = () => request("GET", "/modules/"); export const setModuleState = (key, enabled) => request("POST", `/modules/${key}/state/`, { enabled }); +export const getAuditLog = (query = "") => request("GET", `/audit-log/${query}`); // --- Generieke CRUD voor het kerndomein ----------------------------------- export const listResource = (resource, query = "") => @@ -84,6 +85,12 @@ export const updateResource = (resource, id, body) => export const deleteResource = (resource, id) => request("DELETE", `/${resource}/${id}/`); +export const importPersonenCsv = (csv) => request("POST", "/import/personen/", { csv }); +export const exportUrl = (resource) => `${BASE}/export/${resource}/`; +export const getVervangSuggesties = (afwezigheid) => request("GET", `/vervangingen/suggesties/?afwezigheid=${afwezigheid}`); +export const pasVervangingToe = (body) => request("POST", "/vervangingen/toepassen/", body); +export const getPortal = () => request("GET", "/portal/me/"); + // --- Lokaal-instelling (singleton; dubbele-boekingbeleid) ------------------ export const getLokaalinstelling = () => request("GET", "/lokaalinstelling/"); export const setLokaalinstelling = (data) => request("PUT", "/lokaalinstelling/", data); diff --git a/frontend/src/pages.jsx b/frontend/src/pages.jsx index 2f8c661..2db0958 100644 --- a/frontend/src/pages.jsx +++ b/frontend/src/pages.jsx @@ -5,8 +5,14 @@ import { updateResource, deleteResource, getModules, + getAuditLog, getLokaalinstelling, setLokaalinstelling, + exportUrl, + importPersonenCsv, + getPortal, + getVervangSuggesties, + pasVervangingToe, uploadBestand, wachtwoordReset, adminWachtwoordReset, @@ -1395,6 +1401,11 @@ export function StagePage() { const GEBRUIKERSROLLEN = [ { value: "beheerder", label: "Beheerder" }, { value: "medewerker", label: "Medewerker" }, + { value: "roostermaker", label: "Roostermaker" }, + { value: "directie", label: "Directie" }, + { value: "zorgcoordinator", label: "Zorgcoordinator" }, + { value: "stagebegeleider", label: "Stagebegeleider" }, + { value: "kijker", label: "Alleen lezen" }, { value: "ouder", label: "Ouder" }, { value: "leerling", label: "Leerling" }, ]; @@ -4216,6 +4227,177 @@ export function AutoRoosterPage() { } +/* ------------------------- Audit, export en portaal ----------------------- */ +export function AuditLogPage() { + const [items, setItems] = useState([]); + const [filter, setFilter] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + async function laad() { + setLoading(true); + try { + const q = filter.trim() ? `?actor=${encodeURIComponent(filter.trim())}&limit=200` : "?limit=200"; + setItems(await getAuditLog(q)); + setError(null); + } catch (e) { setError(e.message); } + finally { setLoading(false); } + } + useEffect(() => { laad(); }, []); + + return ( +
+

Auditlog

+ +
+ setFilter(e.target.value)} /> + +
+ {loading ?

Laden…

: ( + + + + {items.map((e) => ( + + + + + + + + + ))} + {items.length === 0 && } + +
TijdGebruikerActieDoelIPExtra
{new Date(e.created_at).toLocaleString("nl-NL")}{e.actor || "—"}{e.action}{e.target || "—"}{e.ip_address || "—"}{e.extra ? JSON.stringify(e.extra) : "{}"}
Nog geen auditregels.
+ )} +
+ ); +} + +export function ImportExportPage() { + const [csv, setCsv] = useState("voornaam,achternaam,rol,groep\n"); + const [resultaat, setResultaat] = useState(null); + const [error, setError] = useState(null); + const exports = ["personen", "groepen", "roosterblokken", "afwezigheden"]; + async function importeer(e) { + e.preventDefault(); + setError(null); setResultaat(null); + try { setResultaat(await importPersonenCsv(csv)); } + catch (err) { setError(err.message); } + } + function leesBestand(file) { + const rd = new FileReader(); + rd.onload = () => setCsv(String(rd.result || "")); + rd.readAsText(file); + } + return ( +
+

Import/export

+ +

Export

+
+ {exports.map((r) => {r}.csv)} +
+

Personen importeren

+ {resultaat &&
{resultaat.aangemaakt} persoon/personen aangemaakt.{resultaat.meldingen?.length ? ` ${resultaat.meldingen.length} melding(en).` : ""}
} +
+
+ + +
+