From 42daa87e5ede970efc176a018e852e1e9f9bc4c0 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 1 Jul 2026 13:15:13 +0200 Subject: [PATCH] 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 --- .../PLAN.md | 23 +++++++ apps/api/prisma/schema.prisma | 2 + apps/api/src/calendar/calendar.controller.ts | 13 ++++ apps/api/src/calendar/calendar.service.ts | 37 +++++++++++- .../dto/create-calendar-source.dto.ts | 4 ++ .../dto/test-calendar-source-config.dto.ts | 33 ++++++++++ .../dto/update-calendar-source.dto.ts | 4 ++ .../calendar/providers/exchange.provider.ts | 7 ++- .../settings/calendar-source-form.tsx | 60 ++++++++++++++++++- apps/web/src/lib/calendar-api.ts | 25 ++++++++ apps/web/src/messages/de.json | 6 ++ apps/web/src/messages/en.json | 6 ++ 12 files changed, 214 insertions(+), 6 deletions(-) create mode 100644 .planning/quick/260701-calendar-domain-test-button/PLAN.md create mode 100644 apps/api/src/calendar/dto/test-calendar-source-config.dto.ts diff --git a/.planning/quick/260701-calendar-domain-test-button/PLAN.md b/.planning/quick/260701-calendar-domain-test-button/PLAN.md new file mode 100644 index 0000000..ac0bf95 --- /dev/null +++ b/.planning/quick/260701-calendar-domain-test-button/PLAN.md @@ -0,0 +1,23 @@ +--- +slug: calendar-domain-test-button +created: "2026-07-01" +status: in-progress +--- + +# Calendar: Domain-Feld + Test-Button + +Füge Domain-Feld (Exchange) und "Verbindung testen"-Button zur Kalenderquellen-Form hinzu. + +## Tasks + +- [ ] T1: Prisma schema — `domain String?` zu CalendarSource +- [ ] T2: Migration ausführen +- [ ] T3: Create + Update DTOs — `domain?: string` hinzufügen +- [ ] T4: TestCalendarSourceConfigDto erstellen +- [ ] T5: CalendarService — domain in SOURCE_SAFE_SELECT, addSource, updateSource; `testConnectionFromConfig` Methode +- [ ] T6: CalendarController — `POST /calendar/sources/test-config` Endpoint +- [ ] T7: ExchangeProvider — domain in Interface + EWS WebCredentials +- [ ] T8: calendar-api.ts (Frontend) — domain in Types + `testSourceConfig` Funktion +- [ ] T9: calendar-source-form.tsx — Domain-Feld + Test-Button mit Statusanzeige +- [ ] T10: i18n de.json + en.json — neue Translations +- [ ] T11: Commit diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 13a19db..ffee08b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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) diff --git a/apps/api/src/calendar/calendar.controller.ts b/apps/api/src/calendar/calendar.controller.ts index 98e1276..0237ed3 100644 --- a/apps/api/src/calendar/calendar.controller.ts +++ b/apps/api/src/calendar/calendar.controller.ts @@ -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. diff --git a/apps/api/src/calendar/calendar.service.ts b/apps/api/src/calendar/calendar.service.ts index 4ed0110..41536ca 100644 --- a/apps/api/src/calendar/calendar.service.ts +++ b/apps/api/src/calendar/calendar.service.ts @@ -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; 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; } @@ -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, }; diff --git a/apps/api/src/calendar/dto/create-calendar-source.dto.ts b/apps/api/src/calendar/dto/create-calendar-source.dto.ts index 1690282..679346f 100644 --- a/apps/api/src/calendar/dto/create-calendar-source.dto.ts +++ b/apps/api/src/calendar/dto/create-calendar-source.dto.ts @@ -41,6 +41,10 @@ export class CreateCalendarSourceDto { @IsIn(['ews', 'graph']) exchangeMode?: string; + @IsOptional() + @IsString() + domain?: string; + @IsOptional() @IsHexColor() color?: string; diff --git a/apps/api/src/calendar/dto/test-calendar-source-config.dto.ts b/apps/api/src/calendar/dto/test-calendar-source-config.dto.ts new file mode 100644 index 0000000..32a65a0 --- /dev/null +++ b/apps/api/src/calendar/dto/test-calendar-source-config.dto.ts @@ -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; +} diff --git a/apps/api/src/calendar/dto/update-calendar-source.dto.ts b/apps/api/src/calendar/dto/update-calendar-source.dto.ts index 75873a4..a0688f5 100644 --- a/apps/api/src/calendar/dto/update-calendar-source.dto.ts +++ b/apps/api/src/calendar/dto/update-calendar-source.dto.ts @@ -45,6 +45,10 @@ export class UpdateCalendarSourceDto { @IsIn(['ews', 'graph']) exchangeMode?: string; + @IsOptional() + @IsString() + domain?: string; + @IsOptional() @IsHexColor() color?: string; diff --git a/apps/api/src/calendar/providers/exchange.provider.ts b/apps/api/src/calendar/providers/exchange.provider.ts index f87ff79..1403cec 100644 --- a/apps/api/src/calendar/providers/exchange.provider.ts +++ b/apps/api/src/calendar/providers/exchange.provider.ts @@ -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 { @@ -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 { 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); diff --git a/apps/web/src/components/settings/calendar-source-form.tsx b/apps/web/src/components/settings/calendar-source-form.tsx index 378d92a..26e9d21 100644 --- a/apps/web/src/components/settings/calendar-source-form.tsx +++ b/apps/web/src/components/settings/calendar-source-form.tsx @@ -3,6 +3,7 @@ import { useCallback, useState } from 'react'; import { useTranslations } from 'next-intl'; import type { CreateSourcePayload } from '@/lib/calendar-api'; +import { testSourceConfig } from '@/lib/calendar-api'; /** * 8-color palette for calendar sources (UI-SPEC). @@ -69,6 +70,8 @@ export function CalendarSourceForm({ const [exchangeMode, setExchangeMode] = useState( initialValues?.exchangeMode ?? 'graph', ); + const [domain, setDomain] = useState(initialValues?.domain ?? ''); + const [testStatus, setTestStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); const [color, setColor] = useState( initialValues?.color ?? SOURCE_COLOR_PALETTE[0].value, ); @@ -103,6 +106,24 @@ export function CalendarSourceForm({ url.trim() !== '' && !urlError; + const handleTest = useCallback(async () => { + if (!validateUrl(url)) return; + setTestStatus('loading'); + try { + const result = await testSourceConfig({ + type, + url: url.trim(), + username: username.trim() || undefined, + password: password || undefined, + exchangeMode: isExchange ? exchangeMode : undefined, + domain: isExchange && domain.trim() ? domain.trim() : undefined, + }); + setTestStatus(result.success ? 'success' : 'error'); + } catch { + setTestStatus('error'); + } + }, [type, url, username, password, exchangeMode, domain, isExchange, validateUrl]); + const handleSubmit = useCallback( async (e: React.FormEvent) => { e.preventDefault(); @@ -122,14 +143,15 @@ export function CalendarSourceForm({ if (password) payload.password = password; } - // Include exchangeMode for Exchange type + // Include exchangeMode and domain for Exchange type if (isExchange) { payload.exchangeMode = exchangeMode; + if (domain.trim()) payload.domain = domain.trim(); } await onSave(payload); }, - [name, type, url, username, password, exchangeMode, color, isICS, isExchange, onSave, validateUrl], + [name, type, url, username, password, exchangeMode, domain, color, isICS, isExchange, onSave, validateUrl], ); return ( @@ -194,6 +216,23 @@ export function CalendarSourceForm({ )} + {/* Domain (Exchange EWS only) */} + {isExchange && ( +
+ + setDomain(e.target.value)} + /> +
+ )} + {/* URL */}
{/* Actions */} -
+
+
+ {testStatus === 'success' && ( +

{t('calendar.formTestSuccess')}

+ )} + {testStatus === 'error' && ( +

{t('calendar.formTestFailed')}

+ )} ); } diff --git a/apps/web/src/lib/calendar-api.ts b/apps/web/src/lib/calendar-api.ts index 8d54151..2f5d347 100644 --- a/apps/web/src/lib/calendar-api.ts +++ b/apps/web/src/lib/calendar-api.ts @@ -22,6 +22,7 @@ export interface CalendarSource { name: string; type: 'caldav' | 'ics' | 'exchange'; exchangeMode?: string | null; + domain?: string | null; url: string; username?: string | null; color?: string | null; @@ -59,6 +60,7 @@ export interface CreateSourcePayload { username?: string; password?: string; exchangeMode?: string; + domain?: string; color?: string; } @@ -73,6 +75,7 @@ export interface UpdateSourcePayload { username?: string; password?: string; exchangeMode?: string; + domain?: string; color?: string; isVisible?: boolean; } @@ -138,6 +141,28 @@ export async function deleteSource(id: string): Promise { if (!res.ok) throw new Error('Failed to delete calendar source'); } +/** + * Test a calendar configuration without saving it. + * POST /calendar/sources/test-config + */ +export async function testSourceConfig(config: { + type: string; + url: string; + username?: string; + password?: string; + exchangeMode?: string; + domain?: string; +}): Promise<{ success: boolean; error?: string }> { + const res = await fetch(`${API_URL}/calendar/sources/test-config`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(config), + }); + if (!res.ok) throw new Error('Test request failed'); + return res.json(); +} + /** * Test connection to a calendar source. * POST /calendar/sources/:id/test diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 7176b36..f134d46 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -202,6 +202,12 @@ "formFieldPassword": "Passwort", "formFieldColor": "Farbe", "formFieldExchangeMode": "Exchange-Modus", + "formFieldDomain": "Domain", + "formFieldDomainHint": "Windows-Domain (z.B. FIRMA)", + "formTestConnection": "Verbindung testen", + "formTesting": "Teste...", + "formTestSuccess": "Verbindung erfolgreich", + "formTestFailed": "Verbindung fehlgeschlagen", "formSave": "Speichern", "formSaving": "Wird gespeichert...", "formCancel": "Abbrechen", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index cb33b3c..16dc900 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -202,6 +202,12 @@ "formFieldPassword": "Password", "formFieldColor": "Color", "formFieldExchangeMode": "Exchange Mode", + "formFieldDomain": "Domain", + "formFieldDomainHint": "Windows domain (e.g. COMPANY)", + "formTestConnection": "Test connection", + "formTesting": "Testing...", + "formTestSuccess": "Connection successful", + "formTestFailed": "Connection failed", "formSave": "Save", "formSaving": "Saving...", "formCancel": "Cancel",