Add CardDav and CalDav servers (#80)

* Add CardDav and CalDav servers

This implements the servers, but not the clients (yet). The implementation is essentially a proxy to Radicale (as a container in `docker-compose.yml`), with certain security assurances.

If you're upgrading, basically you'll need to create a new `data-radicale` directory, and everything else should just work.

This will also release v2.3.0 with those enabled by default. Tested with Thunderbird and Apple Calendar + Contacts.

To disable these, simply add the new config details and comment out or don't add the new `radicale` service from `docker-compose.yml`.

Related to #56
This commit is contained in:
Bruno Bernardino
2025-07-20 10:35:32 +01:00
committed by GitHub
parent 5d324aac9e
commit 781df673dc
17 changed files with 475 additions and 11 deletions

3
.gitignore vendored
View File

@@ -15,3 +15,6 @@ data-files/
# Config # Config
bewcloud.config.ts bewcloud.config.ts
# Radicale files
data-radicale/

View File

@@ -26,7 +26,7 @@ Download/copy [`docker-compose.yml`](/docker-compose.yml), [`.env.sample`](/.env
> `1993:1993` below comes from deno's [docker image](https://github.com/denoland/deno_docker/blob/2abfe921484bdc79d11c7187a9d7b59537457c31/ubuntu.dockerfile#L20-L22) where `1993` is the default user id in it. It might change in the future since I don't control it. > `1993:1993` below comes from deno's [docker image](https://github.com/denoland/deno_docker/blob/2abfe921484bdc79d11c7187a9d7b59537457c31/ubuntu.dockerfile#L20-L22) where `1993` is the default user id in it. It might change in the future since I don't control it.
```sh ```sh
$ mkdir data-files # local directory for storing user-uploaded files $ mkdir data-files data-radicale # local directories for storing user-uploaded files and radicale data
$ sudo chown -R 1993:1993 data-files # solves permission-related issues in the container with uploading files $ sudo chown -R 1993:1993 data-files # solves permission-related issues in the container with uploading files
$ docker compose up -d # makes the app available at http://localhost:8000 $ docker compose up -d # makes the app available at http://localhost:8000
$ docker compose run --rm website bash -c "cd /app && make migrate-db" # initializes/updates the database (only needs to be executed the first time and on any data updates) $ docker compose run --rm website bash -c "cd /app && make migrate-db" # initializes/updates the database (only needs to be executed the first time and on any data updates)
@@ -89,11 +89,14 @@ $ make build # generates all static files for production deploy
Just push to the `main` branch. Just push to the `main` branch.
## Where's Contacts/Calendar (CardDav/CalDav)?! Wasn't this supposed to be a core Nextcloud replacement? ## How does Contacts/CardDav and Calendar/CalDav work?
[Check this tag/release for more info and the code where/when that was being done](https://github.com/bewcloud/bewcloud/releases/tag/v0.0.1-self-made-carddav-caldav). Contacts/CardDav worked and Calendar/CalDav mostly worked as well at that point. CalDav/CardDav is now available since [v2.3.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.3.0), using [Radicale](https://radicale.org/v3.html) via Docker, which is already _very_ efficient (and battle-tested). The clients are not yet implemented. [Check this tag/release for custom-made server and clients where it was all mostly working, except for many edge cases](https://github.com/bewcloud/bewcloud/releases/tag/v0.0.1-self-made-carddav-caldav).
My focus was to get me to replace Nextcloud for me and my family ASAP, and it turns out it's not easy to do it all in a single, installable _thing_, so I focused on the Files UI, sync, and sharing, since [Radicale](https://radicale.org/v3.html) solved my other issues better than my own solution (and it's already _very_ efficient). In order to share a calendar, you can either have a shared user, or you can symlink the calendar to the user's own calendar (simply `ln -s /<absolute-path-to-data-radicale>/collections/collection-root/<owner-user-id>/<calendar-to-share> /<absolute-path-to-data-radicale>/collections/collection-root/<user-id-to-share-with>/`).
> [!NOTE]
> If you're running radicale with docker, the symlink needs to point to the container's directory, usually starting with `/data` if you didn't change the `radicale-config/config`, otherwise the container will fail to load the linked directory.
## How does private file sharing work? ## How does private file sharing work?

View File

@@ -28,6 +28,14 @@ const config: PartialDeep<Config> = {
// host: 'localhost', // host: 'localhost',
// port: 465, // port: 465,
// }, // },
// contacts: {
// enableCardDavServer: true,
// cardDavUrl: 'http://127.0.0.1:5232',
// },
// calendar: {
// enableCalDavServer: true,
// calDavUrl: 'http://127.0.0.1:5232',
// },
}; };
export default config; export default config;

View File

@@ -48,11 +48,22 @@ interface MainFilesProps {
initialPath: string; initialPath: string;
baseUrl: string; baseUrl: string;
isFileSharingAllowed: boolean; isFileSharingAllowed: boolean;
isCardDavEnabled?: boolean;
isCalDavEnabled?: boolean;
fileShareId?: string; fileShareId?: string;
} }
export default function MainFiles( export default function MainFiles(
{ initialDirectories, initialFiles, initialPath, baseUrl, isFileSharingAllowed, fileShareId }: MainFilesProps, {
initialDirectories,
initialFiles,
initialPath,
baseUrl,
isFileSharingAllowed,
isCardDavEnabled,
isCalDavEnabled,
fileShareId,
}: MainFilesProps,
) { ) {
const isAdding = useSignal<boolean>(false); const isAdding = useSignal<boolean>(false);
const isUploading = useSignal<boolean>(false); const isUploading = useSignal<boolean>(false);
@@ -879,6 +890,24 @@ export default function MainFiles(
) )
: null} : null}
{!fileShareId && isCardDavEnabled
? (
<section class='flex flex-row items-center justify-start my-12'>
<span class='font-semibold'>CardDav URL:</span>{' '}
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/carddav</code>
</section>
)
: null}
{!fileShareId && isCalDavEnabled
? (
<section class='flex flex-row items-center justify-start my-12'>
<span class='font-semibold'>CalDav URL:</span>{' '}
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/caldav</code>
</section>
)
: null}
{!fileShareId {!fileShareId
? ( ? (
<CreateDirectoryModal <CreateDirectoryModal

View File

@@ -14,6 +14,29 @@ services:
memlock: memlock:
soft: -1 soft: -1
hard: -1 hard: -1
mem_limit: '256m'
# NOTE: If you don't want to use the CardDav/CalDav servers, you can comment/remove this service.
radicale:
image: tomsquest/docker-radicale:3.5.4.0
ports:
- 127.0.0.1:5232:5232
init: true
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- SETUID
- SETGID
- CHOWN
- KILL
restart: unless-stopped
volumes:
- ./data-radicale:/data
- ./radicale-config:/config:ro
mem_limit: '256m'
volumes: volumes:
pgdata: pgdata:

View File

@@ -1,6 +1,6 @@
services: services:
website: website:
image: ghcr.io/bewcloud/bewcloud:v2.2.3 image: ghcr.io/bewcloud/bewcloud:v2.3.0
restart: always restart: always
ports: ports:
- 127.0.0.1:8000:8000 - 127.0.0.1:8000:8000
@@ -29,6 +29,28 @@ services:
hard: -1 hard: -1
mem_limit: '256m' mem_limit: '256m'
# NOTE: If you don't want to use the CardDav/CalDav servers, you can comment/remove this service.
radicale:
image: tomsquest/docker-radicale:3.5.4.0
ports:
- 127.0.0.1:5232:5232
init: true
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- SETUID
- SETGID
- CHOWN
- KILL
restart: always
volumes:
- ./data-radicale:/data
- ./radicale-config:/config:ro
mem_limit: '256m'
volumes: volumes:
bewcloud-db: bewcloud-db:
driver: local driver: local

View File

@@ -2,6 +2,8 @@
// This file SHOULD be checked into source version control. // This file SHOULD be checked into source version control.
// This file is automatically updated during development when running `dev.ts`. // This file is automatically updated during development when running `dev.ts`.
import * as $_well_known_caldav from './routes/.well-known/caldav.tsx';
import * as $_well_known_carddav from './routes/.well-known/carddav.tsx';
import * as $_404 from './routes/_404.tsx'; import * as $_404 from './routes/_404.tsx';
import * as $_app from './routes/_app.tsx'; import * as $_app from './routes/_app.tsx';
import * as $_middleware from './routes/_middleware.tsx'; import * as $_middleware from './routes/_middleware.tsx';
@@ -45,6 +47,8 @@ import * as $api_news_import_feeds from './routes/api/news/import-feeds.tsx';
import * as $api_news_mark_read from './routes/api/news/mark-read.tsx'; import * as $api_news_mark_read from './routes/api/news/mark-read.tsx';
import * as $api_news_refresh_articles from './routes/api/news/refresh-articles.tsx'; import * as $api_news_refresh_articles from './routes/api/news/refresh-articles.tsx';
import * as $api_notes_save from './routes/api/notes/save.tsx'; import * as $api_notes_save from './routes/api/notes/save.tsx';
import * as $caldav from './routes/caldav.tsx';
import * as $carddav from './routes/carddav.tsx';
import * as $dashboard from './routes/dashboard.tsx'; import * as $dashboard from './routes/dashboard.tsx';
import * as $dav from './routes/dav.tsx'; import * as $dav from './routes/dav.tsx';
import * as $expenses from './routes/expenses.tsx'; import * as $expenses from './routes/expenses.tsx';
@@ -82,6 +86,8 @@ import type { Manifest } from '$fresh/server.ts';
const manifest = { const manifest = {
routes: { routes: {
'./routes/.well-known/caldav.tsx': $_well_known_caldav,
'./routes/.well-known/carddav.tsx': $_well_known_carddav,
'./routes/_404.tsx': $_404, './routes/_404.tsx': $_404,
'./routes/_app.tsx': $_app, './routes/_app.tsx': $_app,
'./routes/_middleware.tsx': $_middleware, './routes/_middleware.tsx': $_middleware,
@@ -125,6 +131,8 @@ const manifest = {
'./routes/api/news/mark-read.tsx': $api_news_mark_read, './routes/api/news/mark-read.tsx': $api_news_mark_read,
'./routes/api/news/refresh-articles.tsx': $api_news_refresh_articles, './routes/api/news/refresh-articles.tsx': $api_news_refresh_articles,
'./routes/api/notes/save.tsx': $api_notes_save, './routes/api/notes/save.tsx': $api_notes_save,
'./routes/caldav.tsx': $caldav,
'./routes/carddav.tsx': $carddav,
'./routes/dashboard.tsx': $dashboard, './routes/dashboard.tsx': $dashboard,
'./routes/dav.tsx': $dav, './routes/dav.tsx': $dav,
'./routes/expenses.tsx': $expenses, './routes/expenses.tsx': $expenses,

View File

@@ -7,12 +7,23 @@ interface FilesWrapperProps {
initialPath: string; initialPath: string;
baseUrl: string; baseUrl: string;
isFileSharingAllowed: boolean; isFileSharingAllowed: boolean;
isCardDavEnabled?: boolean;
isCalDavEnabled?: boolean;
fileShareId?: string; fileShareId?: string;
} }
// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself // This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself
export default function FilesWrapper( export default function FilesWrapper(
{ initialDirectories, initialFiles, initialPath, baseUrl, isFileSharingAllowed, fileShareId }: FilesWrapperProps, {
initialDirectories,
initialFiles,
initialPath,
baseUrl,
isFileSharingAllowed,
isCardDavEnabled,
isCalDavEnabled,
fileShareId,
}: FilesWrapperProps,
) { ) {
return ( return (
<MainFiles <MainFiles
@@ -21,6 +32,8 @@ export default function FilesWrapper(
initialPath={initialPath} initialPath={initialPath}
baseUrl={baseUrl} baseUrl={baseUrl}
isFileSharingAllowed={isFileSharingAllowed} isFileSharingAllowed={isFileSharingAllowed}
isCardDavEnabled={isCardDavEnabled}
isCalDavEnabled={isCalDavEnabled}
fileShareId={fileShareId} fileShareId={fileShareId}
/> />
); );

View File

@@ -36,6 +36,14 @@ export class AppConfig {
host: 'localhost', host: 'localhost',
port: 465, port: 465,
}, },
contacts: {
enableCardDavServer: true,
cardDavUrl: 'http://127.0.0.1:5232',
},
calendar: {
enableCalDavServer: true,
calDavUrl: 'http://127.0.0.1:5232',
},
}; };
} }
@@ -176,4 +184,16 @@ export class AppConfig {
return this.config.email; return this.config.email;
} }
static async getContactsConfig(): Promise<Config['contacts']> {
await this.loadConfig();
return this.config.contacts;
}
static async getCalendarConfig(): Promise<Config['calendar']> {
await this.loadConfig();
return this.config.calendar;
}
} }

View File

@@ -200,6 +200,18 @@ export interface Config {
/** The SMTP port to send emails from */ /** The SMTP port to send emails from */
port: number; port: number;
}; };
contacts: {
/** If true, the CardDAV server will be enabled (proxied) */
enableCardDavServer: boolean;
/** The CardDAV server URL to proxy to */
cardDavUrl: string;
};
calendar: {
/** If true, the CalDAV server will be enabled (proxied) */
enableCalDavServer: boolean;
/** The CalDAV server URL to proxy to */
calDavUrl: string;
};
} }
export type MultiFactorAuthMethodType = 'totp' | 'passkey' | 'email'; export type MultiFactorAuthMethodType = 'totp' | 'passkey' | 'email';

