mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-11 23:26:30 +08:00
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:
@@ -868,6 +868,72 @@
|
||||
"externalWebhookUrl": "",
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -45,8 +45,6 @@ jobs:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }}
|
||||
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
||||
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
||||
- name: Upload web artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -79,8 +77,6 @@ jobs:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }}
|
||||
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
||||
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
||||
- name: Upload admin artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -113,8 +109,6 @@ jobs:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }}
|
||||
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
||||
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
||||
- name: Upload mobile artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -1016,21 +1016,12 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
tests:
|
||||
- name: 'Cloud E2E Test 1/10'
|
||||
- name: 'Cloud E2E Test 1/2'
|
||||
shard: 1
|
||||
script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=1/10
|
||||
- name: 'Cloud E2E Test 2/10'
|
||||
script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=1/2
|
||||
- name: 'Cloud E2E Test 2/2'
|
||||
shard: 2
|
||||
script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=2/10
|
||||
- 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
|
||||
script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=2/2
|
||||
- name: 'Cloud Desktop E2E Test'
|
||||
shard: desktop
|
||||
script: |
|
||||
|
||||
@@ -68,8 +68,6 @@ jobs:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_RELEASE: ${{ inputs.app_version }}
|
||||
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
||||
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
@@ -66,8 +66,6 @@ jobs:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_RELEASE: ${{ inputs.app-version }}
|
||||
RELEASE_VERSION: ${{ inputs.app-version }}
|
||||
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
||||
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
||||
|
||||
- name: Upload web artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -39,8 +39,6 @@ jobs:
|
||||
run: yarn affine @affine/ios build
|
||||
env:
|
||||
PUBLIC_PATH: '/'
|
||||
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
||||
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: 'affine'
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
@@ -69,8 +67,6 @@ jobs:
|
||||
run: yarn affine @affine/android build
|
||||
env:
|
||||
PUBLIC_PATH: '/'
|
||||
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
||||
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: 'affine'
|
||||
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;
|
||||
@@ -937,6 +937,12 @@ model CalendarAccount {
|
||||
providerAccountId String @map("provider_account_id") @db.VarChar
|
||||
displayName String? @map("display_name") @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
|
||||
refreshToken String? @map("refresh_token") @db.Text
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
|
||||
|
||||
@@ -17,6 +17,12 @@ export interface UpsertCalendarAccountInput extends CalendarAccountTokens {
|
||||
providerAccountId: string;
|
||||
displayName?: 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;
|
||||
lastError?: string | null;
|
||||
refreshIntervalMinutes?: number | null;
|
||||
@@ -73,6 +79,12 @@ export class CalendarAccountModel extends BaseModel {
|
||||
providerAccountId: input.providerAccountId,
|
||||
displayName: input.displayName ?? 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,
|
||||
refreshToken: refreshToken ?? null,
|
||||
expiresAt: input.expiresAt ?? null,
|
||||
@@ -85,6 +97,12 @@ export class CalendarAccountModel extends BaseModel {
|
||||
const updateData: Prisma.CalendarAccountUncheckedUpdateInput = {
|
||||
displayName: data.displayName,
|
||||
email: data.email,
|
||||
providerPresetId: data.providerPresetId,
|
||||
serverUrl: data.serverUrl,
|
||||
principalUrl: data.principalUrl,
|
||||
calendarHomeUrl: data.calendarHomeUrl,
|
||||
username: data.username,
|
||||
authType: data.authType,
|
||||
expiresAt: data.expiresAt,
|
||||
scope: data.scope,
|
||||
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,
|
||||
} from '../providers';
|
||||
import type {
|
||||
CalendarProviderListCalendarsParams,
|
||||
CalendarProviderListEventsParams,
|
||||
CalendarProviderStopParams,
|
||||
CalendarProviderWatchParams,
|
||||
@@ -46,7 +47,7 @@ class MockCalendarProvider extends CalendarProvider {
|
||||
return { providerAccountId: 'mock-account' };
|
||||
}
|
||||
|
||||
override async listCalendars(_accessToken: string) {
|
||||
override async listCalendars(_params: CalendarProviderListCalendarsParams) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,33 @@ export interface CalendarGoogleConfig {
|
||||
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 {
|
||||
interface AppConfigSchema {
|
||||
calendar: {
|
||||
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', {
|
||||
google: {
|
||||
desc: 'Google Calendar integration config',
|
||||
@@ -54,4 +104,37 @@ defineModuleConfig('calendar', {
|
||||
}),
|
||||
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' });
|
||||
}
|
||||
|
||||
if (!this.calendar.isProviderAvailable(providerName)) {
|
||||
if (!this.calendar.isProviderAvailableFor(providerName, { oauth: true })) {
|
||||
throw new UnknownOauthProvider({ name: providerName });
|
||||
}
|
||||
|
||||
@@ -157,7 +157,8 @@ export class CalendarController {
|
||||
|
||||
private getCallbackErrorMessage(error: unknown) {
|
||||
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. 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 type { CalendarAccount } from '@prisma/client';
|
||||
|
||||
import { CalendarProviderRequestError, Config, OnEvent } from '../../../base';
|
||||
import { CalendarProviderFactory } from './factory';
|
||||
@@ -54,11 +55,17 @@ export interface CalendarProviderEvent {
|
||||
export interface CalendarProviderListEventsParams {
|
||||
accessToken: string;
|
||||
calendarId: string;
|
||||
account?: CalendarAccount;
|
||||
timeMin?: string;
|
||||
timeMax?: string;
|
||||
syncToken?: string;
|
||||
}
|
||||
|
||||
export interface CalendarProviderListCalendarsParams {
|
||||
accessToken: string;
|
||||
account?: CalendarAccount;
|
||||
}
|
||||
|
||||
export interface CalendarProviderListEventsResult {
|
||||
events: CalendarProviderEvent[];
|
||||
nextSyncToken?: string;
|
||||
@@ -97,7 +104,7 @@ export abstract class CalendarProvider {
|
||||
accessToken: string
|
||||
): Promise<CalendarAccountProfile>;
|
||||
abstract listCalendars(
|
||||
accessToken: string
|
||||
params: CalendarProviderListCalendarsParams
|
||||
): Promise<CalendarProviderCalendar[]>;
|
||||
abstract listEvents(
|
||||
params: CalendarProviderListEventsParams
|
||||
@@ -117,12 +124,17 @@ export abstract class CalendarProvider {
|
||||
}
|
||||
|
||||
get configured() {
|
||||
return (
|
||||
!!this.config &&
|
||||
!!this.config.enabled &&
|
||||
!!this.config.clientId &&
|
||||
!!this.config.clientSecret
|
||||
);
|
||||
if (!this.config || !this.config.enabled) {
|
||||
return false;
|
||||
}
|
||||
if ('clientId' in this.config || 'clientSecret' in this.config) {
|
||||
return Boolean(this.config.clientId && this.config.clientSecret);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
get supportsOAuth() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@OnEvent('config.init')
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CalendarProviderRequestError } from '../../../base';
|
||||
import { CalendarProvider } from './def';
|
||||
import {
|
||||
CalendarProviderEvent,
|
||||
CalendarProviderListCalendarsParams,
|
||||
CalendarProviderListEventsParams,
|
||||
CalendarProviderListEventsResult,
|
||||
CalendarProviderName,
|
||||
@@ -171,7 +172,7 @@ export class GoogleCalendarProvider extends CalendarProvider {
|
||||
};
|
||||
}
|
||||
|
||||
async listCalendars(accessToken: string) {
|
||||
async listCalendars(params: CalendarProviderListCalendarsParams) {
|
||||
const calendars: GoogleCalendarListResponse['items'] = [];
|
||||
let pageToken: string | undefined;
|
||||
|
||||
@@ -188,7 +189,7 @@ export class GoogleCalendarProvider extends CalendarProvider {
|
||||
url.toString(),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { CalDAVProvider } from './caldav';
|
||||
import { GoogleCalendarProvider } from './google';
|
||||
|
||||
export { CalDAVProvider } from './caldav';
|
||||
export type {
|
||||
CalendarAccountProfile,
|
||||
CalendarProviderCalendar,
|
||||
CalendarProviderEvent,
|
||||
CalendarProviderEventTime,
|
||||
CalendarProviderListCalendarsParams,
|
||||
CalendarProviderListEventsParams,
|
||||
CalendarProviderListEventsResult,
|
||||
CalendarProviderTokens,
|
||||
@@ -16,4 +19,4 @@ export { CalendarProvider } from './def';
|
||||
export { CalendarProviderFactory } from './factory';
|
||||
export { CalendarSyncTokenInvalid, GoogleCalendarProvider } from './google';
|
||||
|
||||
export const CalendarProviders = [GoogleCalendarProvider];
|
||||
export const CalendarProviders = [GoogleCalendarProvider, CalDAVProvider];
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { ActionForbidden, AuthenticationRequired } from '../../base';
|
||||
import { ActionForbidden, AuthenticationRequired, Config } from '../../base';
|
||||
import { CurrentUser } from '../../core/auth';
|
||||
import { ServerConfigType } from '../../core/config/types';
|
||||
import { AccessController } from '../../core/permission';
|
||||
@@ -19,8 +19,10 @@ import { CalendarProviderFactory, CalendarProviderName } from './providers';
|
||||
import { CalendarService } from './service';
|
||||
import {
|
||||
CalendarAccountObjectType,
|
||||
CalendarCalDAVProviderPresetObjectType,
|
||||
CalendarEventObjectType,
|
||||
CalendarSubscriptionObjectType,
|
||||
LinkCalDAVAccountInput,
|
||||
LinkCalendarAccountInput,
|
||||
UpdateWorkspaceCalendarsInput,
|
||||
WorkspaceCalendarObjectType,
|
||||
@@ -28,12 +30,29 @@ import {
|
||||
|
||||
@Resolver(() => ServerConfigType)
|
||||
export class CalendarServerConfigResolver {
|
||||
constructor(private readonly providerFactory: CalendarProviderFactory) {}
|
||||
constructor(
|
||||
private readonly providerFactory: CalendarProviderFactory,
|
||||
private readonly config: Config
|
||||
) {}
|
||||
|
||||
@ResolveField(() => [CalendarProviderName])
|
||||
calendarProviders() {
|
||||
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)
|
||||
@@ -140,6 +159,21 @@ export class CalendarMutationResolver {
|
||||
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 })
|
||||
async updateCalendarAccount(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
|
||||
@@ -8,10 +8,12 @@ import { addDays, subDays } from 'date-fns';
|
||||
import {
|
||||
CalendarProviderRequestError,
|
||||
Config,
|
||||
GraphqlBadRequest,
|
||||
Mutex,
|
||||
URLHelper,
|
||||
} from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import type { CalendarCalDAVProviderPreset } from './config';
|
||||
import {
|
||||
CalendarProvider,
|
||||
CalendarProviderEvent,
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
CalendarSyncTokenInvalid,
|
||||
} from './providers';
|
||||
import { CalendarProviderFactory } from './providers';
|
||||
import type { LinkCalDAVAccountInput } from './types';
|
||||
|
||||
const TOKEN_REFRESH_SKEW_MS = 60 * 1000;
|
||||
const DEFAULT_PAST_DAYS = 90;
|
||||
@@ -159,6 +162,92 @@ export class CalendarService {
|
||||
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) {
|
||||
const account = await this.models.calendarAccount.get(accountId);
|
||||
if (!account) {
|
||||
@@ -177,7 +266,10 @@ export class CalendarService {
|
||||
return;
|
||||
}
|
||||
|
||||
const calendars = await provider.listCalendars(accessToken);
|
||||
const calendars = await provider.listCalendars({
|
||||
accessToken,
|
||||
account,
|
||||
});
|
||||
const upserted = [];
|
||||
for (const calendar of calendars) {
|
||||
upserted.push(
|
||||
@@ -245,6 +337,7 @@ export class CalendarService {
|
||||
subscriptionId: subscription.id,
|
||||
calendarId: subscription.externalCalendarId,
|
||||
accessToken,
|
||||
account,
|
||||
syncToken: shouldUseSyncToken
|
||||
? (subscription.syncToken ?? undefined)
|
||||
: undefined,
|
||||
@@ -264,6 +357,7 @@ export class CalendarService {
|
||||
subscriptionId: subscription.id,
|
||||
calendarId: subscription.externalCalendarId,
|
||||
accessToken,
|
||||
account,
|
||||
timeMin,
|
||||
timeMax,
|
||||
subscriptionTimezone: subscription.timezone ?? undefined,
|
||||
@@ -410,7 +504,21 @@ export class CalendarService {
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -418,7 +526,14 @@ export class CalendarService {
|
||||
state: 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: {
|
||||
@@ -426,6 +541,7 @@ export class CalendarService {
|
||||
subscriptionId: string;
|
||||
calendarId: string;
|
||||
accessToken: string;
|
||||
account: CalendarAccount;
|
||||
syncToken?: string;
|
||||
timeMin?: string;
|
||||
timeMax?: string;
|
||||
@@ -434,6 +550,7 @@ export class CalendarService {
|
||||
const response = await params.provider.listEvents({
|
||||
accessToken: params.accessToken,
|
||||
calendarId: params.calendarId,
|
||||
account: params.account,
|
||||
syncToken: params.syncToken,
|
||||
timeMin: params.timeMin,
|
||||
timeMax: params.timeMax,
|
||||
@@ -632,7 +749,8 @@ export class CalendarService {
|
||||
|
||||
private isTokenInvalidError(error: unknown) {
|
||||
if (error instanceof CalendarProviderRequestError) {
|
||||
if (error.status === 401) {
|
||||
const status = error.data?.status ?? error.status;
|
||||
if (status === 401) {
|
||||
return true;
|
||||
}
|
||||
return error.message.includes('invalid_grant');
|
||||
|
||||
@@ -76,6 +76,21 @@ export class CalendarSubscriptionObjectType {
|
||||
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()
|
||||
export class WorkspaceCalendarItemObjectType {
|
||||
@Field()
|
||||
@@ -186,3 +201,18 @@ export class LinkCalendarAccountInput {
|
||||
@Field(() => String, { nullable: true })
|
||||
redirectUri?: string | null;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class LinkCalDAVAccountInput {
|
||||
@Field()
|
||||
providerPresetId!: string;
|
||||
|
||||
@Field()
|
||||
username!: string;
|
||||
|
||||
@Field()
|
||||
password!: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
displayName?: string | null;
|
||||
}
|
||||
|
||||
@@ -206,6 +206,13 @@ type CalendarAccountObjectType {
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
type CalendarCalDAVProviderPresetObjectType {
|
||||
docsUrl: String
|
||||
id: String!
|
||||
label: String!
|
||||
requiresAppPassword: Boolean
|
||||
}
|
||||
|
||||
type CalendarEventObjectType {
|
||||
allDay: Boolean!
|
||||
description: String
|
||||
@@ -1274,6 +1281,13 @@ type LimitedUserType {
|
||||
hasPassword: Boolean
|
||||
}
|
||||
|
||||
input LinkCalDAVAccountInput {
|
||||
displayName: String
|
||||
password: String!
|
||||
providerPresetId: String!
|
||||
username: String!
|
||||
}
|
||||
|
||||
input LinkCalendarAccountInput {
|
||||
provider: CalendarProviderType!
|
||||
redirectUri: String
|
||||
@@ -1462,6 +1476,7 @@ type Mutation {
|
||||
installLicense(license: Upload!, workspaceId: String!): License!
|
||||
inviteMembers(emails: [String!]!, workspaceId: String!): [InviteResult!]!
|
||||
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!
|
||||
|
||||
"""mention user in a doc"""
|
||||
@@ -2039,6 +2054,7 @@ type ServerConfigType {
|
||||
|
||||
"""server base url"""
|
||||
baseUrl: String!
|
||||
calendarCalDAVProviders: [CalendarCalDAVProviderPresetObjectType!]!
|
||||
calendarProviders: [CalendarProviderType!]!
|
||||
|
||||
"""credentials requirement"""
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
query calendarProviders {
|
||||
serverConfig {
|
||||
calendarCalDAVProviders {
|
||||
id
|
||||
label
|
||||
requiresAppPassword
|
||||
docsUrl
|
||||
}
|
||||
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',
|
||||
query: `query calendarProviders {
|
||||
serverConfig {
|
||||
calendarCalDAVProviders {
|
||||
id
|
||||
label
|
||||
requiresAppPassword
|
||||
docsUrl
|
||||
}
|
||||
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 = {
|
||||
id: 'linkCalendarAccountMutation' as const,
|
||||
op: 'linkCalendarAccount',
|
||||
|
||||
@@ -259,6 +259,14 @@ export interface CalendarAccountObjectType {
|
||||
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 {
|
||||
__typename?: 'CalendarEventObjectType';
|
||||
allDay: Scalars['Boolean']['output'];
|
||||
@@ -1466,6 +1474,13 @@ export interface LimitedUserType {
|
||||
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 {
|
||||
provider: CalendarProviderType;
|
||||
redirectUri?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -1632,6 +1647,7 @@ export interface Mutation {
|
||||
installLicense: License;
|
||||
inviteMembers: Array<InviteResult>;
|
||||
leaveWorkspace: Scalars['Boolean']['output'];
|
||||
linkCalDAVAccount: CalendarAccountObjectType;
|
||||
linkCalendarAccount: Scalars['String']['output'];
|
||||
/** mention user in a doc */
|
||||
mentionUser: Scalars['ID']['output'];
|
||||
@@ -1943,6 +1959,10 @@ export interface MutationLeaveWorkspaceArgs {
|
||||
workspaceName?: InputMaybe<Scalars['String']['input']>;
|
||||
}
|
||||
|
||||
export interface MutationLinkCalDavAccountArgs {
|
||||
input: LinkCalDavAccountInput;
|
||||
}
|
||||
|
||||
export interface MutationLinkCalendarAccountArgs {
|
||||
input: LinkCalendarAccountInput;
|
||||
}
|
||||
@@ -2699,6 +2719,7 @@ export interface ServerConfigType {
|
||||
availableWorkspaceFeatures: Array<FeatureType>;
|
||||
/** server base url */
|
||||
baseUrl: Scalars['String']['output'];
|
||||
calendarCalDAVProviders: Array<CalendarCalDavProviderPresetObjectType>;
|
||||
calendarProviders: Array<CalendarProviderType>;
|
||||
/** credentials requirement */
|
||||
credentialsRequirement: CredentialsRequirementType;
|
||||
@@ -3956,6 +3977,35 @@ export type CalendarProvidersQuery = {
|
||||
serverConfig: {
|
||||
__typename?: 'ServerConfigType';
|
||||
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;
|
||||
response: CreateBlobUploadMutation;
|
||||
}
|
||||
| {
|
||||
name: 'linkCalDavAccountMutation';
|
||||
variables: LinkCalDavAccountMutationVariables;
|
||||
response: LinkCalDavAccountMutation;
|
||||
}
|
||||
| {
|
||||
name: 'linkCalendarAccountMutation';
|
||||
variables: LinkCalendarAccountMutationVariables;
|
||||
|
||||
@@ -288,6 +288,10 @@
|
||||
"type": "Object",
|
||||
"desc": "Google Calendar integration config",
|
||||
"link": "https://developers.google.com/calendar/api/guides/push"
|
||||
},
|
||||
"caldav": {
|
||||
"type": "Object",
|
||||
"desc": "CalDAV integration config"
|
||||
}
|
||||
},
|
||||
"captcha": {
|
||||
|
||||
@@ -12,6 +12,15 @@ import useSWR from 'swr';
|
||||
import useSWRImmutable from 'swr/immutable';
|
||||
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
|
||||
*
|
||||
@@ -32,14 +41,7 @@ import useSWRInfinite from 'swr/infinite';
|
||||
*/
|
||||
type useQueryFn = <Query extends GraphQLQuery>(
|
||||
options?: QueryOptions<Query>,
|
||||
config?: Omit<
|
||||
SWRConfiguration<
|
||||
QueryResponse<Query>,
|
||||
GraphQLError,
|
||||
(options: QueryOptions<Query>) => Promise<QueryResponse<Query>>
|
||||
>,
|
||||
'fetcher'
|
||||
>
|
||||
config?: UseQueryConfig<Query>
|
||||
) => SWRResponse<
|
||||
QueryResponse<Query>,
|
||||
GraphQLError,
|
||||
|
||||
+50
@@ -130,3 +130,53 @@ export const empty = style({
|
||||
color: cssVarV2.text.secondary,
|
||||
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,
|
||||
});
|
||||
|
||||
+470
-140
@@ -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 { UrlService } from '@affine/core/modules/url';
|
||||
import { UserFriendlyError } from '@affine/error';
|
||||
import {
|
||||
type CalendarAccountsQuery,
|
||||
calendarAccountsQuery,
|
||||
type CalendarProvidersQuery,
|
||||
calendarProvidersQuery,
|
||||
CalendarProviderType,
|
||||
type GraphQLQuery,
|
||||
linkCalDavAccountMutation,
|
||||
linkCalendarAccountMutation,
|
||||
unlinkCalendarAccountMutation,
|
||||
} from '@affine/graphql';
|
||||
@@ -14,6 +29,7 @@ import { useI18n } from '@affine/i18n';
|
||||
import { GoogleIcon, LinkIcon, TodayIcon } from '@blocksuite/icons/rc';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import {
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -28,6 +44,10 @@ type CalendarAccount = NonNullable<
|
||||
CalendarAccountsQuery['currentUser']
|
||||
>['calendarAccounts'][number];
|
||||
|
||||
type CalendarCalDAVProvider = NonNullable<
|
||||
CalendarProvidersQuery['serverConfig']
|
||||
>['calendarCalDAVProviders'][number];
|
||||
|
||||
const providerMeta = {
|
||||
[CalendarProviderType.Google]: {
|
||||
label: 'Google Calendar',
|
||||
@@ -41,68 +61,328 @@ const providerMeta = {
|
||||
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 = () => {
|
||||
const t = useI18n();
|
||||
const gqlService = useService(GraphQLService);
|
||||
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 [unlinkingAccountId, setUnlinkingAccountId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [openedExternalWindow, setOpenedExternalWindow] = useState(false);
|
||||
|
||||
const revalidate = useCallback(
|
||||
async (signal?: AbortSignal) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [accountsData, providersData] = await Promise.all([
|
||||
gqlService.gql({
|
||||
query: calendarAccountsQuery,
|
||||
context: { signal },
|
||||
}),
|
||||
gqlService.gql({
|
||||
query: calendarProvidersQuery,
|
||||
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]
|
||||
const [caldavDialogOpen, setCaldavDialogOpen] = useState(false);
|
||||
const makeConfig: <Query extends GraphQLQuery>(
|
||||
title: string
|
||||
) => UseQueryConfig<Query> = useCallback(
|
||||
title => ({
|
||||
suspense: false,
|
||||
revalidateOnFocus: openedExternalWindow,
|
||||
onError: error => {
|
||||
notify.error({ title, message: String(error) || undefined });
|
||||
},
|
||||
}),
|
||||
[openedExternalWindow]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
revalidate(controller.signal).catch(() => undefined);
|
||||
return () => controller.abort();
|
||||
}, [revalidate]);
|
||||
const {
|
||||
data: accountsData,
|
||||
isLoading: accountsLoading,
|
||||
mutate: mutateAccounts,
|
||||
} = useQuery(
|
||||
{ query: calendarAccountsQuery },
|
||||
useMemo(
|
||||
() =>
|
||||
makeConfig(t['com.affine.integration.calendar.account.load-error']()),
|
||||
[makeConfig, t]
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openedExternalWindow) return;
|
||||
const handleFocus = () => {
|
||||
revalidate().catch(() => undefined);
|
||||
};
|
||||
window.addEventListener('focus', handleFocus);
|
||||
return () => window.removeEventListener('focus', handleFocus);
|
||||
}, [openedExternalWindow, revalidate]);
|
||||
const {
|
||||
data: providersData,
|
||||
isLoading: providersLoading,
|
||||
mutate: mutateProviders,
|
||||
} = useQuery(
|
||||
{ query: calendarProvidersQuery },
|
||||
|
||||
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(() => {
|
||||
return providers.map(provider => {
|
||||
@@ -117,6 +397,11 @@ export const IntegrationsPanel = () => {
|
||||
|
||||
const handleLink = useCallback(
|
||||
async (provider: CalendarProviderType) => {
|
||||
if (provider === CalendarProviderType.CalDAV) {
|
||||
setCaldavDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setLinking(true);
|
||||
try {
|
||||
const data = await gqlService.gql({
|
||||
@@ -131,12 +416,14 @@ export const IntegrationsPanel = () => {
|
||||
urlService.openExternal(data.linkCalendarAccount);
|
||||
setOpenedExternalWindow(true);
|
||||
} catch (error) {
|
||||
notify.error({ title: 'Failed to start calendar authorization' });
|
||||
notify.error({
|
||||
title: t['com.affine.integration.calendar.auth.start-error'](),
|
||||
});
|
||||
} finally {
|
||||
setLinking(false);
|
||||
}
|
||||
},
|
||||
[gqlService, urlService]
|
||||
[gqlService, t, urlService]
|
||||
);
|
||||
|
||||
const handleUnlink = useCallback(
|
||||
@@ -149,104 +436,147 @@ export const IntegrationsPanel = () => {
|
||||
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) {
|
||||
notify.error({ title: 'Failed to unlink calendar account' });
|
||||
notify.error({
|
||||
title: t['com.affine.integration.calendar.account.unlink-error'](),
|
||||
});
|
||||
} finally {
|
||||
setUnlinkingAccountId(null);
|
||||
}
|
||||
},
|
||||
[gqlService]
|
||||
[gqlService, mutateAccounts, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<CollapsibleWrapper
|
||||
title={t['com.affine.integration.integrations']()}
|
||||
caption={t['com.affine.integration.setting.description']()}
|
||||
>
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div className={styles.panelTitle}>
|
||||
<TodayIcon />
|
||||
<span>{t['com.affine.integration.calendar.name']()}</span>
|
||||
</div>
|
||||
{providerOptions.length ? (
|
||||
<Menu
|
||||
items={providerOptions.map(option => (
|
||||
<MenuItem
|
||||
key={option.provider}
|
||||
prefixIcon={option.icon}
|
||||
onSelect={() => void handleLink(option.provider)}
|
||||
>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
contentOptions={{ align: 'end' }}
|
||||
>
|
||||
<Button variant="primary" loading={linking}>
|
||||
Link
|
||||
<>
|
||||
<CalDAVLinkDialog
|
||||
open={caldavDialogOpen}
|
||||
providers={caldavProviders}
|
||||
onClose={() => setCaldavDialogOpen(false)}
|
||||
onLinked={() => {
|
||||
void Promise.all([mutateAccounts(), mutateProviders()]).catch(
|
||||
() => undefined
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<CollapsibleWrapper
|
||||
title={t['com.affine.integration.integrations']()}
|
||||
caption={t['com.affine.integration.setting.description']()}
|
||||
>
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div className={styles.panelTitle}>
|
||||
<TodayIcon />
|
||||
<span>{t['com.affine.integration.calendar.name']()}</span>
|
||||
</div>
|
||||
{providerOptions.length ? (
|
||||
<Menu
|
||||
items={providerOptions.map(option => (
|
||||
<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>
|
||||
</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>
|
||||
Link
|
||||
</Button>
|
||||
<div className={styles.empty}>
|
||||
{t['com.affine.integration.calendar.account.linked-empty']()}
|
||||
</div>
|
||||
)}
|
||||
</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);
|
||||
|
||||
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>
|
||||
</CollapsibleWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
{
|
||||
"ar": 98,
|
||||
"ca": 99,
|
||||
"ar": 97,
|
||||
"ca": 98,
|
||||
"da": 4,
|
||||
"de": 99,
|
||||
"el-GR": 98,
|
||||
"de": 98,
|
||||
"el-GR": 97,
|
||||
"en": 100,
|
||||
"es-AR": 98,
|
||||
"es-CL": 99,
|
||||
"es": 98,
|
||||
"fa": 98,
|
||||
"fr": 99,
|
||||
"es-AR": 97,
|
||||
"es-CL": 98,
|
||||
"es": 97,
|
||||
"fa": 97,
|
||||
"fr": 98,
|
||||
"hi": 2,
|
||||
"it-IT": 99,
|
||||
"it-IT": 98,
|
||||
"it": 1,
|
||||
"ja": 98,
|
||||
"ko": 99,
|
||||
"ja": 97,
|
||||
"ko": 98,
|
||||
"nb-NO": 48,
|
||||
"pl": 100,
|
||||
"pt-BR": 98,
|
||||
"ru": 99,
|
||||
"sv-SE": 98,
|
||||
"uk": 98,
|
||||
"pl": 98,
|
||||
"pt-BR": 97,
|
||||
"ru": 98,
|
||||
"sv-SE": 97,
|
||||
"uk": 97,
|
||||
"ur": 2,
|
||||
"zh-Hans": 100,
|
||||
"zh-Hant": 98
|
||||
"zh-Hans": 99,
|
||||
"zh-Hant": 97
|
||||
}
|
||||
|
||||
@@ -8281,6 +8281,114 @@ export function useAFFiNEI18N(): {
|
||||
* `All day`
|
||||
*/
|
||||
["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`
|
||||
*/
|
||||
|
||||
@@ -2078,6 +2078,32 @@
|
||||
"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.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.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",
|
||||
|
||||
Vendored
-2
@@ -37,8 +37,6 @@ declare interface BUILD_CONFIG_TYPE {
|
||||
|
||||
CAPTCHA_SITE_KEY: string;
|
||||
SENTRY_DSN: string;
|
||||
MIXPANEL_TOKEN: string;
|
||||
GA4_MEASUREMENT_ID: string;
|
||||
}
|
||||
|
||||
declare var BUILD_CONFIG: BUILD_CONFIG_TYPE;
|
||||
|
||||
@@ -53,8 +53,6 @@ export function getBuildConfig(
|
||||
linkPreviewUrl: '/api/worker/link-preview',
|
||||
CAPTCHA_SITE_KEY: process.env.CAPTCHA_SITE_KEY ?? '',
|
||||
SENTRY_DSN: process.env.SENTRY_DSN ?? '',
|
||||
MIXPANEL_TOKEN: process.env.MIXPANEL_TOKEN ?? '',
|
||||
GA4_MEASUREMENT_ID: process.env.GA4_MEASUREMENT_ID ?? '',
|
||||
};
|
||||
},
|
||||
get beta() {
|
||||
|
||||
Reference in New Issue
Block a user