(freebusy) add availability management (#35)

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.
This commit is contained in:
Sylvain Zimmer
2026-03-10 01:30:42 +01:00
committed by GitHub
parent 9c18f96090
commit 7cb8d5e7b6
49 changed files with 3714 additions and 246 deletions

View File

@@ -82,12 +82,20 @@ Your Docker services should now be up and running! 🎉
You can access the project by going to <http://localhost:8930>.
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:

View File

@@ -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

View File

@@ -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"]

View File

@@ -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"

View File

@@ -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"]

View File

@@ -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.

View File

@@ -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),
),
]

View File

@@ -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.

View File

@@ -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.

View File

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

View File

@@ -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 = (
'<?xml version="1.0" encoding="utf-8"?>\n'
'<cal:schedule-response xmlns:d="DAV:" '
'xmlns:cal="urn:ietf:params:xml:ns:caldav">\n'
" <cal:response>\n"
" <cal:recipient><d:href>mailto:{attendee}</d:href></cal:recipient>\n"
" <cal:request-status>2.0;Success</cal:request-status>\n"
" <cal:calendar-data>"
"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"
"</cal:calendar-data>\n"
" </cal:response>\n"
"</cal:schedule-response>"
)
@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."""

View File

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

View File

@@ -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='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
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

View File

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

View File

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

View File

@@ -0,0 +1,513 @@
<?php
/**
* AvailabilityPlugin - Integrates VAVAILABILITY (RFC 7953) into freebusy responses.
*
* When a freebusy query is made via the scheduling outbox, this plugin
* post-processes the response to add BUSY-UNAVAILABLE periods based on
* each recipient's calendar-availability property.
*
* The calendar-availability property is stored on the user's calendar home
* via the PropertyStorage plugin and contains a VCALENDAR with VAVAILABILITY
* and AVAILABLE components that define working hours.
*
* Runs after Schedule\Plugin (priority 200 on afterMethod:POST).
*/
namespace Calendars\SabreDav;
use Sabre\DAV;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
use Sabre\VObject\Reader;
class AvailabilityPlugin extends ServerPlugin
{
/** @var Server */
protected $server;
/** CalDAV namespace */
private const CALDAV_NS = '{urn:ietf:params:xml:ns:caldav}';
/** calendar-availability property name */
private const AVAILABILITY_PROP = '{urn:ietf:params:xml:ns:caldav}calendar-availability';
public function initialize(Server $server)
{
$this->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',
];
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Calendars\SabreDav;
use Sabre\DAV;
use Sabre\HTTP\RequestInterface;
/**
* Blocks VFREEBUSY queries when the organization sharing level is "none".
*
* The X-CalDAV-Sharing-Level header is set by the Django proxy based on
* the organization's effective sharing level setting.
*
* Regular scheduling requests (invitations) are not affected.
*/
class FreeBusyOrgScopePlugin extends DAV\ServerPlugin
{
protected $server;
public function initialize(DAV\Server $server)
{
$this->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',
];
}
}

View File

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

View File

@@ -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) {

View File

@@ -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<string | null>(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 = ({
</div>
</div>
<TextArea
label={t('calendar.createCalendar.description')}
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
fullWidth
/>
</div>
</Modal>
);

View File

@@ -3,7 +3,7 @@
* Wraps the UI Kit ShareModal for managing calendar sharing via CalDAV.
*/
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { ShareModal } from "@gouvfr-lasuite/ui-kit";
@@ -14,6 +14,7 @@ import {
ToasterItem,
} from "../../../ui/components/toaster/Toaster";
import type { CalDavCalendar } from "../../services/dav/types/caldav-service";
import { fetchAPI } from "@/features/api/fetchApi";
interface CalendarShareModalProps {
isOpen: boolean;
@@ -100,16 +101,67 @@ export const CalendarShareModal = ({
}
}, [isOpen, calendar, fetchSharees]);
const handleSearchUsers = useCallback((query: string) => {
if (EMAIL_REGEX.test(query.trim())) {
const email = query.trim();
setSearchResults([
{ id: email, email, full_name: email },
]);
} else {
setSearchResults([]);
}
}, []);
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleSearchUsers = useCallback(
(query: string) => {
clearTimeout(searchTimerRef.current);
const trimmed = query.trim();
if (trimmed.length < 3) {
// For very short queries, fall back to email-only matching
if (EMAIL_REGEX.test(trimmed)) {
setSearchResults([
{ id: trimmed, email: trimmed, full_name: trimmed },
]);
} else {
setSearchResults([]);
}
return;
}
// Debounce the API call
searchTimerRef.current = setTimeout(async () => {
try {
const response = await fetchAPI("users/", {
params: { q: trimmed },
});
const data = await response.json();
const results: ShareUser[] = (data.results ?? []).map(
(u: { id: string; email: string; full_name: string }) => ({
id: u.id,
email: u.email,
full_name: u.full_name || u.email,
}),
);
// Always allow raw email entry too
if (
EMAIL_REGEX.test(trimmed) &&
!results.some(
(r) => r.email.toLowerCase() === trimmed.toLowerCase(),
)
) {
results.push({
id: trimmed,
email: trimmed,
full_name: trimmed,
});
}
setSearchResults(results);
} catch {
// Fallback to email-only on API error
if (EMAIL_REGEX.test(trimmed)) {
setSearchResults([
{ id: trimmed, email: trimmed, full_name: trimmed },
]);
} else {
setSearchResults([]);
}
}
}, 300);
},
[],
);
const handleInviteUser = useCallback(
async (users: ShareUser[]) => {

View File

@@ -76,12 +76,11 @@ export const useCalendarListState = ({
}, []);
const handleSaveCalendar = useCallback(
async (name: string, color: string, description?: string) => {
async (name: string, color: string) => {
if (modalState.mode === "create") {
const result = await createCalendar({
displayName: name,
color,
description,
components: ['VEVENT'],
});
if (!result.success) {
@@ -91,7 +90,6 @@ export const useCalendarListState = ({
const result = await updateCalendar(modalState.calendar.url, {
displayName: name,
color,
description,
});
if (!result.success) {
throw new Error(result.error);

View File

@@ -12,7 +12,7 @@ export interface CalendarModalProps {
mode: "create" | "edit";
calendar?: CalDavCalendar | null;
onClose: () => void;
onSave: (name: string, color: string, description?: string) => Promise<void>;
onSave: (name: string, color: string) => Promise<void>;
}
/**

View File

@@ -8,6 +8,7 @@
align-items: flex-start;
gap: 0.5rem;
width: 100%;
position: relative;
}
&__add-btn {
@@ -37,6 +38,58 @@
}
}
&__suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 10;
margin: 0;
padding: 0.25rem 0;
list-style: none;
background: var(--c--theme--colors--greyscale-000, #fff);
border: 1px solid var(--c--theme--colors--greyscale-200);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
max-height: 200px;
overflow-y: auto;
}
&__suggestion {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
transition: background-color 0.1s;
&:hover,
&--highlighted {
background-color: var(--c--theme--colors--greyscale-100);
}
&--empty {
color: var(--c--theme--colors--greyscale-500);
font-style: italic;
cursor: default;
&:hover {
background-color: transparent;
}
}
}
&__suggestion-name {
font-size: 0.875rem;
font-weight: 500;
color: var(--c--theme--colors--greyscale-900);
}
&__suggestion-email {
font-size: 0.75rem;
color: var(--c--theme--colors--greyscale-500);
}
&__pills {
display: flex;
flex-wrap: wrap;

View File

@@ -1,8 +1,19 @@
import { useState, useCallback, type KeyboardEvent } from "react";
import {
useState,
useCallback,
useRef,
useEffect,
type KeyboardEvent,
} from "react";
import { Input } from "@gouvfr-lasuite/cunningham-react";
import { Badge } from "@gouvfr-lasuite/ui-kit";
import { useTranslation } from "react-i18next";
import type { IcsAttendee, IcsOrganizer } from "ts-ics";
import {
useUserSearch,
type UserSearchResult,
} from "@/features/users/hooks/useUserSearch";
import { filterSuggestions, isValidEmail } from "./attendees-utils";
interface AttendeesInputProps {
attendees: IcsAttendee[];
@@ -11,11 +22,6 @@ interface AttendeesInputProps {
organizer?: IcsOrganizer;
}
const isValidEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
type BadgeType =
| "accent"
| "neutral"
@@ -59,40 +65,86 @@ export function AttendeesInput({
const { t } = useTranslation();
const [inputValue, setInputValue] = useState("");
const [error, setError] = useState<string | null>(null);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const [showSuggestions, setShowSuggestions] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const addAttendee = useCallback(() => {
const email = inputValue.trim().toLowerCase();
const { data: searchResults, isLoading: isSearching } =
useUserSearch(inputValue);
if (!email) {
return;
}
// Filter out already-added attendees and the organizer
const suggestions = filterSuggestions(
searchResults ?? [],
attendees,
organizerEmail,
);
if (!isValidEmail(email)) {
setError(t("calendar.attendees.invalidEmail"));
return;
}
// Show suggestions when we have results or are searching with enough chars
const trimmedInput = inputValue.trim();
const shouldShowDropdown =
showSuggestions && trimmedInput.length >= 3 && !isSearching;
const hasNoResults =
shouldShowDropdown &&
suggestions.length === 0 &&
searchResults !== undefined;
if (attendees.some((a) => a.email.toLowerCase() === email)) {
setError(t("calendar.attendees.alreadyAdded"));
return;
}
if (organizerEmail && email === organizerEmail.toLowerCase()) {
setError(t("calendar.attendees.cannotAddOrganizer"));
return;
}
const newAttendee: IcsAttendee = {
email,
partstat: "NEEDS-ACTION",
rsvp: true,
role: "REQ-PARTICIPANT",
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setShowSuggestions(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
onChange([...attendees, newAttendee]);
setInputValue("");
setError(null);
}, [inputValue, attendees, onChange, organizerEmail, t]);
const addAttendeeByEmail = useCallback(
(email: string, fullName?: string) => {
const normalized = email.trim().toLowerCase();
if (!normalized) return;
if (!isValidEmail(normalized)) {
setError(t("calendar.attendees.invalidEmail"));
return;
}
if (attendees.some((a) => a.email.toLowerCase() === normalized)) {
setError(t("calendar.attendees.alreadyAdded"));
return;
}
if (organizerEmail && normalized === organizerEmail.toLowerCase()) {
setError(t("calendar.attendees.cannotAddOrganizer"));
return;
}
const newAttendee: IcsAttendee = {
email: normalized,
partstat: "NEEDS-ACTION",
rsvp: true,
role: "REQ-PARTICIPANT",
...(fullName && { cn: fullName }),
};
onChange([...attendees, newAttendee]);
setInputValue("");
setError(null);
setShowSuggestions(false);
setHighlightedIndex(-1);
},
[attendees, onChange, organizerEmail, t],
);
const selectSuggestion = useCallback(
(user: UserSearchResult) => {
addAttendeeByEmail(user.email, user.full_name);
},
[addAttendeeByEmail],
);
const removeAttendee = useCallback(
(emailToRemove: string) => {
@@ -103,16 +155,52 @@ export function AttendeesInput({
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
setShowSuggestions(false);
setHighlightedIndex(-1);
return;
}
if (
shouldShowDropdown &&
suggestions.length > 0 &&
(e.key === "ArrowDown" || e.key === "ArrowUp")
) {
e.preventDefault();
setHighlightedIndex((prev) => {
if (e.key === "ArrowDown") {
return prev < suggestions.length - 1 ? prev + 1 : 0;
}
return prev > 0 ? prev - 1 : suggestions.length - 1;
});
return;
}
if (e.key === "Enter") {
e.preventDefault();
addAttendee();
if (
shouldShowDropdown &&
highlightedIndex >= 0 &&
highlightedIndex < suggestions.length
) {
selectSuggestion(suggestions[highlightedIndex]);
} else {
addAttendeeByEmail(inputValue);
}
}
},
[addAttendee],
[
shouldShowDropdown,
suggestions,
highlightedIndex,
selectSuggestion,
addAttendeeByEmail,
inputValue,
],
);
return (
<div className="attendees-input">
<div className="attendees-input" ref={containerRef}>
<div className="attendees-input__field">
<Input
label={t("calendar.attendees.label")}
@@ -123,12 +211,49 @@ export function AttendeesInput({
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
setShowSuggestions(true);
setHighlightedIndex(-1);
if (error) setError(null);
}}
onKeyDown={handleKeyDown}
onFocus={() => setShowSuggestions(true)}
state={error ? "error" : "default"}
text={error || undefined}
/>
{(shouldShowDropdown && suggestions.length > 0) || hasNoResults ? (
<ul className="attendees-input__suggestions" role="listbox">
{suggestions.map((user, index) => (
<li
key={user.id}
role="option"
aria-selected={index === highlightedIndex}
className={`attendees-input__suggestion${
index === highlightedIndex
? " attendees-input__suggestion--highlighted"
: ""
}`}
onMouseDown={(e) => {
e.preventDefault();
selectSuggestion(user);
}}
onMouseEnter={() => setHighlightedIndex(index)}
>
<span className="attendees-input__suggestion-name">
{user.full_name}
</span>
<span className="attendees-input__suggestion-email">
{user.email}
</span>
</li>
))}
{hasNoResults && (
<li className="attendees-input__suggestion attendees-input__suggestion--empty">
{t("calendar.attendees.noResults")}
</li>
)}
</ul>
) : null}
</div>
<div className="attendees-input__pills">

View File

@@ -21,6 +21,7 @@ import { AttendeesSection } from "./event-modal-sections/AttendeesSection";
import { ResourcesSection } from "./event-modal-sections/ResourcesSection";
import { DescriptionSection } from "./event-modal-sections/DescriptionSection";
import { InvitationResponseSection } from "./event-modal-sections/InvitationResponseSection";
import { FreeBusySection } from "./event-modal-sections/FreeBusySection";
import { SectionPills } from "./event-modal-sections/SectionPills";
import { useResourcePrincipals } from "@/features/resources/api/useResourcePrincipals";
import type { EventModalProps, RecurringDeleteOption } from "./types";
@@ -176,6 +177,11 @@ export const EventModal = ({
},
]
: []),
{
id: "scheduling" as const,
icon: "event_available",
label: t("scheduling.findATime"),
},
],
[t, visioBaseUrl, availableResources.length],
);
@@ -305,6 +311,25 @@ export const EventModal = ({
alwaysOpen
/>
)}
{form.isSectionExpanded("scheduling") && (
<FreeBusySection
attendees={form.attendees}
resourceEmails={form.resources
.map((r) => r.email)
.filter((e): e is string => !!e)}
resourceNames={Object.fromEntries(
form.resources
.filter((r) => r.email)
.map((r) => [r.email!.toLowerCase(), r.name]),
)}
organizerEmail={user?.email}
startDateTime={form.startDateTime}
endDateTime={form.endDateTime}
onStartChange={form.setStartDateTime}
onEndChange={form.setEndDateTime}
alwaysOpen
/>
)}
{availableResources.length > 0 &&
form.isSectionExpanded("resources") && (
<ResourcesSection

View File

@@ -0,0 +1,144 @@
.freebusy-timeline {
padding: 0.25rem 0;
&__header {
display: flex;
align-items: center;
gap: 0.25rem;
margin-bottom: 0.25rem;
}
&__nav {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 50%;
background: none;
cursor: pointer;
color: #5f6368;
padding: 0;
&:hover {
background-color: #f1f3f4;
}
.material-icons {
font-size: 18px;
}
}
&__date {
font-size: 0.75rem;
font-weight: 500;
color: #202124;
min-width: 100px;
text-align: center;
}
&__loading {
display: flex;
align-items: center;
margin-left: auto;
}
&__spinner {
font-size: 14px;
color: #5f6368;
animation: freebusy-spin 1.5s linear infinite;
}
&__status {
font-size: 0.6875rem;
margin-left: auto;
padding: 0.0625rem 0.375rem;
border-radius: 8px;
&--ok {
color: #137333;
background-color: #e6f4ea;
}
&--conflict {
color: #c5221f;
background-color: #fce8e6;
}
}
&__calendar {
border: 1px solid #e0e0e0;
border-radius: 4px;
overflow: hidden;
// Hide the day name row (e.g. "Monday") — redundant with our nav header
.ec-col-head {
display: none;
}
// Shrink the resource label column
.ec-sidebar {
width: 90px !important;
min-width: 90px !important;
max-width: 90px !important;
flex-shrink: 0;
}
.ec-resource {
font-size: 0.625rem;
padding: 0 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Compact row heights, no row separators
.ec-body .ec-resource {
height: 24px !important;
border-bottom: none !important;
}
// Smaller time header labels
.ec-header .ec-time {
font-size: 0.5625rem;
padding: 2px 0;
}
.ec-header {
height: auto !important;
min-height: unset !important;
}
// Single overlay for proposed meeting time across all rows
.freebusy-proposed-overlay {
border-left: 2px solid #137333;
border-right: 2px solid #137333;
background: repeating-linear-gradient(
-45deg,
rgba(19, 115, 51, 0.35),
rgba(19, 115, 51, 0.35) 1px,
transparent 1px,
transparent 7px
);
}
}
&__empty {
padding: 0.75rem;
text-align: center;
font-size: 0.75rem;
color: #5f6368;
border: 1px solid #e0e0e0;
border-radius: 4px;
}
}
@keyframes freebusy-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,274 @@
import { useEffect, useRef, useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { createCalendar, destroyCalendar, ResourceTimeline } from "@event-calendar/core";
import type { FreeBusyResponse } from "../../services/dav/types/caldav-service";
interface FreeBusyTimelineProps {
date: Date;
attendees: FreeBusyResponse[];
displayNames?: Record<string, string>;
eventStart: Date;
eventEnd: Date;
isLoading: boolean;
onDateChange: (date: Date) => void;
onTimeSelect: (start: Date, end: Date) => void;
}
/** Map freebusy type to a background color. */
const BUSY_COLORS: Record<string, string> = {
BUSY: "rgba(26, 115, 232, 0.4)",
"BUSY-UNAVAILABLE": "rgba(95, 99, 104, 0.5)",
"BUSY-TENTATIVE": "rgba(26, 115, 232, 0.2)",
};
export const FreeBusyTimeline = ({
date,
attendees,
displayNames,
eventStart,
eventEnd,
isLoading,
onDateChange,
onTimeSelect,
}: FreeBusyTimelineProps) => {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const calendarRef = useRef<ReturnType<typeof createCalendar> | null>(null);
const handlersRef = useRef({ onTimeSelect, onDateChange });
useEffect(() => {
handlersRef.current = { onTimeSelect, onDateChange };
});
const eventDurationMs = eventEnd.getTime() - eventStart.getTime();
const eventDurationRef = useRef(eventDurationMs);
useEffect(() => {
eventDurationRef.current = eventDurationMs;
}, [eventDurationMs]);
const dateStr = date.toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
});
// Map attendees → resources, using displayNames for friendly labels
const resources = useMemo(
() =>
attendees.map((a) => ({
id: a.attendee,
title:
displayNames?.[a.attendee] ??
displayNames?.[a.attendee.toLowerCase()] ??
a.attendee.split("@")[0],
})),
[attendees, displayNames],
);
// Map busy periods → background events + proposed event overlay
const events = useMemo(() => {
const allEvents: {
id: string;
resourceIds: string[];
start: Date;
end: Date;
display?: string;
backgroundColor?: string;
title?: string;
}[] = attendees.flatMap((a) =>
a.periods.map((period, idx) => ({
id: `busy-${a.attendee}-${idx}`,
resourceIds: [a.attendee],
start: period.start,
end: period.end,
display: "background",
backgroundColor: BUSY_COLORS[period.type] ?? BUSY_COLORS.BUSY,
})),
);
return allEvents;
}, [attendees, date, resources]);
// Count conflicts
const eventOnThisDay =
eventStart.toDateString() === date.toDateString() ||
eventEnd.toDateString() === date.toDateString() ||
(eventStart < date && eventEnd > date);
const conflictCount = eventOnThisDay
? attendees.filter((a) =>
a.periods.some((p) => p.start < eventEnd && p.end > eventStart),
).length
: 0;
// Create / destroy calendar instance
useEffect(() => {
if (!containerRef.current) return;
const ec = createCalendar(containerRef.current, [ResourceTimeline], {
view: "resourceTimelineDay",
headerToolbar: false,
slotMinTime: "07:00",
slotMaxTime: "24:00",
slotDuration: "01:00",
height: "auto",
date,
resources,
events,
editable: false,
selectable: false,
dateClick: (info: { date: Date; resource?: { id: string } }) => {
const clickedDate = info.date;
const dur = eventDurationRef.current;
const newEnd = new Date(clickedDate.getTime() + dur);
handlersRef.current.onTimeSelect(clickedDate, newEnd);
},
});
calendarRef.current = ec;
// Make the time grid areas scrollable while keeping sidebar fixed
const GRID_WIDTH = 1100;
requestAnimationFrame(() => {
if (!containerRef.current) return;
// Structure: .ec-main > .ec-header, .ec-main > .ec-body
const header = containerRef.current.querySelector(
".ec-header",
) as HTMLElement | null;
const body = containerRef.current.querySelector(
".ec-body",
) as HTMLElement | null;
if (!header || !body) return;
// The grid inside header/body holds the time slots
const headerGrid = header.querySelector(".ec-grid") as HTMLElement | null;
const bodyGrid = body.querySelector(".ec-grid") as HTMLElement | null;
if (!headerGrid || !bodyGrid) return;
headerGrid.style.minWidth = `${GRID_WIDTH}px`;
bodyGrid.style.minWidth = `${GRID_WIDTH}px`;
// Make the body scrollable, hide header scrollbar
body.style.overflowX = "auto";
header.style.overflowX = "hidden";
// Sync header scroll when body scrolls
const onBodyScroll = () => {
header.scrollLeft = body.scrollLeft;
};
body.addEventListener("scroll", onBodyScroll);
// Scroll to ~8 AM (1 hour into the 7:00-24:00 range)
const scrollPos = (1 / 17) * GRID_WIDTH;
body.scrollLeft = scrollPos;
header.scrollLeft = scrollPos;
// Inject a single proposed-time overlay spanning all rows
const startsOnDay = eventStart.toDateString() === date.toDateString();
const endsOnDay = eventEnd.toDateString() === date.toDateString();
const spansDay = eventStart < date && eventEnd > date;
if ((startsOnDay || endsOnDay || spansDay) && resources.length > 0) {
const SLOT_MIN = 7; // slotMinTime hours
const SLOT_MAX = 24; // slotMaxTime hours
const totalHours = SLOT_MAX - SLOT_MIN;
const startH = startsOnDay
? eventStart.getHours() + eventStart.getMinutes() / 60
: SLOT_MIN;
const endH = endsOnDay
? eventEnd.getHours() + eventEnd.getMinutes() / 60
: SLOT_MAX;
const leftPct = ((Math.max(startH, SLOT_MIN) - SLOT_MIN) / totalHours) * 100;
const rightPct = ((Math.min(endH, SLOT_MAX) - SLOT_MIN) / totalHours) * 100;
if (rightPct > leftPct) {
const overlay = document.createElement("div");
overlay.className = "freebusy-proposed-overlay";
overlay.style.position = "absolute";
overlay.style.top = "0";
overlay.style.bottom = "0";
overlay.style.left = `${leftPct}%`;
overlay.style.width = `${rightPct - leftPct}%`;
overlay.style.pointerEvents = "none";
overlay.style.zIndex = "5";
bodyGrid.style.position = "relative";
bodyGrid.appendChild(overlay);
}
}
});
return () => {
if (calendarRef.current) {
destroyCalendar(calendarRef.current);
calendarRef.current = null;
}
if (containerRef.current) {
containerRef.current.innerHTML = "";
}
};
// Recreate when data changes
}, [date, resources, events, eventStart, eventEnd]);
const handlePrev = useCallback(() => {
const prev = new Date(date);
prev.setDate(prev.getDate() - 1);
onDateChange(prev);
}, [date, onDateChange]);
const handleNext = useCallback(() => {
const next = new Date(date);
next.setDate(next.getDate() + 1);
onDateChange(next);
}, [date, onDateChange]);
return (
<div className="freebusy-timeline">
<div className="freebusy-timeline__header">
<button
type="button"
className="freebusy-timeline__nav"
onClick={handlePrev}
aria-label={t("scheduling.previousDay")}
>
<span className="material-icons">chevron_left</span>
</button>
<span className="freebusy-timeline__date">{dateStr}</span>
<button
type="button"
className="freebusy-timeline__nav"
onClick={handleNext}
aria-label={t("scheduling.nextDay")}
>
<span className="material-icons">chevron_right</span>
</button>
{isLoading && (
<span className="freebusy-timeline__loading">
<span className="material-icons freebusy-timeline__spinner">
sync
</span>
</span>
)}
{!isLoading && eventOnThisDay && (
<span
className={`freebusy-timeline__status ${
conflictCount > 0
? "freebusy-timeline__status--conflict"
: "freebusy-timeline__status--ok"
}`}
>
{conflictCount > 0
? t("scheduling.conflicts", { count: conflictCount })
: t("scheduling.noConflicts")}
</span>
)}
</div>
{attendees.length === 0 && !isLoading ? (
<div className="freebusy-timeline__empty">
{t("scheduling.addAttendeesToSeeAvailability")}
</div>
) : (
<div ref={containerRef} className="freebusy-timeline__calendar" />
)}
</div>
);
};

View File

@@ -0,0 +1,123 @@
import { filterSuggestions, isValidEmail } from "../attendees-utils";
import type { UserSearchResult } from "@/features/users/hooks/useUserSearch";
import type { IcsAttendee } from "ts-ics";
describe("attendees-utils", () => {
describe("isValidEmail", () => {
it("accepts a valid email", () => {
expect(isValidEmail("user@example.com")).toBe(true);
});
it("rejects an empty string", () => {
expect(isValidEmail("")).toBe(false);
});
it("rejects a string without @", () => {
expect(isValidEmail("not-an-email")).toBe(false);
});
it("rejects a string without domain", () => {
expect(isValidEmail("user@")).toBe(false);
});
it("rejects a string with spaces", () => {
expect(isValidEmail("user @example.com")).toBe(false);
});
});
describe("filterSuggestions", () => {
const makeUser = (
email: string,
name = "Test User",
): UserSearchResult => ({
id: email,
email,
full_name: name,
});
const makeAttendee = (email: string): IcsAttendee => ({
email,
partstat: "NEEDS-ACTION",
rsvp: true,
role: "REQ-PARTICIPANT",
});
it("returns all suggestions when no attendees or organizer", () => {
const results = [
makeUser("alice@org.com", "Alice"),
makeUser("bob@org.com", "Bob"),
];
expect(filterSuggestions(results, [])).toEqual(results);
});
it("filters out already-added attendees", () => {
const results = [
makeUser("alice@org.com"),
makeUser("bob@org.com"),
makeUser("carol@org.com"),
];
const attendees = [makeAttendee("alice@org.com")];
const filtered = filterSuggestions(results, attendees);
expect(filtered).toHaveLength(2);
expect(filtered.map((u) => u.email)).toEqual([
"bob@org.com",
"carol@org.com",
]);
});
it("filters case-insensitively", () => {
const results = [makeUser("Alice@Org.com")];
const attendees = [makeAttendee("alice@org.com")];
expect(filterSuggestions(results, attendees)).toHaveLength(0);
});
it("filters out the organizer email", () => {
const results = [
makeUser("organizer@org.com"),
makeUser("other@org.com"),
];
const filtered = filterSuggestions(
results,
[],
"organizer@org.com",
);
expect(filtered).toHaveLength(1);
expect(filtered[0].email).toBe("other@org.com");
});
it("filters organizer case-insensitively", () => {
const results = [makeUser("Organizer@Org.COM")];
const filtered = filterSuggestions(
results,
[],
"organizer@org.com",
);
expect(filtered).toHaveLength(0);
});
it("filters both attendees and organizer together", () => {
const results = [
makeUser("organizer@org.com"),
makeUser("attendee@org.com"),
makeUser("available@org.com"),
];
const attendees = [makeAttendee("attendee@org.com")];
const filtered = filterSuggestions(
results,
attendees,
"organizer@org.com",
);
expect(filtered).toHaveLength(1);
expect(filtered[0].email).toBe("available@org.com");
});
it("filters out users with no email", () => {
const results = [{ id: "1", email: "", full_name: "No Email" }];
expect(filterSuggestions(results, [])).toHaveLength(0);
});
it("returns empty array for empty results", () => {
expect(filterSuggestions([], [])).toEqual([]);
});
});
});

View File

@@ -0,0 +1,25 @@
import type { IcsAttendee } from "ts-ics";
import type { UserSearchResult } from "@/features/users/hooks/useUserSearch";
/**
* Filter search results to exclude already-added attendees
* and the organizer email.
*/
export function filterSuggestions(
searchResults: UserSearchResult[],
attendees: IcsAttendee[],
organizerEmail?: string,
): UserSearchResult[] {
return searchResults.filter((user) => {
const email = user.email?.toLowerCase();
if (!email) return false;
if (attendees.some((a) => a.email.toLowerCase() === email)) return false;
if (organizerEmail && email === organizerEmail.toLowerCase()) return false;
return true;
});
}
export const isValidEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};

View File

@@ -0,0 +1,97 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { IcsAttendee } from "ts-ics";
import { useCalendarContext } from "../../../contexts/CalendarContext";
import { useFreeBusy } from "../hooks/useFreeBusy";
import { FreeBusyTimeline } from "../FreeBusyTimeline";
import { SectionRow } from "./SectionRow";
import {
formatDateTimeLocal,
parseDateTimeLocal,
} from "../utils/dateFormatters";
interface FreeBusySectionProps {
attendees: IcsAttendee[];
resourceEmails?: string[];
resourceNames?: Record<string, string>;
organizerEmail?: string;
startDateTime: string;
endDateTime: string;
onStartChange: (value: string) => void;
onEndChange: (value: string) => void;
alwaysOpen?: boolean;
}
export const FreeBusySection = ({
attendees,
resourceEmails,
resourceNames,
organizerEmail,
startDateTime,
endDateTime,
onStartChange,
onEndChange,
alwaysOpen,
}: FreeBusySectionProps) => {
const { t } = useTranslation();
const { caldavService } = useCalendarContext();
const eventStart = useMemo(
() => parseDateTimeLocal(startDateTime),
[startDateTime],
);
const eventEnd = useMemo(
() => parseDateTimeLocal(endDateTime),
[endDateTime],
);
const allEmails = useMemo(
() => [
...attendees.map((a) => a.email.toLowerCase()),
...(resourceEmails ?? []).map((e) => e.toLowerCase()),
],
[attendees, resourceEmails],
);
const { data, isLoading } = useFreeBusy({
caldavService,
attendees: allEmails,
organizerEmail,
date: eventStart,
enabled: allEmails.length > 0,
});
const handleDateChange = (date: Date) => {
const newStart = new Date(eventStart);
newStart.setFullYear(date.getFullYear(), date.getMonth(), date.getDate());
const newEnd = new Date(eventEnd);
newEnd.setFullYear(date.getFullYear(), date.getMonth(), date.getDate());
onStartChange(formatDateTimeLocal(newStart));
onEndChange(formatDateTimeLocal(newEnd));
};
const handleTimeSelect = (start: Date, end: Date) => {
onStartChange(formatDateTimeLocal(start));
onEndChange(formatDateTimeLocal(end));
};
return (
<SectionRow
icon="event_available"
label={t("scheduling.findATime")}
alwaysOpen={alwaysOpen}
iconAlign="flex-start"
>
<FreeBusyTimeline
date={eventStart}
attendees={data}
displayNames={resourceNames}
eventStart={eventStart}
eventEnd={eventEnd}
isLoading={isLoading}
onDateChange={handleDateChange}
onTimeSelect={handleTimeSelect}
/>
</SectionRow>
);
};

View File

@@ -426,6 +426,7 @@ export const useEventForm = ({
location,
setLocation,
startDateTime,
setStartDateTime,
endDateTime,
setEndDateTime,
selectedCalendarUrl,

View File

@@ -0,0 +1,78 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import type { FreeBusyResponse } from "../../../services/dav/types/caldav-service";
import type { CalDavService } from "../../../services/dav/CalDavService";
interface UseFreeBusyOptions {
caldavService: CalDavService;
attendees: string[];
organizerEmail?: string;
date: Date;
enabled: boolean;
}
interface UseFreeBusyResult {
data: FreeBusyResponse[];
isLoading: boolean;
error: string | null;
refresh: () => void;
}
/**
* Hook to query freebusy data for a list of attendees on a given date.
* Automatically re-queries when attendees or date change.
*/
export function useFreeBusy({
caldavService,
attendees,
organizerEmail,
date,
enabled,
}: UseFreeBusyOptions): UseFreeBusyResult {
const queryClient = useQueryClient();
// Stable key: sort attendees so order doesn't trigger refetch
const attendeesKey = useMemo(
() => [...attendees].sort().join(","),
[attendees],
);
const dateKey = date.toISOString().slice(0, 10);
const queryKey = ["freebusy", attendeesKey, organizerEmail, dateKey];
const query = useQuery({
queryKey,
queryFn: async (): Promise<FreeBusyResponse[]> => {
const start = new Date(date);
start.setHours(0, 0, 0, 0);
const end = new Date(date);
end.setHours(23, 59, 59, 999);
const result = await caldavService.queryFreeBusy({
attendees,
timeRange: { start, end },
organizer: organizerEmail
? { email: organizerEmail, name: organizerEmail.split("@")[0] }
: undefined,
});
if (result.success && result.data) {
return result.data;
}
throw new Error(result.error ?? "Failed to query availability");
},
enabled: enabled && attendees.length > 0,
retry: false,
});
const refresh = useCallback(() => {
void queryClient.invalidateQueries({ queryKey });
}, [queryClient, queryKey]);
return {
data: query.data ?? [],
isLoading: query.isLoading,
error: query.error?.message ?? null,
refresh,
};
}

View File

@@ -7,6 +7,7 @@ import { useEffect, useRef, MutableRefObject } from "react";
import { useTranslation } from "react-i18next";
import {
createCalendar,
destroyCalendar,
TimeGrid,
DayGrid,
List,
@@ -252,13 +253,9 @@ export const useSchedulerInit = ({
calendarRef.current = ec as unknown as CalendarApi;
return () => {
// @event-calendar/core is Svelte-based and uses $destroy
// Always call $destroy before clearing the container to avoid memory leaks
if (calendarRef.current) {
const calendar = calendarRef.current as CalendarApi;
if (typeof calendar.$destroy === 'function') {
calendar.$destroy();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
destroyCalendar(calendarRef.current as any);
calendarRef.current = null;
}
// Clear the container only after calendar is destroyed

View File

@@ -83,7 +83,8 @@ export type EventFormSectionId =
| "recurrence"
| "attendees"
| "resources"
| "videoConference";
| "videoConference"
| "scheduling";
/**
* Attachment metadata (UI only, no actual file upload).
@@ -130,7 +131,6 @@ export interface CalendarApi {
addEvent: (event: unknown) => void;
unselect: () => void;
refetchEvents: () => void;
$destroy?: () => void;
}
/**

View File

@@ -62,6 +62,7 @@ import {
buildSyncCollectionXml,
buildPrincipalSearchXml,
executeDavRequest,
escapeXml,
CALENDAR_PROPS,
parseCalendarComponents,
parseSharePrivilege,
@@ -793,20 +794,22 @@ export class CalDavService {
throw new Error('Scheduling outbox not found')
}
// Construct full URL - outboxUrl is relative to serverUrl
// Construct full URL - outboxUrl from PROPFIND is an absolute path (e.g. /caldav/calendars/...)
// so we only need to prepend the origin, not the full serverUrl (which already has /caldav/)
const fullOutboxUrl = outboxUrl.startsWith('http')
? outboxUrl
: `${this._account!.serverUrl}${outboxUrl.startsWith('/') ? outboxUrl.slice(1) : outboxUrl}`
: `${new URL(this._account!.serverUrl).origin}${outboxUrl}`
// Use fetch directly to avoid davRequest URL construction issues in dev mode
// Note: fetchOptions is spread first so its headers don't override our Content-Type
const response = await fetch(fullOutboxUrl, {
...this._account!.fetchOptions,
method: 'POST',
headers: {
'Content-Type': 'text/calendar; charset=utf-8; method=' + request.method,
...this._account!.headers,
'Content-Type': 'text/calendar; charset=utf-8; method=' + request.method,
},
body: iCalString,
...this._account!.fetchOptions,
})
if (!response.ok) {
@@ -910,31 +913,91 @@ ${attendeeLines}
END:VFREEBUSY
END:VCALENDAR`
const responses = await davRequest({
url: outboxUrl,
init: {
method: 'POST',
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
...this._account!.headers,
},
body: fbRequest,
// Construct full URL - outboxUrl from PROPFIND is an absolute path (e.g. /caldav/calendars/...)
// so we only need to prepend the origin, not the full serverUrl (which already has /caldav/)
const fullOutboxUrl = outboxUrl.startsWith('http')
? outboxUrl
: `${new URL(this._account!.serverUrl).origin}${outboxUrl}`
// Note: fetchOptions is spread first so its headers don't override our Content-Type
const response = await fetch(fullOutboxUrl, {
...this._account!.fetchOptions,
method: 'POST',
headers: {
...this._account!.headers,
'Content-Type': 'text/calendar; charset=utf-8',
},
fetchOptions: this._account!.fetchOptions,
body: fbRequest,
})
const response = responses[0]
if (!response?.ok) {
throw new Error(`Failed to query free/busy: ${response?.status}`)
if (!response.ok) {
throw new Error(`Failed to query free/busy: ${response.status}`)
}
return request.attendees.map((email) => ({
attendee: email,
periods: [],
}))
const xmlText = await response.text()
return parseScheduleFreeBusyResponse(xmlText)
}, 'Failed to query free/busy')
}
// ============================================================================
// Availability (Working Hours)
// ============================================================================
/**
* Get the user's calendar availability (working hours).
* Reads the {urn:ietf:params:xml:ns:caldav}calendar-availability property
* from the calendar home via PROPFIND.
*/
async getAvailability(): Promise<CalDavResponse<string | null>> {
if (!this._account?.homeUrl) {
return { success: false, error: 'Not connected' }
}
return withErrorHandling(async () => {
const response = await propfind({
url: this._account!.homeUrl!,
props: {
[`${DAVNamespaceShort.CALDAV}:calendar-availability`]: {},
},
headers: this._account!.headers,
fetchOptions: this._account!.fetchOptions,
depth: '0',
})
return response[0]?.props?.['calendarAvailability'] ?? null
}, 'Failed to get availability')
}
/**
* Set the user's calendar availability (working hours).
* Stores a VCALENDAR with VAVAILABILITY/AVAILABLE components
* on the calendar home via PROPPATCH.
*/
async setAvailability(
vcalendarText: string,
): Promise<CalDavResponse> {
if (!this._account?.homeUrl) {
return { success: false, error: 'Not connected' }
}
const body = `<?xml version="1.0" encoding="utf-8"?>
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:set>
<D:prop>
<C:calendar-availability>${escapeXml(vcalendarText)}</C:calendar-availability>
</D:prop>
</D:set>
</D:propertyupdate>`
return executeDavRequest({
url: this._account.homeUrl,
method: 'PROPPATCH',
body,
headers: this._account.headers,
fetchOptions: this._account.fetchOptions,
})
}
// ============================================================================
// Sync Operations
// ============================================================================
@@ -1189,3 +1252,127 @@ END:VCALENDAR`
export function createCalDavService(): CalDavService {
return new CalDavService()
}
// ============================================================================
// FreeBusy Response Parsing
// ============================================================================
/**
* Parse a CalDAV schedule-response XML containing VFREEBUSY data.
* Response format defined in RFC 6638.
*/
function parseScheduleFreeBusyResponse(
xmlText: string,
): FreeBusyResponse[] {
const parser = new DOMParser()
const doc = parser.parseFromString(xmlText, 'application/xml')
const results: FreeBusyResponse[] = []
const CAL_NS = 'urn:ietf:params:xml:ns:caldav'
const DAV_NS = 'DAV:'
const responseElements = doc.getElementsByTagNameNS(CAL_NS, 'response')
for (let i = 0; i < responseElements.length; i++) {
const responseEl = responseElements[i]
// Check request-status — skip unknown users (3.x = error)
const statusEl = responseEl.getElementsByTagNameNS(CAL_NS, 'request-status')[0]
const status = statusEl?.textContent ?? ''
if (status.startsWith('3.')) continue
// Extract recipient email
const recipientEl = responseEl.getElementsByTagNameNS(CAL_NS, 'recipient')[0]
const hrefEl = recipientEl?.getElementsByTagNameNS(DAV_NS, 'href')[0]
const href = hrefEl?.textContent ?? ''
const email = href.replace(/^mailto:/i, '').toLowerCase()
// Extract calendar-data (ICS text containing VFREEBUSY)
const calDataEl = responseEl.getElementsByTagNameNS(CAL_NS, 'calendar-data')[0]
const icsText = calDataEl?.textContent ?? ''
const periods = parseFreeBusyPeriods(icsText)
results.push({ attendee: email, periods })
}
return results
}
/**
* Parse FREEBUSY lines from a VFREEBUSY ICS component.
* Format: FREEBUSY;FBTYPE=BUSY:20260310T090000Z/20260310T100000Z
*/
function parseFreeBusyPeriods(icsText: string): FreeBusyResponse['periods'] {
const periods: FreeBusyResponse['periods'] = []
const lines = icsText.split(/\r?\n/)
for (const line of lines) {
if (!line.startsWith('FREEBUSY')) continue
// Split into params and value: FREEBUSY;FBTYPE=BUSY:start/end
const colonIdx = line.indexOf(':')
if (colonIdx === -1) continue
const params = line.substring(0, colonIdx)
const value = line.substring(colonIdx + 1)
// Parse FBTYPE (default BUSY)
let fbType: 'BUSY' | 'BUSY-UNAVAILABLE' | 'BUSY-TENTATIVE' | 'FREE' = 'BUSY'
const typeMatch = params.match(/FBTYPE=([A-Z-]+)/i)
if (typeMatch) {
fbType = typeMatch[1].toUpperCase() as typeof fbType
}
// Parse comma-separated periods
const periodStrs = value.split(',')
for (const periodStr of periodStrs) {
const [startStr, endStr] = periodStr.split('/')
if (!startStr || !endStr) continue
const start = parseIcsDateTime(startStr)
if (!start) continue
let end: Date | null
if (endStr.startsWith('P')) {
// Duration format (e.g., PT1H)
end = addIsoDuration(start, endStr)
} else {
end = parseIcsDateTime(endStr)
}
if (!end) continue
periods.push({ start, end, type: fbType })
}
}
return periods
}
/** Parse an ICS datetime string like 20260310T090000Z into a Date. */
function parseIcsDateTime(str: string): Date | null {
const m = str.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?$/)
if (!m) return null
return new Date(
Date.UTC(
parseInt(m[1]),
parseInt(m[2]) - 1,
parseInt(m[3]),
parseInt(m[4]),
parseInt(m[5]),
parseInt(m[6]),
),
)
}
/** Add an ISO 8601 duration (e.g., PT1H30M) to a Date. */
function addIsoDuration(date: Date, duration: string): Date {
const m = duration.match(/P(?:(\d+)D)?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/)
if (!m) return date
const ms =
(parseInt(m[1] || '0') * 86400 +
parseInt(m[2] || '0') * 3600 +
parseInt(m[3] || '0') * 60 +
parseInt(m[4] || '0')) *
1000
return new Date(date.getTime() + ms)
}

View File

@@ -28,7 +28,28 @@
"logout": "Logout",
"login": "Login",
"my_account": "My Account",
"settings": "Settings",
"settings": {
"label": "Settings",
"workingHours": {
"title": "Working Hours",
"description": "Set your available hours for scheduling",
"when": "When",
"everyMonday": "Every Monday",
"everyTuesday": "Every Tuesday",
"everyWednesday": "Every Wednesday",
"everyThursday": "Every Thursday",
"everyFriday": "Every Friday",
"everySaturday": "Every Saturday",
"everySunday": "Every Sunday",
"specificDate": "Specific date...",
"start": "Start",
"end": "End",
"addAvailability": "Add availability",
"removeAvailability": "Remove",
"save": "Save",
"saved": "Working hours saved"
}
},
"api": {
"error": {
"unexpected": "An unexpected error occurred."
@@ -331,7 +352,8 @@
"participants": "Participants",
"organizer": "Organizer",
"viewProfile": "View profile",
"cannotRemoveOrganizer": "Cannot remove organizer"
"cannotRemoveOrganizer": "Cannot remove organizer",
"noResults": "No users found"
},
"resources": {
"placeholder": "Select a resource...",
@@ -514,6 +536,14 @@
"copy": "Copy token",
"copied": "Token copied to clipboard."
}
},
"scheduling": {
"findATime": "Find a time",
"previousDay": "Previous day",
"nextDay": "Next day",
"noConflicts": "No conflicts",
"conflicts": "{{count}} conflict(s)",
"addAttendeesToSeeAvailability": "Add attendees to see availability"
}
}
},
@@ -585,7 +615,28 @@
"logout": "Déconnexion",
"login": "Connexion",
"my_account": "Mon Compte",
"settings": "Paramètres",
"settings": {
"label": "Paramètres",
"workingHours": {
"title": "Heures de travail",
"description": "Définissez vos heures de disponibilité pour la planification",
"when": "Quand",
"everyMonday": "Chaque lundi",
"everyTuesday": "Chaque mardi",
"everyWednesday": "Chaque mercredi",
"everyThursday": "Chaque jeudi",
"everyFriday": "Chaque vendredi",
"everySaturday": "Chaque samedi",
"everySunday": "Chaque dimanche",
"specificDate": "Date spécifique...",
"start": "Début",
"end": "Fin",
"addAvailability": "Ajouter une disponibilité",
"removeAvailability": "Supprimer",
"save": "Enregistrer",
"saved": "Heures de travail enregistrées"
}
},
"api": {
"error": {
"unexpected": "Une erreur inattendue est survenue."
@@ -1152,7 +1203,8 @@
"participants": "Participants",
"organizer": "Organisateur",
"viewProfile": "Voir le profil",
"cannotRemoveOrganizer": "Impossible de retirer l'organisateur"
"cannotRemoveOrganizer": "Impossible de retirer l'organisateur",
"noResults": "Aucun utilisateur trouvé"
},
"resources": {
"placeholder": "Sélectionner une ressource...",
@@ -1335,6 +1387,14 @@
"copy": "Copier le jeton",
"copied": "Jeton copié dans le presse-papiers."
}
},
"scheduling": {
"findATime": "Trouver un créneau",
"previousDay": "Jour précédent",
"nextDay": "Jour suivant",
"noConflicts": "Aucun conflit",
"conflicts": "{{count}} conflit(s)",
"addAttendeesToSeeAvailability": "Ajoutez des participants pour voir les disponibilités"
}
}
},
@@ -1401,7 +1461,28 @@
"logout": "Uitloggen",
"login": "Inloggen",
"my_account": "Mijn account",
"settings": "Instellingen",
"settings": {
"label": "Instellingen",
"workingHours": {
"title": "Werkuren",
"description": "Stel uw beschikbare uren in voor planning",
"when": "Wanneer",
"everyMonday": "Elke maandag",
"everyTuesday": "Elke dinsdag",
"everyWednesday": "Elke woensdag",
"everyThursday": "Elke donderdag",
"everyFriday": "Elke vrijdag",
"everySaturday": "Elke zaterdag",
"everySunday": "Elke zondag",
"specificDate": "Specifieke datum...",
"start": "Start",
"end": "Einde",
"addAvailability": "Beschikbaarheid toevoegen",
"removeAvailability": "Verwijderen",
"save": "Opslaan",
"saved": "Werkuren opgeslagen"
}
},
"api": {
"error": {
"unexpected": "Er is een onverwachte fout opgetreden."
@@ -1715,7 +1796,8 @@
"participants": "Deelnemers",
"organizer": "Organisator",
"viewProfile": "Profiel bekijken",
"cannotRemoveOrganizer": "Kan organisator niet verwijderen"
"cannotRemoveOrganizer": "Kan organisator niet verwijderen",
"noResults": "Geen gebruikers gevonden"
},
"resources": {
"placeholder": "Selecteer een middel...",
@@ -1898,6 +1980,14 @@
"copy": "Token kopiëren",
"copied": "Token gekopieerd naar klembord."
}
},
"scheduling": {
"findATime": "Zoek een tijdstip",
"previousDay": "Vorige dag",
"nextDay": "Volgende dag",
"noConflicts": "Geen conflicten",
"conflicts": "{{count}} conflict(en)",
"addAttendeesToSeeAvailability": "Voeg deelnemers toe om beschikbaarheid te zien"
}
}
}

View File

@@ -43,13 +43,10 @@ const ApplicationMenu = () => {
const router = useRouter();
const { user } = useAuth();
if (!user?.can_admin) return null;
if (!user) return null;
return (
<DropdownMenu
isOpen={isOpen}
onOpenChange={setIsOpen}
options={[
const adminOptions = user.can_admin
? [
{
label: t("resources.title"),
icon: <Icon name="meeting_room" type={IconType.OUTLINED} />,
@@ -60,12 +57,26 @@ const ApplicationMenu = () => {
icon: <Icon name="integration_instructions" type={IconType.OUTLINED} />,
callback: () => void router.push("/integrations"),
},
]
: [];
return (
<DropdownMenu
isOpen={isOpen}
onOpenChange={setIsOpen}
options={[
{
label: t("settings.workingHours.title"),
icon: <Icon name="schedule" type={IconType.OUTLINED} />,
callback: () => void router.push("/settings"),
},
...adminOptions,
]}
>
<Button
onClick={() => setIsOpen(true)}
icon={<Icon name="settings" type={IconType.OUTLINED} />}
aria-label={t("settings")}
aria-label={t("settings.label")}
color="brand"
variant="tertiary"
/>

View File

@@ -0,0 +1,157 @@
import {
slotsToVCalendar,
vCalendarToSlots,
} from "../availability-ics";
import type { AvailabilitySlots } from "../types";
describe("availability-ics", () => {
describe("slotsToVCalendar", () => {
it("converts recurring weekday slots to VCALENDAR", () => {
const slots: AvailabilitySlots = [
{ id: "1", when: { type: "recurring", day: "monday" }, start: "09:00", end: "17:00" },
{ id: "2", when: { type: "recurring", day: "friday" }, start: "09:00", end: "17:00" },
];
const result = slotsToVCalendar(slots);
expect(result).toContain("BEGIN:VCALENDAR");
expect(result).toContain("BEGIN:VAVAILABILITY");
expect(result).toContain("RRULE:FREQ=WEEKLY;BYDAY=MO,FR");
expect(result).toContain("T090000");
expect(result).toContain("T170000");
});
it("creates separate blocks for different time ranges", () => {
const slots: AvailabilitySlots = [
{ id: "1", when: { type: "recurring", day: "monday" }, start: "09:00", end: "12:00" },
{ id: "2", when: { type: "recurring", day: "monday" }, start: "14:00", end: "18:00" },
];
const result = slotsToVCalendar(slots);
const count = (result.match(/BEGIN:AVAILABLE/g) || []).length;
expect(count).toBe(2);
});
it("handles specific-date slots without RRULE", () => {
const slots: AvailabilitySlots = [
{ id: "1", when: { type: "specific", date: "2026-03-15" }, start: "10:00", end: "14:00" },
];
const result = slotsToVCalendar(slots);
expect(result).toContain("DTSTART:20260315T100000");
expect(result).toContain("DTEND:20260315T140000");
expect(result).not.toContain("RRULE");
});
it("handles mixed recurring and specific-date slots", () => {
const slots: AvailabilitySlots = [
{ id: "1", when: { type: "recurring", day: "tuesday" }, start: "09:00", end: "17:00" },
{ id: "2", when: { type: "specific", date: "2026-04-01" }, start: "10:00", end: "15:00" },
];
const result = slotsToVCalendar(slots);
expect(result).toContain("RRULE:FREQ=WEEKLY;BYDAY=TU");
expect(result).toContain("DTSTART:20260401T100000");
expect(result).not.toMatch(/RRULE.*\n.*20260401/);
});
it("handles empty slots array", () => {
const result = slotsToVCalendar([]);
expect(result).toContain("BEGIN:VAVAILABILITY");
expect(result).not.toContain("BEGIN:AVAILABLE");
});
});
describe("vCalendarToSlots", () => {
it("parses recurring AVAILABLE blocks into slots", () => {
const vcal = `BEGIN:VCALENDAR
BEGIN:VAVAILABILITY
BEGIN:AVAILABLE
DTSTART:20260105T090000
DTEND:20260105T170000
RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR
END:AVAILABLE
END:VAVAILABILITY
END:VCALENDAR`;
const result = vCalendarToSlots(vcal);
expect(result).toHaveLength(3);
expect(result.map((s) => (s.when as { day: string }).day)).toEqual([
"monday", "wednesday", "friday",
]);
expect(result[0].start).toBe("09:00");
expect(result[0].end).toBe("17:00");
});
it("parses specific-date AVAILABLE blocks", () => {
const vcal = `BEGIN:VCALENDAR
BEGIN:VAVAILABILITY
BEGIN:AVAILABLE
DTSTART:20260315T100000
DTEND:20260315T140000
END:AVAILABLE
END:VAVAILABILITY
END:VCALENDAR`;
const result = vCalendarToSlots(vcal);
expect(result).toHaveLength(1);
expect(result[0].when).toEqual({ type: "specific", date: "2026-03-15" });
expect(result[0].start).toBe("10:00");
expect(result[0].end).toBe("14:00");
});
it("round-trips recurring slots", () => {
const original: AvailabilitySlots = [
{ id: "1", when: { type: "recurring", day: "monday" }, start: "08:00", end: "12:00" },
{ id: "2", when: { type: "recurring", day: "monday" }, start: "14:00", end: "18:00" },
{ id: "3", when: { type: "recurring", day: "wednesday" }, start: "09:00", end: "17:00" },
];
const vcal = slotsToVCalendar(original);
const parsed = vCalendarToSlots(vcal);
expect(parsed).toHaveLength(3);
// Check monday slots
const monSlots = parsed.filter(
(s) => s.when.type === "recurring" && s.when.day === "monday",
);
expect(monSlots).toHaveLength(2);
expect(monSlots.map((s) => s.start).sort()).toEqual(["08:00", "14:00"]);
});
it("round-trips specific-date slots", () => {
const original: AvailabilitySlots = [
{ id: "1", when: { type: "specific", date: "2026-12-25" }, start: "10:00", end: "14:00" },
];
const vcal = slotsToVCalendar(original);
const parsed = vCalendarToSlots(vcal);
expect(parsed).toHaveLength(1);
expect(parsed[0].when).toEqual({ type: "specific", date: "2026-12-25" });
expect(parsed[0].start).toBe("10:00");
expect(parsed[0].end).toBe("14:00");
});
it("returns empty array for invalid input", () => {
expect(vCalendarToSlots("not valid ics")).toEqual([]);
});
it("returns empty array for VCALENDAR with no AVAILABLE", () => {
const vcal = `BEGIN:VCALENDAR\nBEGIN:VAVAILABILITY\nEND:VAVAILABILITY\nEND:VCALENDAR`;
expect(vCalendarToSlots(vcal)).toEqual([]);
});
it("filters out past specific dates on parse", () => {
const vcal = `BEGIN:VCALENDAR
BEGIN:VAVAILABILITY
BEGIN:AVAILABLE
DTSTART:20200101T090000
DTEND:20200101T170000
END:AVAILABLE
END:VAVAILABILITY
END:VCALENDAR`;
expect(vCalendarToSlots(vcal)).toEqual([]);
});
it("filters out past specific dates on save", () => {
const slots: AvailabilitySlots = [
{ id: "1", when: { type: "specific", date: "2020-01-01" }, start: "09:00", end: "17:00" },
{ id: "2", when: { type: "recurring", day: "monday" }, start: "09:00", end: "17:00" },
];
const result = slotsToVCalendar(slots);
expect(result).not.toContain("20200101");
expect(result).toContain("BYDAY=MO");
});
});
});

View File

@@ -0,0 +1,93 @@
import { useEffect, useMemo, useState } from "react";
import {
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { CalDavService } from "@/features/calendar/services/dav/CalDavService";
import {
caldavServerUrl,
headers,
fetchOptions,
} from "@/features/calendar/utils/DavClient";
import { useAuth } from "@/features/auth/Auth";
import {
DEFAULT_AVAILABILITY,
type AvailabilitySlots,
} from "../types";
import {
slotsToVCalendar,
vCalendarToSlots,
} from "../availability-ics";
const WORKING_HOURS_KEY = ["working-hours"];
export const useWorkingHours = () => {
const { user } = useAuth();
const queryClient = useQueryClient();
const caldavService = useMemo(() => new CalDavService(), []);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
if (!user) return;
let cancelled = false;
caldavService
.connect({ serverUrl: caldavServerUrl, headers, fetchOptions })
.then((result) => {
if (!cancelled && result.success) {
setIsConnected(true);
}
})
.catch((err) => {
console.error("Failed to connect to CalDAV:", err);
});
return () => {
cancelled = true;
};
}, [caldavService, user]);
const query = useQuery({
queryKey: WORKING_HOURS_KEY,
queryFn: async (): Promise<AvailabilitySlots> => {
const result = await caldavService.getAvailability();
if (
!result.success ||
typeof result.data !== "string" ||
!result.data.includes("VAVAILABILITY")
) {
return DEFAULT_AVAILABILITY;
}
const slots = vCalendarToSlots(result.data);
return slots.length > 0 ? slots : DEFAULT_AVAILABILITY;
},
enabled: isConnected,
});
const mutation = useMutation({
mutationFn: async (slots: AvailabilitySlots) => {
const vcalendar = slotsToVCalendar(slots);
const result = await caldavService.setAvailability(vcalendar);
if (!result.success) {
throw new Error(
result.error ?? "Failed to save availability",
);
}
},
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: WORKING_HOURS_KEY,
});
},
});
return {
slots: query.data ?? DEFAULT_AVAILABILITY,
isLoading: !isConnected || query.isLoading,
save: mutation.mutateAsync,
isSaving: mutation.isPending,
};
};

View File

@@ -0,0 +1,164 @@
import type { DayOfWeek, AvailabilitySlots } from "./types";
import { generateSlotId } from "./types";
/** Map day names to iCal BYDAY abbreviations */
const DAY_TO_ICAL: Record<DayOfWeek, string> = {
monday: "MO",
tuesday: "TU",
wednesday: "WE",
thursday: "TH",
friday: "FR",
saturday: "SA",
sunday: "SU",
};
/** Reverse map: iCal abbreviation to day name */
const ICAL_TO_DAY: Record<string, DayOfWeek> = Object.fromEntries(
Object.entries(DAY_TO_ICAL).map(([k, v]) => [v, k as DayOfWeek]),
) as Record<string, DayOfWeek>;
/**
* Anchor dates for each weekday (week of 2026-01-05, a Monday).
* Used as DTSTART reference for recurring AVAILABLE components.
*/
const DAY_ANCHORS: Record<DayOfWeek, string> = {
monday: "20260105",
tuesday: "20260106",
wednesday: "20260107",
thursday: "20260108",
friday: "20260109",
saturday: "20260110",
sunday: "20260111",
};
/**
* Convert AvailabilitySlots to a VCALENDAR string with VAVAILABILITY.
*
* Groups recurring slots with identical time ranges into single
* AVAILABLE components with multi-day BYDAY rules.
* Specific-date slots each get their own AVAILABLE block without RRULE.
*/
export function slotsToVCalendar(slots: AvailabilitySlots): string {
// Separate recurring vs specific
const recurringSlots = slots.filter(
(s) => s.when.type === "recurring",
);
const specificSlots = slots.filter(
(s) => s.when.type === "specific",
);
// Group recurring by time range
const groups = new Map<
string,
{ days: DayOfWeek[]; start: string; end: string }
>();
for (const slot of recurringSlots) {
if (slot.when.type !== "recurring") continue;
const key = `${slot.start}-${slot.end}`;
const group = groups.get(key);
if (group) {
group.days.push(slot.when.day);
} else {
groups.set(key, {
days: [slot.when.day],
start: slot.start,
end: slot.end,
});
}
}
const blocks: string[] = [];
// Recurring blocks
for (const { days, start, end } of groups.values()) {
const byDay = days.map((d) => DAY_TO_ICAL[d]).join(",");
const anchor = DAY_ANCHORS[days[0]];
const startTime = start.replace(":", "");
const endTime = end.replace(":", "");
blocks.push(`BEGIN:AVAILABLE
DTSTART:${anchor}T${startTime}00
DTEND:${anchor}T${endTime}00
RRULE:FREQ=WEEKLY;BYDAY=${byDay}
END:AVAILABLE`);
}
// Specific-date blocks (skip past dates)
const today = new Date().toISOString().slice(0, 10);
for (const slot of specificSlots) {
if (slot.when.type !== "specific") continue;
if (slot.when.date < today) continue;
const dateStr = slot.when.date.replace(/-/g, "");
const startTime = slot.start.replace(":", "");
const endTime = slot.end.replace(":", "");
blocks.push(`BEGIN:AVAILABLE
DTSTART:${dateStr}T${startTime}00
DTEND:${dateStr}T${endTime}00
END:AVAILABLE`);
}
const inner = blocks.length > 0 ? "\n" + blocks.join("\n") + "\n" : "\n";
return `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Calendars//Working Hours//EN
BEGIN:VAVAILABILITY${inner}END:VAVAILABILITY
END:VCALENDAR`;
}
/**
* Parse a VCALENDAR with VAVAILABILITY back into AvailabilitySlots.
*/
export function vCalendarToSlots(vcalendar: string): AvailabilitySlots {
const slots: AvailabilitySlots = [];
const availableRegex = /BEGIN:AVAILABLE[\s\S]*?END:AVAILABLE/g;
let match;
while ((match = availableRegex.exec(vcalendar)) !== null) {
const block = match[0];
const startMatch = block.match(
/DTSTART:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})\d{2}/,
);
const endMatch = block.match(
/DTEND:\d{8}T(\d{2})(\d{2})\d{2}/,
);
if (!startMatch || !endMatch) continue;
const start = `${startMatch[4]}:${startMatch[5]}`;
const end = `${endMatch[1]}:${endMatch[2]}`;
const dateStr = `${startMatch[1]}-${startMatch[2]}-${startMatch[3]}`;
const byDayMatch = block.match(/BYDAY=([A-Z,]+)/);
if (byDayMatch) {
// Recurring: create one slot per day in the BYDAY list
const days = byDayMatch[1].split(",");
for (const icalDay of days) {
const dayName = ICAL_TO_DAY[icalDay];
if (dayName) {
slots.push({
id: generateSlotId(),
when: { type: "recurring", day: dayName },
start,
end,
});
}
}
} else {
// Specific date — skip past dates
const today = new Date().toISOString().slice(0, 10);
if (dateStr < today) continue;
slots.push({
id: generateSlotId(),
when: { type: "specific", date: dateStr },
start,
end,
});
}
}
return slots;
}

View File

@@ -0,0 +1,102 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
DAYS_OF_WEEK,
type AvailabilitySlot,
type AvailabilityWhen,
type DayOfWeek,
} from "../types";
interface AvailabilityRowProps {
slot: AvailabilitySlot;
onChange: (id: string, updates: Partial<AvailabilitySlot>) => void;
onDelete: (id: string) => void;
}
export const AvailabilityRow = ({
slot,
onChange,
onDelete,
}: AvailabilityRowProps) => {
const { t } = useTranslation();
const selectValue =
slot.when.type === "recurring" ? slot.when.day : "specific";
const handleWhenChange = useCallback(
(value: string) => {
let when: AvailabilityWhen;
if (value === "specific") {
// Default to today's date
const today = new Date().toISOString().split("T")[0];
when = { type: "specific", date: today };
} else {
when = { type: "recurring", day: value as DayOfWeek };
}
onChange(slot.id, { when });
},
[onChange, slot.id],
);
const handleDateChange = useCallback(
(date: string) => {
onChange(slot.id, {
when: { type: "specific", date },
});
},
[onChange, slot.id],
);
return (
<div className="working-hours__row">
<div className="working-hours__row-when">
<select
className="working-hours__select"
value={selectValue}
onChange={(e) => handleWhenChange(e.target.value)}
>
{DAYS_OF_WEEK.map((day) => (
<option key={day} value={day}>
{t(`settings.workingHours.every${day.charAt(0).toUpperCase() + day.slice(1)}`)}
</option>
))}
<option value="specific">
{t("settings.workingHours.specificDate")}
</option>
</select>
{slot.when.type === "specific" && (
<input
type="date"
className="working-hours__date-input"
value={slot.when.date}
onChange={(e) => handleDateChange(e.target.value)}
/>
)}
</div>
<input
type="time"
className="working-hours__row-time"
value={slot.start}
onChange={(e) => onChange(slot.id, { start: e.target.value })}
/>
<input
type="time"
className="working-hours__row-time"
value={slot.end}
onChange={(e) => onChange(slot.id, { end: e.target.value })}
/>
<button
type="button"
className="working-hours__row-delete"
onClick={() => onDelete(slot.id)}
aria-label={t("settings.workingHours.removeAvailability")}
>
<span className="material-icons">close</span>
</button>
</div>
);
};

View File

@@ -0,0 +1,150 @@
.settings-page {
max-width: 900px;
margin: 0 auto;
padding: var(--c--globals--spacings--base);
}
.working-hours {
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--c--globals--spacings--xs);
h2 {
margin: 0;
font-size: 1.5rem;
}
}
&__title-row {
display: flex;
align-items: center;
gap: var(--c--globals--spacings--xs);
}
&__description {
color: var(--c--contextuals--content--semantic--neutral--tertiary);
margin-bottom: var(--c--globals--spacings--base);
}
&__grid {
display: flex;
flex-direction: column;
gap: var(--c--globals--spacings--2xs);
}
&__grid-header {
display: grid;
grid-template-columns: 1fr 120px 120px 36px;
gap: var(--c--globals--spacings--sm);
padding: var(--c--globals--spacings--xs) var(--c--globals--spacings--sm);
font-weight: 600;
font-size: 0.85rem;
color: var(--c--contextuals--content--semantic--neutral--tertiary);
}
&__row {
display: grid;
grid-template-columns: 1fr 120px 120px 36px;
gap: var(--c--globals--spacings--sm);
align-items: center;
padding: var(--c--globals--spacings--xs) var(--c--globals--spacings--sm);
border: 1px solid var(--c--contextuals--border--semantic--neutral--secondary);
border-radius: 8px;
transition: box-shadow 0.15s ease;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
}
&__row-when {
display: flex;
align-items: center;
gap: var(--c--globals--spacings--xs);
min-width: 0;
}
&__select {
flex: 1;
min-width: 0;
padding: var(--c--globals--spacings--2xs) var(--c--globals--spacings--xs);
border: 1px solid var(--c--contextuals--border--semantic--neutral--secondary);
border-radius: 6px;
font-size: 0.9rem;
background: var(--c--contextuals--background--surface--primary);
color: var(--c--contextuals--content--semantic--neutral--primary);
cursor: pointer;
}
&__date-input {
width: 140px;
padding: var(--c--globals--spacings--2xs) var(--c--globals--spacings--xs);
border: 1px solid var(--c--contextuals--border--semantic--neutral--secondary);
border-radius: 6px;
font-size: 0.9rem;
background: var(--c--contextuals--background--surface--primary);
color: var(--c--contextuals--content--semantic--neutral--primary);
}
&__row-time {
padding: var(--c--globals--spacings--2xs) var(--c--globals--spacings--xs);
border: 1px solid var(--c--contextuals--border--semantic--neutral--secondary);
border-radius: 6px;
font-size: 0.9rem;
background: var(--c--contextuals--background--surface--primary);
color: var(--c--contextuals--content--semantic--neutral--primary);
}
&__row-delete {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: none;
cursor: pointer;
color: var(--c--contextuals--content--semantic--neutral--tertiary);
padding: 0;
&:hover {
background-color: var(--c--contextuals--background--surface--secondary);
color: var(--c--contextuals--content--semantic--danger--primary);
}
.material-icons {
font-size: 18px;
}
}
&__add-row {
margin-top: var(--c--globals--spacings--xs);
}
&__actions {
display: flex;
justify-content: flex-end;
margin-top: var(--c--globals--spacings--base);
}
&__loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--c--globals--spacings--xl) 0;
color: var(--c--contextuals--content--semantic--neutral--tertiary);
.material-icons {
font-size: 48px;
margin-bottom: var(--c--globals--spacings--sm);
}
}
&__spinner {
animation: spin 1.5s linear infinite;
}
}

View File

@@ -0,0 +1,161 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import { useRouter } from "next/router";
import {
addToast,
ToasterItem,
} from "@/features/ui/components/toaster/Toaster";
import { useWorkingHours } from "../api/useWorkingHours";
import {
type AvailabilitySlot,
type AvailabilitySlots,
generateSlotId,
} from "../types";
import { AvailabilityRow } from "./AvailabilityRow";
interface AvailabilityFormProps {
initialSlots: AvailabilitySlots;
onSave: (slots: AvailabilitySlots) => Promise<void>;
isSaving: boolean;
}
const AvailabilityForm = ({
initialSlots,
onSave,
isSaving,
}: AvailabilityFormProps) => {
const { t } = useTranslation();
const router = useRouter();
const [slots, setSlots] = useState<AvailabilitySlots>(initialSlots);
const addSlot = useCallback(() => {
setSlots((prev) => [
...prev,
{
id: generateSlotId(),
when: { type: "recurring", day: "monday" },
start: "09:00",
end: "18:00",
},
]);
}, []);
const removeSlot = useCallback((id: string) => {
setSlots((prev) => prev.filter((s) => s.id !== id));
}, []);
const updateSlot = useCallback(
(id: string, updates: Partial<AvailabilitySlot>) => {
setSlots((prev) =>
prev.map((s) => (s.id === id ? { ...s, ...updates } : s)),
);
},
[],
);
const handleSave = async () => {
try {
await onSave(slots);
addToast(
<ToasterItem type="info">
<span>{t("settings.workingHours.saved")}</span>
</ToasterItem>,
);
} catch {
addToast(
<ToasterItem type="error">
<span>{t("api.error.unexpected")}</span>
</ToasterItem>,
);
}
};
return (
<div className="working-hours">
<div className="working-hours__header">
<div className="working-hours__title-row">
<Button
color="neutral"
size="small"
icon={
<span className="material-icons">arrow_back</span>
}
onClick={() => void router.push("/calendar")}
aria-label={t("app_title")}
/>
<h2>{t("settings.workingHours.title")}</h2>
</div>
</div>
<p className="working-hours__description">
{t("settings.workingHours.description")}
</p>
<div className="working-hours__grid">
<div className="working-hours__grid-header">
<span>{t("settings.workingHours.when")}</span>
<span>{t("settings.workingHours.start")}</span>
<span>{t("settings.workingHours.end")}</span>
<span />
</div>
{slots.map((slot) => (
<AvailabilityRow
key={slot.id}
slot={slot}
onChange={updateSlot}
onDelete={removeSlot}
/>
))}
</div>
<div className="working-hours__add-row">
<Button
color="neutral"
size="small"
icon={<span className="material-icons">add</span>}
onClick={addSlot}
>
{t("settings.workingHours.addAvailability")}
</Button>
</div>
<div className="working-hours__actions">
<Button
color="brand"
onClick={() => void handleSave()}
disabled={isSaving}
>
{t("settings.workingHours.save")}
</Button>
</div>
</div>
);
};
export const WorkingHoursSettings = () => {
const { slots, isLoading, save, isSaving } = useWorkingHours();
if (isLoading) {
return (
<div className="working-hours__loading">
<span className="material-icons working-hours__spinner">
hourglass_empty
</span>
</div>
);
}
return (
<AvailabilityForm
key={JSON.stringify(slots)}
initialSlots={slots}
onSave={save}
isSaving={isSaving}
/>
);
};

View File

@@ -0,0 +1,46 @@
export interface DaySchedule {
start: string; // "HH:MM" format
end: string; // "HH:MM" format
}
export type DayOfWeek =
| "monday"
| "tuesday"
| "wednesday"
| "thursday"
| "friday"
| "saturday"
| "sunday";
export const DAYS_OF_WEEK: DayOfWeek[] = [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
];
export type AvailabilityWhen =
| { type: "recurring"; day: DayOfWeek }
| { type: "specific"; date: string }; // "YYYY-MM-DD"
export interface AvailabilitySlot {
id: string;
when: AvailabilityWhen;
start: string; // "HH:MM"
end: string; // "HH:MM"
}
export type AvailabilitySlots = AvailabilitySlot[];
export const generateSlotId = (): string => crypto.randomUUID();
export const DEFAULT_AVAILABILITY: AvailabilitySlots = [
{ id: "d-mon", when: { type: "recurring", day: "monday" }, start: "09:00", end: "18:00" },
{ id: "d-tue", when: { type: "recurring", day: "tuesday" }, start: "09:00", end: "18:00" },
{ id: "d-wed", when: { type: "recurring", day: "wednesday" }, start: "09:00", end: "18:00" },
{ id: "d-thu", when: { type: "recurring", day: "thursday" }, start: "09:00", end: "18:00" },
{ id: "d-fri", when: { type: "recurring", day: "friday" }, start: "09:00", end: "18:00" },
];

View File

@@ -0,0 +1,44 @@
import { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchAPI } from "@/features/api/fetchApi";
export interface UserSearchResult {
id: string;
email: string;
full_name: string;
}
interface PaginatedResponse {
count: number;
next: string | null;
previous: string | null;
results: UserSearchResult[];
}
const DEBOUNCE_MS = 300;
const MIN_QUERY_LENGTH = 3;
function useDebouncedValue(value: string, delay: number): string {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
export const useUserSearch = (query: string) => {
const debouncedQuery = useDebouncedValue(query.trim(), DEBOUNCE_MS);
return useQuery({
queryKey: ["users", "search", debouncedQuery],
queryFn: async () => {
const response = await fetchAPI("users/", {
params: { q: debouncedQuery },
});
const data = (await response.json()) as PaginatedResponse;
return data.results;
},
enabled: debouncedQuery.length >= MIN_QUERY_LENGTH,
});
};

View File

@@ -0,0 +1,79 @@
import { useEffect } from "react";
import { MainLayout } from "@gouvfr-lasuite/ui-kit";
import Head from "next/head";
import { useTranslation } from "next-i18next";
import { useRouter } from "next/router";
import { login, useAuth } from "@/features/auth/Auth";
import { GlobalLayout } from "@/features/layouts/components/global/GlobalLayout";
import {
HeaderIcon,
HeaderRight,
} from "@/features/layouts/components/header/Header";
import { SpinnerPage } from "@/features/ui/components/spinner/SpinnerPage";
import { Toaster } from "@/features/ui/components/toaster/Toaster";
import { WorkingHoursSettings } from "@/features/settings/components/WorkingHoursSettings";
export default function SettingsPage() {
const { t } = useTranslation();
const { user } = useAuth();
const router = useRouter();
useEffect(() => {
if (!user) {
login(window.location.href);
} else if (user.can_access === false) {
void router.push("/no-access");
}
}, [user, router]);
if (!user || user.can_access === false) {
return <SpinnerPage />;
}
return (
<>
<Head>
<title>
{t("settings.workingHours.title")} -{" "}
{t("app_title")}
</title>
<meta
name="description"
content={t(
"settings.workingHours.description",
)}
/>
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<link rel="icon" href="/favicon.png" />
</Head>
<div className="settings-page">
<WorkingHoursSettings />
</div>
<Toaster />
</>
);
}
SettingsPage.getLayout = function getLayout(
page: React.ReactElement,
) {
return (
<GlobalLayout>
<MainLayout
enableResize={false}
hideLeftPanelOnDesktop={true}
icon={<HeaderIcon />}
rightHeaderContent={<HeaderRight />}
>
{page}
</MainLayout>
</GlobalLayout>
);
};

View File

@@ -1,29 +1,31 @@
@use "@gouvfr-lasuite/ui-kit/sass/fonts";
@use "@gouvfr-lasuite/ui-kit/style";
@use "./cunningham-tokens.css";
@use "./../features/layouts/components/global/GlobalLayout.scss";
@use "./../features/feedback/Feedback.scss";
@use "./../features/layouts/components/header/index.scss";
@use "./../features/layouts/components/global/GlobalLayout";
@use "./../features/feedback/Feedback";
@use "./../features/layouts/components/header/index";
@use "./../features/ui/components/toaster";
@use "./../features/ui/components/generic-disclaimer/GenericDisclaimer.scss";
@use "./../features/ui/components/spinner/SpinnerPage.scss";
@use "./../features/ui/components/logo/DynamicCalendarLogo.scss";
@use "./../features/layouts/components/left-panel/LeftPanelMobile.scss";
@use "./../features/calendar/components/left-panel/MiniCalendar.scss";
@use "./../features/calendar/components/calendar-list/CalendarList.scss";
@use "./../features/calendar/components/left-panel/LeftPanel.scss";
@use "./../features/calendar/components/scheduler/EventModal.scss";
@use "./../features/calendar/components/scheduler/RecurrenceEditor.scss";
@use "./../features/calendar/components/scheduler/AttendeesInput.scss";
@use "./../features/calendar/components/scheduler/Scheduler.scss";
@use "./../features/calendar/components/scheduler/scheduler-theme.scss";
@use "./../features/calendar/components/scheduler/SchedulerToolbar.scss";
@use "./../features/calendar/components/scheduler/event-modal-sections/SectionRow.scss";
@use "./../features/calendar/components/scheduler/event-modal-sections/InvitationResponseSection.scss";
@use "./../features/calendar/components/scheduler/event-modal-sections/SectionPill.scss";
@use "./../features/ui/components/generic-disclaimer/GenericDisclaimer";
@use "./../features/ui/components/spinner/SpinnerPage";
@use "./../features/ui/components/logo/DynamicCalendarLogo";
@use "./../features/layouts/components/left-panel/LeftPanelMobile";
@use "./../features/calendar/components/left-panel/MiniCalendar";
@use "./../features/calendar/components/calendar-list/CalendarList";
@use "./../features/calendar/components/left-panel/LeftPanel";
@use "./../features/calendar/components/scheduler/EventModal";
@use "./../features/calendar/components/scheduler/RecurrenceEditor";
@use "./../features/calendar/components/scheduler/AttendeesInput";
@use "./../features/calendar/components/scheduler/Scheduler";
@use "./../features/calendar/components/scheduler/scheduler-theme";
@use "./../features/calendar/components/scheduler/SchedulerToolbar";
@use "./../features/calendar/components/scheduler/event-modal-sections/SectionRow";
@use "./../features/calendar/components/scheduler/event-modal-sections/InvitationResponseSection";
@use "./../features/calendar/components/scheduler/event-modal-sections/SectionPill";
@use "./../features/calendar/components/scheduler/FreeBusyTimeline";
@use "./../features/resources/components/Resources";
@use "./../pages/index.scss" as *;
@use "./../pages/calendar.scss" as *;
@use "./../features/settings/components/WorkingHoursSettings";
@use "./../pages/index" as *;
@use "./../pages/calendar" as *;
html,
body {
margin: 0;

View File

@@ -25,7 +25,6 @@ declare module "@event-calendar/core" {
updateEvent: (event: unknown) => void;
removeEventById: (id: string) => void;
unselect: () => void;
$destroy?: () => void;
}
export function createCalendar(
@@ -34,6 +33,8 @@ declare module "@event-calendar/core" {
options: Record<string, unknown>
): Calendar;
export function destroyCalendar(calendar: Calendar): void;
export const TimeGrid: unknown;
export const DayGrid: unknown;
export const List: unknown;