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:
2026-07-01 13:15:13 +02:00
parent 2e0e7290ba
commit 42daa87e5e
12 changed files with 214 additions and 6 deletions
@@ -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
+2
View File
@@ -40,6 +40,7 @@ model User {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
lastLoginAt DateTime? lastLoginAt DateTime?
avatarPath String? avatarPath String?
accentColor String?
passwordResetTokens PasswordResetToken[] passwordResetTokens PasswordResetToken[]
@@index([tenantId]) @@index([tenantId])
@@ -156,6 +157,7 @@ model CalendarSource {
name String name String
type String // 'caldav' | 'ics' | 'exchange' type String // 'caldav' | 'ics' | 'exchange'
exchangeMode String? // 'ews' | 'graph' — only for exchange type exchangeMode String? // 'ews' | 'graph' — only for exchange type
domain String? // Exchange EWS only: Windows domain (e.g. COMPANY)
url String url String
username String? username String?
encryptedPassword String? // AES-256-GCM ciphertext (iv:authTag:ciphertext hex) encryptedPassword String? // AES-256-GCM ciphertext (iv:authTag:ciphertext hex)
@@ -14,6 +14,7 @@ import { Request } from 'express';
import { CalendarService } from './calendar.service'; import { CalendarService } from './calendar.service';
import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto'; import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto';
import { UpdateCalendarSourceDto } from './dto/update-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'; import { CalendarEventsQueryDto } from './dto/calendar-events-query.dto';
/** /**
@@ -103,6 +104,18 @@ export class CalendarController {
return this.calendarService.deleteSource(id, userId); 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 * POST /calendar/sources/:id/test
* Tests connection to a calendar source. Implemented in Task 3. * Tests connection to a calendar source. Implemented in Task 3.
+35 -2
View File
@@ -8,6 +8,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { CalendarCryptoService } from './crypto.service'; import { CalendarCryptoService } from './crypto.service';
import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto'; import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto';
import { UpdateCalendarSourceDto } from './dto/update-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 { ICSProvider } from './providers/ics.provider';
import { CalDAVProvider } from './providers/caldav.provider'; import { CalDAVProvider } from './providers/caldav.provider';
import { ExchangeProvider } from './providers/exchange.provider'; import { ExchangeProvider } from './providers/exchange.provider';
@@ -33,13 +34,13 @@ export interface CalendarEvent {
*/ */
export interface CalendarProvider { export interface CalendarProvider {
fetchEvents( 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, from: Date,
to: Date, to: Date,
): Promise<CalendarEvent[]>; ): Promise<CalendarEvent[]>;
testConnection( 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>; ): Promise<boolean>;
} }
@@ -54,6 +55,7 @@ const SOURCE_SAFE_SELECT = {
name: true, name: true,
type: true, type: true,
exchangeMode: true, exchangeMode: true,
domain: true,
url: true, url: true,
username: true, username: true,
// encryptedPassword: NEVER included — T-05-09 // encryptedPassword: NEVER included — T-05-09
@@ -149,6 +151,7 @@ export class CalendarService {
url: dto.url, url: dto.url,
username: dto.username ?? null, username: dto.username ?? null,
exchangeMode: dto.exchangeMode ?? null, exchangeMode: dto.exchangeMode ?? null,
domain: dto.domain ?? null,
color: dto.color ?? '#3B82F6', color: dto.color ?? '#3B82F6',
}; };
@@ -191,6 +194,7 @@ export class CalendarService {
if (dto.url !== undefined) data.url = dto.url; if (dto.url !== undefined) data.url = dto.url;
if (dto.username !== undefined) data.username = dto.username; if (dto.username !== undefined) data.username = dto.username;
if (dto.exchangeMode !== undefined) data.exchangeMode = dto.exchangeMode; 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.color !== undefined) data.color = dto.color;
if (dto.isVisible !== undefined) data.isVisible = dto.isVisible; if (dto.isVisible !== undefined) data.isVisible = dto.isVisible;
@@ -250,6 +254,7 @@ export class CalendarService {
? this.crypto.decrypt(source.encryptedPassword) ? this.crypto.decrypt(source.encryptedPassword)
: undefined, : undefined,
exchangeMode: source.exchangeMode, exchangeMode: source.exchangeMode,
domain: source.domain ?? undefined,
id: source.id, 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). * 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) ? this.crypto.decrypt(source.encryptedPassword)
: undefined, : undefined,
exchangeMode: source.exchangeMode, exchangeMode: source.exchangeMode,
domain: source.domain ?? undefined,
id: source.id, id: source.id,
color: source.color, color: source.color,
}; };
@@ -41,6 +41,10 @@ export class CreateCalendarSourceDto {
@IsIn(['ews', 'graph']) @IsIn(['ews', 'graph'])
exchangeMode?: string; exchangeMode?: string;
@IsOptional()
@IsString()
domain?: string;
@IsOptional() @IsOptional()
@IsHexColor() @IsHexColor()
color?: string; 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']) @IsIn(['ews', 'graph'])
exchangeMode?: string; exchangeMode?: string;
@IsOptional()
@IsString()
domain?: string;
@IsOptional() @IsOptional()
@IsHexColor() @IsHexColor()
color?: string; color?: string;
@@ -24,6 +24,7 @@ export class ExchangeProvider implements CalendarProvider {
username?: string; username?: string;
password?: string; password?: string;
exchangeMode?: string | null; exchangeMode?: string | null;
domain?: string;
id: string; id: string;
color?: string | null; color?: string | null;
}, },
@@ -56,6 +57,7 @@ export class ExchangeProvider implements CalendarProvider {
username?: string; username?: string;
password?: string; password?: string;
exchangeMode?: string | null; exchangeMode?: string | null;
domain?: string;
id: string; id: string;
}, },
): Promise<boolean> { ): Promise<boolean> {
@@ -144,6 +146,7 @@ export class ExchangeProvider implements CalendarProvider {
url: string; url: string;
username?: string; username?: string;
password?: string; password?: string;
domain?: string;
id: string; id: string;
color?: string | null; color?: string | null;
}, },
@@ -158,6 +161,7 @@ export class ExchangeProvider implements CalendarProvider {
service.Credentials = new ews.WebCredentials( service.Credentials = new ews.WebCredentials(
source.username || '', source.username || '',
source.password || '', source.password || '',
source.domain || undefined,
); );
// CalendarView constructor accepts JS Dates in ews-javascript-api // 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. * Tests EWS connection by binding to the calendar folder.
*/ */
private async testEwsConnection( private async testEwsConnection(
source: { url: string; username?: string; password?: string }, source: { url: string; username?: string; password?: string; domain?: string },
): Promise<boolean> { ): Promise<boolean> {
const ews: any = await import('ews-javascript-api'); const ews: any = await import('ews-javascript-api');
@@ -219,6 +223,7 @@ export class ExchangeProvider implements CalendarProvider {
service.Credentials = new ews.WebCredentials( service.Credentials = new ews.WebCredentials(
source.username || '', source.username || '',
source.password || '', source.password || '',
source.domain || undefined,
); );
await ews.Folder.Bind(service, ews.WellKnownFolderName.Calendar); await ews.Folder.Bind(service, ews.WellKnownFolderName.Calendar);
@@ -3,6 +3,7 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import type { CreateSourcePayload } from '@/lib/calendar-api'; import type { CreateSourcePayload } from '@/lib/calendar-api';
import { testSourceConfig } from '@/lib/calendar-api';
/** /**
* 8-color palette for calendar sources (UI-SPEC). * 8-color palette for calendar sources (UI-SPEC).
@@ -69,6 +70,8 @@ export function CalendarSourceForm({
const [exchangeMode, setExchangeMode] = useState( const [exchangeMode, setExchangeMode] = useState(
initialValues?.exchangeMode ?? 'graph', initialValues?.exchangeMode ?? 'graph',
); );
const [domain, setDomain] = useState(initialValues?.domain ?? '');
const [testStatus, setTestStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [color, setColor] = useState( const [color, setColor] = useState(
initialValues?.color ?? SOURCE_COLOR_PALETTE[0].value, initialValues?.color ?? SOURCE_COLOR_PALETTE[0].value,
); );
@@ -103,6 +106,24 @@ export function CalendarSourceForm({
url.trim() !== '' && url.trim() !== '' &&
!urlError; !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( const handleSubmit = useCallback(
async (e: React.FormEvent) => { async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -122,14 +143,15 @@ export function CalendarSourceForm({
if (password) payload.password = password; if (password) payload.password = password;
} }
// Include exchangeMode for Exchange type // Include exchangeMode and domain for Exchange type
if (isExchange) { if (isExchange) {
payload.exchangeMode = exchangeMode; payload.exchangeMode = exchangeMode;
if (domain.trim()) payload.domain = domain.trim();
} }
await onSave(payload); 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 ( return (
@@ -194,6 +216,23 @@ export function CalendarSourceForm({
</div> </div>
)} )}
{/* Domain (Exchange EWS only) */}
{isExchange && (
<div>
<label htmlFor="exchange-domain" className="mb-1 block text-sm text-foreground">
{t('calendar.formFieldDomain')}
</label>
<input
id="exchange-domain"
type="text"
placeholder={t('calendar.formFieldDomainHint')}
className="h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm text-foreground"
value={domain}
onChange={(e) => setDomain(e.target.value)}
/>
</div>
)}
{/* URL */} {/* URL */}
<div> <div>
<label htmlFor="source-url" className="mb-1 block text-sm text-foreground"> <label htmlFor="source-url" className="mb-1 block text-sm text-foreground">
@@ -282,7 +321,7 @@ export function CalendarSourceForm({
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex gap-3 pt-2"> <div className="flex flex-wrap gap-3 pt-2">
<button <button
type="submit" type="submit"
data-testid="source-form-submit" data-testid="source-form-submit"
@@ -291,6 +330,15 @@ export function CalendarSourceForm({
> >
{isSaving ? t('calendar.formSaving') : t('calendar.formSave')} {isSaving ? t('calendar.formSaving') : t('calendar.formSave')}
</button> </button>
<button
type="button"
data-testid="source-form-test"
disabled={!isFormValid || testStatus === 'loading'}
onClick={handleTest}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
{testStatus === 'loading' ? t('calendar.formTesting') : t('calendar.formTestConnection')}
</button>
<button <button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
@@ -299,6 +347,12 @@ export function CalendarSourceForm({
{t('calendar.formCancel')} {t('calendar.formCancel')}
</button> </button>
</div> </div>
{testStatus === 'success' && (
<p className="text-sm text-green-600 dark:text-green-400">{t('calendar.formTestSuccess')}</p>
)}
{testStatus === 'error' && (
<p className="text-sm text-destructive">{t('calendar.formTestFailed')}</p>
)}
</form> </form>
); );
} }
+25
View File
@@ -22,6 +22,7 @@ export interface CalendarSource {
name: string; name: string;
type: 'caldav' | 'ics' | 'exchange'; type: 'caldav' | 'ics' | 'exchange';
exchangeMode?: string | null; exchangeMode?: string | null;
domain?: string | null;
url: string; url: string;
username?: string | null; username?: string | null;
color?: string | null; color?: string | null;
@@ -59,6 +60,7 @@ export interface CreateSourcePayload {
username?: string; username?: string;
password?: string; password?: string;
exchangeMode?: string; exchangeMode?: string;
domain?: string;
color?: string; color?: string;
} }
@@ -73,6 +75,7 @@ export interface UpdateSourcePayload {
username?: string; username?: string;
password?: string; password?: string;
exchangeMode?: string; exchangeMode?: string;
domain?: string;
color?: string; color?: string;
isVisible?: boolean; isVisible?: boolean;
} }
@@ -138,6 +141,28 @@ export async function deleteSource(id: string): Promise<void> {
if (!res.ok) throw new Error('Failed to delete calendar source'); 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. * Test connection to a calendar source.
* POST /calendar/sources/:id/test * POST /calendar/sources/:id/test
+6
View File
@@ -202,6 +202,12 @@
"formFieldPassword": "Passwort", "formFieldPassword": "Passwort",
"formFieldColor": "Farbe", "formFieldColor": "Farbe",
"formFieldExchangeMode": "Exchange-Modus", "formFieldExchangeMode": "Exchange-Modus",
"formFieldDomain": "Domain",
"formFieldDomainHint": "Windows-Domain (z.B. FIRMA)",
"formTestConnection": "Verbindung testen",
"formTesting": "Teste...",
"formTestSuccess": "Verbindung erfolgreich",
"formTestFailed": "Verbindung fehlgeschlagen",
"formSave": "Speichern", "formSave": "Speichern",
"formSaving": "Wird gespeichert...", "formSaving": "Wird gespeichert...",
"formCancel": "Abbrechen", "formCancel": "Abbrechen",
+6
View File
@@ -202,6 +202,12 @@
"formFieldPassword": "Password", "formFieldPassword": "Password",
"formFieldColor": "Color", "formFieldColor": "Color",
"formFieldExchangeMode": "Exchange Mode", "formFieldExchangeMode": "Exchange Mode",
"formFieldDomain": "Domain",
"formFieldDomainHint": "Windows domain (e.g. COMPANY)",
"formTestConnection": "Test connection",
"formTesting": "Testing...",
"formTestSuccess": "Connection successful",
"formTestFailed": "Connection failed",
"formSave": "Save", "formSave": "Save",
"formSaving": "Saving...", "formSaving": "Saving...",
"formCancel": "Cancel", "formCancel": "Cancel",