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 = `${tag}>`;
+ 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 = `${tag}>`;
+ 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() {
)}
+ {editSaveSuccess && (
+ {t('calendar.saveSuccess') || 'Gespeichert'}
+ )}
Edit Source
@@ -310,12 +316,13 @@ export function CalendarSettingsPanel() {
onSave={async (payload) => {
setIsSaving(true);
setEditSaveError(null);
+ setEditSaveSuccess(false);
try {
const updated = await updateSource(editingId, payload);
setSources((prev) =>
prev.map((s) => (s.id === editingId ? updated : s)),
);
- setEditingId(null);
+ setEditSaveSuccess(true);
} catch {
setEditSaveError(t('calendar.saveError') || 'Fehler beim Speichern');
} finally {
diff --git a/apps/web/src/components/settings/calendar-source-form.tsx b/apps/web/src/components/settings/calendar-source-form.tsx
index 26e9d21..255e469 100644
--- a/apps/web/src/components/settings/calendar-source-form.tsx
+++ b/apps/web/src/components/settings/calendar-source-form.tsx
@@ -3,7 +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';
+import { testSourceConfig, testSource } from '@/lib/calendar-api';
/**
* 8-color palette for calendar sources (UI-SPEC).
@@ -110,6 +110,12 @@ export function CalendarSourceForm({
if (!validateUrl(url)) return;
setTestStatus('loading');
try {
+ // In edit mode with no new password entered, use saved credentials via the source endpoint
+ if (initialValues?.id && !password) {
+ const result = await testSource(initialValues.id);
+ setTestStatus(result.success ? 'success' : 'error');
+ return;
+ }
const result = await testSourceConfig({
type,
url: url.trim(),
@@ -122,7 +128,7 @@ export function CalendarSourceForm({
} catch {
setTestStatus('error');
}
- }, [type, url, username, password, exchangeMode, domain, isExchange, validateUrl]);
+ }, [initialValues?.id, type, url, username, password, exchangeMode, domain, isExchange, validateUrl]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json
index 0f634e4..999dddb 100644
--- a/apps/web/src/messages/de.json
+++ b/apps/web/src/messages/de.json
@@ -209,6 +209,7 @@
"formTestSuccess": "Verbindung erfolgreich",
"formTestFailed": "Verbindung fehlgeschlagen",
"saveError": "Speichern fehlgeschlagen. Bitte Eingaben prüfen.",
+ "saveSuccess": "Erfolgreich gespeichert",
"formSave": "Speichern",
"formSaving": "Wird gespeichert...",
"formCancel": "Abbrechen",
diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json
index 8acb6ed..6c7485e 100644
--- a/apps/web/src/messages/en.json
+++ b/apps/web/src/messages/en.json
@@ -209,6 +209,7 @@
"formTestSuccess": "Connection successful",
"formTestFailed": "Connection failed",
"saveError": "Save failed. Please check your input.",
+ "saveSuccess": "Saved successfully",
"formSave": "Save",
"formSaving": "Saving...",
"formCancel": "Cancel",