From 8cdbfb9519e184edbaf4686d71246c229498714f Mon Sep 17 00:00:00 2001 From: Mel <97147377+MelissaAutumn@users.noreply.github.com> Date: Wed, 16 Oct 2024 14:05:32 -0700 Subject: [PATCH] CalDAV autodiscovery (#718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial WIP of caldav in FTUE + as an external connection * Additional changes for caldav on FTUE: * Renamed ftueStep.GooglePermissions to ftueStep.CalendarProvider * Add a switch button during the FTUE calendar provider step * Add modal text * Refactor LoginView::handleFormError to be generic and now lives in utils * Allow force fetching of external connections * Add CalDAV as an external connection option * Refactor GoogleOauthProvider to be more in-line with how CalDAVProvider works * Add shortcuts for decrypt/encrypt in Python utils * Cache dns lookup for the remainder of the ttl * Add missing i18n * Adjust how caldav connections are tested, and only pull in supported calendars * Split the various methods we check caldav urls into separate functions * Check if service is explicitly not supported * Add some tests * 🌐 Update German translation * Fix handleFormErrors * Rebase migration down id * Fix unused imports, add some optional props, and fix secure calendars * Fix a string lookup issue --------- Co-authored-by: Andreas Müller --- backend/requirements.txt | 1 + backend/src/appointment/commands/update_db.py | 1 + .../src/appointment/controller/calendar.py | 163 ++++++++++++++++-- backend/src/appointment/database/models.py | 1 + .../src/appointment/database/repo/calendar.py | 6 +- backend/src/appointment/database/schemas.py | 6 + backend/src/appointment/main.py | 2 + ...cf5d3ee14b_update_external_connections_.py | 32 ++++ backend/src/appointment/routes/api.py | 55 ++++-- backend/src/appointment/routes/caldav.py | 127 ++++++++++++++ backend/src/appointment/routes/schedule.py | 2 + backend/src/appointment/utils.py | 8 + backend/test/conftest.py | 2 +- backend/test/integration/test_auth.py | 55 +++++- backend/test/integration/test_calendar.py | 2 +- backend/test/integration/test_schedule.py | 6 +- backend/test/unit/test_calendar_tools.py | 18 ++ .../src/components/FTUE/CalDavProvider.vue | 147 ++++++++++++++++ .../src/components/FTUE/CalendarProvider.vue | 66 +++++++ ...ermissions.vue => GoogleOauthProvider.vue} | 30 +++- .../src/components/SettingsConnections.vue | 66 ++++++- frontend/src/definitions.ts | 13 +- frontend/src/locales/de.json | 36 +++- frontend/src/locales/en.json | 31 +++- frontend/src/models.ts | 1 + .../src/stores/external-connections-store.ts | 22 ++- frontend/src/stores/ftue-store.ts | 8 +- frontend/src/utils.ts | 67 ++++++- .../src/views/FirstTimeUserExperienceView.vue | 4 +- frontend/src/views/LoginView.vue | 29 +--- 30 files changed, 894 insertions(+), 113 deletions(-) create mode 100644 backend/src/appointment/migrations/versions/2024_10_09_2006-71cf5d3ee14b_update_external_connections_.py create mode 100644 backend/src/appointment/routes/caldav.py create mode 100644 frontend/src/components/FTUE/CalDavProvider.vue create mode 100644 frontend/src/components/FTUE/CalendarProvider.vue rename frontend/src/components/FTUE/{GooglePermissions.vue => GoogleOauthProvider.vue} (89%) diff --git a/backend/requirements.txt b/backend/requirements.txt index 651f7df58..6e18d2721 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -34,3 +34,4 @@ redis==5.0.7 hiredis==2.3.2 posthog==3.5.0 slowapi==0.1.9 +dnspython==2.7.0 diff --git a/backend/src/appointment/commands/update_db.py b/backend/src/appointment/commands/update_db.py index 922911625..f4d3fa64f 100644 --- a/backend/src/appointment/commands/update_db.py +++ b/backend/src/appointment/commands/update_db.py @@ -37,3 +37,4 @@ def run(): else: print('Database already initialized, running migrations') command.upgrade(alembic_cfg, 'head') + print('Finished checking database') diff --git a/backend/src/appointment/controller/calendar.py b/backend/src/appointment/controller/calendar.py index fe1bbe961..150782e9f 100644 --- a/backend/src/appointment/controller/calendar.py +++ b/backend/src/appointment/controller/calendar.py @@ -8,10 +8,13 @@ import time import zoneinfo import os +from socket import getaddrinfo +from urllib.parse import urlparse, urljoin import caldav.lib.error import requests import sentry_sdk +from dns.exception import DNSException from redis import Redis, RedisCluster from caldav import DAVClient from fastapi import BackgroundTasks @@ -19,7 +22,10 @@ from icalendar import Calendar, Event, vCalAddress, vText from datetime import datetime, timedelta, timezone, UTC +from sqlalchemy.orm import Session + from .. import utils +from ..database.schemas import CalendarConnection from ..defines import REDIS_REMOTE_EVENTS_KEY, DATEFMT from .apis.google_client import GoogleClient from ..database.models import CalendarProvider, BookingStatus @@ -279,40 +285,79 @@ def delete_events(self, start): class CalDavConnector(BaseConnector): - def __init__(self, subscriber_id: int, calendar_id: int, redis_instance, url: str, user: str, password: str): + def __init__(self, db: Session, subscriber_id: int, calendar_id: int, redis_instance, url: str, user: str, password: str): super().__init__(subscriber_id, calendar_id, redis_instance) + self.db = db self.provider = CalendarProvider.caldav - self.url = url if url[-1] == '/' else url + '/' + self.url = url self.password = password self.user = user # connect to the CalDAV server - self.client = DAVClient(url=url, username=user, password=password) + self.client = DAVClient(url=self.url, username=self.user, password=self.password) def test_connection(self) -> bool: """Ensure the connection information is correct and the calendar connection works""" + supports_vevent = False + try: - cal = self.client.calendar(url=self.url) - supported_comps = cal.get_supported_components() + cals = self.client.principal() + for cal in cals.calendars(): + supported_comps = cal.get_supported_components() + supports_vevent = 'VEVENT' in supported_comps + # If one supports it, then that's good enough! + if supports_vevent: + break except IndexError as ex: # Library has an issue with top level urls, probably due to caldav spec? - logging.error(f'Error testing connection {ex}') + logging.error(f'IE: Error testing connection {ex}') return False except KeyError as ex: - logging.error(f'Error testing connection {ex}') + logging.error(f'KE: Error testing connection {ex}') return False - except requests.exceptions.RequestException: # Max retries exceeded, bad connection, missing schema, etc... + except requests.exceptions.RequestException as ex: # Max retries exceeded, bad connection, missing schema, etc... return False - except caldav.lib.error.NotFoundError: # Good server, bad url. + except caldav.lib.error.NotFoundError as ex: # Good server, bad url. return False # They need at least VEVENT support for appointment to work. - return 'VEVENT' in supported_comps + return supports_vevent def sync_calendars(self): - # We don't sync anything for caldav, but might as well bust event cache. - self.bust_cached_events(all_calendars=True) + error_occurred = False + + principal = self.client.principal() + for cal in principal.calendars(): + # Does this calendar support vevents? + supported_comps = cal.get_supported_components() + + if 'VEVENT' not in supported_comps: + continue + + calendar = schemas.CalendarConnection( + title=cal.name, + url=str(cal.url), + user=self.user, + password=self.password, + provider=CalendarProvider.caldav + ) + + # add calendar + try: + repo.calendar.update_or_create( + db=self.db, calendar=calendar, calendar_url=calendar.url, subscriber_id=self.subscriber_id + ) + except Exception as err: + logging.warning( + f'[calendar.sync_calendars] Error occurred while creating calendar. Error: {str(err)}' + ) + error_occurred = True + + if not error_occurred: + self.bust_cached_events(all_calendars=True) + + return error_occurred def list_calendars(self): """find all calendars on the remote server""" @@ -647,6 +692,7 @@ def existing_events_for_schedule( ) else: con = CalDavConnector( + db=db, redis_instance=redis, url=calendar.url, user=calendar.user, @@ -684,3 +730,96 @@ def existing_events_for_schedule( ) return existing_events + + @staticmethod + def dns_caldav_lookup(url, secure=True): + import dns.resolver + + secure_character = 's' if secure else '' + dns_url = f'_caldav{secure_character}._tcp.{url}' + path = '' + + # Check if they have a caldav subdomain, on error just return none + try: + records = dns.resolver.resolve(dns_url, 'SRV') + except DNSException: + return None, None + + # Check if they have any relative paths setup + try: + txt_records = dns.resolver.resolve(dns_url, 'TXT') + + for txt_record in txt_records: + # Remove any quotes from the txt record + txt_record = str(txt_record).replace('"', '') + if txt_record.startswith('path='): + path = txt_record[5:] + except DNSException: + pass + + # Append a slash at the end if it's missing + path += '' if path.endswith('/') else '/' + + # Grab the first item or None + caldav_host = None + port = 443 if secure else 80 + ttl = records.rrset.ttl or 300 + if len(records) > 0: + port = str(records[0].port) + caldav_host = str(records[0].target)[:-1] + + # Service not provided + if caldav_host == ".": + return None, None + + if '://' in caldav_host: + # Remove any protocols first! + caldav_host.replace('http://', '').replace('https://', '') + + # We should only be pulling the secure link + caldav_host = f'http{secure_character}://{caldav_host}:{port}{path}' + + return caldav_host, ttl + + @staticmethod + def well_known_caldav_lookup(url: str) -> str|None: + parsed_url = urlparse(url) + + # Do they have a well-known? + if parsed_url.path == '' and parsed_url.hostname != '': + try: + response = requests.get(urljoin(url, '/.well-known/caldav'), allow_redirects=False) + if response.is_redirect: + redirect = response.headers.get('Location') + if redirect: + # Fastmail really needs that ending slash + return redirect if redirect.endswith('/') else redirect + '/' + except requests.exceptions.ConnectionError: + # Ignore connection errors here + pass + return None + + @staticmethod + def fix_caldav_urls(url: str) -> str: + """Fix up some common url issues with some caldav providers""" + parsed_url = urlparse(url) + + # Handle any fastmail issues + if 'fastmail.com' in parsed_url.hostname: + if not parsed_url.path.startswith('/dav'): + url = f'{url}/dav/' + + # Url needs to end with slash + if not url.endswith('/'): + url += '/' + + # Google is weird - We also don't support them right now. + elif ('api.googlecontent.com' in parsed_url.hostname + or 'apidata.googleusercontent.com' in parsed_url.hostname): + if len(parsed_url.path) == 0: + url += '/caldav/v2/' + elif 'calendar.google.com' in parsed_url.hostname or '': + # Use the caldav url instead + url = 'https://api.googlecontent.com/caldav/v2/' + + return url diff --git a/backend/src/appointment/database/models.py b/backend/src/appointment/database/models.py index 29b431d73..6aae28144 100644 --- a/backend/src/appointment/database/models.py +++ b/backend/src/appointment/database/models.py @@ -70,6 +70,7 @@ class ExternalConnectionType(enum.Enum): zoom = 1 google = 2 fxa = 3 + caldav = 4 class MeetingLinkProviderType(enum.StrEnum): diff --git a/backend/src/appointment/database/repo/calendar.py b/backend/src/appointment/database/repo/calendar.py index df245a978..16bd7fc18 100644 --- a/backend/src/appointment/database/repo/calendar.py +++ b/backend/src/appointment/database/repo/calendar.py @@ -4,6 +4,7 @@ """ from datetime import datetime +from typing import Optional from fastapi import HTTPException from sqlalchemy.orm import Session @@ -135,11 +136,14 @@ def delete_by_subscriber(db: Session, subscriber_id: int): return True -def delete_by_subscriber_and_provider(db: Session, subscriber_id: int, provider: models.CalendarProvider): +def delete_by_subscriber_and_provider(db: Session, subscriber_id: int, provider: models.CalendarProvider, user: Optional[str] = None): """Delete all subscriber's calendar by a provider""" calendars = get_by_subscriber(db, subscriber_id=subscriber_id) for calendar in calendars: if calendar.provider == provider: + # If user is provided and it's not the same as the calendar user then we can skip + if user and user != calendar.user: + continue delete(db, calendar_id=calendar.id) return True diff --git a/backend/src/appointment/database/schemas.py b/backend/src/appointment/database/schemas.py index 2edc82a0e..840e1a0d5 100644 --- a/backend/src/appointment/database/schemas.py +++ b/backend/src/appointment/database/schemas.py @@ -257,6 +257,12 @@ class CalendarConnection(CalendarConnectionOut): password: str +class CalendarConnectionIn(CalendarConnection): + url: str = Field(min_length=1) + user: str = Field(min_length=1) + password: Optional[str] + + class Calendar(CalendarConnection): id: int owner_id: int diff --git a/backend/src/appointment/main.py b/backend/src/appointment/main.py index a6fd28b9e..54f4fec50 100644 --- a/backend/src/appointment/main.py +++ b/backend/src/appointment/main.py @@ -121,6 +121,7 @@ def server(): from .routes import api from .routes import auth from .routes import account + from .routes import caldav from .routes import google from .routes import schedule from .routes import invite @@ -217,6 +218,7 @@ async def catch_rate_limit_exceeded_errors(request, exc): app.include_router(api.router) app.include_router(auth.router) # Special case! app.include_router(account.router, prefix='/account') + app.include_router(caldav.router, prefix='/caldav') app.include_router(google.router, prefix='/google') app.include_router(schedule.router, prefix='/schedule') app.include_router(invite.router, prefix='/invite') diff --git a/backend/src/appointment/migrations/versions/2024_10_09_2006-71cf5d3ee14b_update_external_connections_.py b/backend/src/appointment/migrations/versions/2024_10_09_2006-71cf5d3ee14b_update_external_connections_.py new file mode 100644 index 000000000..8d5df6f82 --- /dev/null +++ b/backend/src/appointment/migrations/versions/2024_10_09_2006-71cf5d3ee14b_update_external_connections_.py @@ -0,0 +1,32 @@ +"""update external connections type enum with caldav + +Revision ID: 71cf5d3ee14b +Revises: 01d80f00243f +Create Date: 2024-10-09 20:06:47.631534 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '71cf5d3ee14b' +down_revision = '502d0217a555' +branch_labels = None +depends_on = None + + +old_external_connections = ','.join(['"zoom"', '"google"', '"fxa"']) +new_external_connections = ','.join(['"zoom"', '"google"', '"fxa"', '"caldav"']) + + +def upgrade() -> None: + op.execute( + f'ALTER TABLE `external_connections` MODIFY COLUMN `type` enum({new_external_connections}) NOT NULL AFTER `name`;' # noqa: E501 + ) + + +def downgrade() -> None: + op.execute( + f'ALTER TABLE `external_connections` MODIFY COLUMN `type` enum({old_external_connections}) NOT NULL AFTER `name`;' # noqa: E501 + ) diff --git a/backend/src/appointment/routes/api.py b/backend/src/appointment/routes/api.py index c08084df0..098edcbfe 100644 --- a/backend/src/appointment/routes/api.py +++ b/backend/src/appointment/routes/api.py @@ -1,8 +1,10 @@ import datetime +import json import logging import os import secrets import uuid +from urllib.parse import urlparse import requests.exceptions import sentry_sdk @@ -172,6 +174,7 @@ def create_my_calendar( ) else: con = CalDavConnector( + db=db, redis_instance=None, url=calendar.url, user=calendar.user, @@ -294,6 +297,7 @@ def read_remote_calendars( ) else: con = CalDavConnector( + db=None, redis_instance=None, url=connection.url, user=connection.user, @@ -319,27 +323,43 @@ def sync_remote_calendars( ): """endpoint to sync calendars from a remote server""" # Create a list of connections and loop through them with sync - # TODO: Also handle CalDAV connections - - external_connection = utils.list_first( + google_ec = utils.list_first( repo.external_connection.get_by_type(db, subscriber.id, schemas.ExternalConnectionType.google) ) + caldav_ecs = repo.external_connection.get_by_type(db, subscriber.id, schemas.ExternalConnectionType.caldav) + + # Filter out any nulls + ecs = list(filter(lambda ec: ec, [ + google_ec, + *caldav_ecs + ])) - if external_connection is None or external_connection.token is None: + if len(ecs) == 0: raise RemoteCalendarConnectionError() - connections = [ - GoogleConnector( - db=db, - redis_instance=redis, - google_client=google_client, - remote_calendar_id=None, - calendar_id=None, - subscriber_id=subscriber.id, - google_tkn=external_connection.token, - ), - ] - for connection in connections: + for ec in ecs: + if ec.type == schemas.ExternalConnectionType.google: + connection = GoogleConnector( + db=db, + redis_instance=redis, + google_client=google_client, + remote_calendar_id=None, + calendar_id=None, + subscriber_id=subscriber.id, + google_tkn=ec.token, + ) + else: + url, username = json.loads(ec.type_id) + connection = CalDavConnector( + db=db, + subscriber_id=subscriber.id, + redis_instance=redis, + url=url, + user=username, + password=ec.token, + calendar_id=None, + ) + error_occurred = connection.sync_calendars() # And then redirect back to frontend if error_occurred: @@ -382,6 +402,7 @@ def read_remote_events( ) else: con = CalDavConnector( + db=db, redis_instance=redis_instance, url=db_calendar.url, user=db_calendar.user, @@ -464,3 +485,5 @@ def terms(): with open(f'{os.path.dirname(__file__)}/../templates/legal/en/terms.jinja2') as fh: contents = fh.read() return HTMLResponse(contents) + + diff --git a/backend/src/appointment/routes/caldav.py b/backend/src/appointment/routes/caldav.py new file mode 100644 index 000000000..c9627a8a0 --- /dev/null +++ b/backend/src/appointment/routes/caldav.py @@ -0,0 +1,127 @@ +import json +from typing import Optional +from urllib.parse import urlparse + +import requests + +from fastapi import APIRouter, Depends, Request +from redis import Redis +from sqlalchemy.orm import Session + +from appointment import utils +from appointment.controller.apis.google_client import GoogleClient +from appointment.controller.calendar import CalDavConnector, Tools, GoogleConnector +from appointment.database import models, schemas, repo +from appointment.dependencies.auth import get_subscriber +from appointment.dependencies.database import get_db, get_redis +from appointment.dependencies.google import get_google_client +from appointment.exceptions.validation import RemoteCalendarConnectionError + +router = APIRouter() + + +@router.post('/auth') +def caldav_autodiscover_auth( + connection: schemas.CalendarConnectionIn, + db: Session = Depends(get_db), + subscriber: models.Subscriber = Depends(get_subscriber), + redis_client: Redis = Depends(get_redis) +): + """Connects a principal caldav server""" + + # Does url need a protocol? + if '://' not in connection.url: + connection.url = f'https://{connection.url}' + + secure_protocol = 'https://' in connection.url + + dns_lookup_cache_key = f'dns:{utils.encrypt(connection.url)}' + + lookup_url = None + if redis_client: + lookup_url = redis_client.get(dns_lookup_cache_key) + + # Do a dns lookup first + if lookup_url is None: + parsed_url = urlparse(connection.url) + lookup_url, ttl = Tools.dns_caldav_lookup(parsed_url.hostname, secure=secure_protocol) + # set the cached lookup for the remainder of the dns ttl + if redis_client and lookup_url: + redis_client.set(dns_lookup_cache_key, utils.encrypt(lookup_url), ex=ttl) + else: + # Extract the cached value + lookup_url = utils.decrypt(lookup_url) + + # Check for well-known + if lookup_url is None: + lookup_url = Tools.well_known_caldav_lookup(connection.url) + + # If we have a lookup_url then apply it + if lookup_url: + connection.url = lookup_url + + # Finally perform any final fixups needed + connection.url = Tools.fix_caldav_urls(connection.url) + + con = CalDavConnector( + db=db, + redis_instance=None, + url=connection.url, + user=connection.user, + password=connection.password, + subscriber_id=subscriber.id, + calendar_id=None, + ) + + if not con.test_connection(): + raise RemoteCalendarConnectionError() + + caldav_id = json.dumps([connection.url, connection.user]) + external_connection = repo.external_connection.get_by_type( + db, subscriber.id, models.ExternalConnectionType.caldav, caldav_id + ) + + # Create or update the external connection + if not external_connection: + external_connection_schema = schemas.ExternalConnection( + name=connection.user, + type=models.ExternalConnectionType.caldav, + type_id=caldav_id, + owner_id=subscriber.id, + token=connection.password, + ) + + repo.external_connection.create(db, external_connection_schema) + else: + repo.external_connection.update_token( + db, connection.password, subscriber.id, models.ExternalConnectionType.caldav, caldav_id + ) + + con.sync_calendars() + return True + + +@router.post('/disconnect') +def disconnect_account( + type_id: Optional[str] = None, + db: Session = Depends(get_db), + subscriber: models.Subscriber = Depends(get_subscriber), +): + """Disconnects a google account. Removes associated data from our services and deletes the connection details.""" + ec = utils.list_first( + repo.external_connection.get_by_type(db, subscriber_id=subscriber.id, type=models.ExternalConnectionType.caldav, type_id=type_id) + ) + + if ec is None: + return RemoteCalendarConnectionError() + + # Deserialize the url/user + _, user = json.loads(ec.type_id) + + # Remove all the caldav calendars associated with this user + repo.calendar.delete_by_subscriber_and_provider(db, subscriber.id, provider=models.CalendarProvider.caldav, user=user) + + # Remove their account details + repo.external_connection.delete_by_type(db, subscriber.id, ec.type, ec.type_id) + + return True diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index 48e8c5f90..519ff571c 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -273,6 +273,7 @@ def request_schedule_availability_slot( ) else: con = CalDavConnector( + db=db, redis_instance=redis, subscriber_id=subscriber.id, calendar_id=calendar.id, @@ -556,6 +557,7 @@ def handle_schedule_availability_decision( ) else: con = CalDavConnector( + db=db, redis_instance=redis, subscriber_id=subscriber.id, calendar_id=calendar.id, diff --git a/backend/src/appointment/utils.py b/backend/src/appointment/utils.py index e48b70e6e..7b4ec1744 100644 --- a/backend/src/appointment/utils.py +++ b/backend/src/appointment/utils.py @@ -46,6 +46,14 @@ def setup_encryption_engine(): return engine +def encrypt(value): + return setup_encryption_engine().encrypt(value) + + +def decrypt(value): + return setup_encryption_engine().decrypt(value) + + def retrieve_user_url_data(url): """URL Decodes, and retrieves username, signature, and main url from ///""" parsed_url = parse.urlparse(url) diff --git a/backend/test/conftest.py b/backend/test/conftest.py index 4a6ed7c12..162e7c052 100644 --- a/backend/test/conftest.py +++ b/backend/test/conftest.py @@ -43,7 +43,7 @@ def _patch_caldav_connector(monkeypatch): # Create a mock caldav connector class MockCaldavConnector: @staticmethod - def __init__(self, redis_instance, url, user, password, subscriber_id, calendar_id): + def __init__(self, db, redis_instance, url, user, password, subscriber_id, calendar_id): """We don't want to initialize a client""" pass diff --git a/backend/test/integration/test_auth.py b/backend/test/integration/test_auth.py index 05cf07e13..8e729cc56 100644 --- a/backend/test/integration/test_auth.py +++ b/backend/test/integration/test_auth.py @@ -1,11 +1,13 @@ +import json import os import secrets from datetime import timedelta +from unittest.mock import patch from appointment.dependencies import auth from appointment.l10n import l10n from appointment.routes.auth import create_access_token -from defines import FXA_CLIENT_PATCH, auth_headers +from defines import FXA_CLIENT_PATCH, auth_headers, TEST_USER_ID from appointment.database import repo, models @@ -444,3 +446,54 @@ def test_fxa_token_failed_due_to_empty_auth(self, make_basic_subscriber, with_cl ) assert response.status_code == 401, response.text + + +class TestCalDAV: + def test_auth(self, with_db, with_client): + """Test authenticating a caldav connection""" + # Remove any possibility of caching + os.environ['REDIS_URL'] = '' + + with with_db() as db: + ecs = repo.external_connection.get_by_type(db, TEST_USER_ID, models.ExternalConnectionType.caldav) + assert len(ecs) == 0 + + with patch('appointment.controller.calendar.Tools.dns_caldav_lookup') as mock: + mock.return_value = "https://example.com", 300 + + with patch('appointment.controller.calendar.CalDavConnector.sync_calendars') as sync_mock: + sync_mock.return_value = None + + response = with_client.post( + '/caldav/auth', json={'user': 'test@example.com', 'url': 'example.com', 'password': 'test'}, headers=auth_headers + ) + + mock.assert_called() + sync_mock.assert_called() + + assert response.status_code == 200 + + with with_db() as db: + ecs = repo.external_connection.get_by_type(db, TEST_USER_ID, models.ExternalConnectionType.caldav) + assert len(ecs) == 1 + + def test_disconnect(self, with_db, with_client, make_external_connections, make_caldav_calendar): + """Ensure we remove the external connection and any related calendars""" + username = 'username' + type_id = json.dumps(['url', username]) + ec = make_external_connections(TEST_USER_ID, type=models.ExternalConnectionType.caldav, type_id=type_id) + calendar = make_caldav_calendar(subscriber_id=TEST_USER_ID, user=username) + + response = with_client.post( + '/caldav/disconnect', json={'type_id': ec.type_id}, + headers=auth_headers + ) + + assert response.status_code == 200, response.content + + with with_db() as db: + ecs = repo.external_connection.get_by_type(db, TEST_USER_ID, models.ExternalConnectionType.caldav, type_id=type_id) + assert len(ecs) == 0 + + calendar = repo.calendar.get(db, calendar.id) + assert calendar is None diff --git a/backend/test/integration/test_calendar.py b/backend/test/integration/test_calendar.py index e9cd14af5..6e97470df 100644 --- a/backend/test/integration/test_calendar.py +++ b/backend/test/integration/test_calendar.py @@ -30,7 +30,7 @@ def get_mock_connector_class(): class MockCaldavConnector: @staticmethod - def __init__(self, subscriber_id, calendar_id, redis_instance, url, user, password): + def __init__(self, db, subscriber_id, calendar_id, redis_instance, url, user, password): """We don't want to initialize a client""" pass diff --git a/backend/test/integration/test_schedule.py b/backend/test/integration/test_schedule.py index 764df9dbe..4c72ad563 100644 --- a/backend/test/integration/test_schedule.py +++ b/backend/test/integration/test_schedule.py @@ -289,7 +289,7 @@ def test_public_availability( ): class MockCaldavConnector: @staticmethod - def __init__(self, redis_instance, url, user, password, subscriber_id, calendar_id): + def __init__(self, db, redis_instance, url, user, password, subscriber_id, calendar_id): """We don't want to initialize a client""" pass @@ -404,7 +404,7 @@ def test_public_availability_with_blockers( class MockCaldavConnector: @staticmethod - def __init__(self, redis_instance, url, user, password, subscriber_id, calendar_id): + def __init__(self, db, redis_instance, url, user, password, subscriber_id, calendar_id): """We don't want to initialize a client""" pass @@ -471,7 +471,7 @@ class TestRequestScheduleAvailability: def mock_connector(self, monkeypatch): class MockCaldavConnector: @staticmethod - def __init__(self, redis_instance, url, user, password, subscriber_id, calendar_id): + def __init__(self, db, redis_instance, url, user, password, subscriber_id, calendar_id): """We don't want to initialize a client""" pass diff --git a/backend/test/unit/test_calendar_tools.py b/backend/test/unit/test_calendar_tools.py index 6ca991ac1..b2e6fa355 100644 --- a/backend/test/unit/test_calendar_tools.py +++ b/backend/test/unit/test_calendar_tools.py @@ -70,3 +70,21 @@ def test_meeting_url_in_location(self, with_db, make_google_calendar, make_appoi ics = Tools().create_vevent(appointment, slot, subscriber) assert ics assert ':'.join(['LOCATION', slot.meeting_link_url]) + + +class TestDnsCaldavLookup: + def test_for_host(self): + host, ttl = Tools.dns_caldav_lookup('thunderbird.net') + assert host == 'https://apidata.googleusercontent.com:443/' + assert ttl + + def test_for_txt_record(self): + """This domain is used with permission from the owner (me, melissa autumn!)""" + host, ttl = Tools.dns_caldav_lookup('melissaautumn.com') + assert host == 'https://caldav.fastmail.com:443/dav/' + assert ttl + + def test_no_records(self): + host, ttl = Tools.dns_caldav_lookup('appointment.day') + assert host is None + assert ttl is None diff --git a/frontend/src/components/FTUE/CalDavProvider.vue b/frontend/src/components/FTUE/CalDavProvider.vue new file mode 100644 index 000000000..06ac2ec04 --- /dev/null +++ b/frontend/src/components/FTUE/CalDavProvider.vue @@ -0,0 +1,147 @@ + + + diff --git a/frontend/src/components/FTUE/CalendarProvider.vue b/frontend/src/components/FTUE/CalendarProvider.vue new file mode 100644 index 000000000..43662d57a --- /dev/null +++ b/frontend/src/components/FTUE/CalendarProvider.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/frontend/src/components/FTUE/GooglePermissions.vue b/frontend/src/components/FTUE/GoogleOauthProvider.vue similarity index 89% rename from frontend/src/components/FTUE/GooglePermissions.vue rename to frontend/src/components/FTUE/GoogleOauthProvider.vue index 2f0c5e693..d11ff1496 100644 --- a/frontend/src/components/FTUE/GooglePermissions.vue +++ b/frontend/src/components/FTUE/GoogleOauthProvider.vue @@ -16,6 +16,16 @@ const { t } = useI18n(); const route = useRoute(); const router = useRouter(); const call = inject(callKey); +// component properties +interface Props { + showPrevious?: boolean, + showSwitch?: boolean, +} +withDefaults(defineProps(), { + showPrevious: false, + showSwitch: false, +}); +const emits = defineEmits(['next', 'previous', 'switch']); const isLoading = ref(false); @@ -61,7 +71,7 @@ onMounted(async () => { return; } - await nextStep(call); + emits('next'); } }); @@ -109,12 +119,18 @@ const onSubmit = async () => {
+ + {{ t('calDAVForm.switchToCalDAV') }} + {{ t('label.back') }} { .content { display: flex; margin: auto; + width: 100%; } .card { @@ -166,12 +183,19 @@ const onSubmit = async () => { height: 1.625rem; } +.btn-switch { + margin-left: 0; + margin-right: auto; +} + .buttons { display: flex; width: 100%; gap: 1rem; justify-content: center; margin-top: 2rem; + right: auto; + left: auto; } @media (--md) { diff --git a/frontend/src/components/SettingsConnections.vue b/frontend/src/components/SettingsConnections.vue index be488ab1a..3e304a26d 100644 --- a/frontend/src/components/SettingsConnections.vue +++ b/frontend/src/components/SettingsConnections.vue @@ -1,7 +1,5 @@