From fa66139230b0c95cb7481db70c214049f890fae6 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Mon, 4 May 2026 03:45:49 +0800 Subject: [PATCH] feat(server): add flag for calendar enable (#14896) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### PR Dependency Tree * **PR #14896** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## Summary by CodeRabbit * **New Features** * Added configuration option to manage Google Calendar account linking access. Administrators can now disable new account connections to control calendar service integrations. When disabled, the Google provider is hidden from available options and new linking attempts are blocked, while existing accounts remain fully functional. --- .../calendar/__tests__/service.spec.ts | 51 +++++++++++++++++- .../server/src/plugins/calendar/config.ts | 4 ++ .../server/src/plugins/calendar/controller.ts | 2 + .../server/src/plugins/calendar/resolver.ts | 9 +++- .../server/src/plugins/calendar/service.ts | 53 +++++++++++++++++++ 5 files changed, 117 insertions(+), 2 deletions(-) diff --git a/packages/backend/server/src/plugins/calendar/__tests__/service.spec.ts b/packages/backend/server/src/plugins/calendar/__tests__/service.spec.ts index 9ced4dd7a..9cb2ac468 100644 --- a/packages/backend/server/src/plugins/calendar/__tests__/service.spec.ts +++ b/packages/backend/server/src/plugins/calendar/__tests__/service.spec.ts @@ -5,7 +5,12 @@ import test from 'ava'; import { createModule } from '../../../__tests__/create-module'; import { Mockers } from '../../../__tests__/mocks'; -import { CalendarProviderRequestError, CryptoHelper } from '../../../base'; +import { + CalendarProviderRequestError, + Config, + CryptoHelper, + GraphqlBadRequest, +} from '../../../base'; import { ConfigModule } from '../../../base/config'; import { ServerConfigModule } from '../../../core/config'; import type { @@ -89,6 +94,7 @@ const calendarService = module.get(CalendarService); const calendarCronJobs = module.get(CalendarCronJobs); const providerFactory = module.get(CalendarProviderFactory); const models = module.get(Models); +const config = module.get(Config); module.get(CryptoHelper).onConfigInit(); const createAccount = async ( @@ -168,6 +174,7 @@ const createSubscription = async ( }; test.afterEach.always(() => { + config.calendar.google.allowNewAccounts = true; mock.reset(); module.queue.add.resetHistory(); module.queue.remove.resetHistory(); @@ -202,6 +209,48 @@ test('listAccounts includes calendars count', async t => { t.is(counts.get(accountB.id), 1); }); +test('assertCanLinkProvider blocks new google calendar accounts when disabled', async t => { + config.calendar.google.allowNewAccounts = false; + const user = await module.create(Mockers.User); + + const error = await t.throwsAsync( + calendarService.assertCanLinkProvider(user.id, CalendarProviderName.Google) + ); + t.true(error instanceof GraphqlBadRequest); + t.is( + (error as GraphqlBadRequest).data?.code, + 'calendar_provider_link_disabled' + ); +}); + +test('assertCanLinkProvider allows users with an existing google calendar account', async t => { + config.calendar.google.allowNewAccounts = false; + const user = await module.create(Mockers.User); + await createAccount(user.id); + + await t.notThrowsAsync( + calendarService.assertCanLinkProvider(user.id, CalendarProviderName.Google) + ); +}); + +test('handleOAuthCallback does not persist new google account when linking is disabled', async t => { + config.calendar.google.allowNewAccounts = false; + const provider = new MockCalendarProvider(); + providerFactory.register(provider); + const user = await module.create(Mockers.User); + + const error = await t.throwsAsync( + calendarService.handleOAuthCallback({ + provider: CalendarProviderName.Google, + code: 'code', + redirectUri: 'https://example.com/callback', + userId: user.id, + }) + ); + t.true(error instanceof GraphqlBadRequest); + t.is((await models.calendarAccount.listByUser(user.id)).length, 0); +}); + test('syncSubscription resets invalid sync token and maps events', async t => { const user = await module.create(Mockers.User); const account = await createAccount(user.id); diff --git a/packages/backend/server/src/plugins/calendar/config.ts b/packages/backend/server/src/plugins/calendar/config.ts index 4136d2298..4ccf29b45 100644 --- a/packages/backend/server/src/plugins/calendar/config.ts +++ b/packages/backend/server/src/plugins/calendar/config.ts @@ -4,6 +4,7 @@ import { defineModuleConfig, JSONSchema } from '../../base'; export interface CalendarGoogleConfig { enabled: boolean; + allowNewAccounts?: boolean; clientId: string; clientSecret: string; externalWebhookUrl?: string; @@ -46,6 +47,7 @@ const schema: JSONSchema = { type: 'object', properties: { enabled: { type: 'boolean' }, + allowNewAccounts: { type: 'boolean' }, clientId: { type: 'string' }, clientSecret: { type: 'string' }, externalWebhookUrl: { type: 'string' }, @@ -86,6 +88,7 @@ defineModuleConfig('calendar', { desc: 'Google Calendar integration config', default: { enabled: false, + allowNewAccounts: true, clientId: '', clientSecret: '', externalWebhookUrl: '', @@ -95,6 +98,7 @@ defineModuleConfig('calendar', { schema, shape: z.object({ enabled: z.boolean(), + allowNewAccounts: z.boolean().optional(), clientId: z.string(), clientSecret: z.string(), externalWebhookUrl: z diff --git a/packages/backend/server/src/plugins/calendar/controller.ts b/packages/backend/server/src/plugins/calendar/controller.ts index 3ae2dceb8..a0aea8e04 100644 --- a/packages/backend/server/src/plugins/calendar/controller.ts +++ b/packages/backend/server/src/plugins/calendar/controller.ts @@ -46,6 +46,8 @@ export class CalendarController { throw new UnknownOauthProvider({ name: providerName }); } + await this.calendar.assertCanLinkProvider(user.id, providerName); + const state = await this.oauth.saveOAuthState({ provider: providerName, userId: user.id, diff --git a/packages/backend/server/src/plugins/calendar/resolver.ts b/packages/backend/server/src/plugins/calendar/resolver.ts index 134c751fd..271e38b4d 100644 --- a/packages/backend/server/src/plugins/calendar/resolver.ts +++ b/packages/backend/server/src/plugins/calendar/resolver.ts @@ -38,7 +38,12 @@ export class CalendarServerConfigResolver { @ResolveField(() => [CalendarProviderName]) calendarProviders() { - return this.providerFactory.providers; + return this.providerFactory.providers.filter(provider => { + return ( + provider !== CalendarProviderName.Google || + this.config.calendar.google.allowNewAccounts !== false + ); + }); } @ResolveField(() => [CalendarCalDAVProviderPresetObjectType]) @@ -166,6 +171,8 @@ export class CalendarMutationResolver { throw new AuthenticationRequired(); } + await this.calendar.assertCanLinkProvider(user.id, input.provider); + const state = await this.oauth.saveOAuthState({ provider: input.provider, userId: user.id, diff --git a/packages/backend/server/src/plugins/calendar/service.ts b/packages/backend/server/src/plugins/calendar/service.ts index 1bf357d01..2785215bb 100644 --- a/packages/backend/server/src/plugins/calendar/service.ts +++ b/packages/backend/server/src/plugins/calendar/service.ts @@ -154,6 +154,11 @@ export class CalendarService { const provider = this.requireProvider(params.provider); const tokens = await provider.exchangeCode(params.code, params.redirectUri); const profile = await provider.getAccountProfile(tokens.accessToken); + await this.assertCanPersistProviderAccount( + params.userId, + params.provider, + profile.providerAccountId + ); const account = await this.models.calendarAccount.upsert({ userId: params.userId, @@ -565,6 +570,19 @@ export class CalendarService { return true; } + async assertCanLinkProvider(userId: string, provider: CalendarProviderName) { + if (this.canCreateNewAccounts(provider)) { + return; + } + + const accounts = await this.models.calendarAccount.listByUser(userId); + if (accounts.some(account => account.provider === provider)) { + return; + } + + throw this.providerLinkDisabledError(provider); + } + getAuthUrl( provider: CalendarProviderName, state: string, @@ -580,6 +598,41 @@ export class CalendarService { return instance.getAuthUrl(state, redirectUri); } + private async assertCanPersistProviderAccount( + userId: string, + provider: CalendarProviderName, + providerAccountId: string + ) { + if (this.canCreateNewAccounts(provider)) { + return; + } + + const account = await this.models.calendarAccount.getByProviderAccount( + userId, + provider, + providerAccountId + ); + if (account) { + return; + } + + throw this.providerLinkDisabledError(provider); + } + + private canCreateNewAccounts(provider: CalendarProviderName) { + return ( + provider !== CalendarProviderName.Google || + this.config.calendar.google.allowNewAccounts !== false + ); + } + + private providerLinkDisabledError(provider: CalendarProviderName) { + return new GraphqlBadRequest({ + code: 'calendar_provider_link_disabled', + message: `${provider} calendar account linking is disabled.`, + }); + } + private async syncWithProvider(params: { provider: CalendarProvider; subscriptionId: string;