feat(server): add flag for calendar enable (#14896)

#### PR Dependency Tree


* **PR #14896** 👈

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**
* 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.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-05-04 03:45:49 +08:00
committed by GitHub
parent 027d163921
commit fa66139230
5 changed files with 117 additions and 2 deletions

View File

@@ -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);

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;