Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ TB_ACCOUNTS_HOST=http://localhost:8087
TB_ACCOUNTS_CALLBACK=http://localhost:5000/accounts/callback
TB_ACCOUNTS_CLIENT_ID
TB_ACCOUNTS_SECRET
TB_ACCOUNTS_CALDAV_URL=https://stage-thundermail.com

# -- GOOGLE AUTH --
GOOGLE_AUTH_CLIENT_ID=
Expand Down
1 change: 1 addition & 0 deletions backend/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ CALDAV_TEST_USER=hello-world
CALDAV_TEST_PASS=fake-pass
GOOGLE_TEST_USER=
GOOGLE_TEST_PASS=
TB_ACCOUNTS_CALDAV_URL=https://stage-thundermail.com

TEST_USER_EMAIL=test@example.org

Expand Down
16 changes: 14 additions & 2 deletions backend/src/appointment/controller/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from dns.exception import DNSException
from redis import Redis, RedisCluster
from caldav import DAVClient
from caldav.requests import HTTPBearerAuth
from fastapi import BackgroundTasks
from google.oauth2.credentials import Credentials
from icalendar import Calendar, Event, vCalAddress, vText
Expand Down Expand Up @@ -320,7 +321,15 @@ def delete_events(self, start):

class CalDavConnector(BaseConnector):
def __init__(
self, db: Session, subscriber_id: int, calendar_id: int, redis_instance, url: str, user: str, password: str
self,
db: Session,
subscriber_id: int,
calendar_id: int,
redis_instance,
url: str,
user: str | None = None,
password: str | None = None,
token: str | None = None,
):
super().__init__(subscriber_id, calendar_id, redis_instance)

