feat: basic caldav support (#14372)

fix #13531 

#### PR Dependency Tree


* **PR #14372** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* CalDAV calendar integration: link and sync CalDAV-compatible calendars
(discovery, listing, event sync).
* New UI flow and dialog to link CalDAV accounts with provider
selection, credentials, and display name.

* **API / Config**
* Server exposes CalDAV provider presets in config and new GraphQL
mutation to link CalDAV accounts.
  * New calendar config section for CalDAV with validation and defaults.

* **Tests**
  * Comprehensive CalDAV integration test suite added.

* **Chores**
* Removed analytics tokens from build configuration and reduced Cloud
E2E test shards.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-02-05 03:04:21 +08:00
committed by GitHub
parent 403f16b404
commit a655b79166
34 changed files with 2995 additions and 217 deletions
+66
View File
@@ -868,6 +868,72 @@
"externalWebhookUrl": "", "externalWebhookUrl": "",
"webhookVerificationToken": "" "webhookVerificationToken": ""
} }
},
"caldav": {
"type": "object",
"description": "CalDAV integration config\n@default {\"enabled\":false,\"allowCustomProvider\":false,\"providers\":[],\"allowInsecureHttp\":false,\"allowedHosts\":[],\"blockPrivateNetwork\":true,\"requestTimeoutMs\":10000,\"maxRedirects\":5}",
"properties": {
"enabled": {
"type": "boolean"
},
"allowCustomProvider": {
"type": "boolean"
},
"providers": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"label": {
"type": "string"
},
"serverUrl": {
"type": "string"
},
"authType": {
"type": "string"
},
"requiresAppPassword": {
"type": "boolean"
},
"docsUrl": {
"type": "string"
}
}
}
},
"allowInsecureHttp": {
"type": "boolean"
},
"allowedHosts": {
"type": "array",
"items": {
"type": "string"
}
},
"blockPrivateNetwork": {
"type": "boolean"
},
"requestTimeoutMs": {
"type": "number"
},
"maxRedirects": {
"type": "number"
}
},
"default": {
"enabled": false,
"allowCustomProvider": false,
"providers": [],
"allowInsecureHttp": false,
"allowedHosts": [],
"blockPrivateNetwork": true,
"requestTimeoutMs": 10000,
"maxRedirects": 5
}
} }
} }
}, },
-6
View File
@@ -45,8 +45,6 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }} PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }}
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
- name: Upload web artifact - name: Upload web artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
@@ -79,8 +77,6 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }} PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }}
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
- name: Upload admin artifact - name: Upload admin artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
@@ -113,8 +109,6 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }} PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }}
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
- name: Upload mobile artifact - name: Upload mobile artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
+4 -13
View File
@@ -1016,21 +1016,12 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
tests: tests:
- name: 'Cloud E2E Test 1/10' - name: 'Cloud E2E Test 1/2'
shard: 1 shard: 1
script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=1/10 script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=1/2
- name: 'Cloud E2E Test 2/10' - name: 'Cloud E2E Test 2/2'
shard: 2 shard: 2
script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=2/10 script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=2/2
- name: 'Cloud E2E Test 3/10'
shard: 3
script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=3/10
- name: 'Cloud E2E Test 4/10'
shard: 4
script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=4/10
- name: 'Cloud E2E Test 5/10'
shard: 5
script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=5/10
- name: 'Cloud Desktop E2E Test' - name: 'Cloud Desktop E2E Test'
shard: desktop shard: desktop
script: | script: |
@@ -68,8 +68,6 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_RELEASE: ${{ inputs.app_version }} SENTRY_RELEASE: ${{ inputs.app_version }}
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
-2
View File
@@ -66,8 +66,6 @@ jobs:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_RELEASE: ${{ inputs.app-version }} SENTRY_RELEASE: ${{ inputs.app-version }}
RELEASE_VERSION: ${{ inputs.app-version }} RELEASE_VERSION: ${{ inputs.app-version }}
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
- name: Upload web artifact - name: Upload web artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
-4
View File
@@ -39,8 +39,6 @@ jobs:
run: yarn affine @affine/ios build run: yarn affine @affine/ios build
env: env:
PUBLIC_PATH: '/' PUBLIC_PATH: '/'
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: 'affine' SENTRY_PROJECT: 'affine'
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
@@ -69,8 +67,6 @@ jobs:
run: yarn affine @affine/android build run: yarn affine @affine/android build
env: env:
PUBLIC_PATH: '/' PUBLIC_PATH: '/'
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: 'affine' SENTRY_PROJECT: 'affine'
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
@@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "calendar_accounts" ADD COLUMN "auth_type" VARCHAR,
ADD COLUMN "calendar_home_url" VARCHAR,
ADD COLUMN "principal_url" VARCHAR,
ADD COLUMN "provider_preset_id" VARCHAR,
ADD COLUMN "server_url" VARCHAR,
ADD COLUMN "username" VARCHAR;
+6
View File
@@ -937,6 +937,12 @@ model CalendarAccount {
providerAccountId String @map("provider_account_id") @db.VarChar providerAccountId String @map("provider_account_id") @db.VarChar
displayName String? @map("display_name") @db.VarChar displayName String? @map("display_name") @db.VarChar
email String? @db.VarChar email String? @db.VarChar
providerPresetId String? @map("provider_preset_id") @db.VarChar
serverUrl String? @map("server_url") @db.VarChar
principalUrl String? @map("principal_url") @db.VarChar
calendarHomeUrl String? @map("calendar_home_url") @db.VarChar
username String? @db.VarChar
authType String? @map("auth_type") @db.VarChar
accessToken String? @map("access_token") @db.Text accessToken String? @map("access_token") @db.Text
refreshToken String? @map("refresh_token") @db.Text refreshToken String? @map("refresh_token") @db.Text
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3) expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
@@ -17,6 +17,12 @@ export interface UpsertCalendarAccountInput extends CalendarAccountTokens {
providerAccountId: string; providerAccountId: string;
displayName?: string | null; displayName?: string | null;
email?: string | null; email?: string | null;
providerPresetId?: string | null;
serverUrl?: string | null;
principalUrl?: string | null;
calendarHomeUrl?: string | null;
username?: string | null;
authType?: string | null;
status?: string | null; status?: string | null;
lastError?: string | null; lastError?: string | null;
refreshIntervalMinutes?: number | null; refreshIntervalMinutes?: number | null;
@@ -73,6 +79,12 @@ export class CalendarAccountModel extends BaseModel {
providerAccountId: input.providerAccountId, providerAccountId: input.providerAccountId,
displayName: input.displayName ?? null, displayName: input.displayName ?? null,
email: input.email ?? null, email: input.email ?? null,
providerPresetId: input.providerPresetId ?? null,
serverUrl: input.serverUrl ?? null,
principalUrl: input.principalUrl ?? null,
calendarHomeUrl: input.calendarHomeUrl ?? null,
username: input.username ?? null,
authType: input.authType ?? null,
accessToken: accessToken ?? null, accessToken: accessToken ?? null,
refreshToken: refreshToken ?? null, refreshToken: refreshToken ?? null,
expiresAt: input.expiresAt ?? null, expiresAt: input.expiresAt ?? null,
@@ -85,6 +97,12 @@ export class CalendarAccountModel extends BaseModel {
const updateData: Prisma.CalendarAccountUncheckedUpdateInput = { const updateData: Prisma.CalendarAccountUncheckedUpdateInput = {
displayName: data.displayName, displayName: data.displayName,
email: data.email, email: data.email,
providerPresetId: data.providerPresetId,
serverUrl: data.serverUrl,
principalUrl: data.principalUrl,
calendarHomeUrl: data.calendarHomeUrl,
username: data.username,
authType: data.authType,
expiresAt: data.expiresAt, expiresAt: data.expiresAt,
scope: data.scope, scope: data.scope,
status: data.status, status: data.status,
@@ -0,0 +1,531 @@
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import test from 'ava';
import { createModule } from '../../../__tests__/create-module';
import { Mockers } from '../../../__tests__/mocks';
import { CryptoHelper, GraphqlBadRequest, Mutex } from '../../../base';
import { ConfigModule } from '../../../base/config';
import { ServerConfigModule } from '../../../core/config';
import { Models } from '../../../models';
import { CalendarModule } from '..';
import {
CalDAVProvider,
CalendarProviderFactory,
CalendarProviderName,
} from '../providers';
import { CalendarService } from '../service';
const USERNAME = 'caldav-user@example.com';
const PASSWORD = 'caldav-pass';
const AUTH_HEADER = `Basic ${Buffer.from(`${USERNAME}:${PASSWORD}`).toString('base64')}`;
const buildVCalendar = (lines: string[]) =>
['BEGIN:VCALENDAR', 'VERSION:2.0', ...lines, 'END:VCALENDAR'].join('\r\n');
const allDayEvent = buildVCalendar([
'BEGIN:VEVENT',
'UID:all-day',
'DTSTART;VALUE=DATE:20250101',
'DTEND;VALUE=DATE:20250102',
'SUMMARY:All Day Event',
'END:VEVENT',
]);
const timezoneEvent = buildVCalendar([
'BEGIN:VEVENT',
'UID:tz-event',
'DTSTART;TZID=America/Los_Angeles:20250103T090000',
'DTEND;TZID=America/Los_Angeles:20250103T100000',
'SUMMARY:Timezone Event',
'END:VEVENT',
]);
const recurrenceEvent = buildVCalendar([
'BEGIN:VEVENT',
'UID:recurrence-event',
'RECURRENCE-ID;TZID=UTC:20250104T090000',
'DTSTART;TZID=UTC:20250104T100000',
'DTEND;TZID=UTC:20250104T110000',
'SUMMARY:Recurring Instance',
'END:VEVENT',
]);
const principalResponse = `<?xml version="1.0" encoding="UTF-8"?>
<multistatus xmlns="DAV:">
<response>
<href>/caldav/</href>
<propstat>
<prop>
<current-user-principal>
<href>/principals/user/</href>
</current-user-principal>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
</multistatus>`;
const homeSetResponse = `<?xml version="1.0" encoding="UTF-8"?>
<multistatus xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<response>
<href>/principals/user/</href>
<propstat>
<prop>
<calendar-home-set>
<href>/calendars/user/</href>
</calendar-home-set>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
</multistatus>`;
const calendarListResponse = `<?xml version="1.0" encoding="UTF-8"?>
<multistatus xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:IC="http://apple.com/ns/ical/">
<response>
<href>/calendars/user/</href>
<propstat>
<prop>
<resourcetype>
<collection />
</resourcetype>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
<response>
<href>/calendars/user/home/</href>
<propstat>
<prop>
<displayname>Home</displayname>
<resourcetype>
<collection />
<calendar xmlns="urn:ietf:params:xml:ns:caldav" />
</resourcetype>
<calendar-timezone>BEGIN:VTIMEZONE\nTZID:UTC\nEND:VTIMEZONE</calendar-timezone>
<calendar-color xmlns="http://apple.com/ns/ical/">#ff0000</calendar-color>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
</multistatus>`;
const calendarQueryResponse = `<?xml version="1.0" encoding="UTF-8"?>
<multistatus xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<response>
<href>/calendars/user/home/all-day.ics</href>
<propstat>
<prop>
<getetag>"1"</getetag>
<calendar-data>${allDayEvent}</calendar-data>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
<response>
<href>/calendars/user/home/timezone.ics</href>
<propstat>
<prop>
<getetag>"2"</getetag>
<calendar-data>${timezoneEvent}</calendar-data>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
<response>
<href>/calendars/user/home/recurrence.ics</href>
<propstat>
<prop>
<getetag>"3"</getetag>
<calendar-data>${recurrenceEvent}</calendar-data>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
</multistatus>`;
const createCalDAVServer = async (options?: {
syncCollectionStatus?: number;
}) => {
const requests: Array<{ method: string; url: string; body: string }> = [];
const server = createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.from(chunk));
}
const body = Buffer.concat(chunks).toString('utf-8');
requests.push({
method: req.method ?? '',
url: req.url ?? '',
body,
});
if (req.headers.authorization !== AUTH_HEADER) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="CalDAV"' });
res.end();
return;
}
if (req.url === '/.well-known/caldav') {
res.writeHead(302, { Location: '/caldav/' });
res.end();
return;
}
if (req.method === 'PROPFIND' && req.url === '/caldav/') {
res.writeHead(207, { 'Content-Type': 'application/xml' });
res.end(principalResponse);
return;
}
if (req.method === 'PROPFIND' && req.url === '/principals/user/') {
res.writeHead(207, { 'Content-Type': 'application/xml' });
res.end(homeSetResponse);
return;
}
if (req.method === 'PROPFIND' && req.url === '/calendars/user/') {
res.writeHead(207, { 'Content-Type': 'application/xml' });
res.end(calendarListResponse);
return;
}
if (req.method === 'REPORT' && req.url === '/calendars/user/home/') {
if (body.includes('sync-collection')) {
const status = options?.syncCollectionStatus ?? 207;
if (status !== 207) {
res.writeHead(status);
res.end();
return;
}
}
res.writeHead(207, { 'Content-Type': 'application/xml' });
res.end(calendarQueryResponse);
return;
}
res.writeHead(404);
res.end();
});
await new Promise<void>(resolve => server.listen(0, resolve));
const port = (server.address() as AddressInfo).port;
return {
server,
baseUrl: `http://127.0.0.1:${port}`,
requests,
};
};
const createRedirectServer = async () => {
const server = createServer((req, res) => {
if (req.url === '/.well-known/caldav') {
res.writeHead(302, { Location: '/.well-known/caldav' });
res.end();
return;
}
res.writeHead(404);
res.end();
});
await new Promise<void>(resolve => server.listen(0, resolve));
const port = (server.address() as AddressInfo).port;
return {
server,
baseUrl: `http://127.0.0.1:${port}`,
};
};
const createCalendarModule = async (caldavConfig: Record<string, unknown>) => {
const module = await createModule({
imports: [
ServerConfigModule,
CalendarModule,
ConfigModule.override({
calendar: {
google: {
enabled: false,
clientId: '',
clientSecret: '',
externalWebhookUrl: '',
webhookVerificationToken: '',
},
caldav: caldavConfig,
},
}),
],
tapModule: builder => {
const testLock = {
fromTest: true,
async [Symbol.asyncDispose]() {},
};
builder.overrideProvider(Mutex).useValue({
acquire: async () => testLock,
});
},
});
module.get(CryptoHelper).onConfigInit();
const caldavProvider = module.get(CalDAVProvider);
caldavProvider.onConfigInit();
module.get(CalendarProviderFactory).register(caldavProvider);
return module;
};
test('linkCalDAVAccount discovers calendars and parses events', async t => {
const server = await createCalDAVServer();
t.teardown(() => server.server.close());
const module = await createCalendarModule({
enabled: true,
allowInsecureHttp: true,
blockPrivateNetwork: false,
providers: [
{
id: 'test',
label: 'Test CalDAV',
serverUrl: `${server.baseUrl}/caldav/`,
authType: 'basic',
},
],
});
t.teardown(() => module.close());
const calendarService = module.get(CalendarService);
const models = module.get(Models) as any;
const user = await module.create(Mockers.User);
const account = await calendarService.linkCalDAVAccount({
userId: user.id,
input: {
providerPresetId: 'test',
username: USERNAME,
password: PASSWORD,
displayName: 'Test CalDAV',
},
});
const subscriptions = await models.calendarSubscription.listByAccount(
account.id
);
t.is(subscriptions.length, 1);
const events = await models.calendarEvent.listBySubscriptionsInRange(
[subscriptions[0].id],
new Date('2020-01-01T00:00:00.000Z'),
new Date('2030-01-01T00:00:00.000Z')
);
t.is(events.length, 3);
const allDay = events.find(
(event: (typeof events)[number]) => event.title === 'All Day Event'
);
t.truthy(allDay);
t.is(allDay?.allDay, true);
t.is(allDay?.startAtUtc.toISOString(), '2025-01-01T00:00:00.000Z');
t.is(allDay?.endAtUtc.toISOString(), '2025-01-02T00:00:00.000Z');
t.is(allDay?.originalTimezone, 'UTC');
const tzEvent = events.find(
(event: (typeof events)[number]) => event.title === 'Timezone Event'
);
t.truthy(tzEvent);
t.is(tzEvent?.originalTimezone, 'America/Los_Angeles');
t.is(tzEvent?.startAtUtc.toISOString(), '2025-01-03T17:00:00.000Z');
t.is(tzEvent?.endAtUtc.toISOString(), '2025-01-03T18:00:00.000Z');
const recurrence = events.find(
(event: (typeof events)[number]) => event.title === 'Recurring Instance'
);
t.truthy(recurrence);
t.is(recurrence?.recurrenceId, '2025-01-04T09:00:00.000Z');
});
test('syncSubscription falls back when sync-collection is rejected', async t => {
const server = await createCalDAVServer({ syncCollectionStatus: 403 });
t.teardown(() => server.server.close());
const module = await createCalendarModule({
enabled: true,
allowInsecureHttp: true,
blockPrivateNetwork: false,
providers: [
{
id: 'test',
label: 'Test CalDAV',
serverUrl: `${server.baseUrl}/caldav/`,
authType: 'basic',
},
],
});
t.teardown(() => module.close());
const calendarService = module.get(CalendarService);
const models = module.get(Models) as any;
const user = await module.create(Mockers.User);
const account = await models.calendarAccount.upsert({
userId: user.id,
provider: CalendarProviderName.CalDAV,
providerAccountId: `${server.baseUrl}/principals/user/`,
displayName: 'Test',
email: USERNAME,
accessToken: PASSWORD,
refreshToken: null,
expiresAt: null,
scope: null,
status: 'active',
lastError: null,
providerPresetId: 'test',
serverUrl: server.baseUrl,
principalUrl: `${server.baseUrl}/principals/user/`,
calendarHomeUrl: `${server.baseUrl}/calendars/user/`,
username: USERNAME,
authType: 'basic',
});
const subscription = await models.calendarSubscription.upsert({
accountId: account.id,
provider: CalendarProviderName.CalDAV,
externalCalendarId: `${server.baseUrl}/calendars/user/home/`,
displayName: 'Home',
timezone: 'UTC',
color: null,
enabled: true,
});
await models.calendarSubscription.updateSync(subscription.id, {
syncToken: 'stale-token',
});
await calendarService.syncSubscription(subscription.id);
t.true(
server.requests.some(
request =>
request.method === 'REPORT' &&
request.url === '/calendars/user/home/' &&
request.body.includes('sync-collection')
)
);
const updatedSubscription = await models.calendarSubscription.get(
subscription.id
);
t.is(updatedSubscription?.syncToken, null);
const events = await models.calendarEvent.listBySubscriptionsInRange(
[subscription.id],
new Date('2020-01-01T00:00:00.000Z'),
new Date('2030-01-01T00:00:00.000Z')
);
t.is(events.length, 3);
});
test('linkCalDAVAccount blocks private network hosts', async t => {
const module = await createCalendarModule({
enabled: true,
allowInsecureHttp: true,
blockPrivateNetwork: true,
providers: [
{
id: 'blocked',
label: 'Blocked CalDAV',
serverUrl: 'http://127.0.0.1:1/caldav/',
},
],
});
t.teardown(() => module.close());
const calendarService = module.get(CalendarService);
const user = await module.create(Mockers.User);
const error = await t.throwsAsync(async () => {
await calendarService.linkCalDAVAccount({
userId: user.id,
input: {
providerPresetId: 'blocked',
username: USERNAME,
password: PASSWORD,
displayName: null,
},
});
});
t.true(error instanceof GraphqlBadRequest);
t.is((error as GraphqlBadRequest).data?.code, 'caldav_private_network');
});
test('linkCalDAVAccount enforces allowed hosts', async t => {
const module = await createCalendarModule({
enabled: true,
providers: [
{
id: 'blocked',
label: 'Blocked CalDAV',
serverUrl: 'https://blocked.example.com/caldav/',
},
],
allowedHosts: ['allowed.com'],
});
t.teardown(() => module.close());
const calendarService = module.get(CalendarService);
const user = await module.create(Mockers.User);
const error = await t.throwsAsync(async () => {
await calendarService.linkCalDAVAccount({
userId: user.id,
input: {
providerPresetId: 'blocked',
username: USERNAME,
password: PASSWORD,
displayName: null,
},
});
});
t.true(error instanceof GraphqlBadRequest);
t.is((error as GraphqlBadRequest).data?.code, 'caldav_host_blocked');
});
test('linkCalDAVAccount enforces redirect limits', async t => {
const server = await createRedirectServer();
t.teardown(() => server.server.close());
const module = await createCalendarModule({
enabled: true,
allowInsecureHttp: true,
blockPrivateNetwork: false,
maxRedirects: 0,
providers: [
{
id: 'redirect',
label: 'Redirect CalDAV',
serverUrl: `${server.baseUrl}/caldav/`,
authType: 'basic',
},
],
});
t.teardown(() => module.close());
const calendarService = module.get(CalendarService);
const user = await module.create(Mockers.User);
const error = await t.throwsAsync(async () => {
await calendarService.linkCalDAVAccount({
userId: user.id,
input: {
providerPresetId: 'redirect',
username: USERNAME,
password: PASSWORD,
displayName: null,
},
});
});
t.true(error instanceof GraphqlBadRequest);
t.is((error as GraphqlBadRequest).data?.code, 'caldav_max_redirects');
});
@@ -21,6 +21,7 @@ import {
CalendarSyncTokenInvalid, CalendarSyncTokenInvalid,
} from '../providers'; } from '../providers';
import type { import type {
CalendarProviderListCalendarsParams,
CalendarProviderListEventsParams, CalendarProviderListEventsParams,
CalendarProviderStopParams, CalendarProviderStopParams,
CalendarProviderWatchParams, CalendarProviderWatchParams,
@@ -46,7 +47,7 @@ class MockCalendarProvider extends CalendarProvider {
return { providerAccountId: 'mock-account' }; return { providerAccountId: 'mock-account' };
} }
override async listCalendars(_accessToken: string) { override async listCalendars(_params: CalendarProviderListCalendarsParams) {
return []; return [];
} }
@@ -10,10 +10,33 @@ export interface CalendarGoogleConfig {
webhookVerificationToken?: string; webhookVerificationToken?: string;
} }
export type CalendarCalDAVAuthType = 'auto' | 'basic' | 'digest';
export interface CalendarCalDAVProviderPreset {
id: string;
label: string;
serverUrl: string;
authType?: CalendarCalDAVAuthType;
requiresAppPassword?: boolean;
docsUrl?: string;
}
export interface CalendarCalDAVConfig {
enabled: boolean;
allowCustomProvider?: boolean;
providers: CalendarCalDAVProviderPreset[];
allowInsecureHttp?: boolean;
allowedHosts?: string[];
blockPrivateNetwork?: boolean;
requestTimeoutMs?: number;
maxRedirects?: number;
}
declare global { declare global {
interface AppConfigSchema { interface AppConfigSchema {
calendar: { calendar: {
google: ConfigItem<CalendarGoogleConfig>; google: ConfigItem<CalendarGoogleConfig>;
caldav: ConfigItem<CalendarCalDAVConfig>;
}; };
} }
} }
@@ -29,6 +52,33 @@ const schema: JSONSchema = {
}, },
}; };
const caldavSchema: JSONSchema = {
type: 'object',
properties: {
enabled: { type: 'boolean' },
allowCustomProvider: { type: 'boolean' },
providers: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
label: { type: 'string' },
serverUrl: { type: 'string' },
authType: { type: 'string' },
requiresAppPassword: { type: 'boolean' },
docsUrl: { type: 'string' },
},
},
},
allowInsecureHttp: { type: 'boolean' },
allowedHosts: { type: 'array', items: { type: 'string' } },
blockPrivateNetwork: { type: 'boolean' },
requestTimeoutMs: { type: 'number' },
maxRedirects: { type: 'number' },
},
};
defineModuleConfig('calendar', { defineModuleConfig('calendar', {
google: { google: {
desc: 'Google Calendar integration config', desc: 'Google Calendar integration config',
@@ -54,4 +104,37 @@ defineModuleConfig('calendar', {
}), }),
link: 'https://developers.google.com/calendar/api/guides/push', link: 'https://developers.google.com/calendar/api/guides/push',
}, },
caldav: {
desc: 'CalDAV integration config',
default: {
enabled: false,
allowCustomProvider: false,
providers: [],
allowInsecureHttp: false,
allowedHosts: [],
blockPrivateNetwork: true,
requestTimeoutMs: 10_000,
maxRedirects: 5,
},
schema: caldavSchema,
shape: z.object({
enabled: z.boolean(),
allowCustomProvider: z.boolean().optional(),
providers: z.array(
z.object({
id: z.string(),
label: z.string(),
serverUrl: z.string().url(),
authType: z.enum(['auto', 'basic', 'digest']).optional(),
requiresAppPassword: z.boolean().optional(),
docsUrl: z.string().url().optional(),
})
),
allowInsecureHttp: z.boolean().optional(),
allowedHosts: z.array(z.string()).optional(),
blockPrivateNetwork: z.boolean().optional(),
requestTimeoutMs: z.number().int().positive().optional(),
maxRedirects: z.number().int().nonnegative().optional(),
}),
},
}); });
@@ -42,7 +42,7 @@ export class CalendarController {
throw new MissingOauthQueryParameter({ name: 'provider' }); throw new MissingOauthQueryParameter({ name: 'provider' });
} }
if (!this.calendar.isProviderAvailable(providerName)) { if (!this.calendar.isProviderAvailableFor(providerName, { oauth: true })) {
throw new UnknownOauthProvider({ name: providerName }); throw new UnknownOauthProvider({ name: providerName });
} }
@@ -157,7 +157,8 @@ export class CalendarController {
private getCallbackErrorMessage(error: unknown) { private getCallbackErrorMessage(error: unknown) {
if (error instanceof CalendarProviderRequestError) { if (error instanceof CalendarProviderRequestError) {
if (error.status === 403) { const status = error.data?.status ?? error.status;
if (status === 403) {
return 'Calendar authorization failed: insufficient permissions. Please reauthorize and allow Calendar access.'; return 'Calendar authorization failed: insufficient permissions. Please reauthorize and allow Calendar access.';
} }
return 'Calendar authorization failed. Please try again.'; return 'Calendar authorization failed. Please try again.';
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,5 @@
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import type { CalendarAccount } from '@prisma/client';
import { CalendarProviderRequestError, Config, OnEvent } from '../../../base'; import { CalendarProviderRequestError, Config, OnEvent } from '../../../base';
import { CalendarProviderFactory } from './factory'; import { CalendarProviderFactory } from './factory';
@@ -54,11 +55,17 @@ export interface CalendarProviderEvent {
export interface CalendarProviderListEventsParams { export interface CalendarProviderListEventsParams {
accessToken: string; accessToken: string;
calendarId: string; calendarId: string;
account?: CalendarAccount;
timeMin?: string; timeMin?: string;
timeMax?: string; timeMax?: string;
syncToken?: string; syncToken?: string;
} }
export interface CalendarProviderListCalendarsParams {
accessToken: string;
account?: CalendarAccount;
}
export interface CalendarProviderListEventsResult { export interface CalendarProviderListEventsResult {
events: CalendarProviderEvent[]; events: CalendarProviderEvent[];
nextSyncToken?: string; nextSyncToken?: string;
@@ -97,7 +104,7 @@ export abstract class CalendarProvider {
accessToken: string accessToken: string
): Promise<CalendarAccountProfile>; ): Promise<CalendarAccountProfile>;
abstract listCalendars( abstract listCalendars(
accessToken: string params: CalendarProviderListCalendarsParams
): Promise<CalendarProviderCalendar[]>; ): Promise<CalendarProviderCalendar[]>;
abstract listEvents( abstract listEvents(
params: CalendarProviderListEventsParams params: CalendarProviderListEventsParams
@@ -117,12 +124,17 @@ export abstract class CalendarProvider {
} }
get configured() { get configured() {
return ( if (!this.config || !this.config.enabled) {
!!this.config && return false;
!!this.config.enabled && }
!!this.config.clientId && if ('clientId' in this.config || 'clientSecret' in this.config) {
!!this.config.clientSecret return Boolean(this.config.clientId && this.config.clientSecret);
); }
return true;
}
get supportsOAuth() {
return true;
} }
@OnEvent('config.init') @OnEvent('config.init')
@@ -4,6 +4,7 @@ import { CalendarProviderRequestError } from '../../../base';
import { CalendarProvider } from './def'; import { CalendarProvider } from './def';
import { import {
CalendarProviderEvent, CalendarProviderEvent,
CalendarProviderListCalendarsParams,
CalendarProviderListEventsParams, CalendarProviderListEventsParams,
CalendarProviderListEventsResult, CalendarProviderListEventsResult,
CalendarProviderName, CalendarProviderName,
@@ -171,7 +172,7 @@ export class GoogleCalendarProvider extends CalendarProvider {
}; };
} }
async listCalendars(accessToken: string) { async listCalendars(params: CalendarProviderListCalendarsParams) {
const calendars: GoogleCalendarListResponse['items'] = []; const calendars: GoogleCalendarListResponse['items'] = [];
let pageToken: string | undefined; let pageToken: string | undefined;
@@ -188,7 +189,7 @@ export class GoogleCalendarProvider extends CalendarProvider {
url.toString(), url.toString(),
{ {
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${params.accessToken}`,
}, },
} }
); );
@@ -1,10 +1,13 @@
import { CalDAVProvider } from './caldav';
import { GoogleCalendarProvider } from './google'; import { GoogleCalendarProvider } from './google';
export { CalDAVProvider } from './caldav';
export type { export type {
CalendarAccountProfile, CalendarAccountProfile,
CalendarProviderCalendar, CalendarProviderCalendar,
CalendarProviderEvent, CalendarProviderEvent,
CalendarProviderEventTime, CalendarProviderEventTime,
CalendarProviderListCalendarsParams,
CalendarProviderListEventsParams, CalendarProviderListEventsParams,
CalendarProviderListEventsResult, CalendarProviderListEventsResult,
CalendarProviderTokens, CalendarProviderTokens,
@@ -16,4 +19,4 @@ export { CalendarProvider } from './def';
export { CalendarProviderFactory } from './factory'; export { CalendarProviderFactory } from './factory';
export { CalendarSyncTokenInvalid, GoogleCalendarProvider } from './google'; export { CalendarSyncTokenInvalid, GoogleCalendarProvider } from './google';
export const CalendarProviders = [GoogleCalendarProvider]; export const CalendarProviders = [GoogleCalendarProvider, CalDAVProvider];
@@ -7,7 +7,7 @@ import {
Resolver, Resolver,
} from '@nestjs/graphql'; } from '@nestjs/graphql';
import { ActionForbidden, AuthenticationRequired } from '../../base'; import { ActionForbidden, AuthenticationRequired, Config } from '../../base';
import { CurrentUser } from '../../core/auth'; import { CurrentUser } from '../../core/auth';
import { ServerConfigType } from '../../core/config/types'; import { ServerConfigType } from '../../core/config/types';
import { AccessController } from '../../core/permission'; import { AccessController } from '../../core/permission';
@@ -19,8 +19,10 @@ import { CalendarProviderFactory, CalendarProviderName } from './providers';
import { CalendarService } from './service'; import { CalendarService } from './service';
import { import {
CalendarAccountObjectType, CalendarAccountObjectType,
CalendarCalDAVProviderPresetObjectType,
CalendarEventObjectType, CalendarEventObjectType,
CalendarSubscriptionObjectType, CalendarSubscriptionObjectType,
LinkCalDAVAccountInput,
LinkCalendarAccountInput, LinkCalendarAccountInput,
UpdateWorkspaceCalendarsInput, UpdateWorkspaceCalendarsInput,
WorkspaceCalendarObjectType, WorkspaceCalendarObjectType,
@@ -28,12 +30,29 @@ import {
@Resolver(() => ServerConfigType) @Resolver(() => ServerConfigType)
export class CalendarServerConfigResolver { export class CalendarServerConfigResolver {
constructor(private readonly providerFactory: CalendarProviderFactory) {} constructor(
private readonly providerFactory: CalendarProviderFactory,
private readonly config: Config
) {}
@ResolveField(() => [CalendarProviderName]) @ResolveField(() => [CalendarProviderName])
calendarProviders() { calendarProviders() {
return this.providerFactory.providers; return this.providerFactory.providers;
} }
@ResolveField(() => [CalendarCalDAVProviderPresetObjectType])
calendarCalDAVProviders() {
const caldavConfig = this.config.calendar.caldav;
if (!caldavConfig?.enabled) {
return [];
}
return caldavConfig.providers.map(provider => ({
id: provider.id,
label: provider.label,
requiresAppPassword: provider.requiresAppPassword ?? null,
docsUrl: provider.docsUrl ?? null,
}));
}
} }
@Resolver(() => UserType) @Resolver(() => UserType)
@@ -140,6 +159,21 @@ export class CalendarMutationResolver {
return this.calendar.getAuthUrl(input.provider, state, callbackUrl); return this.calendar.getAuthUrl(input.provider, state, callbackUrl);
} }
@Mutation(() => CalendarAccountObjectType)
async linkCalDAVAccount(
@CurrentUser() user: CurrentUser | null,
@Args('input') input: LinkCalDAVAccountInput
) {
if (!user) {
throw new AuthenticationRequired();
}
return await this.calendar.linkCalDAVAccount({
userId: user.id,
input,
});
}
@Mutation(() => CalendarAccountObjectType, { nullable: true }) @Mutation(() => CalendarAccountObjectType, { nullable: true })
async updateCalendarAccount( async updateCalendarAccount(
@CurrentUser() user: CurrentUser, @CurrentUser() user: CurrentUser,
@@ -8,10 +8,12 @@ import { addDays, subDays } from 'date-fns';
import { import {
CalendarProviderRequestError, CalendarProviderRequestError,
Config, Config,
GraphqlBadRequest,
Mutex, Mutex,
URLHelper, URLHelper,
} from '../../base'; } from '../../base';
import { Models } from '../../models'; import { Models } from '../../models';
import type { CalendarCalDAVProviderPreset } from './config';
import { import {
CalendarProvider, CalendarProvider,
CalendarProviderEvent, CalendarProviderEvent,
@@ -20,6 +22,7 @@ import {
CalendarSyncTokenInvalid, CalendarSyncTokenInvalid,
} from './providers'; } from './providers';
import { CalendarProviderFactory } from './providers'; import { CalendarProviderFactory } from './providers';
import type { LinkCalDAVAccountInput } from './types';
const TOKEN_REFRESH_SKEW_MS = 60 * 1000; const TOKEN_REFRESH_SKEW_MS = 60 * 1000;
const DEFAULT_PAST_DAYS = 90; const DEFAULT_PAST_DAYS = 90;
@@ -159,6 +162,92 @@ export class CalendarService {
return account; return account;
} }
async linkCalDAVAccount(params: {
userId: string;
input: LinkCalDAVAccountInput;
}) {
const caldavConfig = this.config.calendar.caldav;
if (!caldavConfig?.enabled) {
throw new GraphqlBadRequest({
code: 'caldav_disabled',
message: 'CalDAV integration is not enabled.',
});
}
const preset = caldavConfig.providers.find(
provider => provider.id === params.input.providerPresetId
);
if (!preset) {
throw new GraphqlBadRequest({
code: 'caldav_provider_not_found',
message: 'CalDAV provider is not available.',
});
}
const provider = this.requireProvider(CalendarProviderName.CalDAV);
if (!('discoverAccount' in provider)) {
throw new GraphqlBadRequest({
code: 'caldav_provider_unavailable',
message: 'CalDAV provider is not configured.',
});
}
const discovery = await (
provider as CalendarProvider & {
discoverAccount: (input: {
preset: CalendarCalDAVProviderPreset;
username: string;
password: string;
}) => Promise<{
providerAccountId: string;
serverUrl: string;
principalUrl: string;
calendarHomeUrl: string;
authType?: string | null;
}>;
}
).discoverAccount({
preset,
username: params.input.username,
password: params.input.password,
});
const account = await this.models.calendarAccount.upsert({
userId: params.userId,
provider: CalendarProviderName.CalDAV,
providerAccountId: discovery.providerAccountId,
displayName: params.input.displayName ?? null,
email: params.input.username,
accessToken: params.input.password,
refreshToken: null,
expiresAt: null,
scope: null,
status: 'active',
lastError: null,
providerPresetId: params.input.providerPresetId,
serverUrl: discovery.serverUrl,
principalUrl: discovery.principalUrl,
calendarHomeUrl: discovery.calendarHomeUrl,
username: params.input.username,
authType: discovery.authType ?? null,
});
try {
await this.syncAccountCalendars(account.id);
} catch (error) {
if (error instanceof CalendarProviderRequestError) {
await this.models.calendarAccount.updateStatus(
account.id,
'invalid',
error.message
);
}
throw error;
}
return account;
}
async syncAccountCalendars(accountId: string) { async syncAccountCalendars(accountId: string) {
const account = await this.models.calendarAccount.get(accountId); const account = await this.models.calendarAccount.get(accountId);
if (!account) { if (!account) {
@@ -177,7 +266,10 @@ export class CalendarService {
return; return;
} }
const calendars = await provider.listCalendars(accessToken); const calendars = await provider.listCalendars({
accessToken,
account,
});
const upserted = []; const upserted = [];
for (const calendar of calendars) { for (const calendar of calendars) {
upserted.push( upserted.push(
@@ -245,6 +337,7 @@ export class CalendarService {
subscriptionId: subscription.id, subscriptionId: subscription.id,
calendarId: subscription.externalCalendarId, calendarId: subscription.externalCalendarId,
accessToken, accessToken,
account,
syncToken: shouldUseSyncToken syncToken: shouldUseSyncToken
? (subscription.syncToken ?? undefined) ? (subscription.syncToken ?? undefined)
: undefined, : undefined,
@@ -264,6 +357,7 @@ export class CalendarService {
subscriptionId: subscription.id, subscriptionId: subscription.id,
calendarId: subscription.externalCalendarId, calendarId: subscription.externalCalendarId,
accessToken, accessToken,
account,
timeMin, timeMin,
timeMax, timeMax,
subscriptionTimezone: subscription.timezone ?? undefined, subscriptionTimezone: subscription.timezone ?? undefined,
@@ -410,7 +504,21 @@ export class CalendarService {
} }
isProviderAvailable(provider: CalendarProviderName) { isProviderAvailable(provider: CalendarProviderName) {
return !!this.providerFactory.get(provider); return this.isProviderAvailableFor(provider);
}
isProviderAvailableFor(
provider: CalendarProviderName,
options?: { oauth?: boolean }
) {
const instance = this.providerFactory.get(provider);
if (!instance) {
return false;
}
if (options?.oauth) {
return instance.supportsOAuth;
}
return true;
} }
getAuthUrl( getAuthUrl(
@@ -418,7 +526,14 @@ export class CalendarService {
state: string, state: string,
redirectUri: string redirectUri: string
) { ) {
return this.requireProvider(provider).getAuthUrl(state, redirectUri); const instance = this.requireProvider(provider);
if (!instance.supportsOAuth) {
throw new GraphqlBadRequest({
code: 'calendar_provider_oauth_unsupported',
message: 'Selected calendar provider does not support OAuth.',
});
}
return instance.getAuthUrl(state, redirectUri);
} }
private async syncWithProvider(params: { private async syncWithProvider(params: {
@@ -426,6 +541,7 @@ export class CalendarService {
subscriptionId: string; subscriptionId: string;
calendarId: string; calendarId: string;
accessToken: string; accessToken: string;
account: CalendarAccount;
syncToken?: string; syncToken?: string;
timeMin?: string; timeMin?: string;
timeMax?: string; timeMax?: string;
@@ -434,6 +550,7 @@ export class CalendarService {
const response = await params.provider.listEvents({ const response = await params.provider.listEvents({
accessToken: params.accessToken, accessToken: params.accessToken,
calendarId: params.calendarId, calendarId: params.calendarId,
account: params.account,
syncToken: params.syncToken, syncToken: params.syncToken,
timeMin: params.timeMin, timeMin: params.timeMin,
timeMax: params.timeMax, timeMax: params.timeMax,
@@ -632,7 +749,8 @@ export class CalendarService {
private isTokenInvalidError(error: unknown) { private isTokenInvalidError(error: unknown) {
if (error instanceof CalendarProviderRequestError) { if (error instanceof CalendarProviderRequestError) {
if (error.status === 401) { const status = error.data?.status ?? error.status;
if (status === 401) {
return true; return true;
} }
return error.message.includes('invalid_grant'); return error.message.includes('invalid_grant');
@@ -76,6 +76,21 @@ export class CalendarSubscriptionObjectType {
lastSyncAt?: Date | null; lastSyncAt?: Date | null;
} }
@ObjectType()
export class CalendarCalDAVProviderPresetObjectType {
@Field()
id!: string;
@Field()
label!: string;
@Field(() => Boolean, { nullable: true })
requiresAppPassword?: boolean | null;
@Field(() => String, { nullable: true })
docsUrl?: string | null;
}
@ObjectType() @ObjectType()
export class WorkspaceCalendarItemObjectType { export class WorkspaceCalendarItemObjectType {
@Field() @Field()
@@ -186,3 +201,18 @@ export class LinkCalendarAccountInput {
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
redirectUri?: string | null; redirectUri?: string | null;
} }
@InputType()
export class LinkCalDAVAccountInput {
@Field()
providerPresetId!: string;
@Field()
username!: string;
@Field()
password!: string;
@Field(() => String, { nullable: true })
displayName?: string | null;
}
+16
View File
@@ -206,6 +206,13 @@ type CalendarAccountObjectType {
updatedAt: DateTime! updatedAt: DateTime!
} }
type CalendarCalDAVProviderPresetObjectType {
docsUrl: String
id: String!
label: String!
requiresAppPassword: Boolean
}
type CalendarEventObjectType { type CalendarEventObjectType {
allDay: Boolean! allDay: Boolean!
description: String description: String
@@ -1274,6 +1281,13 @@ type LimitedUserType {
hasPassword: Boolean hasPassword: Boolean
} }
input LinkCalDAVAccountInput {
displayName: String
password: String!
providerPresetId: String!
username: String!
}
input LinkCalendarAccountInput { input LinkCalendarAccountInput {
provider: CalendarProviderType! provider: CalendarProviderType!
redirectUri: String redirectUri: String
@@ -1462,6 +1476,7 @@ type Mutation {
installLicense(license: Upload!, workspaceId: String!): License! installLicense(license: Upload!, workspaceId: String!): License!
inviteMembers(emails: [String!]!, workspaceId: String!): [InviteResult!]! inviteMembers(emails: [String!]!, workspaceId: String!): [InviteResult!]!
leaveWorkspace(sendLeaveMail: Boolean @deprecated(reason: "no used anymore"), workspaceId: String!, workspaceName: String @deprecated(reason: "no longer used")): Boolean! leaveWorkspace(sendLeaveMail: Boolean @deprecated(reason: "no used anymore"), workspaceId: String!, workspaceName: String @deprecated(reason: "no longer used")): Boolean!
linkCalDAVAccount(input: LinkCalDAVAccountInput!): CalendarAccountObjectType!
linkCalendarAccount(input: LinkCalendarAccountInput!): String! linkCalendarAccount(input: LinkCalendarAccountInput!): String!
"""mention user in a doc""" """mention user in a doc"""
@@ -2039,6 +2054,7 @@ type ServerConfigType {
"""server base url""" """server base url"""
baseUrl: String! baseUrl: String!
calendarCalDAVProviders: [CalendarCalDAVProviderPresetObjectType!]!
calendarProviders: [CalendarProviderType!]! calendarProviders: [CalendarProviderType!]!
"""credentials requirement""" """credentials requirement"""
@@ -1,5 +1,11 @@
query calendarProviders { query calendarProviders {
serverConfig { serverConfig {
calendarCalDAVProviders {
id
label
requiresAppPassword
docsUrl
}
calendarProviders calendarProviders
} }
} }
@@ -0,0 +1,15 @@
mutation linkCalDavAccount($input: LinkCalDAVAccountInput!) {
linkCalDAVAccount(input: $input) {
id
provider
providerAccountId
displayName
email
status
lastError
refreshIntervalMinutes
calendarsCount
createdAt
updatedAt
}
}
@@ -656,11 +656,37 @@ export const calendarProvidersQuery = {
op: 'calendarProviders', op: 'calendarProviders',
query: `query calendarProviders { query: `query calendarProviders {
serverConfig { serverConfig {
calendarCalDAVProviders {
id
label
requiresAppPassword
docsUrl
}
calendarProviders calendarProviders
} }
}`, }`,
}; };
export const linkCalDavAccountMutation = {
id: 'linkCalDavAccountMutation' as const,
op: 'linkCalDavAccount',
query: `mutation linkCalDavAccount($input: LinkCalDAVAccountInput!) {
linkCalDAVAccount(input: $input) {
id
provider
providerAccountId
displayName
email
status
lastError
refreshIntervalMinutes
calendarsCount
createdAt
updatedAt
}
}`,
};
export const linkCalendarAccountMutation = { export const linkCalendarAccountMutation = {
id: 'linkCalendarAccountMutation' as const, id: 'linkCalendarAccountMutation' as const,
op: 'linkCalendarAccount', op: 'linkCalendarAccount',
+55
View File
@@ -259,6 +259,14 @@ export interface CalendarAccountObjectType {
updatedAt: Scalars['DateTime']['output']; updatedAt: Scalars['DateTime']['output'];
} }
export interface CalendarCalDavProviderPresetObjectType {
__typename?: 'CalendarCalDAVProviderPresetObjectType';
docsUrl: Maybe<Scalars['String']['output']>;
id: Scalars['String']['output'];
label: Scalars['String']['output'];
requiresAppPassword: Maybe<Scalars['Boolean']['output']>;
}
export interface CalendarEventObjectType { export interface CalendarEventObjectType {
__typename?: 'CalendarEventObjectType'; __typename?: 'CalendarEventObjectType';
allDay: Scalars['Boolean']['output']; allDay: Scalars['Boolean']['output'];
@@ -1466,6 +1474,13 @@ export interface LimitedUserType {
hasPassword: Maybe<Scalars['Boolean']['output']>; hasPassword: Maybe<Scalars['Boolean']['output']>;
} }
export interface LinkCalDavAccountInput {
displayName?: InputMaybe<Scalars['String']['input']>;
password: Scalars['String']['input'];
providerPresetId: Scalars['String']['input'];
username: Scalars['String']['input'];
}
export interface LinkCalendarAccountInput { export interface LinkCalendarAccountInput {
provider: CalendarProviderType; provider: CalendarProviderType;
redirectUri?: InputMaybe<Scalars['String']['input']>; redirectUri?: InputMaybe<Scalars['String']['input']>;
@@ -1632,6 +1647,7 @@ export interface Mutation {
installLicense: License; installLicense: License;
inviteMembers: Array<InviteResult>; inviteMembers: Array<InviteResult>;
leaveWorkspace: Scalars['Boolean']['output']; leaveWorkspace: Scalars['Boolean']['output'];
linkCalDAVAccount: CalendarAccountObjectType;
linkCalendarAccount: Scalars['String']['output']; linkCalendarAccount: Scalars['String']['output'];
/** mention user in a doc */ /** mention user in a doc */
mentionUser: Scalars['ID']['output']; mentionUser: Scalars['ID']['output'];
@@ -1943,6 +1959,10 @@ export interface MutationLeaveWorkspaceArgs {
workspaceName?: InputMaybe<Scalars['String']['input']>; workspaceName?: InputMaybe<Scalars['String']['input']>;
} }
export interface MutationLinkCalDavAccountArgs {
input: LinkCalDavAccountInput;
}
export interface MutationLinkCalendarAccountArgs { export interface MutationLinkCalendarAccountArgs {
input: LinkCalendarAccountInput; input: LinkCalendarAccountInput;
} }
@@ -2699,6 +2719,7 @@ export interface ServerConfigType {
availableWorkspaceFeatures: Array<FeatureType>; availableWorkspaceFeatures: Array<FeatureType>;
/** server base url */ /** server base url */
baseUrl: Scalars['String']['output']; baseUrl: Scalars['String']['output'];
calendarCalDAVProviders: Array<CalendarCalDavProviderPresetObjectType>;
calendarProviders: Array<CalendarProviderType>; calendarProviders: Array<CalendarProviderType>;
/** credentials requirement */ /** credentials requirement */
credentialsRequirement: CredentialsRequirementType; credentialsRequirement: CredentialsRequirementType;
@@ -3956,6 +3977,35 @@ export type CalendarProvidersQuery = {
serverConfig: { serverConfig: {
__typename?: 'ServerConfigType'; __typename?: 'ServerConfigType';
calendarProviders: Array<CalendarProviderType>; calendarProviders: Array<CalendarProviderType>;
calendarCalDAVProviders: Array<{
__typename?: 'CalendarCalDAVProviderPresetObjectType';
id: string;
label: string;
requiresAppPassword: boolean | null;
docsUrl: string | null;
}>;
};
};
export type LinkCalDavAccountMutationVariables = Exact<{
input: LinkCalDavAccountInput;
}>;
export type LinkCalDavAccountMutation = {
__typename?: 'Mutation';
linkCalDAVAccount: {
__typename?: 'CalendarAccountObjectType';
id: string;
provider: CalendarProviderType;
providerAccountId: string;
displayName: string | null;
email: string | null;
status: string;
lastError: string | null;
refreshIntervalMinutes: number;
calendarsCount: number;
createdAt: string;
updatedAt: string;
}; };
}; };
@@ -7656,6 +7706,11 @@ export type Mutations =
variables: CreateBlobUploadMutationVariables; variables: CreateBlobUploadMutationVariables;
response: CreateBlobUploadMutation; response: CreateBlobUploadMutation;
} }
| {
name: 'linkCalDavAccountMutation';
variables: LinkCalDavAccountMutationVariables;
response: LinkCalDavAccountMutation;
}
| { | {
name: 'linkCalendarAccountMutation'; name: 'linkCalendarAccountMutation';
variables: LinkCalendarAccountMutationVariables; variables: LinkCalendarAccountMutationVariables;
+4
View File
@@ -288,6 +288,10 @@
"type": "Object", "type": "Object",
"desc": "Google Calendar integration config", "desc": "Google Calendar integration config",
"link": "https://developers.google.com/calendar/api/guides/push" "link": "https://developers.google.com/calendar/api/guides/push"
},
"caldav": {
"type": "Object",
"desc": "CalDAV integration config"
} }
}, },
"captcha": { "captcha": {
@@ -12,6 +12,15 @@ import useSWR from 'swr';
import useSWRImmutable from 'swr/immutable'; import useSWRImmutable from 'swr/immutable';
import useSWRInfinite from 'swr/infinite'; import useSWRInfinite from 'swr/infinite';
export type UseQueryConfig<Query extends GraphQLQuery = GraphQLQuery> = Omit<
SWRConfiguration<
QueryResponse<Query>,
GraphQLError,
(options: QueryOptions<Query>) => Promise<QueryResponse<Query>>
>,
'fetcher'
>;
/** /**
* A `useSWR` wrapper for sending graphql queries * A `useSWR` wrapper for sending graphql queries
* *
@@ -32,14 +41,7 @@ import useSWRInfinite from 'swr/infinite';
*/ */
type useQueryFn = <Query extends GraphQLQuery>( type useQueryFn = <Query extends GraphQLQuery>(
options?: QueryOptions<Query>, options?: QueryOptions<Query>,
config?: Omit< config?: UseQueryConfig<Query>
SWRConfiguration<
QueryResponse<Query>,
GraphQLError,
(options: QueryOptions<Query>) => Promise<QueryResponse<Query>>
>,
'fetcher'
>
) => SWRResponse< ) => SWRResponse<
QueryResponse<Query>, QueryResponse<Query>,
GraphQLError, GraphQLError,
@@ -130,3 +130,53 @@ export const empty = style({
color: cssVarV2.text.secondary, color: cssVarV2.text.secondary,
padding: '12px 0', padding: '12px 0',
}); });
export const caldavDialog = style({
display: 'flex',
flexDirection: 'column',
gap: 20,
});
export const caldavField = style({
display: 'flex',
flexDirection: 'column',
gap: 10,
});
export const caldavLabel = style({
fontSize: 12,
lineHeight: '18px',
color: cssVarV2.text.secondary,
});
export const caldavProviderButton = style({
width: '100%',
justifyContent: 'space-between',
});
export const caldavHint = style({
display: 'flex',
gap: 6,
flexWrap: 'wrap',
fontSize: 12,
lineHeight: '18px',
color: cssVarV2.text.secondary,
});
export const caldavLink = style({
color: cssVarV2.text.primary,
textDecoration: 'underline',
});
export const caldavError = style({
fontSize: 12,
lineHeight: '18px',
color: cssVarV2.status.error,
});
export const caldavFooter = style({
display: 'flex',
justifyContent: 'flex-end',
gap: 12,
marginTop: 12,
});
@@ -1,12 +1,27 @@
import { Button, Loading, Menu, MenuItem, notify } from '@affine/component'; import {
Button,
Input,
Loading,
Menu,
MenuItem,
Modal,
notify,
} from '@affine/component';
import {
useQuery,
type UseQueryConfig,
} from '@affine/core/components/hooks/use-query';
import { GraphQLService } from '@affine/core/modules/cloud'; import { GraphQLService } from '@affine/core/modules/cloud';
import { UrlService } from '@affine/core/modules/url'; import { UrlService } from '@affine/core/modules/url';
import { UserFriendlyError } from '@affine/error'; import { UserFriendlyError } from '@affine/error';
import { import {
type CalendarAccountsQuery, type CalendarAccountsQuery,
calendarAccountsQuery, calendarAccountsQuery,
type CalendarProvidersQuery,
calendarProvidersQuery, calendarProvidersQuery,
CalendarProviderType, CalendarProviderType,
type GraphQLQuery,
linkCalDavAccountMutation,
linkCalendarAccountMutation, linkCalendarAccountMutation,
unlinkCalendarAccountMutation, unlinkCalendarAccountMutation,
} from '@affine/graphql'; } from '@affine/graphql';
@@ -14,6 +29,7 @@ import { useI18n } from '@affine/i18n';
import { GoogleIcon, LinkIcon, TodayIcon } from '@blocksuite/icons/rc'; import { GoogleIcon, LinkIcon, TodayIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra'; import { useService } from '@toeverything/infra';
import { import {
type FormEvent,
type ReactNode, type ReactNode,
useCallback, useCallback,
useEffect, useEffect,
@@ -28,6 +44,10 @@ type CalendarAccount = NonNullable<
CalendarAccountsQuery['currentUser'] CalendarAccountsQuery['currentUser']
>['calendarAccounts'][number]; >['calendarAccounts'][number];
type CalendarCalDAVProvider = NonNullable<
CalendarProvidersQuery['serverConfig']
>['calendarCalDAVProviders'][number];
const providerMeta = { const providerMeta = {
[CalendarProviderType.Google]: { [CalendarProviderType.Google]: {
label: 'Google Calendar', label: 'Google Calendar',
@@ -41,68 +61,328 @@ const providerMeta = {
Record<CalendarProviderType, { label: string; icon: ReactNode }> Record<CalendarProviderType, { label: string; icon: ReactNode }>
>; >;
const CalDAVLinkDialog = ({
open,
providers,
onClose,
onLinked,
}: {
open: boolean;
providers: CalendarCalDAVProvider[];
onClose: () => void;
onLinked: () => void;
}) => {
const t = useI18n();
const gqlService = useService(GraphQLService);
const [providerId, setProviderId] = useState<string | null>(null);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [submitting, setSubmitting] = useState(false);
const [errors, setErrors] = useState<{
provider?: string;
username?: string;
password?: string;
}>({});
const selectedProvider = useMemo(() => {
if (providerId) {
const match = providers.find(provider => provider.id === providerId);
if (match) {
return match;
}
}
return providers[0] ?? null;
}, [providerId, providers]);
useEffect(() => {
if (!open) return;
setProviderId(providers[0]?.id ?? null);
setUsername('');
setPassword('');
setDisplayName('');
setErrors({});
}, [open, providers]);
const handleProviderSelect = useCallback(
(provider: CalendarCalDAVProvider) => {
setProviderId(provider.id);
setErrors(prev => ({ ...prev, provider: undefined }));
},
[]
);
const handleUsernameInput = useCallback(
(event: FormEvent<HTMLInputElement>) => {
setUsername(event.currentTarget.value);
setErrors(prev => ({ ...prev, username: undefined }));
},
[]
);
const handlePasswordInput = useCallback(
(event: FormEvent<HTMLInputElement>) => {
setPassword(event.currentTarget.value);
setErrors(prev => ({ ...prev, password: undefined }));
},
[]
);
const handleDisplayNameInput = useCallback(
(event: FormEvent<HTMLInputElement>) => {
setDisplayName(event.currentTarget.value);
},
[]
);
const handleSubmit = useCallback(async () => {
const nextErrors: {
provider?: string;
username?: string;
password?: string;
} = {};
if (!selectedProvider) {
nextErrors.provider =
t['com.affine.integration.calendar.caldav.field.provider.error']();
}
if (!username.trim()) {
nextErrors.username =
t['com.affine.integration.calendar.caldav.field.username.error']();
}
if (!password) {
nextErrors.password =
t['com.affine.integration.calendar.caldav.field.password.error']();
}
if (Object.keys(nextErrors).length) {
setErrors(nextErrors);
return;
}
setSubmitting(true);
try {
await gqlService.gql({
query: linkCalDavAccountMutation,
variables: {
input: {
providerPresetId: selectedProvider!.id,
username: username.trim(),
password,
displayName: displayName.trim() || null,
},
},
});
onLinked();
onClose();
} catch (error) {
const message =
error instanceof UserFriendlyError ? error.message : String(error);
notify.error({
title: t['com.affine.integration.calendar.caldav.link.failed'](),
message: message || undefined,
});
} finally {
setSubmitting(false);
}
}, [
displayName,
gqlService,
onClose,
onLinked,
password,
selectedProvider,
t,
username,
]);
return (
<Modal
open={open}
width={480}
title={t['com.affine.integration.calendar.caldav.link.title']()}
onOpenChange={nextOpen => {
if (!nextOpen) onClose();
}}
contentOptions={{ className: styles.caldavDialog }}
>
<div className={styles.caldavField}>
<div className={styles.caldavLabel}>
{t['com.affine.integration.calendar.caldav.field.provider']()}
</div>
<Menu
items={providers.map(provider => (
<MenuItem
key={provider.id}
onSelect={() => handleProviderSelect(provider)}
>
{provider.label}
</MenuItem>
))}
contentOptions={{ align: 'start' }}
>
<Button
className={styles.caldavProviderButton}
disabled={!providers.length}
>
{selectedProvider?.label ??
t[
'com.affine.integration.calendar.caldav.field.provider.placeholder'
]()}
</Button>
</Menu>
{errors.provider ? (
<div className={styles.caldavError}>{errors.provider}</div>
) : null}
{selectedProvider?.requiresAppPassword ? (
<div className={styles.caldavHint}>
{t['com.affine.integration.calendar.caldav.hint.app-password']()}
{selectedProvider.docsUrl ? (
<a
className={styles.caldavLink}
href={selectedProvider.docsUrl}
target="_blank"
rel="noreferrer noopener"
>
{t['com.affine.integration.calendar.caldav.hint.learn-more']()}
</a>
) : null}
</div>
) : selectedProvider?.docsUrl ? (
<div className={styles.caldavHint}>
<a
className={styles.caldavLink}
href={selectedProvider.docsUrl}
target="_blank"
rel="noreferrer noopener"
>
{t['com.affine.integration.calendar.caldav.hint.guide']()}
</a>
</div>
) : null}
</div>
<div className={styles.caldavField}>
<div className={styles.caldavLabel}>
{t['com.affine.integration.calendar.caldav.field.username']()}
</div>
<Input
value={username}
onInput={handleUsernameInput}
placeholder={t[
'com.affine.integration.calendar.caldav.field.username.placeholder'
]()}
status={errors.username ? 'error' : 'default'}
disabled={submitting}
/>
{errors.username ? (
<div className={styles.caldavError}>{errors.username}</div>
) : null}
</div>
<div className={styles.caldavField}>
<div className={styles.caldavLabel}>
{t['com.affine.integration.calendar.caldav.field.password']()}
</div>
<Input
value={password}
onInput={handlePasswordInput}
placeholder={t[
'com.affine.integration.calendar.caldav.field.password.placeholder'
]()}
type="password"
status={errors.password ? 'error' : 'default'}
disabled={submitting}
/>
{errors.password ? (
<div className={styles.caldavError}>{errors.password}</div>
) : null}
</div>
<div className={styles.caldavField}>
<div className={styles.caldavLabel}>
{t['com.affine.integration.calendar.caldav.field.displayName']()}
</div>
<Input
value={displayName}
onInput={handleDisplayNameInput}
placeholder={t[
'com.affine.integration.calendar.caldav.field.displayName.placeholder'
]()}
disabled={submitting}
/>
</div>
<div className={styles.caldavFooter}>
<Button disabled={submitting} onClick={onClose}>
{t['Cancel']()}
</Button>
<Button
variant="primary"
loading={submitting}
disabled={submitting || !providers.length}
onClick={() => void handleSubmit()}
>
{t['com.affine.integration.calendar.account.link']()}
</Button>
</div>
</Modal>
);
};
export const IntegrationsPanel = () => { export const IntegrationsPanel = () => {
const t = useI18n(); const t = useI18n();
const gqlService = useService(GraphQLService); const gqlService = useService(GraphQLService);
const urlService = useService(UrlService); const urlService = useService(UrlService);
const [accounts, setAccounts] = useState<CalendarAccount[]>([]);
const [providers, setProviders] = useState<CalendarProviderType[]>([]);
const [loading, setLoading] = useState(true);
const [linking, setLinking] = useState(false); const [linking, setLinking] = useState(false);
const [unlinkingAccountId, setUnlinkingAccountId] = useState<string | null>( const [unlinkingAccountId, setUnlinkingAccountId] = useState<string | null>(
null null
); );
const [openedExternalWindow, setOpenedExternalWindow] = useState(false); const [openedExternalWindow, setOpenedExternalWindow] = useState(false);
const [caldavDialogOpen, setCaldavDialogOpen] = useState(false);
const revalidate = useCallback( const makeConfig: <Query extends GraphQLQuery>(
async (signal?: AbortSignal) => { title: string
setLoading(true); ) => UseQueryConfig<Query> = useCallback(
try { title => ({
const [accountsData, providersData] = await Promise.all([ suspense: false,
gqlService.gql({ revalidateOnFocus: openedExternalWindow,
query: calendarAccountsQuery, onError: error => {
context: { signal }, notify.error({ title, message: String(error) || undefined });
}), },
gqlService.gql({ }),
query: calendarProvidersQuery, [openedExternalWindow]
context: { signal },
}),
]);
setAccounts(accountsData.currentUser?.calendarAccounts ?? []);
setProviders(providersData.serverConfig.calendarProviders ?? []);
} catch (error) {
if (
signal?.aborted ||
(error instanceof UserFriendlyError && error.is('REQUEST_ABORTED'))
) {
return;
}
notify.error({
title: 'Failed to load calendar accounts',
message: String(error) || undefined,
});
} finally {
setLoading(false);
}
},
[gqlService]
); );
useEffect(() => { const {
const controller = new AbortController(); data: accountsData,
revalidate(controller.signal).catch(() => undefined); isLoading: accountsLoading,
return () => controller.abort(); mutate: mutateAccounts,
}, [revalidate]); } = useQuery(
{ query: calendarAccountsQuery },
useMemo(
() =>
makeConfig(t['com.affine.integration.calendar.account.load-error']()),
[makeConfig, t]
)
);
useEffect(() => { const {
if (!openedExternalWindow) return; data: providersData,
const handleFocus = () => { isLoading: providersLoading,
revalidate().catch(() => undefined); mutate: mutateProviders,
}; } = useQuery(
window.addEventListener('focus', handleFocus); { query: calendarProvidersQuery },
return () => window.removeEventListener('focus', handleFocus);
}, [openedExternalWindow, revalidate]); useMemo(
() =>
makeConfig(t['com.affine.integration.calendar.provider.load-error']()),
[makeConfig, t]
)
);
const accounts: CalendarAccount[] =
accountsData?.currentUser?.calendarAccounts ?? [];
const providers = useMemo(
() => providersData?.serverConfig.calendarProviders ?? [],
[providersData]
);
const caldavProviders =
providersData?.serverConfig.calendarCalDAVProviders ?? [];
const loading = accountsLoading || providersLoading;
const providerOptions = useMemo(() => { const providerOptions = useMemo(() => {
return providers.map(provider => { return providers.map(provider => {
@@ -117,6 +397,11 @@ export const IntegrationsPanel = () => {
const handleLink = useCallback( const handleLink = useCallback(
async (provider: CalendarProviderType) => { async (provider: CalendarProviderType) => {
if (provider === CalendarProviderType.CalDAV) {
setCaldavDialogOpen(true);
return;
}
setLinking(true); setLinking(true);
try { try {
const data = await gqlService.gql({ const data = await gqlService.gql({
@@ -131,12 +416,14 @@ export const IntegrationsPanel = () => {
urlService.openExternal(data.linkCalendarAccount); urlService.openExternal(data.linkCalendarAccount);
setOpenedExternalWindow(true); setOpenedExternalWindow(true);
} catch (error) { } catch (error) {
notify.error({ title: 'Failed to start calendar authorization' }); notify.error({
title: t['com.affine.integration.calendar.auth.start-error'](),
});
} finally { } finally {
setLinking(false); setLinking(false);
} }
}, },
[gqlService, urlService] [gqlService, t, urlService]
); );
const handleUnlink = useCallback( const handleUnlink = useCallback(
@@ -149,104 +436,147 @@ export const IntegrationsPanel = () => {
accountId, accountId,
}, },
}); });
setAccounts(prev => prev.filter(account => account.id !== accountId)); await mutateAccounts(
current => {
if (!current?.currentUser) {
return current;
}
return {
...current,
currentUser: {
...current.currentUser,
calendarAccounts: current.currentUser.calendarAccounts.filter(
account => account.id !== accountId
),
},
};
},
{
revalidate: false,
}
);
} catch (error) { } catch (error) {
notify.error({ title: 'Failed to unlink calendar account' }); notify.error({
title: t['com.affine.integration.calendar.account.unlink-error'](),
});
} finally { } finally {
setUnlinkingAccountId(null); setUnlinkingAccountId(null);
} }
}, },
[gqlService] [gqlService, mutateAccounts, t]
); );
return ( return (
<CollapsibleWrapper <>
title={t['com.affine.integration.integrations']()} <CalDAVLinkDialog
caption={t['com.affine.integration.setting.description']()} open={caldavDialogOpen}
> providers={caldavProviders}
<div className={styles.panel}> onClose={() => setCaldavDialogOpen(false)}
<div className={styles.panelHeader}> onLinked={() => {
<div className={styles.panelTitle}> void Promise.all([mutateAccounts(), mutateProviders()]).catch(
<TodayIcon /> () => undefined
<span>{t['com.affine.integration.calendar.name']()}</span> );
</div> }}
{providerOptions.length ? ( />
<Menu <CollapsibleWrapper
items={providerOptions.map(option => ( title={t['com.affine.integration.integrations']()}
<MenuItem caption={t['com.affine.integration.setting.description']()}
key={option.provider} >
prefixIcon={option.icon} <div className={styles.panel}>
onSelect={() => void handleLink(option.provider)} <div className={styles.panelHeader}>
> <div className={styles.panelTitle}>
{option.label} <TodayIcon />
</MenuItem> <span>{t['com.affine.integration.calendar.name']()}</span>
))} </div>
contentOptions={{ align: 'end' }} {providerOptions.length ? (
> <Menu
<Button variant="primary" loading={linking}> items={providerOptions.map(option => (
Link <MenuItem
key={option.provider}
prefixIcon={option.icon}
onSelect={() => void handleLink(option.provider)}
>
{option.label}
</MenuItem>
))}
contentOptions={{ align: 'end' }}
>
<Button variant="primary" loading={linking}>
{t['com.affine.integration.calendar.account.link']()}
</Button>
</Menu>
) : (
<Button variant="primary" disabled>
{t['com.affine.integration.calendar.account.link']()}
</Button> </Button>
</Menu> )}
</div>
{loading ? (
<div className={styles.loading}>
<Loading size={20} />
</div>
) : accounts.length ? (
<div className={styles.accountList}>
{accounts.map(account => {
const meta = providerMeta[account.provider];
const title =
account.displayName ?? account.email ?? account.id;
const subtitle = account.displayName ? account.email : null;
const showStatus =
account.status !== 'active' || Boolean(account.lastError);
const statusMessage = account.lastError
? t['com.affine.integration.calendar.account.status.failed']({
error: account.lastError,
})
: t[
'com.affine.integration.calendar.account.status.failed-reconnect'
]();
return (
<div key={account.id} className={styles.accountRow}>
<div className={styles.accountInfo}>
<div className={styles.accountIcon}>
{meta?.icon ?? <LinkIcon />}
</div>
<div className={styles.accountDetails}>
<div className={styles.accountName}>{title}</div>
<div className={styles.accountMeta}>
{subtitle ? <span>{subtitle}</span> : null}
<span>
{t['com.affine.integration.calendar.account.count'](
{ count: String(account.calendarsCount) }
)}
</span>
</div>
{showStatus ? (
<div className={styles.accountStatus}>
<span className={styles.statusDot} />
{statusMessage}
</div>
) : null}
</div>
</div>
<div className={styles.accountActions}>
<Button
variant="error"
disabled={unlinkingAccountId === account.id}
onClick={() => void handleUnlink(account.id)}
>
{t['com.affine.integration.calendar.account.unlink']()}
</Button>
</div>
</div>
);
})}
</div>
) : ( ) : (
<Button variant="primary" disabled> <div className={styles.empty}>
Link {t['com.affine.integration.calendar.account.linked-empty']()}
</Button> </div>
)} )}
</div> </div>
</CollapsibleWrapper>
{loading ? ( </>
<div className={styles.loading}>
<Loading size={20} />
</div>
) : accounts.length ? (
<div className={styles.accountList}>
{accounts.map(account => {
const meta = providerMeta[account.provider];
const title = account.displayName ?? account.email ?? account.id;
const subtitle = account.displayName ? account.email : null;
const showStatus =
account.status !== 'active' || Boolean(account.lastError);
return (
<div key={account.id} className={styles.accountRow}>
<div className={styles.accountInfo}>
<div className={styles.accountIcon}>
{meta?.icon ?? <LinkIcon />}
</div>
<div className={styles.accountDetails}>
<div className={styles.accountName}>{title}</div>
<div className={styles.accountMeta}>
{subtitle ? <span>{subtitle}</span> : null}
<span>
{account.calendarsCount} calendar
{account.calendarsCount === 1 ? '' : 's'}
</span>
</div>
{showStatus ? (
<div className={styles.accountStatus}>
<span className={styles.statusDot} />
Authorization failed. Please reconnect your account.
</div>
) : null}
</div>
</div>
<div className={styles.accountActions}>
<Button
variant="error"
disabled={unlinkingAccountId === account.id}
onClick={() => void handleUnlink(account.id)}
>
Unlink
</Button>
</div>
</div>
);
})}
</div>
) : (
<div className={styles.empty}>No calendar accounts linked yet.</div>
)}
</div>
</CollapsibleWrapper>
); );
}; };
@@ -1,27 +1,27 @@
{ {
"ar": 98, "ar": 97,
"ca": 99, "ca": 98,
"da": 4, "da": 4,
"de": 99, "de": 98,
"el-GR": 98, "el-GR": 97,
"en": 100, "en": 100,
"es-AR": 98, "es-AR": 97,
"es-CL": 99, "es-CL": 98,
"es": 98, "es": 97,
"fa": 98, "fa": 97,
"fr": 99, "fr": 98,
"hi": 2, "hi": 2,
"it-IT": 99, "it-IT": 98,
"it": 1, "it": 1,
"ja": 98, "ja": 97,
"ko": 99, "ko": 98,
"nb-NO": 48, "nb-NO": 48,
"pl": 100, "pl": 98,
"pt-BR": 98, "pt-BR": 97,
"ru": 99, "ru": 98,
"sv-SE": 98, "sv-SE": 97,
"uk": 98, "uk": 97,
"ur": 2, "ur": 2,
"zh-Hans": 100, "zh-Hans": 99,
"zh-Hant": 98 "zh-Hant": 97
} }
+108
View File
@@ -8281,6 +8281,114 @@ export function useAFFiNEI18N(): {
* `All day` * `All day`
*/ */
["com.affine.integration.calendar.all-day"](): string; ["com.affine.integration.calendar.all-day"](): string;
/**
* `Failed to load calendar accounts`
*/
["com.affine.integration.calendar.account.load-error"](): string;
/**
* `Failed to load calendar providers`
*/
["com.affine.integration.calendar.provider.load-error"](): string;
/**
* `Failed to start calendar authorization`
*/
["com.affine.integration.calendar.auth.start-error"](): string;
/**
* `Failed to unlink calendar account`
*/
["com.affine.integration.calendar.account.unlink-error"](): string;
/**
* `Unlink`
*/
["com.affine.integration.calendar.account.unlink"](): string;
/**
* `Link`
*/
["com.affine.integration.calendar.account.link"](): string;
/**
* `No calendar accounts linked yet.`
*/
["com.affine.integration.calendar.account.linked-empty"](): string;
/**
* `Authorization failed: {{error}}`
*/
["com.affine.integration.calendar.account.status.failed"](options: {
readonly error: string;
}): string;
/**
* `Authorization failed. Please reconnect your account.`
*/
["com.affine.integration.calendar.account.status.failed-reconnect"](): string;
/**
* `{{count}} calendar`
*/
["com.affine.integration.calendar.account.count"](options: {
readonly count: string;
}): string;
/**
* `Link CalDAV account`
*/
["com.affine.integration.calendar.caldav.link.title"](): string;
/**
* `Failed to link CalDAV account`
*/
["com.affine.integration.calendar.caldav.link.failed"](): string;
/**
* `Provider`
*/
["com.affine.integration.calendar.caldav.field.provider"](): string;
/**
* `Select provider`
*/
["com.affine.integration.calendar.caldav.field.provider.placeholder"](): string;
/**
* `Please select a provider.`
*/
["com.affine.integration.calendar.caldav.field.provider.error"](): string;
/**
* `Username`
*/
["com.affine.integration.calendar.caldav.field.username"](): string;
/**
* `email@example.com`
*/
["com.affine.integration.calendar.caldav.field.username.placeholder"](): string;
/**
* `Username is required.`
*/
["com.affine.integration.calendar.caldav.field.username.error"](): string;
/**
* `Password`
*/
["com.affine.integration.calendar.caldav.field.password"](): string;
/**
* `Password or app-specific password`
*/
["com.affine.integration.calendar.caldav.field.password.placeholder"](): string;
/**
* `Password is required.`
*/
["com.affine.integration.calendar.caldav.field.password.error"](): string;
/**
* `Display name (optional)`
*/
["com.affine.integration.calendar.caldav.field.displayName"](): string;
/**
* `My CalDAV`
*/
["com.affine.integration.calendar.caldav.field.displayName.placeholder"](): string;
/**
* `App-specific password required.`
*/
["com.affine.integration.calendar.caldav.hint.app-password"](): string;
/**
* `Learn more`
*/
["com.affine.integration.calendar.caldav.hint.learn-more"](): string;
/**
* `Provider setup guide`
*/
["com.affine.integration.calendar.caldav.hint.guide"](): string;
/** /**
* `New doc` * `New doc`
*/ */
@@ -2078,6 +2078,32 @@
"com.affine.integration.calendar.new-url-label": "Calendar URL", "com.affine.integration.calendar.new-url-label": "Calendar URL",
"com.affine.integration.calendar.save-error": "An error occurred while saving the calendar settings", "com.affine.integration.calendar.save-error": "An error occurred while saving the calendar settings",
"com.affine.integration.calendar.all-day": "All day", "com.affine.integration.calendar.all-day": "All day",
"com.affine.integration.calendar.account.load-error": "Failed to load calendar accounts",
"com.affine.integration.calendar.provider.load-error": "Failed to load calendar providers",
"com.affine.integration.calendar.auth.start-error": "Failed to start calendar authorization",
"com.affine.integration.calendar.account.unlink-error": "Failed to unlink calendar account",
"com.affine.integration.calendar.account.unlink": "Unlink",
"com.affine.integration.calendar.account.link": "Link",
"com.affine.integration.calendar.account.linked-empty": "No calendar accounts linked yet.",
"com.affine.integration.calendar.account.status.failed": "Authorization failed: {{error}}",
"com.affine.integration.calendar.account.status.failed-reconnect": "Authorization failed. Please reconnect your account.",
"com.affine.integration.calendar.account.count": "{{count}} calendar",
"com.affine.integration.calendar.caldav.link.title": "Link CalDAV account",
"com.affine.integration.calendar.caldav.link.failed": "Failed to link CalDAV account",
"com.affine.integration.calendar.caldav.field.provider": "Provider",
"com.affine.integration.calendar.caldav.field.provider.placeholder": "Select provider",
"com.affine.integration.calendar.caldav.field.provider.error": "Please select a provider.",
"com.affine.integration.calendar.caldav.field.username": "Username",
"com.affine.integration.calendar.caldav.field.username.placeholder": "email@example.com",
"com.affine.integration.calendar.caldav.field.username.error": "Username is required.",
"com.affine.integration.calendar.caldav.field.password": "Password",
"com.affine.integration.calendar.caldav.field.password.placeholder": "Password or app-specific password",
"com.affine.integration.calendar.caldav.field.password.error": "Password is required.",
"com.affine.integration.calendar.caldav.field.displayName": "Display name (optional)",
"com.affine.integration.calendar.caldav.field.displayName.placeholder": "My CalDAV",
"com.affine.integration.calendar.caldav.hint.app-password": "App-specific password required.",
"com.affine.integration.calendar.caldav.hint.learn-more": "Learn more",
"com.affine.integration.calendar.caldav.hint.guide": "Provider setup guide",
"com.affine.integration.calendar.new-doc": "New doc", "com.affine.integration.calendar.new-doc": "New doc",
"com.affine.integration.calendar.show-events": "Show calendar events", "com.affine.integration.calendar.show-events": "Show calendar events",
"com.affine.integration.calendar.show-events-desc": "Enabling this setting allows you to connect your calendar events to your Journal in AFFiNE", "com.affine.integration.calendar.show-events-desc": "Enabling this setting allows you to connect your calendar events to your Journal in AFFiNE",
-2
View File
@@ -37,8 +37,6 @@ declare interface BUILD_CONFIG_TYPE {
CAPTCHA_SITE_KEY: string; CAPTCHA_SITE_KEY: string;
SENTRY_DSN: string; SENTRY_DSN: string;
MIXPANEL_TOKEN: string;
GA4_MEASUREMENT_ID: string;
} }
declare var BUILD_CONFIG: BUILD_CONFIG_TYPE; declare var BUILD_CONFIG: BUILD_CONFIG_TYPE;
-2
View File
@@ -53,8 +53,6 @@ export function getBuildConfig(
linkPreviewUrl: '/api/worker/link-preview', linkPreviewUrl: '/api/worker/link-preview',
CAPTCHA_SITE_KEY: process.env.CAPTCHA_SITE_KEY ?? '', CAPTCHA_SITE_KEY: process.env.CAPTCHA_SITE_KEY ?? '',
SENTRY_DSN: process.env.SENTRY_DSN ?? '', SENTRY_DSN: process.env.SENTRY_DSN ?? '',
MIXPANEL_TOKEN: process.env.MIXPANEL_TOKEN ?? '',
GA4_MEASUREMENT_ID: process.env.GA4_MEASUREMENT_ID ?? '',
}; };
}, },
get beta() { get beta() {