149
radicale-config/config Normal file
View File

@@ -0,0 +1,149 @@
# -*- mode: conf -*-
# vim:ft=cfg
# Config file for Radicale - A simple calendar server
[server]
# CalDAV server hostnames separated by a comma
# IPv4 syntax: address:port
# IPv6 syntax: [address]:port
hosts = 0.0.0.0:5232
# Max parallel connections
#max_connections = 8
# Max size of request body (bytes)
#max_content_length = 100000000
# Socket timeout (seconds)
#timeout = 30
[encoding]
# Encoding for responding requests
#request = utf-8
# Encoding for storing local collections
#stock = utf-8
[auth]
# Authentication method
#type = none
type = http_x_remote_user
# Cache logins for until expiration time
#cache_logins = false
# Expiration time for caching successful logins in seconds
#cache_successful_logins_expiry = 15
## Expiration time of caching failed logins in seconds
#cache_failed_logins_expiry = 90
# IMAP server hostname
# Syntax: address | address:port | [address]:port | imap.server.tld
#imap_host = localhost
# Secure the IMAP connection
# Value: tls | starttls | none
#imap_security = tls
# OAuth2 token endpoint URL
#oauth2_token_endpoint = <URL>
[rights]
# Rights backend
# Value: authenticated | owner_only | owner_write | from_file
#type = owner_only
# File for rights management from_file
#file = /etc/radicale/rights
# Permit delete of a collection (global)
#permit_delete_collection = True
# Permit overwrite of a collection (global)
#permit_overwrite_collection = True
# URL Decode the given username (when URL-encoded by the client - useful for iOS devices when using email address)
# urldecode_username = False
[storage]
# Storage backend
# Value: multifilesystem | multifilesystem_nolock
#type = multifilesystem
# Folder for storing local collections, created if not present
filesystem_folder = /data/collections
# Delete sync token that are older (seconds)
#max_sync_token_age = 2592000
# Skip broken item instead of triggering an exception
#skip_broken_item = True
[web]
# Web interface backend
# Value: none | internal
#type = internal
[logging]
# Threshold for the logger
# Value: debug | info | warning | error | critical
#level = info
# Don't include passwords in logs
#mask_passwords = True
# Log bad PUT request content
#bad_put_request_content = False
# Log backtrace on level=debug
#backtrace_on_debug = False
# Log request header on level=debug
#request_header_on_debug = False
# Log request content on level=debug
#request_content_on_debug = False
# Log response content on level=debug
#response_content_on_debug = False
# Log rights rule which doesn't match on level=debug
#rights_rule_doesnt_match_on_debug = False
# Log storage cache actions on level=debug
#storage_cache_actions_on_debug = False
[headers]
# Additional HTTP headers
#Access-Control-Allow-Origin = *
[hook]
# Hook types
# Value: none | rabbitmq
#type = none
#rabbitmq_endpoint =
#rabbitmq_topic =
#rabbitmq_queue_type = classic
[reporting]
# When returning a free-busy report, limit the number of returned
# occurences per event to prevent DOS attacks.
#max_freebusy_occurrence = 10000