Expand All @@ -336,7 +345,10 @@ def __init__(
sentry_sdk.set_tag('caldav_host', parsed_url.hostname)

# connect to the CalDAV server
self.client = DAVClient(url=self.url, username=self.user, password=self.password)
if token:
self.client = DAVClient(url=self.url, auth=HTTPBearerAuth(token))
else:
self.client = DAVClient(url=self.url, username=self.user, password=self.password)

def get_busy_time(self, calendar_ids: list, start: str, end: str):
"""Retrieve a list of { start, end } dicts that will indicate busy time for a user
Expand Down
4 changes: 2 additions & 2 deletions backend/src/appointment/database/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,8 +285,8 @@ class CalendarConnection(CalendarConnectionOut):


class CalendarConnectionIn(CalendarConnection):
url: str = Field(min_length=1)
user: str = Field(min_length=1)
url: str = Optional[str]
user: str = Optional[str]
password: Optional[str]


Expand Down
93 changes: 92 additions & 1 deletion backend/src/appointment/routes/caldav.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import json
import urllib
from typing import Optional
Expand All @@ -11,7 +12,7 @@
from appointment import utils
from appointment.controller.calendar import CalDavConnector, Tools
from appointment.database import models, schemas, repo
from appointment.dependencies.auth import get_subscriber
from appointment.dependencies.auth import get_subscriber, oauth2_scheme
from appointment.dependencies.database import get_db, get_redis
from appointment.exceptions.calendar import TestConnectionFailed
from appointment.exceptions.misc import UnexpectedBehaviourWarning
Expand Down Expand Up @@ -128,6 +129,96 @@ def caldav_autodiscover_auth(
return True


@router.post('/oidc/auth')
def oidc_autodiscover_auth(
db: Session = Depends(get_db),
subscriber: models.Subscriber = Depends(get_subscriber),
redis_client: Redis = Depends(get_redis),
token: str = Depends(oauth2_scheme),
):
"""Connects a principal caldav server through oidc token auth"""

connection_url = os.getenv('TB_ACCOUNTS_CALDAV_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)

if lookup_url and 'http' not in lookup_url:
debug_obj = {'url': lookup_url, 'branch': 'CACHE'}
# Raise and catch the unexpected behaviour warning so we can get proper stacktrace in sentry...
try:
sentry_sdk.set_extra('debug_object', debug_obj)
raise UnexpectedBehaviourWarning(message='Cache incorrect', info=debug_obj)
except UnexpectedBehaviourWarning as ex:
sentry_sdk.capture_exception(ex)

# Clear cache for that key
redis_client.delete(dns_lookup_cache_key)

# Ignore cached result and look it up again
lookup_url = None

# 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=True)
# 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)

# If we have a lookup_url then apply it
if lookup_url and 'http' not in lookup_url:
connection_url = urllib.parse.urljoin(connection_url, lookup_url)
elif lookup_url:
connection_url = lookup_url

con = CalDavConnector(
db=db,
redis_instance=None,
url=connection_url,
subscriber_id=subscriber.id,
calendar_id=None,
token=token,
)

try:
if not con.test_connection():
raise RemoteCalendarConnectionError()
except TestConnectionFailed as ex:
raise RemoteCalendarConnectionError(reason=ex.reason)

caldav_name = subscriber.email
caldav_id = json.dumps([connection_url, caldav_name])

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=caldav_name,
type=models.ExternalConnectionType.caldav,
type_id=caldav_id,
owner_id=subscriber.id,
token=token,
)

external_connection = repo.external_connection.create(db, external_connection_schema)
else:
external_connection = repo.external_connection.update_token(
db, token, subscriber.id, models.ExternalConnectionType.caldav, caldav_id
)

con.sync_calendars(external_connection_id=external_connection.id)
return True


@router.post('/', response_model=schemas.CalendarOut)
def create_my_calendar(
calendar: schemas.CalendarConnection,
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/assets/svg/icons/mail.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion frontend/src/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
"connectCalendarGoogle": "Mit Google Kalender verbinden",
"connectCalendarGoogleInfo": "Synchronisiere Thunderbird Appointment mit deinem Google Kalender. Du kannst auswählen, welche Kalender eingeschlossen werden sollen.",
"connectCalendarTBPro": "Mit Thunderbird Pro verbinden",
"connectCalendarTBProInfo": "Synchronisiere Thunderbird Appointment mit deinem Thunderbird Pro Kalender. Du kannst auswählen, welche Kalender eingeschlossen werden sollen.",
"connectCalendarTBProInfo": "Synchronisiere deinen Appointment Kalender mit deinem Thunderbird Pro Kalender (CalDAV).",
"connectWithCalDav": "Mit CalDAV verbinden",
"calendarUrl": "Kalender URL",
"calendarUrlPlaceholder": "Vollständige URL eingeben",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
"connectCalendarGoogle": "Connect with Google Calendar",
"connectCalendarGoogleInfo": "Sync Thunderbird Appointment with your Google Calendar. You'll be able to choose which calendars to include.",
"connectCalendarTBPro": "Connect with Thunderbird Pro",
"connectCalendarTBProInfo": "Sync Thunderbird Appointment with your Thunderbird Pro calendar. You'll be able to choose which calendars to include.",
"connectCalendarTBProInfo": "Sync your Appointment calendar with your Thundermail calendar (CalDAV).",
"connectWithCalDav": "Connect with CalDav",
"calendarUrl": "Calendar URL",
"calendarUrlPlaceholder": "Enter full URL",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/stores/calendar-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ export const useCalendarStore = defineStore('calendars', () => {
window.location.href = googleUrl.data.value.slice(1, -1);
};

const connectOIDCCalendar = async () => {
await call.value('caldav/oidc/auth').post();
};

/**
* Retrieve the calendar object by id
* @param id
Expand Down Expand Up @@ -142,6 +146,7 @@ export const useCalendarStore = defineStore('calendars', () => {
$reset,
connectGoogleCalendar,
connectCalendar,
connectOIDCCalendar,
disconnectCalendar,
updateCalendar,
syncCalendars,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ defineProps<{
description: string;
iconSrc: string;
iconAlt: string;
showDescription?: boolean;
}>();
</script>

Expand All @@ -13,7 +14,7 @@ defineProps<{

<div>
<h3>{{ title }}</h3>
<p>{{ description }}</p>
<p v-if="showDescription">{{ description }}</p>
</div>
</button>
</template>
Expand All @@ -27,6 +28,7 @@ button {
border-radius: 0.5rem;
text-align: left;
align-items: center;
width: 100%;

img {
width: 2.25rem;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const handleKeydown = (event: KeyboardEvent) => {
:iconSrc="iconSrc"
:iconAlt="iconAlt"
tabindex="-1"
:showDescription="isSelected"
/>
</label>
</template>
Expand All @@ -63,7 +64,7 @@ const handleKeydown = (event: KeyboardEvent) => {
label {
display: block;
cursor: pointer;
border: 2px solid transparent;
border: 1px solid transparent;
border-radius: 0.5rem;
outline: none;

Expand All @@ -73,7 +74,8 @@ label {
}

&.selected {
border-color: color-mix(in srgb, var(--colour-ti-highlight) 30%, transparent);
border-color: var(--colour-primary-default);
background-color: var(--colour-primary-soft);
}
}

Expand Down
14 changes: 8 additions & 6 deletions frontend/src/views/FTUEView/steps/ConnectYourCalendarStep.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { PrimaryButton, NoticeBar, NoticeBarTypes } from '@thunderbirdops/services-ui';
import { useFTUEStore } from '@/stores/ftue-store';
import { useCalendarStore } from '@/stores/calendar-store';
import calendarIcon from '@/assets/svg/icons/calendar.svg';
import mailIcon from '@/assets/svg/icons/mail.svg';
import googleCalendarLogo from '@/assets/svg/google-calendar-logo.svg';
import { FtueStep } from '@/definitions';

Expand All @@ -13,11 +15,12 @@ import RadioProviderCardButton from '../components/RadioProviderCardButton.vue';

const { t } = useI18n();

const calendarStore = useCalendarStore();
const ftueStore = useFTUEStore();
const { errorMessage } = storeToRefs(ftueStore);

type CalendarProvider = 'caldav' | 'google' | 'oidc';
const calendarProvider = ref<CalendarProvider | null>(null);
const calendarProvider = ref<CalendarProvider | null>('oidc');

const onBackButtonClick = () => {
ftueStore.moveToStep(FtueStep.SetupProfile, true);
Expand All @@ -28,7 +31,7 @@ const onContinueButtonClick = async () => {

switch (calendarProvider.value) {
case 'oidc':
// TODO: Implement OIDC flow (get the token and try to authenticate)
await calendarStore.connectOIDCCalendar();
break;
case 'caldav':
await ftueStore.moveToStep(FtueStep.ConnectCalendarsCalDav);
Expand All @@ -51,16 +54,15 @@ const onContinueButtonClick = async () => {
</notice-bar>

<div class="radio-group" role="radiogroup" :aria-label="t('ftue.connectYourCalendar')">
<!-- TODO: Implement OIDC / TB Pro Calendar auto-connect through token -->
<!-- <radio-provider-card-button
<radio-provider-card-button
:title="t('ftue.connectCalendarTBPro')"
:description="t('ftue.connectCalendarTBProInfo')"
:iconSrc="calendarIcon"
:iconSrc="mailIcon"
:iconAlt="t('ftue.appointmentLogo')"
value="oidc"
name="calendar-provider"
v-model="calendarProvider"
/> -->
/>

<radio-provider-card-button
:title="t('ftue.connectCalendarCalDav')"
Expand Down
1 change: 1 addition & 0 deletions frontend/src/views/FTUEView/steps/VideoMeetingLinkStep.vue
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ onMounted(async () => {
:iconSrc="zoomLogo"
:iconAlt="t('ftue.zoomIcon')"
@click="onZoomButtonClick()"
:showDescription="true"
/>
</div>

Expand Down
2 changes: 2 additions & 0 deletions pulumi/config.dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,8 @@ resources:
value: "60"
- name: OIDC_FALLBACK_MATCH_BY_EMAIL
value: "True"
- name: TB_ACCOUNTS_CALDAV_URL
value: https://stage-thundermail.com

tb:autoscale:EcsServiceAutoscaler:
backend:
Expand Down
2 changes: 2 additions & 0 deletions pulumi/config.prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,8 @@ resources:
value: https://appointment.tb.pro/api/v1/zoom/callback
- name: ZOOM_API_NEW_APP
value: "False"
- name: TB_ACCOUNTS_CALDAV_URL
value: https://thundermail.com

tb:autoscale:EcsServiceAutoscaler:
backend:
Expand Down
2 changes: 2 additions & 0 deletions pulumi/config.stage.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,8 @@ resources:
value: "False"
- name: ZOOM_AUTH_CALLBACK
value: https://appointment-stage.tb.pro/api/v1/zoom/callback
- name: TB_ACCOUNTS_CALDAV_URL
value: https://stage-thundermail.com

tb:autoscale:EcsServiceAutoscaler:
backend:
Expand Down