feat(calendar): add domain field and test-connection button to Exchange sources
- Prisma: domain String? added to CalendarSource model (db push applied) - DTOs: domain in CreateCalendarSourceDto, UpdateCalendarSourceDto, new TestCalendarSourceConfigDto - Service: domain in SOURCE_SAFE_SELECT, addSource, updateSource; new testConnectionFromConfig method - Controller: POST /calendar/sources/test-config (before :id routes to avoid collision) - ExchangeProvider: domain in all source interfaces; passed as 3rd arg to EWS WebCredentials - Frontend: domain in CalendarSource/CreateSourcePayload/UpdateSourcePayload; testSourceConfig API fn - Form: domain field (Exchange-only), "Test connection" button with idle/loading/success/error states - i18n: de+en keys for formFieldDomain, formFieldDomainHint, formTestConnection, formTesting, formTestSuccess, formTestFailed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,7 @@ model User {
|
||||
updatedAt DateTime @updatedAt
|
||||
lastLoginAt DateTime?
|
||||
avatarPath String?
|
||||
accentColor String?
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@ -156,6 +157,7 @@ model CalendarSource {
|
||||
name String
|
||||
type String // 'caldav' | 'ics' | 'exchange'
|
||||
exchangeMode String? // 'ews' | 'graph' — only for exchange type
|
||||
domain String? // Exchange EWS only: Windows domain (e.g. COMPANY)
|
||||
url String
|
||||
username String?
|
||||
encryptedPassword String? // AES-256-GCM ciphertext (iv:authTag:ciphertext hex)
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Request } from 'express';
|
||||
import { CalendarService } from './calendar.service';
|
||||
import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto';
|
||||
import { UpdateCalendarSourceDto } from './dto/update-calendar-source.dto';
|
||||
import { TestCalendarSourceConfigDto } from './dto/test-calendar-source-config.dto';
|
||||
import { CalendarEventsQueryDto } from './dto/calendar-events-query.dto';
|
||||
|
||||
/**
|
||||
@@ -103,6 +104,18 @@ export class CalendarController {
|
||||
return this.calendarService.deleteSource(id, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /calendar/sources/test-config
|
||||
* Tests a calendar configuration without saving. Used by the form "Test connection" button.
|
||||
* Must be declared BEFORE sources/:id routes to avoid route collision.
|
||||
*/
|
||||
@Post('sources/test-config')
|
||||
async testSourceConfig(
|
||||
@Body() dto: TestCalendarSourceConfigDto,
|
||||
) {
|
||||
return this.calendarService.testConnectionFromConfig(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /calendar/sources/:id/test
|
||||
* Tests connection to a calendar source. Implemented in Task 3.
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CalendarCryptoService } from './crypto.service';
|
||||
import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto';
|
||||
import { UpdateCalendarSourceDto } from './dto/update-calendar-source.dto';
|
||||
import { TestCalendarSourceConfigDto } from './dto/test-calendar-source-config.dto';
|
||||
import { ICSProvider } from './providers/ics.provider';
|
||||
import { CalDAVProvider } from './providers/caldav.provider';
|
||||
import { ExchangeProvider } from './providers/exchange.provider';
|
||||
@@ -33,13 +34,13 @@ export interface CalendarEvent {
|
||||
*/
|
||||
export interface CalendarProvider {
|
||||
fetchEvents(
|
||||
source: { url: string; username?: string; password?: string; exchangeMode?: string | null; id: string; color?: string | null },
|
||||
source: { url: string; username?: string; password?: string; exchangeMode?: string | null; domain?: string; id: string; color?: string | null },
|
||||
from: Date,
|
||||
to: Date,
|
||||
): Promise<CalendarEvent[]>;
|
||||
|
||||
testConnection(
|
||||
source: { url: string; username?: string; password?: string; exchangeMode?: string | null; id: string },
|
||||
source: { url: string; username?: string; password?: string; exchangeMode?: string | null; domain?: string; id: string },
|
||||
): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -54,6 +55,7 @@ const SOURCE_SAFE_SELECT = {
|
||||
name: true,
|
||||
type: true,
|
||||
exchangeMode: true,
|
||||
domain: true,
|
||||
url: true,
|
||||
username: true,
|
||||
// encryptedPassword: NEVER included — T-05-09
|
||||
@@ -149,6 +151,7 @@ export class CalendarService {
|
||||
url: dto.url,
|
||||
username: dto.username ?? null,
|
||||
exchangeMode: dto.exchangeMode ?? null,
|
||||
domain: dto.domain ?? null,
|
||||
color: dto.color ?? '#3B82F6',
|
||||
};
|
||||
|
||||
@@ -191,6 +194,7 @@ export class CalendarService {
|
||||
if (dto.url !== undefined) data.url = dto.url;
|
||||
if (dto.username !== undefined) data.username = dto.username;
|
||||
if (dto.exchangeMode !== undefined) data.exchangeMode = dto.exchangeMode;
|
||||
if (dto.domain !== undefined) data.domain = dto.domain;
|
||||
if (dto.color !== undefined) data.color = dto.color;
|
||||
if (dto.isVisible !== undefined) data.isVisible = dto.isVisible;
|
||||
|
||||
@@ -250,6 +254,7 @@ export class CalendarService {
|
||||
? this.crypto.decrypt(source.encryptedPassword)
|
||||
: undefined,
|
||||
exchangeMode: source.exchangeMode,
|
||||
domain: source.domain ?? undefined,
|
||||
id: source.id,
|
||||
};
|
||||
|
||||
@@ -275,6 +280,33 @@ export class CalendarService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests a calendar source configuration without saving it to the database.
|
||||
* Used by the "Test connection" button in the form before the source is created.
|
||||
*/
|
||||
async testConnectionFromConfig(
|
||||
dto: TestCalendarSourceConfigDto,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
await this.validateUrlNotPrivate(dto.url);
|
||||
|
||||
const provider = this.getProvider(dto.type);
|
||||
const tempSource = {
|
||||
url: dto.url,
|
||||
username: dto.username,
|
||||
password: dto.password,
|
||||
exchangeMode: dto.exchangeMode ?? null,
|
||||
domain: dto.domain,
|
||||
id: 'test-config',
|
||||
};
|
||||
|
||||
try {
|
||||
const success = await provider.testConnection(tempSource);
|
||||
return { success };
|
||||
} catch {
|
||||
return { success: false, error: 'Connection failed' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate events from all visible sources for a user (CAL-02/CAL-03).
|
||||
*
|
||||
@@ -335,6 +367,7 @@ export class CalendarService {
|
||||
? this.crypto.decrypt(source.encryptedPassword)
|
||||
: undefined,
|
||||
exchangeMode: source.exchangeMode,
|
||||
domain: source.domain ?? undefined,
|
||||
id: source.id,
|
||||
color: source.color,
|
||||
};
|
||||
|
||||
@@ -41,6 +41,10 @@ export class CreateCalendarSourceDto {
|
||||
@IsIn(['ews', 'graph'])
|
||||
exchangeMode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
domain?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
color?: string;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
} from 'class-validator';
|
||||
|
||||
export class TestCalendarSourceConfigDto {
|
||||
@IsIn(['caldav', 'ics', 'exchange'])
|
||||
type!: string;
|
||||
|
||||
@IsUrl(
|
||||
{ protocols: ['https'], require_protocol: true },
|
||||
{ message: 'URL must use HTTPS protocol' },
|
||||
)
|
||||
url!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
username?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
password?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ews', 'graph'])
|
||||
exchangeMode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
domain?: string;
|
||||
}
|
||||
@@ -45,6 +45,10 @@ export class UpdateCalendarSourceDto {
|
||||
@IsIn(['ews', 'graph'])
|
||||
exchangeMode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
domain?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
color?: string;
|
||||
|
||||
@@ -24,6 +24,7 @@ export class ExchangeProvider implements CalendarProvider {
|
||||
username?: string;
|
||||
password?: string;
|
||||
exchangeMode?: string | null;
|
||||
domain?: string;
|
||||
id: string;
|
||||
color?: string | null;
|
||||
},
|
||||
@@ -56,6 +57,7 @@ export class ExchangeProvider implements CalendarProvider {
|
||||
username?: string;
|
||||
password?: string;
|
||||
exchangeMode?: string | null;
|
||||
domain?: string;
|
||||
id: string;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
@@ -144,6 +146,7 @@ export class ExchangeProvider implements CalendarProvider {
|
||||
url: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
domain?: string;
|
||||
id: string;
|
||||
color?: string | null;
|
||||
},
|
||||
@@ -158,6 +161,7 @@ export class ExchangeProvider implements CalendarProvider {
|
||||
service.Credentials = new ews.WebCredentials(
|
||||
source.username || '',
|
||||
source.password || '',
|
||||
source.domain || undefined,
|
||||
);
|
||||
|
||||
// CalendarView constructor accepts JS Dates in ews-javascript-api
|
||||
@@ -210,7 +214,7 @@ export class ExchangeProvider implements CalendarProvider {
|
||||
* Tests EWS connection by binding to the calendar folder.
|
||||
*/
|
||||
private async testEwsConnection(
|
||||
source: { url: string; username?: string; password?: string },
|
||||
source: { url: string; username?: string; password?: string; domain?: string },
|
||||
): Promise<boolean> {
|
||||
const ews: any = await import('ews-javascript-api');
|
||||
|
||||
@@ -219,6 +223,7 @@ export class ExchangeProvider implements CalendarProvider {
|
||||
service.Credentials = new ews.WebCredentials(
|
||||
source.username || '',
|
||||
source.password || '',
|
||||
source.domain || undefined,
|
||||
);
|
||||
|
||||
await ews.Folder.Bind(service, ews.WellKnownFolderName.Calendar);
|
||||
|
||||
Reference in New Issue
Block a user