From e35276243ad6d721601f1514d8de3e18b07928fc Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 1 Jul 2026 14:05:07 +0200 Subject: [PATCH] fix(calendar): EWS uses NTLM auth + edit form stays open after save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace ews-javascript-api (Basic Auth only) with httpntlm for EWS connections - testEwsConnection uses GetFolder SOAP via NTLM - fetchViaEws uses FindItem CalendarView SOAP via NTLM - Edit form no longer auto-closes on save — shows "Erfolgreich gespeichert" instead - Test button in edit mode uses saved credentials via /sources/:id/test endpoint - Add saveSuccess i18n key (de/en) Co-Authored-By: Claude Sonnet 4.6 --- .../calendar/providers/exchange.provider.ts | 189 ++++++++++++++---- .../settings/calendar-settings-panel.tsx | 15 +- .../settings/calendar-source-form.tsx | 10 +- apps/web/src/messages/de.json | 1 + apps/web/src/messages/en.json | 1 + 5 files changed, 170 insertions(+), 46 deletions(-) diff --git a/apps/api/src/calendar/providers/exchange.provider.ts b/apps/api/src/calendar/providers/exchange.provider.ts index 1403cec..1585f61 100644 --- a/apps/api/src/calendar/providers/exchange.provider.ts +++ b/apps/api/src/calendar/providers/exchange.provider.ts @@ -1,6 +1,65 @@ import { Injectable, Logger } from '@nestjs/common'; import { CalendarEvent, CalendarProvider } from '../calendar.service'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const httpntlm = require('httpntlm') as { + post: (opts: any, cb: (err: Error | null, res: any) => void) => void; +}; + +const NS_SOAP = 'http://schemas.xmlsoap.org/soap/envelope/'; +const NS_TYPES = 'http://schemas.microsoft.com/exchange/services/2006/types'; +const NS_MESSAGES = 'http://schemas.microsoft.com/exchange/services/2006/messages'; + +function soapEnvelope(body: string): string { + return ` + + ${body} +`; +} + +function escapeXml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function extractAll(xml: string, tag: string): string[] { + const results: string[] = []; + const open = `<${tag}`; + const close = ``; + let pos = 0; + while (pos < xml.length) { + const start = xml.indexOf(open, pos); + if (start === -1) break; + const end = xml.indexOf(close, start); + if (end === -1) break; + const innerStart = xml.indexOf('>', start) + 1; + results.push(xml.slice(innerStart, end)); + pos = end + close.length; + } + return results; +} + +function extractAttr(xml: string, tag: string, attr: string): string { + const tagStart = xml.indexOf(`<${tag}`); + if (tagStart === -1) return ''; + const tagEnd = xml.indexOf('>', tagStart); + const tagStr = xml.slice(tagStart, tagEnd + 1); + const attrMatch = tagStr.match(new RegExp(`${attr}="([^"]*)"`)); + return attrMatch ? attrMatch[1] : ''; +} + +function ntlmPost(opts: { + url: string; username: string; password: string; + domain: string; workstation: string; body: string; + headers: Record; +}): Promise<{ statusCode: number; body: string }> { + return new Promise((resolve, reject) => { + httpntlm.post(opts, (err, res) => { + if (err) return reject(err); + resolve({ statusCode: res.statusCode, body: res.body?.toString('utf-8') ?? '' }); + }); + }); +} + /** * Exchange calendar provider — dispatches on exchangeMode ('graph' vs 'ews'). * @@ -135,11 +194,8 @@ export class ExchangeProvider implements CalendarProvider { } /** - * Fetches events via Exchange Web Services (on-premise Exchange). - * Uses ews-javascript-api with FindAppointments over a CalendarView. - * - * Note: ews-javascript-api has no TypeScript definitions; - * we use dynamic import + any casting for type safety. + * Fetches calendar events via EWS using NTLM authentication (on-premise Exchange). + * Uses raw SOAP + httpntlm — replaces ews-javascript-api which only supports Basic Auth. */ private async fetchViaEws( source: { @@ -153,36 +209,54 @@ export class ExchangeProvider implements CalendarProvider { from: Date, to: Date, ): Promise { - // Dynamic import — ews-javascript-api is JS-only, no .d.ts - const ews: any = await import('ews-javascript-api'); + const fromIso = from.toISOString(); + const toIso = to.toISOString(); - const service = new ews.ExchangeService(ews.ExchangeVersion.Exchange2013); - service.Url = new ews.Uri(source.url); - service.Credentials = new ews.WebCredentials( - source.username || '', - source.password || '', - source.domain || undefined, - ); + const findSoap = soapEnvelope(` + + + IdOnly + + + + + + + + + + + + + `); - // CalendarView constructor accepts JS Dates in ews-javascript-api - const calendarView = new ews.CalendarView(from, to, 100); - const findResults = await service.FindAppointments( - ews.WellKnownFolderName.Calendar, - calendarView, - ); + const res = await this.ewsNtlmPost(source, findSoap, 'FindItem'); + + if (res.statusCode !== 200) { + this.logger.warn(`EWS FindItem calendar returned HTTP ${res.statusCode}`); + return []; + } const events: CalendarEvent[] = []; + const itemBlocks = this.splitItemBlocks(res.body, 't:CalendarItem'); + + for (const block of itemBlocks) { + const uid = extractAttr(block, 't:ItemId', 'Id'); + const title = extractAll(block, 't:Subject')[0] ?? 'Untitled'; + const startStr = extractAll(block, 't:Start')[0] ?? ''; + const endStr = extractAll(block, 't:End')[0] ?? ''; + const allDayStr = extractAll(block, 't:IsAllDayEvent')[0] ?? 'false'; + const location = extractAll(block, 't:Location')[0] ?? undefined; - for (const appointment of findResults.Items) { events.push({ - id: `${source.id}-${appointment.Id?.UniqueId || String(Date.now())}`, + id: `${source.id}-${uid || String(Date.now())}`, sourceId: source.id, - title: appointment.Subject || 'Untitled', - start: appointment.Start ? new Date(String(appointment.Start)) : new Date(), - end: appointment.End ? new Date(String(appointment.End)) : new Date(), - allDay: appointment.IsAllDayEvent || false, - location: appointment.Location || undefined, - description: undefined, // Body requires separate load call + title, + start: startStr ? new Date(startStr) : new Date(), + end: endStr ? new Date(endStr) : new Date(), + allDay: allDayStr === 'true', + location: location || undefined, + description: undefined, color: source.color ?? undefined, }); } @@ -190,6 +264,41 @@ export class ExchangeProvider implements CalendarProvider { return events; } + private splitItemBlocks(xml: string, tag: string): string[] { + const blocks: string[] = []; + const open = `<${tag}`; + const close = ``; + let pos = 0; + while (pos < xml.length) { + const start = xml.indexOf(open, pos); + if (start === -1) break; + const end = xml.indexOf(close, start); + if (end === -1) break; + blocks.push(xml.slice(start, end + close.length)); + pos = end + close.length; + } + return blocks; + } + + private async ewsNtlmPost( + source: { url: string; username?: string; password?: string; domain?: string }, + soap: string, + action: string, + ): Promise<{ statusCode: number; body: string }> { + return ntlmPost({ + url: source.url, + username: source.username ?? '', + password: source.password ?? '', + domain: source.domain ?? '', + workstation: '', + body: soap, + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + 'SOAPAction': `"http://schemas.microsoft.com/exchange/services/2006/messages/${action}"`, + }, + }); + } + /** * Tests Graph API connection by requesting calendar list. */ @@ -211,22 +320,22 @@ export class ExchangeProvider implements CalendarProvider { } /** - * Tests EWS connection by binding to the calendar folder. + * Tests EWS connection using NTLM auth — GetFolder on calendar folder. */ private async testEwsConnection( source: { url: string; username?: string; password?: string; domain?: string }, ): Promise { - const ews: any = await import('ews-javascript-api'); + const soap = soapEnvelope(` + + + IdOnly + + + + + `); - const service = new ews.ExchangeService(ews.ExchangeVersion.Exchange2013); - service.Url = new ews.Uri(source.url); - service.Credentials = new ews.WebCredentials( - source.username || '', - source.password || '', - source.domain || undefined, - ); - - await ews.Folder.Bind(service, ews.WellKnownFolderName.Calendar); - return true; + const res = await this.ewsNtlmPost(source, soap, 'GetFolder'); + return res.statusCode === 200 && !res.body.includes('ResponseClass="Error"'); } } diff --git a/apps/web/src/components/settings/calendar-settings-panel.tsx b/apps/web/src/components/settings/calendar-settings-panel.tsx index caf6e41..22b7eed 100644 --- a/apps/web/src/components/settings/calendar-settings-panel.tsx +++ b/apps/web/src/components/settings/calendar-settings-panel.tsx @@ -39,6 +39,7 @@ export function CalendarSettingsPanel() { const [isSaving, setIsSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [editSaveError, setEditSaveError] = useState(null); + const [editSaveSuccess, setEditSaveSuccess] = useState(false); const [testResults, setTestResults] = useState>({}); // Load sources on mount @@ -234,9 +235,11 @@ export function CalendarSettingsPanel() {