From 7cb8d5e7b602a36055542e256c6686b1adc97593 Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Tue, 10 Mar 2026 01:30:42 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(freebusy)=20add=20availability=20mana?= =?UTF-8?q?gement=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds organization-level default calendar sharing controls, "Find a Time" scheduling UI with a Free/Busy timeline showing attendee availability and conflicts, Working hours editor in Settings to manage and save availability, Autocomplete attendee search with debounced, partial name/email matching and timezone display. Fixes #26. Fixes #25. Fixes #24. --- README.md | 18 +- compose.yaml | 2 + docker/auth/realm.json | 66 ++- src/backend/calendars/settings.py | 13 +- src/backend/core/api/serializers.py | 13 +- src/backend/core/api/viewsets.py | 68 ++- ...0002_organization_default_sharing_level.py | 18 + src/backend/core/models.py | 26 + src/backend/core/services/caldav_service.py | 6 +- src/backend/core/tests/test_api_users.py | 207 ++++--- src/backend/core/tests/test_caldav_proxy.py | 226 ++++++++ src/backend/core/tests/test_organizations.py | 97 ++++ .../core/tests/test_permissions_security.py | 28 + src/backend/core/urls.py | 5 + src/caldav/server.php | 17 +- src/caldav/src/AvailabilityPlugin.php | 513 ++++++++++++++++++ src/caldav/src/FreeBusyOrgScopePlugin.php | 69 +++ src/caldav/src/PrincipalsRoot.php | 31 +- .../calendars/src/features/calendar/api.ts | 12 +- .../calendar-list/CalendarModal.tsx | 14 +- .../calendar-list/CalendarShareModal.tsx | 74 ++- .../hooks/useCalendarListState.ts | 4 +- .../components/calendar-list/types.ts | 2 +- .../components/scheduler/AttendeesInput.scss | 53 ++ .../components/scheduler/AttendeesInput.tsx | 199 +++++-- .../components/scheduler/EventModal.tsx | 25 + .../scheduler/FreeBusyTimeline.scss | 144 +++++ .../components/scheduler/FreeBusyTimeline.tsx | 274 ++++++++++ .../__tests__/attendees-utils.test.ts | 123 +++++ .../components/scheduler/attendees-utils.ts | 25 + .../event-modal-sections/FreeBusySection.tsx | 97 ++++ .../scheduler/hooks/useEventForm.ts | 1 + .../components/scheduler/hooks/useFreeBusy.ts | 78 +++ .../scheduler/hooks/useSchedulerInit.ts | 9 +- .../calendar/components/scheduler/types.ts | 4 +- .../calendar/services/dav/CalDavService.ts | 229 +++++++- .../src/features/i18n/translations.json | 102 +++- .../layouts/components/header/Header.tsx | 25 +- .../__tests__/availability-ics.test.ts | 157 ++++++ .../features/settings/api/useWorkingHours.ts | 93 ++++ .../src/features/settings/availability-ics.ts | 164 ++++++ .../settings/components/AvailabilityRow.tsx | 102 ++++ .../components/WorkingHoursSettings.scss | 150 +++++ .../components/WorkingHoursSettings.tsx | 161 ++++++ .../calendars/src/features/settings/types.ts | 46 ++ .../src/features/users/hooks/useUserSearch.ts | 44 ++ .../apps/calendars/src/pages/settings.tsx | 79 +++ .../apps/calendars/src/styles/globals.scss | 44 +- src/frontend/apps/calendars/types.d.ts | 3 +- 49 files changed, 3714 insertions(+), 246 deletions(-) create mode 100644 src/backend/core/migrations/0002_organization_default_sharing_level.py create mode 100644 src/caldav/src/AvailabilityPlugin.php create mode 100644 src/caldav/src/FreeBusyOrgScopePlugin.php create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/scheduler/FreeBusyTimeline.scss create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/scheduler/FreeBusyTimeline.tsx create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/scheduler/__tests__/attendees-utils.test.ts create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/scheduler/attendees-utils.ts create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/scheduler/event-modal-sections/FreeBusySection.tsx create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useFreeBusy.ts create mode 100644 src/frontend/apps/calendars/src/features/settings/__tests__/availability-ics.test.ts create mode 100644 src/frontend/apps/calendars/src/features/settings/api/useWorkingHours.ts create mode 100644 src/frontend/apps/calendars/src/features/settings/availability-ics.ts create mode 100644 src/frontend/apps/calendars/src/features/settings/components/AvailabilityRow.tsx create mode 100644 src/frontend/apps/calendars/src/features/settings/components/WorkingHoursSettings.scss create mode 100644 src/frontend/apps/calendars/src/features/settings/components/WorkingHoursSettings.tsx create mode 100644 src/frontend/apps/calendars/src/features/settings/types.ts create mode 100644 src/frontend/apps/calendars/src/features/users/hooks/useUserSearch.ts create mode 100644 src/frontend/apps/calendars/src/pages/settings.tsx diff --git a/README.md b/README.md index fbadbee..8427207 100644 --- a/README.md +++ b/README.md @@ -82,12 +82,20 @@ Your Docker services should now be up and running! 🎉 You can access the project by going to . -You will be prompted to log in. The default credentials are: +You will be prompted to log in. The following test users are +pre-configured in Keycloak (password = username prefix): -``` -username: calendars -password: calendars -``` +| Email | Password | Org domain | +|---|---|---| +| `user1@example.local` | `user1` | `example.local` | +| `user2@example.local` | `user2` | `example.local` | +| `user3@example.local` | `user3` | `example.local` | +| `user1.2@example2.local` | `user1.2` | `example2.local` | +| `user2.2@example2.local` | `user2.2` | `example2.local` | + +Users sharing the same domain are placed in the same organization +automatically on first login. Use users from different domains +(`example.local` vs `example2.local`) to test cross-org isolation. Note that if you need to run them afterward, you can use the eponym Make rule: diff --git a/compose.yaml b/compose.yaml index 3073f31..62a6a94 100644 --- a/compose.yaml +++ b/compose.yaml @@ -11,6 +11,8 @@ services: interval: 1s timeout: 2s retries: 300 + volumes: + - ./docker/postgresql/init-databases.sh:/docker-entrypoint-initdb.d/init-databases.sh:ro env_file: - env.d/development/postgresql.defaults - env.d/development/postgresql.local diff --git a/docker/auth/realm.json b/docker/auth/realm.json index 59c00ec..394d5e6 100644 --- a/docker/auth/realm.json +++ b/docker/auth/realm.json @@ -51,15 +51,71 @@ "failureFactor": 30, "users": [ { - "username": "calendars", - "email": "calendars@calendars.world", - "firstName": "John", - "lastName": "Doe", + "username": "user1", + "email": "user1@example.local", + "firstName": "User", + "lastName": "One", "enabled": true, "credentials": [ { "type": "password", - "value": "calendars" + "value": "user1" + } + ], + "realmRoles": ["user"] + }, + { + "username": "user2", + "email": "user2@example.local", + "firstName": "User", + "lastName": "Two", + "enabled": true, + "credentials": [ + { + "type": "password", + "value": "user2" + } + ], + "realmRoles": ["user"] + }, + { + "username": "user3", + "email": "user3@example.local", + "firstName": "User", + "lastName": "Three", + "enabled": true, + "credentials": [ + { + "type": "password", + "value": "user3" + } + ], + "realmRoles": ["user"] + }, + { + "username": "user1.2", + "email": "user1.2@example2.local", + "firstName": "User", + "lastName": "One-Bis", + "enabled": true, + "credentials": [ + { + "type": "password", + "value": "user1.2" + } + ], + "realmRoles": ["user"] + }, + { + "username": "user2.2", + "email": "user2.2@example2.local", + "firstName": "User", + "lastName": "Two-Bis", + "enabled": true, + "credentials": [ + { + "type": "password", + "value": "user2.2" } ], "realmRoles": ["user"] diff --git a/src/backend/calendars/settings.py b/src/backend/calendars/settings.py index 7cdf115..d7acfb3 100755 --- a/src/backend/calendars/settings.py +++ b/src/backend/calendars/settings.py @@ -83,6 +83,15 @@ class Base(Configuration): CALDAV_INTERNAL_API_KEY = SecretFileValue( None, environ_name="CALDAV_INTERNAL_API_KEY", environ_prefix=None ) + + # Default calendar sharing level for new organizations. + # Controls what colleagues in the same org can see by default. + # Values: "none", "freebusy", "read", "write" + ORG_DEFAULT_SHARING_LEVEL = values.Value( + "freebusy", + environ_name="ORG_DEFAULT_SHARING_LEVEL", + environ_prefix=None, + ) # Salt for django-fernet-encrypted-fields (Channel tokens, etc.) # Used with SECRET_KEY to derive Fernet encryption keys via PBKDF2 SALT_KEY = values.Value( @@ -931,8 +940,8 @@ class Development(Base): EMAIL_PORT = 1025 EMAIL_USE_TLS = False EMAIL_USE_SSL = False - DEFAULT_FROM_EMAIL = "calendars@calendars.world" - CALENDAR_INVITATION_FROM_EMAIL = "calendars@calendars.world" + DEFAULT_FROM_EMAIL = "noreply@example.local" + CALENDAR_INVITATION_FROM_EMAIL = "noreply@example.local" APP_NAME = "Calendars (dev)" APP_URL = "http://localhost:8931" diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 9f81108..9dd8682 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -4,6 +4,7 @@ from django.conf import settings from django.utils.text import slugify from rest_framework import serializers +from timezone_field.rest_framework import TimeZoneSerializerField from core import models from core.entitlements import EntitlementsUnavailableError, get_user_entitlements @@ -13,10 +14,16 @@ from core.models import uuid_to_urlsafe class OrganizationSerializer(serializers.ModelSerializer): """Serialize organizations.""" + sharing_level = serializers.SerializerMethodField(read_only=True) + class Meta: model = models.Organization - fields = ["id", "name"] - read_only_fields = ["id", "name"] + fields = ["id", "name", "sharing_level"] + read_only_fields = ["id", "name", "sharing_level"] + + def get_sharing_level(self, org) -> str: + """Return the effective sharing level (org override or server default).""" + return org.effective_sharing_level class UserLiteSerializer(serializers.ModelSerializer): @@ -32,6 +39,7 @@ class UserSerializer(serializers.ModelSerializer): """Serialize users.""" email = serializers.SerializerMethodField(read_only=True) + timezone = TimeZoneSerializerField(use_pytz=False) class Meta: model = models.User @@ -40,6 +48,7 @@ class UserSerializer(serializers.ModelSerializer): "email", "full_name", "language", + "timezone", ] read_only_fields = ["id", "email", "full_name"] diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index ae519f5..240aa1a 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -5,6 +5,7 @@ import logging from django.conf import settings from django.core.cache import cache +from django.db.models import Q from django.utils.text import slugify from rest_framework import mixins, pagination, response, status, views, viewsets @@ -98,9 +99,9 @@ class UserViewSet( def get_queryset(self): """ - Limit listed users by querying the email field. + Limit listed users by querying email or full_name. Scoped to the requesting user's organization. - If query contains "@", search exactly. Otherwise return empty. + Minimum 3 characters required. """ queryset = self.queryset @@ -112,15 +113,13 @@ class UserViewSet( return queryset.none() queryset = queryset.filter(organization_id=self.request.user.organization_id) - if not (query := self.request.query_params.get("q", "")) or len(query) < 5: + if not (query := self.request.query_params.get("q", "")) or len(query) < 3: return queryset.none() - # For emails, match exactly - if "@" in query: - return queryset.filter(email__iexact=query).order_by("email") - - # For non-email queries, return empty (no fuzzy search) - return queryset.none() + # Search by email (partial, case-insensitive) or full name + return queryset.filter( + Q(email__icontains=query) | Q(full_name__icontains=query) + ).order_by("full_name", "email")[:50] @action( detail=False, @@ -210,6 +209,57 @@ class ConfigView(views.APIView): return theme_customization +class OrganizationSettingsViewSet(viewsets.ViewSet): + """ViewSet for organization settings (sharing level, etc.). + + Only org admins can update settings; all org members can read them. + """ + + permission_classes = [IsAuthenticated] + + def retrieve(self, request, pk=None): # pylint: disable=unused-argument + """GET /api/v1.0/organization-settings/current/""" + org = request.user.organization + if not org: + return response.Response( + {"detail": "User has no organization."}, + status=status.HTTP_404_NOT_FOUND, + ) + return response.Response(serializers.OrganizationSerializer(org).data) + + def partial_update(self, request, pk=None): # pylint: disable=unused-argument + """PATCH /api/v1.0/organization-settings/current/""" + if not request.user.organization: + return response.Response( + {"detail": "User has no organization."}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Check admin permission + perm = permissions.IsOrgAdmin() + if not perm.has_permission(request, self): + return response.Response( + {"detail": "Only org admins can update settings."}, + status=status.HTTP_403_FORBIDDEN, + ) + + org = request.user.organization + sharing_level = request.data.get("default_sharing_level") + if sharing_level is not None: + valid = {c[0] for c in models.SharingLevel.choices} + if sharing_level not in valid: + return response.Response( + { + "detail": f"Invalid sharing level. Must be one of: {', '.join(valid)}" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + org.default_sharing_level = sharing_level + org.save(update_fields=["default_sharing_level", "updated_at"]) + + return response.Response(serializers.OrganizationSerializer(org).data) + + class CalendarViewSet(viewsets.GenericViewSet): """ViewSet for calendar operations. diff --git a/src/backend/core/migrations/0002_organization_default_sharing_level.py b/src/backend/core/migrations/0002_organization_default_sharing_level.py new file mode 100644 index 0000000..2b13cc7 --- /dev/null +++ b/src/backend/core/migrations/0002_organization_default_sharing_level.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.9 on 2026-03-09 08:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='organization', + name='default_sharing_level', + field=models.CharField(blank=True, choices=[('none', 'No sharing'), ('freebusy', 'Free/Busy only'), ('read', 'Read access'), ('write', 'Read/Write access')], help_text='Default calendar sharing level for org members. Null means use the server-wide default (ORG_DEFAULT_SHARING_LEVEL).', max_length=10, null=True), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index ce80b8b..42633fa 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -66,6 +66,15 @@ class BaseModel(models.Model): super().save(*args, **kwargs) +class SharingLevel(models.TextChoices): + """Calendar sharing visibility levels within an organization.""" + + NONE = "none", "No sharing" + FREEBUSY = "freebusy", "Free/Busy only" + READ = "read", "Read access" + WRITE = "write", "Read/Write access" + + class Organization(BaseModel): """Organization model, populated from OIDC claims and entitlements. @@ -81,6 +90,16 @@ class Organization(BaseModel): db_index=True, help_text="Organization identifier from OIDC claim or email domain.", ) + default_sharing_level = models.CharField( + max_length=10, + choices=SharingLevel.choices, + null=True, + blank=True, + help_text=( + "Default calendar sharing level for org members. " + "Null means use the server-wide default (ORG_DEFAULT_SHARING_LEVEL)." + ), + ) class Meta: db_table = "calendars_organization" @@ -90,6 +109,13 @@ class Organization(BaseModel): def __str__(self): return self.name or self.external_id + @property + def effective_sharing_level(self): + """Return the effective sharing level, falling back to server default.""" + if self.default_sharing_level: + return self.default_sharing_level + return settings.ORG_DEFAULT_SHARING_LEVEL + def delete(self, *args, **kwargs): """Delete org after cleaning up members' CalDAV data. diff --git a/src/backend/core/services/caldav_service.py b/src/backend/core/services/caldav_service.py index 6c394ca..2114b26 100644 --- a/src/backend/core/services/caldav_service.py +++ b/src/backend/core/services/caldav_service.py @@ -58,11 +58,15 @@ class CalDAVHTTPClient: """ if not user.email: raise ValueError("User has no email address") - return { + headers = { "X-Api-Key": cls.get_api_key(), "X-Forwarded-User": user.email, "X-CalDAV-Organization": str(user.organization_id), } + org = getattr(user, "organization", None) + if org and hasattr(org, "effective_sharing_level"): + headers["X-CalDAV-Sharing-Level"] = org.effective_sharing_level + return headers def build_url(self, path: str, query: str = "") -> str: """Build a full CalDAV URL from a resource path. diff --git a/src/backend/core/tests/test_api_users.py b/src/backend/core/tests/test_api_users.py index 36a71c4..308980f 100644 --- a/src/backend/core/tests/test_api_users.py +++ b/src/backend/core/tests/test_api_users.py @@ -73,61 +73,38 @@ def test_api_users_list_query_inactive(): def test_api_users_list_query_short_queries(): - """ - Queries shorter than 5 characters should return an empty result set. - """ - + """Queries shorter than 3 characters should return an empty result set.""" user = factories.UserFactory() + org = user.organization client = APIClient() client.force_login(user) - factories.UserFactory(email="john.doe@example.com") - factories.UserFactory(email="john.lennon@example.com") + factories.UserFactory(email="john.doe@example.com", organization=org) response = client.get("/api/v1.0/users/?q=jo") assert response.status_code == 200 assert response.json()["results"] == [] - response = client.get("/api/v1.0/users/?q=john") - assert response.status_code == 200 - assert response.json()["results"] == [] - - # Non-email queries (without @) return empty - response = client.get("/api/v1.0/users/?q=john.") + response = client.get("/api/v1.0/users/?q=j") assert response.status_code == 200 assert response.json()["results"] == [] -def test_api_users_list_limit(settings): - """ - Authenticated users should be able to list users and the number of results - should be limited to 10. - """ +def test_api_users_list_limit(settings): # pylint: disable=unused-argument + """Results should be bounded even with many matching users.""" user = factories.UserFactory() org = user.organization client = APIClient() client.force_login(user) - # Use a base name with a length equal 5 to test that the limit is applied - base_name = "alice" - for i in range(15): - factories.UserFactory(email=f"{base_name}.{i}@example.com", organization=org) + for i in range(55): + factories.UserFactory(email=f"alice.{i}@example.com", organization=org) - # Non-email queries (without @) return empty - response = client.get( - "/api/v1.0/users/?q=alice", - ) + # Partial match returns results (capped at 50) + response = client.get("/api/v1.0/users/?q=alice") assert response.status_code == 200 - assert response.json()["results"] == [] - - # Email queries require exact match - settings.API_USERS_LIST_LIMIT = 100 - response = client.get( - "/api/v1.0/users/?q=alice.0@example.com", - ) - assert response.status_code == 200 - assert len(response.json()["results"]) == 1 + assert len(response.json()["results"]) == 50 def test_api_users_list_throttling_authenticated(settings): @@ -154,8 +131,7 @@ def test_api_users_list_throttling_authenticated(settings): def test_api_users_list_query_email(settings): """ - Authenticated users should be able to list users and filter by email. - Only exact email matches are returned (case-insensitive). + Authenticated users should be able to search users by partial email. """ settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user_list_burst"] = "9999/minute" @@ -167,42 +143,35 @@ def test_api_users_list_query_email(settings): client.force_login(user) dave = factories.UserFactory(email="david.bowman@work.com", organization=org) - factories.UserFactory(email="nicole.bowman@work.com", organization=org) + nicole = factories.UserFactory(email="nicole.bowman@work.com", organization=org) # Exact match works - response = client.get( - "/api/v1.0/users/?q=david.bowman@work.com", - ) + response = client.get("/api/v1.0/users/?q=david.bowman@work.com") assert response.status_code == 200 - user_ids = [user["id"] for user in response.json()["results"]] - assert user_ids == [str(dave.id)] + user_ids = [u["id"] for u in response.json()["results"]] + assert str(dave.id) in user_ids + + # Partial email match works + response = client.get("/api/v1.0/users/?q=bowman@work") + assert response.status_code == 200 + user_ids = [u["id"] for u in response.json()["results"]] + assert str(dave.id) in user_ids + assert str(nicole.id) in user_ids # Case-insensitive match works - response = client.get( - "/api/v1.0/users/?q=David.Bowman@Work.COM", - ) + response = client.get("/api/v1.0/users/?q=David.Bowman@Work.COM") assert response.status_code == 200 - user_ids = [user["id"] for user in response.json()["results"]] - assert user_ids == [str(dave.id)] + user_ids = [u["id"] for u in response.json()["results"]] + assert str(dave.id) in user_ids - # Typos don't match (exact match only) - response = client.get( - "/api/v1.0/users/?q=davig.bovman@worm.com", - ) + # Typos don't match + response = client.get("/api/v1.0/users/?q=davig.bovman@worm.com") assert response.status_code == 200 - user_ids = [user["id"] for user in response.json()["results"]] - assert user_ids == [] - - response = client.get( - "/api/v1.0/users/?q=davig.bovman@worm.cop", - ) - assert response.status_code == 200 - user_ids = [user["id"] for user in response.json()["results"]] - assert user_ids == [] + assert response.json()["results"] == [] -def test_api_users_list_query_email_matching(): - """Email queries return exact matches only (case-insensitive).""" +def test_api_users_list_query_email_partial_matching(): + """Partial email queries return matching users.""" user = factories.UserFactory() org = user.organization @@ -212,29 +181,111 @@ def test_api_users_list_query_email_matching(): user1 = factories.UserFactory( email="alice.johnson@example.gouv.fr", organization=org ) - factories.UserFactory(email="alice.johnnson@example.gouv.fr", organization=org) + user2 = factories.UserFactory( + email="alice.johnnson@example.gouv.fr", organization=org + ) factories.UserFactory(email="alice.kohlson@example.gouv.fr", organization=org) user4 = factories.UserFactory( email="alicia.johnnson@example.gouv.fr", organization=org ) - factories.UserFactory(email="alicia.johnnson@example.gov.uk", organization=org) + # Different org user should not appear + other_org_user = factories.UserFactory(email="alice.johnnson@example.gov.uk") factories.UserFactory(email="alice.thomson@example.gouv.fr", organization=org) - # Exact match returns only that user - response = client.get( - "/api/v1.0/users/?q=alice.johnson@example.gouv.fr", - ) + # Partial match on "alice.john" returns alice.johnson and alice.johnnson + response = client.get("/api/v1.0/users/?q=alice.john") assert response.status_code == 200 - user_ids = [user["id"] for user in response.json()["results"]] - assert user_ids == [str(user1.id)] + user_ids = [u["id"] for u in response.json()["results"]] + assert str(user1.id) in user_ids + assert str(user2.id) in user_ids + assert str(other_org_user.id) not in user_ids - # Different email returns different user - response = client.get("/api/v1.0/users/?q=alicia.johnnson@example.gouv.fr") + # Partial match on "alicia" returns alicia.johnnson (same org only) + response = client.get("/api/v1.0/users/?q=alicia") assert response.status_code == 200 - user_ids = [user["id"] for user in response.json()["results"]] + user_ids = [u["id"] for u in response.json()["results"]] assert user_ids == [str(user4.id)] +def test_api_users_list_query_by_name(): + """Users should be searchable by full name (partial, case-insensitive).""" + user = factories.UserFactory() + org = user.organization + + client = APIClient() + client.force_login(user) + + alice = factories.UserFactory( + email="alice@example.com", full_name="Alice Johnson", organization=org + ) + bob = factories.UserFactory( + email="bob@example.com", full_name="Bob Smith", organization=org + ) + factories.UserFactory( + email="charlie@example.com", full_name="Charlie Johnson", organization=org + ) + + # Search by first name + response = client.get("/api/v1.0/users/?q=Alice") + assert response.status_code == 200 + user_ids = [u["id"] for u in response.json()["results"]] + assert str(alice.id) in user_ids + assert str(bob.id) not in user_ids + + # Search by last name matches multiple users + response = client.get("/api/v1.0/users/?q=Johnson") + assert response.status_code == 200 + assert len(response.json()["results"]) == 2 + + # Case-insensitive + response = client.get("/api/v1.0/users/?q=bob") + assert response.status_code == 200 + user_ids = [u["id"] for u in response.json()["results"]] + assert str(bob.id) in user_ids + + +def test_api_users_list_cross_org_isolation(): + """Users from different organizations should not see each other.""" + org1 = factories.OrganizationFactory(name="Org One") + org2 = factories.OrganizationFactory(name="Org Two") + + user1 = factories.UserFactory( + email="user1@org1.com", full_name="Shared Name", organization=org1 + ) + factories.UserFactory( + email="user2@org2.com", full_name="Shared Name", organization=org2 + ) + + client = APIClient() + client.force_login(user1) + + # Search by shared name - should only return same-org user + response = client.get("/api/v1.0/users/?q=Shared") + assert response.status_code == 200 + user_ids = [u["id"] for u in response.json()["results"]] + assert str(user1.id) in user_ids + assert len(user_ids) == 1 + + # Search by cross-org email - should return nothing + response = client.get("/api/v1.0/users/?q=user2@org2.com") + assert response.status_code == 200 + assert response.json()["results"] == [] + + +def test_api_users_list_includes_self(): + """Search should include the requesting user if they match.""" + user = factories.UserFactory(email="alice@example.com", full_name="Alice Test") + + client = APIClient() + client.force_login(user) + + # User should find themselves + response = client.get("/api/v1.0/users/?q=alice") + assert response.status_code == 200 + user_ids = [u["id"] for u in response.json()["results"]] + assert str(user.id) in user_ids + + def test_api_users_retrieve_me_anonymous(): """Anonymous users should not be allowed to list users.""" factories.UserFactory.create_batch(2) @@ -271,11 +322,13 @@ def test_api_users_retrieve_me_authenticated(): "email": user.email, "full_name": user.full_name, "language": user.language, + "timezone": str(user.timezone), "can_access": True, "can_admin": True, "organization": { "id": str(user.organization.id), "name": user.organization.name, + "sharing_level": "freebusy", }, } @@ -440,8 +493,8 @@ def test_api_users_update_anonymous(): def test_api_users_update_authenticated_self(): """ - Authenticated users should be able to update their own user but only "language" - and "timezone" fields. + Authenticated users should be able to update their own user but only "language", + "timezone" fields. """ user = factories.UserFactory() @@ -528,8 +581,8 @@ def test_api_users_patch_anonymous(): def test_api_users_patch_authenticated_self(): """ - Authenticated users should be able to patch their own user but only "language" - and "timezone" fields. + Authenticated users should be able to patch their own user but only "language", + "timezone" fields. """ user = factories.UserFactory() diff --git a/src/backend/core/tests/test_caldav_proxy.py b/src/backend/core/tests/test_caldav_proxy.py index dba33c5..f8ad1eb 100644 --- a/src/backend/core/tests/test_caldav_proxy.py +++ b/src/backend/core/tests/test_caldav_proxy.py @@ -335,6 +335,232 @@ class TestCalDAVProxy: assert response.status_code == HTTP_400_BAD_REQUEST +@pytest.mark.django_db +class TestCalDAVFreeBusy: + """Tests for free/busy queries via CalDAV outbox POST.""" + + FREEBUSY_REQUEST = ( + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//Test//EN\r\n" + "METHOD:REQUEST\r\n" + "BEGIN:VFREEBUSY\r\n" + "DTSTART:20260309T000000Z\r\n" + "DTEND:20260310T000000Z\r\n" + "ORGANIZER:mailto:{organizer}\r\n" + "ATTENDEE:mailto:{attendee}\r\n" + "END:VFREEBUSY\r\n" + "END:VCALENDAR" + ) + + FREEBUSY_RESPONSE = ( + '\n' + '\n' + " \n" + " mailto:{attendee}\n" + " 2.0;Success\n" + " " + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//SabreDAV//EN\r\n" + "BEGIN:VFREEBUSY\r\n" + "DTSTART:20260309T000000Z\r\n" + "DTEND:20260310T000000Z\r\n" + "FREEBUSY:20260309T100000Z/20260309T110000Z\r\n" + "END:VFREEBUSY\r\n" + "END:VCALENDAR" + "\n" + " \n" + "" + ) + + @responses.activate + def test_freebusy_post_forwarded_with_correct_content_type(self): + """POST to outbox should forward text/calendar content-type to CalDAV.""" + user = factories.UserFactory(email="alice@example.com") + client = APIClient() + client.force_login(user) + + caldav_url = settings.CALDAV_URL + outbox_path = f"calendars/users/{user.email}/outbox/" + responses.add( + responses.Response( + method="POST", + url=f"{caldav_url}/caldav/{outbox_path}", + status=HTTP_200_OK, + body=self.FREEBUSY_RESPONSE.format(attendee="bob@example.com"), + headers={"Content-Type": "application/xml"}, + ) + ) + + body = self.FREEBUSY_REQUEST.format( + organizer=user.email, attendee="bob@example.com" + ) + response = client.generic( + "POST", + f"/caldav/{outbox_path}", + data=body, + content_type="text/calendar; charset=utf-8", + ) + + assert response.status_code == HTTP_200_OK + assert len(responses.calls) == 1 + + # Verify content-type is forwarded (not overwritten to application/xml) + forwarded = responses.calls[0].request + assert "text/calendar" in forwarded.headers["Content-Type"] + + @responses.activate + def test_freebusy_post_forwards_body(self): + """POST to outbox should forward the iCalendar body unchanged.""" + user = factories.UserFactory(email="alice@example.com") + client = APIClient() + client.force_login(user) + + caldav_url = settings.CALDAV_URL + outbox_path = f"calendars/users/{user.email}/outbox/" + responses.add( + responses.Response( + method="POST", + url=f"{caldav_url}/caldav/{outbox_path}", + status=HTTP_200_OK, + body=self.FREEBUSY_RESPONSE.format(attendee="bob@example.com"), + headers={"Content-Type": "application/xml"}, + ) + ) + + body = self.FREEBUSY_REQUEST.format( + organizer=user.email, attendee="bob@example.com" + ) + client.generic( + "POST", + f"/caldav/{outbox_path}", + data=body, + content_type="text/calendar; charset=utf-8", + ) + + # Verify the body was forwarded + forwarded = responses.calls[0].request + assert b"BEGIN:VCALENDAR" in forwarded.body + assert b"VFREEBUSY" in forwarded.body + assert b"bob@example.com" in forwarded.body + + @responses.activate + def test_freebusy_post_forwards_auth_headers(self): + """POST to outbox should include X-Forwarded-User and X-Api-Key.""" + user = factories.UserFactory(email="alice@example.com") + client = APIClient() + client.force_login(user) + + caldav_url = settings.CALDAV_URL + outbox_path = f"calendars/users/{user.email}/outbox/" + responses.add( + responses.Response( + method="POST", + url=f"{caldav_url}/caldav/{outbox_path}", + status=HTTP_200_OK, + body=self.FREEBUSY_RESPONSE.format(attendee="bob@example.com"), + headers={"Content-Type": "application/xml"}, + ) + ) + + body = self.FREEBUSY_REQUEST.format( + organizer=user.email, attendee="bob@example.com" + ) + client.generic( + "POST", + f"/caldav/{outbox_path}", + data=body, + content_type="text/calendar; charset=utf-8", + ) + + forwarded = responses.calls[0].request + assert forwarded.headers["X-Forwarded-User"] == user.email + assert forwarded.headers["X-Api-Key"] == settings.CALDAV_OUTBOUND_API_KEY + + @responses.activate + def test_freebusy_post_returns_schedule_response(self): + """POST to outbox should return the CalDAV schedule-response XML.""" + user = factories.UserFactory(email="alice@example.com") + client = APIClient() + client.force_login(user) + + caldav_url = settings.CALDAV_URL + outbox_path = f"calendars/users/{user.email}/outbox/" + response_body = self.FREEBUSY_RESPONSE.format(attendee="bob@example.com") + responses.add( + responses.Response( + method="POST", + url=f"{caldav_url}/caldav/{outbox_path}", + status=HTTP_200_OK, + body=response_body, + headers={"Content-Type": "application/xml"}, + ) + ) + + body = self.FREEBUSY_REQUEST.format( + organizer=user.email, attendee="bob@example.com" + ) + response = client.generic( + "POST", + f"/caldav/{outbox_path}", + data=body, + content_type="text/calendar; charset=utf-8", + ) + + assert response.status_code == HTTP_200_OK + # Verify the schedule-response is returned to the client + root = ET.fromstring(response.content) + ns = {"cal": "urn:ietf:params:xml:ns:caldav", "d": "DAV:"} + status = root.find(".//cal:request-status", ns) + assert status is not None + assert "2.0" in status.text + + def test_freebusy_post_requires_authentication(self): + """POST to outbox should require authentication.""" + client = APIClient() + response = client.generic( + "POST", + "/caldav/calendars/users/alice@example.com/outbox/", + data="BEGIN:VCALENDAR\r\nEND:VCALENDAR", + content_type="text/calendar", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + + @responses.activate + def test_freebusy_post_includes_organization_header(self): + """POST to outbox should include X-CalDAV-Organization header.""" + user = factories.UserFactory(email="alice@example.com") + client = APIClient() + client.force_login(user) + + caldav_url = settings.CALDAV_URL + outbox_path = f"calendars/users/{user.email}/outbox/" + responses.add( + responses.Response( + method="POST", + url=f"{caldav_url}/caldav/{outbox_path}", + status=HTTP_200_OK, + body=self.FREEBUSY_RESPONSE.format(attendee="bob@example.com"), + headers={"Content-Type": "application/xml"}, + ) + ) + + body = self.FREEBUSY_REQUEST.format( + organizer=user.email, attendee="bob@example.com" + ) + client.generic( + "POST", + f"/caldav/{outbox_path}", + data=body, + content_type="text/calendar; charset=utf-8", + ) + + forwarded = responses.calls[0].request + assert forwarded.headers["X-CalDAV-Organization"] == str(user.organization_id) + + class TestValidateCaldavProxyPath: """Tests for validate_caldav_proxy_path utility.""" diff --git a/src/backend/core/tests/test_organizations.py b/src/backend/core/tests/test_organizations.py index 5979879..f1290cc 100644 --- a/src/backend/core/tests/test_organizations.py +++ b/src/backend/core/tests/test_organizations.py @@ -194,3 +194,100 @@ def test_user_list_same_org_visible(): assert len(data) == 1 assert data[0]["email"] == "carol@example.com" get_entitlements_backend.cache_clear() + + +# -- Sharing level -- + + +@pytest.mark.django_db +def test_effective_sharing_level_defaults_to_server_setting(): + """Organization without override returns server-wide default.""" + org = factories.OrganizationFactory(default_sharing_level=None) + assert org.effective_sharing_level == "freebusy" + + +@pytest.mark.django_db +@override_settings(ORG_DEFAULT_SHARING_LEVEL="none") +def test_effective_sharing_level_follows_server_override(): + """Organization without override returns overridden server default.""" + org = factories.OrganizationFactory(default_sharing_level=None) + assert org.effective_sharing_level == "none" + + +@pytest.mark.django_db +def test_effective_sharing_level_org_override(): + """Organization with explicit level ignores server default.""" + org = factories.OrganizationFactory(default_sharing_level="read") + assert org.effective_sharing_level == "read" + + +# -- Organization settings API -- + + +@pytest.mark.django_db +@override_settings( + ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend", + ENTITLEMENTS_BACKEND_PARAMETERS={}, +) +def test_org_settings_retrieve(): + """GET /organization-settings/current/ returns org sharing level.""" + get_entitlements_backend.cache_clear() + org = factories.OrganizationFactory(external_id="test-org") + user = factories.UserFactory(organization=org) + + client = APIClient() + client.force_authenticate(user=user) + response = client.get("/api/v1.0/organization-settings/current/") + + assert response.status_code == HTTP_200_OK + assert response.json()["sharing_level"] == "freebusy" + get_entitlements_backend.cache_clear() + + +@pytest.mark.django_db +@override_settings( + ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend", + ENTITLEMENTS_BACKEND_PARAMETERS={}, +) +def test_org_settings_update_sharing_level(): + """PATCH /organization-settings/current/ updates sharing level.""" + get_entitlements_backend.cache_clear() + org = factories.OrganizationFactory(external_id="test-org") + user = factories.UserFactory(organization=org) + + client = APIClient() + client.force_authenticate(user=user) + response = client.patch( + "/api/v1.0/organization-settings/current/", + {"default_sharing_level": "none"}, + format="json", + ) + + assert response.status_code == HTTP_200_OK + org.refresh_from_db() + assert org.default_sharing_level == "none" + assert response.json()["sharing_level"] == "none" + get_entitlements_backend.cache_clear() + + +@pytest.mark.django_db +@override_settings( + ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend", + ENTITLEMENTS_BACKEND_PARAMETERS={}, +) +def test_org_settings_update_rejects_invalid_level(): + """PATCH with invalid sharing level returns 400.""" + get_entitlements_backend.cache_clear() + org = factories.OrganizationFactory(external_id="test-org") + user = factories.UserFactory(organization=org) + + client = APIClient() + client.force_authenticate(user=user) + response = client.patch( + "/api/v1.0/organization-settings/current/", + {"default_sharing_level": "superadmin"}, + format="json", + ) + + assert response.status_code == 400 + get_entitlements_backend.cache_clear() diff --git a/src/backend/core/tests/test_permissions_security.py b/src/backend/core/tests/test_permissions_security.py index 1abbf42..d540e94 100644 --- a/src/backend/core/tests/test_permissions_security.py +++ b/src/backend/core/tests/test_permissions_security.py @@ -272,6 +272,34 @@ class TestCalDAVProxyOrgHeader: assert request.headers["X-CalDAV-Organization"] == str(org.id) assert request.headers["X-CalDAV-Organization"] != "spoofed-org-id" + @responses.activate + def test_proxy_sends_sharing_level_header(self): + """CalDAV proxy sends X-CalDAV-Sharing-Level from org's effective level.""" + org = factories.OrganizationFactory( + external_id="org-fb", default_sharing_level="none" + ) + user = factories.UserFactory(email="alice@example.com", organization=org) + + client = APIClient() + client.force_login(user) + + caldav_url = settings.CALDAV_URL + responses.add( + responses.Response( + method="PROPFIND", + url=f"{caldav_url}/caldav/principals/resources/", + status=HTTP_207_MULTI_STATUS, + body='', + headers={"Content-Type": "application/xml"}, + ) + ) + + client.generic("PROPFIND", "/caldav/principals/resources/") + + assert len(responses.calls) == 1 + request = responses.calls[0].request + assert request.headers["X-CalDAV-Sharing-Level"] == "none" + # --------------------------------------------------------------------------- # IsEntitledToAccess permission — fail-closed diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index e59dbdd..816eb73 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -20,6 +20,11 @@ router.register("users", viewsets.UserViewSet, basename="users") router.register("calendars", viewsets.CalendarViewSet, basename="calendars") router.register("resources", viewsets.ResourceViewSet, basename="resources") router.register("channels", ChannelViewSet, basename="channels") +router.register( + "organization-settings", + viewsets.OrganizationSettingsViewSet, + basename="organization-settings", +) urlpatterns = [ path( diff --git a/src/caldav/server.php b/src/caldav/server.php index 87657ba..95118b6 100644 --- a/src/caldav/server.php +++ b/src/caldav/server.php @@ -17,6 +17,8 @@ use Calendars\SabreDav\AttendeeNormalizerPlugin; use Calendars\SabreDav\InternalApiPlugin; use Calendars\SabreDav\ResourceAutoSchedulePlugin; use Calendars\SabreDav\ResourceMkCalendarBlockPlugin; +use Calendars\SabreDav\FreeBusyOrgScopePlugin; +use Calendars\SabreDav\AvailabilityPlugin; use Calendars\SabreDav\CalendarsRoot; use Calendars\SabreDav\CustomCalDAVPlugin; use Calendars\SabreDav\PrincipalsRoot; @@ -88,7 +90,12 @@ $principalBackend->setServer($server); $server->addPlugin($authPlugin); $server->addPlugin(new CustomCalDAVPlugin()); $server->addPlugin(new CardDAV\Plugin()); -$server->addPlugin(new DAVACL\Plugin()); +// PrincipalsRoot is a plain DAV\Collection (not IPrincipalCollection), so the +// default principalCollectionSet ['principals'] would skip it during principal +// search. Point directly to the child IPrincipalCollection nodes instead. +$aclPlugin = new DAVACL\Plugin(); +$aclPlugin->principalCollectionSet = ['principals/users', 'principals/resources']; +$server->addPlugin($aclPlugin); $server->addPlugin(new DAV\Browser\Plugin()); // Add ICS export plugin for iCal subscription URLs @@ -169,6 +176,10 @@ if ($defaultCallbackUrl) { $imipPlugin = new HttpCallbackIMipPlugin($callbackApiKey, $defaultCallbackUrl); $server->addPlugin($imipPlugin); +// Enforce org-level freebusy sharing settings +// Blocks VFREEBUSY queries when X-CalDAV-Sharing-Level is "none" +$server->addPlugin(new FreeBusyOrgScopePlugin()); + // Add CalDAV scheduling support // See https://sabre.io/dav/scheduling/ // The Schedule\Plugin will automatically find and use the IMipPlugin we just added @@ -183,6 +194,10 @@ $server->addPlugin(new ResourceAutoSchedulePlugin($pdo, $caldavBackend)); // Block MKCALENDAR on resource principals (each resource has exactly one calendar) $server->addPlugin(new ResourceMkCalendarBlockPlugin()); +// Add availability integration for freebusy responses +// Reads calendar-availability property and adds BUSY-UNAVAILABLE periods +$server->addPlugin(new AvailabilityPlugin()); + // Add property storage plugin for custom properties (resource metadata, etc.) $server->addPlugin(new DAV\PropertyStorage\Plugin( new DAV\PropertyStorage\Backend\PDO($pdo) diff --git a/src/caldav/src/AvailabilityPlugin.php b/src/caldav/src/AvailabilityPlugin.php new file mode 100644 index 0000000..fc8d52c --- /dev/null +++ b/src/caldav/src/AvailabilityPlugin.php @@ -0,0 +1,513 @@ +server = $server; + // Priority 200: runs after Schedule\Plugin (110) has built the response + $server->on('afterMethod:POST', [$this, 'afterPost'], 200); + } + + /** + * Post-process scheduling outbox responses to inject BUSY-UNAVAILABLE periods. + */ + public function afterPost(RequestInterface $request, ResponseInterface $response) + { + // Only process successful responses + if ($response->getStatus() !== 200) { + return; + } + + // Only process outbox requests + $path = $request->getPath(); + if (strpos($path, 'outbox') === false) { + return; + } + + // Only process XML responses + $contentType = $response->getHeader('Content-Type'); + if (!$contentType || strpos($contentType, 'application/xml') === false) { + return; + } + + $body = $response->getBodyAsString(); + if (!$body) { + return; + } + + try { + $modified = $this->processScheduleResponse($body); + if ($modified !== null) { + $response->setBody($modified); + } + } catch (\Exception $e) { + error_log("[AvailabilityPlugin] Error processing response: " . $e->getMessage()); + } + } + + /** + * Parse the schedule-response XML and inject BUSY-UNAVAILABLE periods + * for each recipient that has a calendar-availability property. + * + * @param string $xml The original XML response body + * @return string|null Modified XML or null if no changes + */ + private function processScheduleResponse($xml) + { + $dom = new \DOMDocument(); + $dom->preserveWhiteSpace = true; + $dom->formatOutput = false; + + if (!@$dom->loadXML($xml)) { + error_log("[AvailabilityPlugin] Failed to parse XML response"); + return null; + } + + $xpath = new \DOMXPath($dom); + $xpath->registerNamespace('D', 'DAV:'); + $xpath->registerNamespace('C', 'urn:ietf:params:xml:ns:caldav'); + + // Find all schedule-response/response elements + $responses = $xpath->query('//C:schedule-response/C:response'); + if (!$responses || $responses->length === 0) { + return null; + } + + $modified = false; + + foreach ($responses as $responseNode) { + // Extract recipient email + $recipientNodes = $xpath->query('.//C:recipient/D:href', $responseNode); + if (!$recipientNodes || $recipientNodes->length === 0) { + continue; + } + $recipientHref = $recipientNodes->item(0)->textContent; + $email = $this->extractEmail($recipientHref); + if (!$email) { + continue; + } + + // Find calendar-data element + $calDataNodes = $xpath->query('.//C:calendar-data', $responseNode); + if (!$calDataNodes || $calDataNodes->length === 0) { + continue; + } + $calDataNode = $calDataNodes->item(0); + $icsData = $calDataNode->textContent; + + if (strpos($icsData, 'VFREEBUSY') === false) { + continue; + } + + // Get the user's availability property + $availability = $this->getCalendarAvailability($email); + if (!$availability) { + continue; + } + + // Parse available windows from the VAVAILABILITY + $availableWindows = $this->parseAvailableWindows($availability); + if (empty($availableWindows)) { + continue; + } + + // Extract the freebusy query range from the VFREEBUSY component + $queryRange = $this->extractFreebusyRange($icsData); + if (!$queryRange) { + continue; + } + + // Compute BUSY-UNAVAILABLE periods + $busyPeriods = $this->computeBusyUnavailable( + $queryRange['start'], + $queryRange['end'], + $availableWindows + ); + + if (empty($busyPeriods)) { + continue; + } + + // Inject BUSY-UNAVAILABLE lines into the ICS data + $modifiedIcs = $this->injectBusyUnavailable($icsData, $busyPeriods); + if ($modifiedIcs !== null) { + $calDataNode->textContent = ''; + $calDataNode->appendChild($dom->createTextNode($modifiedIcs)); + $modified = true; + } + } + + if ($modified) { + return $dom->saveXML(); + } + + return null; + } + + /** + * Extract email from a mailto: URI. + * + * @param string $uri + * @return string|null + */ + private function extractEmail($uri) + { + if (stripos($uri, 'mailto:') === 0) { + return strtolower(substr($uri, 7)); + } + return null; + } + + /** + * Get the calendar-availability property for a user. + * + * Resolves the calendar home path from the principal URI via the + * CalDAV plugin rather than hardcoding the path structure. + * + * @param string $email + * @return string|null The VCALENDAR string or null + */ + private function getCalendarAvailability($email) + { + $caldavPlugin = $this->server->getPlugin('caldav'); + if (!$caldavPlugin) { + return null; + } + + $principalUri = 'principals/users/' . $email; + $calendarHomePath = $caldavPlugin->getCalendarHomeForPrincipal($principalUri); + if (!$calendarHomePath) { + return null; + } + + try { + $properties = $this->server->getProperties( + $calendarHomePath, + [self::AVAILABILITY_PROP] + ); + + if (isset($properties[self::AVAILABILITY_PROP])) { + return $properties[self::AVAILABILITY_PROP]; + } + } catch (\Exception $e) { + error_log("[AvailabilityPlugin] Failed to get availability for user: " + . $e->getMessage()); + } + + return null; + } + + /** + * Parse VAVAILABILITY/AVAILABLE components to extract available windows. + * + * Returns an array of available window definitions, each with: + * - 'startTime': time string "HH:MM:SS" + * - 'endTime': time string "HH:MM:SS" + * - 'days': array of day-of-week integers (1=Monday .. 7=Sunday, ISO-8601) + * - 'specificDate': string "Y-m-d" if this is a specific-date window (no RRULE) + * + * @param string $vcalendarStr + * @return array + */ + private function parseAvailableWindows($vcalendarStr) + { + $windows = []; + + try { + $vcalendar = Reader::read($vcalendarStr); + } catch (\Exception $e) { + error_log("[AvailabilityPlugin] Failed to parse VAVAILABILITY: " . $e->getMessage()); + return $windows; + } + + if (!isset($vcalendar->VAVAILABILITY)) { + return $windows; + } + + foreach ($vcalendar->VAVAILABILITY as $vavailability) { + if (!isset($vavailability->AVAILABLE)) { + continue; + } + + foreach ($vavailability->AVAILABLE as $available) { + if (!isset($available->DTSTART) || !isset($available->DTEND)) { + continue; + } + + $dtstart = $available->DTSTART->getDateTime(); + $dtend = $available->DTEND->getDateTime(); + + $startTime = $dtstart->format('H:i:s'); + $endTime = $dtend->format('H:i:s'); + + // Parse RRULE to get BYDAY + $days = []; + $specificDate = null; + if (isset($available->RRULE)) { + $rrule = (string)$available->RRULE; + if (preg_match('/BYDAY=([A-Z,]+)/', $rrule, $matches)) { + $dayMap = [ + 'MO' => 1, + 'TU' => 2, + 'WE' => 3, + 'TH' => 4, + 'FR' => 5, + 'SA' => 6, + 'SU' => 7, + ]; + foreach (explode(',', $matches[1]) as $day) { + if (isset($dayMap[$day])) { + $days[] = $dayMap[$day]; + } + } + } + } else { + // No RRULE: specific-date availability, scoped to DTSTART date + $specificDate = $dtstart->format('Y-m-d'); + $days = [(int)$dtstart->format('N')]; + } + + $windows[] = [ + 'startTime' => $startTime, + 'endTime' => $endTime, + 'days' => $days, + 'specificDate' => $specificDate, + ]; + } + } + + return $windows; + } + + /** + * Extract the DTSTART and DTEND range from a VFREEBUSY component. + * + * @param string $icsData + * @return array|null ['start' => DateTimeImmutable, 'end' => DateTimeImmutable] + */ + private function extractFreebusyRange($icsData) + { + try { + $vcalendar = Reader::read($icsData); + } catch (\Exception $e) { + error_log("[AvailabilityPlugin] Failed to parse VFREEBUSY ICS: " . $e->getMessage()); + return null; + } + + if (!isset($vcalendar->VFREEBUSY)) { + return null; + } + + $vfreebusy = $vcalendar->VFREEBUSY; + + if (!isset($vfreebusy->DTSTART) || !isset($vfreebusy->DTEND)) { + return null; + } + + return [ + 'start' => $vfreebusy->DTSTART->getDateTime(), + 'end' => $vfreebusy->DTEND->getDateTime(), + ]; + } + + /** + * Compute BUSY-UNAVAILABLE periods for times outside available windows. + * + * TODO: The available times in DTSTART/DTEND of AVAILABLE are treated as + * UTC for now. Proper timezone handling would require resolving the TZID + * from the VAVAILABILITY component and converting accordingly. + * + * @param \DateTimeInterface $rangeStart + * @param \DateTimeInterface $rangeEnd + * @param array $windows Available windows from parseAvailableWindows() + * @return array Array of ['start' => DateTimeImmutable, 'end' => DateTimeImmutable] + */ + private function computeBusyUnavailable( + \DateTimeInterface $rangeStart, + \DateTimeInterface $rangeEnd, + array $windows + ) { + $utc = new \DateTimeZone('UTC'); + $busyPeriods = []; + + // Iterate day by day through the range + $currentDay = new \DateTimeImmutable( + $rangeStart->format('Y-m-d'), + $utc + ); + $endDay = new \DateTimeImmutable( + $rangeEnd->format('Y-m-d'), + $utc + ); + + while ($currentDay <= $endDay) { + $dayOfWeek = (int)$currentDay->format('N'); // 1=Monday .. 7=Sunday + $dayStart = $currentDay; + $dayEnd = $currentDay->modify('+1 day'); + + // Clamp to the query range + $effectiveDayStart = $dayStart < $rangeStart + ? new \DateTimeImmutable($rangeStart->format('Y-m-d\TH:i:s'), $utc) + : $dayStart; + $effectiveDayEnd = $dayEnd > $rangeEnd + ? new \DateTimeImmutable($rangeEnd->format('Y-m-d\TH:i:s'), $utc) + : $dayEnd; + + if ($effectiveDayStart >= $effectiveDayEnd) { + $currentDay = $currentDay->modify('+1 day'); + continue; + } + + // Collect available slots for this day of the week + $availableSlots = []; + $dateStr = $currentDay->format('Y-m-d'); + foreach ($windows as $window) { + // Skip specific-date windows that don't match this day + if ($window['specificDate'] !== null && $window['specificDate'] !== $dateStr) { + continue; + } + if (in_array($dayOfWeek, $window['days'], true)) { + $slotStart = new \DateTimeImmutable( + $currentDay->format('Y-m-d') . 'T' . $window['startTime'], + $utc + ); + $slotEnd = new \DateTimeImmutable( + $currentDay->format('Y-m-d') . 'T' . $window['endTime'], + $utc + ); + + // Clamp to effective day range + if ($slotStart < $effectiveDayStart) { + $slotStart = $effectiveDayStart; + } + if ($slotEnd > $effectiveDayEnd) { + $slotEnd = $effectiveDayEnd; + } + + if ($slotStart < $slotEnd) { + $availableSlots[] = [ + 'start' => $slotStart, + 'end' => $slotEnd, + ]; + } + } + } + + // Sort available slots by start time + usort($availableSlots, function ($a, $b) { + return $a['start'] <=> $b['start']; + }); + + // Merge overlapping slots + $mergedSlots = []; + foreach ($availableSlots as $slot) { + if (empty($mergedSlots)) { + $mergedSlots[] = $slot; + } else { + $last = &$mergedSlots[count($mergedSlots) - 1]; + if ($slot['start'] <= $last['end']) { + if ($slot['end'] > $last['end']) { + $last['end'] = $slot['end']; + } + } else { + $mergedSlots[] = $slot; + } + unset($last); + } + } + + // Compute gaps (BUSY-UNAVAILABLE periods) + $cursor = $effectiveDayStart; + foreach ($mergedSlots as $slot) { + if ($cursor < $slot['start']) { + $busyPeriods[] = [ + 'start' => $cursor, + 'end' => $slot['start'], + ]; + } + $cursor = $slot['end']; + } + if ($cursor < $effectiveDayEnd) { + $busyPeriods[] = [ + 'start' => $cursor, + 'end' => $effectiveDayEnd, + ]; + } + + $currentDay = $currentDay->modify('+1 day'); + } + + return $busyPeriods; + } + + /** + * Inject FREEBUSY;FBTYPE=BUSY-UNAVAILABLE lines into a VFREEBUSY ICS string. + * + * @param string $icsData + * @param array $busyPeriods + * @return string|null Modified ICS data or null if injection failed + */ + private function injectBusyUnavailable($icsData, array $busyPeriods) + { + // Build FREEBUSY lines + $lines = ''; + foreach ($busyPeriods as $period) { + $start = $period['start']->format('Ymd\THis\Z'); + $end = $period['end']->format('Ymd\THis\Z'); + $lines .= "FREEBUSY;FBTYPE=BUSY-UNAVAILABLE:{$start}/{$end}\r\n"; + } + + // Insert before END:VFREEBUSY + $pos = strpos($icsData, "END:VFREEBUSY"); + if ($pos === false) { + return null; + } + + return substr($icsData, 0, $pos) . $lines . substr($icsData, $pos); + } + + public function getPluginName() + { + return 'availability'; + } + + public function getPluginInfo() + { + return [ + 'name' => $this->getPluginName(), + 'description' => 'Integrates VAVAILABILITY (RFC 7953) into freebusy responses', + ]; + } +} diff --git a/src/caldav/src/FreeBusyOrgScopePlugin.php b/src/caldav/src/FreeBusyOrgScopePlugin.php new file mode 100644 index 0000000..ffd9d9a --- /dev/null +++ b/src/caldav/src/FreeBusyOrgScopePlugin.php @@ -0,0 +1,69 @@ +server = $server; + // Priority 99: before Schedule\Plugin (110) processes freebusy + $server->on('beforeMethod:POST', [$this, 'beforePost'], 99); + } + + /** + * Intercept POST to scheduling outbox and block VFREEBUSY if sharing is "none". + */ + public function beforePost(RequestInterface $request) + { + $path = $request->getPath(); + + // Only intercept outbox requests (where freebusy queries are sent) + if (strpos($path, '/outbox') === false) { + return; + } + + $sharingLevel = $request->getHeader('X-CalDAV-Sharing-Level'); + + // Only block when sharing is explicitly disabled + if ($sharingLevel !== 'none') { + return; + } + + // Read body to check if this is a VFREEBUSY request + $body = $request->getBodyAsString(); + $request->setBody($body); // Reset stream for subsequent reads + + if (stripos($body, 'VFREEBUSY') !== false) { + throw new DAV\Exception\Forbidden( + 'Free/busy queries are not allowed when organization sharing is disabled' + ); + } + } + + public function getPluginName() + { + return 'freebusy-org-scope'; + } + + public function getPluginInfo() + { + return [ + 'name' => $this->getPluginName(), + 'description' => 'Enforces organization-level freebusy sharing settings', + ]; + } +} diff --git a/src/caldav/src/PrincipalsRoot.php b/src/caldav/src/PrincipalsRoot.php index ea36fe3..e300bc2 100644 --- a/src/caldav/src/PrincipalsRoot.php +++ b/src/caldav/src/PrincipalsRoot.php @@ -77,29 +77,46 @@ class NamedPrincipalCollection extends CalDAV\Principal\Collection { return $this->nodeName; } + + /** + * Return SchedulablePrincipal nodes that allow authenticated users to + * read principal properties (required for CalDAV scheduling / freebusy). + */ + public function getChildForPrincipal(array $principal) + { + return new SchedulablePrincipal($this->principalBackend, $principal); + } } /** - * Principal collection for resources that returns ResourcePrincipal nodes. + * Principal collection for resources. * * Resource principals have no DAV owner, so the default ACL (which only * grants {DAV:}all to {DAV:}owner) blocks all property reads with 403. - * This collection returns ResourcePrincipal nodes that additionally grant - * {DAV:}read to {DAV:}authenticated, allowing any logged-in user to - * discover resource names, types, and emails via PROPFIND. + * This collection returns SchedulablePrincipal nodes that additionally grant + * {DAV:}read to {DAV:}authenticated. */ class ResourcePrincipalCollection extends NamedPrincipalCollection { public function getChildForPrincipal(array $principal) { - return new ResourcePrincipal($this->principalBackend, $principal); + return new SchedulablePrincipal($this->principalBackend, $principal); } } /** - * A principal node with a permissive read ACL for resource discovery. + * A principal node with read ACL for authenticated users. + * + * Required for CalDAV scheduling: the Schedule\Plugin looks up other users' + * calendar-home-set and schedule-inbox-URL via principalSearch(), which + * triggers a propFind that is subject to ACL. Without read access, the + * properties return 403 and freebusy queries fail with "Could not find + * calendar-home-set". + * + * Also used for resource discovery (any logged-in user can discover resource + * names, types, and emails via PROPFIND). */ -class ResourcePrincipal extends CalDAV\Principal\User +class SchedulablePrincipal extends CalDAV\Principal\User { public function getACL() { diff --git a/src/frontend/apps/calendars/src/features/calendar/api.ts b/src/frontend/apps/calendars/src/features/calendar/api.ts index 521d43c..19bab09 100644 --- a/src/frontend/apps/calendars/src/features/calendar/api.ts +++ b/src/frontend/apps/calendars/src/features/calendar/api.ts @@ -140,11 +140,7 @@ interface ImportTaskResponse { */ export interface TaskStatus { status: "PENDING" | "PROGRESS" | "SUCCESS" | "FAILURE"; - result: { - status: string; - result: ImportEventsResult | null; - error: string | null; - } | null; + result: ImportEventsResult | null; error: string | null; progress?: number; message?: string; @@ -202,14 +198,14 @@ export const pollImportTask = async ( return; } - if (status.status === "SUCCESS" && status.result?.result) { - resolve(status.result.result); + if (status.status === "SUCCESS" && status.result) { + resolve(status.result); return; } reject( new Error( - status.result?.error ?? status.error ?? "Import failed", + status.error ?? "Import failed", ), ); } catch (error) { diff --git a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarModal.tsx b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarModal.tsx index 93c839f..c2f90cc 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarModal.tsx +++ b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarModal.tsx @@ -10,7 +10,6 @@ import { Input, Modal, ModalSize, - TextArea, } from "@gouvfr-lasuite/cunningham-react"; import { DEFAULT_COLORS } from "./constants"; @@ -26,7 +25,6 @@ export const CalendarModal = ({ const { t } = useTranslation(); const [name, setName] = useState(""); const [color, setColor] = useState(DEFAULT_COLORS[0]); - const [description, setDescription] = useState(""); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -36,11 +34,9 @@ export const CalendarModal = ({ if (mode === "edit" && calendar) { setName(calendar.displayName || ""); setColor(calendar.color || DEFAULT_COLORS[0]); - setDescription(calendar.description || ""); } else { setName(""); setColor(DEFAULT_COLORS[0]); - setDescription(""); } setError(null); } @@ -55,7 +51,7 @@ export const CalendarModal = ({ setIsLoading(true); setError(null); try { - await onSave(name.trim(), color, description.trim() || undefined); + await onSave(name.trim(), color); onClose(); } catch (err) { setError(err instanceof Error ? err.message : t('api.error.unexpected')); @@ -67,7 +63,6 @@ export const CalendarModal = ({ const handleClose = () => { setName(""); setColor(DEFAULT_COLORS[0]); - setDescription(""); setError(null); onClose(); }; @@ -133,13 +128,6 @@ export const CalendarModal = ({ -