View File

@@ -0,0 +1,9 @@
import { Handler } from 'fresh/server.ts';
import { FreshContextState } from '/lib/types.ts';
interface Data {}
export const handler: Handler<Data, FreshContextState> = () => {
return new Response('Redirecting...', { status: 301, headers: { 'Location': '/caldav' } });
};

View File

@@ -0,0 +1,9 @@
import { Handler } from 'fresh/server.ts';
import { FreshContextState } from '/lib/types.ts';
interface Data {}
export const handler: Handler<Data, FreshContextState> = () => {
return new Response('Redirecting...', { status: 301, headers: { 'Location': '/carddav' } });
};

View File

@@ -7,7 +7,10 @@ export const handler = [
async function handleCors(request: Request, context: FreshContext<FreshContextState>) { async function handleCors(request: Request, context: FreshContext<FreshContextState>) {
const path = new URL(request.url).pathname; const path = new URL(request.url).pathname;
if (request.method == 'OPTIONS' && path !== '/dav' && !path.startsWith('/dav/')) { if (
request.method == 'OPTIONS' && path !== '/dav' && !path.startsWith('/dav/') && path !== '/carddav' &&
!path.startsWith('/carddav/') && path !== '/caldav' && !path.startsWith('/caldav/')
) {
const response = new Response(null, { const response = new Response(null, {
status: 204, status: 204,
}); });
@@ -68,10 +71,14 @@ export const handler = [
const response = await context.next(); const response = await context.next();
console.info(`${new Date().toISOString()} - [${response.status}] ${request.method} ${request.url}`); console.info(`${new Date().toISOString()} - [${response.status}] ${request.method} ${request.url}`);
// NOTE: Uncomment when debugging WebDav stuff // NOTE: Uncomment when debugging WebDav/CardDav/CalDav stuff
// if (request.url.includes('/dav')) { // if (request.url.includes('/dav') || request.url.includes('/carddav') || request.url.includes('/caldav')) {
// console.info(`Request`, request.headers); // console.info(`Request`, request.headers);
// try {
// console.info((await request.clone().text()) || '<No Body>'); // console.info((await request.clone().text()) || '<No Body>');
// } catch (_error) {
// console.info('<No Body>');
// }
// console.info(`Response`, response.headers); // console.info(`Response`, response.headers);
// console.info(`Status`, response.status); // console.info(`Status`, response.status);
// } // }

69
routes/caldav.tsx Normal file
View File

@@ -0,0 +1,69 @@
import { Handler, RouteConfig } from 'fresh/server.ts';
import { FreshContextState } from '/lib/types.ts';
import { AppConfig } from '/lib/config.ts';
interface Data {}
export const config: RouteConfig = {
routeOverride: '/caldav/:path*',
};
export const handler: Handler<Data, FreshContextState> = async (request, context) => {
const calendarConfig = await AppConfig.getCalendarConfig();
if (!calendarConfig.enableCalDavServer) {
return new Response('Not Found', { status: 404 });
}
if (!context.state.user) {
return new Response('Unauthorized', {
status: 401,
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
});
}
const { path } = context.params;
const userId = context.state.user.id;
try {
const requestBodyText = await request.clone().text();
// Remove the `/caldav/` prefix from the hrefs in the request
const parsedRequestBodyText = requestBodyText.replaceAll('<href>/caldav/', `<href>/`).replaceAll(
':href>/caldav/',
`:href>/`,
);
const response = await fetch(`${calendarConfig.calDavUrl}/${path}`, {
headers: {
...Object.fromEntries(request.headers.entries()),
'X-Remote-User': `${userId}`,
},
method: request.method,
body: parsedRequestBodyText,
});
if (response.status === 204) {
return new Response(null, { status: 204 });
}
const responseBodyText = await response.clone().text();
// Add the `/caldav/` prefix to the hrefs in the response
const parsedBodyResponseText = responseBodyText.replaceAll('<href>/', `<href>/caldav/`).replaceAll(
':href>/',
`:href>/caldav/`,
);
return new Response(parsedBodyResponseText, {
status: response.status,
headers: response.headers,
});
} catch (error) {
console.error(error);
}
return new Response(null, { status: 405 });
};

69
routes/carddav.tsx Normal file
View File

@@ -0,0 +1,69 @@
import { Handler, RouteConfig } from 'fresh/server.ts';
import { FreshContextState } from '/lib/types.ts';
import { AppConfig } from '/lib/config.ts';
interface Data {}
export const config: RouteConfig = {
routeOverride: '/carddav/:path*',
};
export const handler: Handler<Data, FreshContextState> = async (request, context) => {
const contactsConfig = await AppConfig.getContactsConfig();
if (!contactsConfig.enableCardDavServer) {
return new Response('Not Found', { status: 404 });
}
if (!context.state.user) {
return new Response('Unauthorized', {
status: 401,
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
});
}
const { path } = context.params;
const userId = context.state.user.id;
try {
const requestBodyText = await request.clone().text();
// Remove the `/carddav/` prefix from the hrefs in the request
const parsedRequestBodyText = requestBodyText.replaceAll('<href>/carddav/', `<href>/`).replaceAll(
':href>/carddav/',
`:href>/`,
);
const response = await fetch(`${contactsConfig.cardDavUrl}/${path}`, {
headers: {
...Object.fromEntries(request.headers.entries()),
'X-Remote-User': `${userId}`,
},
method: request.method,
body: parsedRequestBodyText,
});
if (response.status === 204) {
return new Response(null, { status: 204 });
}
const responseBodyText = await response.clone().text();
// Add the `/carddav/` prefix to the hrefs in the response
const parsedBodyResponseText = responseBodyText.replaceAll('<href>/', `<href>/carddav/`).replaceAll(
':href>/',
`:href>/carddav/`,
);
return new Response(parsedBodyResponseText, {
status: response.status,
headers: response.headers,
});
} catch (error) {
console.error(error);
}
return new Response(null, { status: 405 });
};

View File

@@ -11,6 +11,8 @@ interface Data {
currentPath: string; currentPath: string;
baseUrl: string; baseUrl: string;
isFileSharingAllowed: boolean; isFileSharingAllowed: boolean;
isCardDavEnabled: boolean;
isCalDavEnabled: boolean;
} }
export const handler: Handlers<Data, FreshContextState> = { export const handler: Handlers<Data, FreshContextState> = {
@@ -40,6 +42,11 @@ export const handler: Handlers<Data, FreshContextState> = {
const userFiles = await FileModel.list(context.state.user.id, currentPath); const userFiles = await FileModel.list(context.state.user.id, currentPath);
const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed(); const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed();
const contactsConfig = await AppConfig.getContactsConfig();
const calendarConfig = await AppConfig.getCalendarConfig();
const isCardDavEnabled = contactsConfig.enableCardDavServer;
const isCalDavEnabled = calendarConfig.enableCalDavServer;
return await context.render({ return await context.render({
userDirectories, userDirectories,
@@ -47,6 +54,8 @@ export const handler: Handlers<Data, FreshContextState> = {
currentPath, currentPath,
baseUrl, baseUrl,
isFileSharingAllowed: isPublicFileSharingAllowed, isFileSharingAllowed: isPublicFileSharingAllowed,
isCardDavEnabled,
isCalDavEnabled,
}); });
}, },
}; };
@@ -60,6 +69,8 @@ export default function FilesPage({ data }: PageProps<Data, FreshContextState>)
initialPath={data.currentPath} initialPath={data.currentPath}
baseUrl={data.baseUrl} baseUrl={data.baseUrl}
isFileSharingAllowed={data.isFileSharingAllowed} isFileSharingAllowed={data.isFileSharingAllowed}
isCardDavEnabled={data.isCardDavEnabled}
isCalDavEnabled={data.isCalDavEnabled}
/> />
</main> </main>
); );