fix(calendar): EWS uses NTLM auth + edit form stays open after save

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 14:05:07 +02:00
parent 51d8c2f14e
commit e35276243a
5 changed files with 170 additions and 46 deletions
@@ -1,6 +1,65 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { CalendarEvent, CalendarProvider } from '../calendar.service'; 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 `<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="${NS_SOAP}" xmlns:t="${NS_TYPES}" xmlns:m="${NS_MESSAGES}">
<soap:Body>${body}</soap:Body>
</soap:Envelope>`;
}
function escapeXml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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<string, string>;
}): 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'). * 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). * Fetches calendar events via EWS using NTLM authentication (on-premise Exchange).
* Uses ews-javascript-api with FindAppointments over a CalendarView. * Uses raw SOAP + httpntlm — replaces ews-javascript-api which only supports Basic Auth.
*
* Note: ews-javascript-api has no TypeScript definitions;
* we use dynamic import + any casting for type safety.
*/ */
private async fetchViaEws( private async fetchViaEws(
source: { source: {
@@ -153,36 +209,54 @@ export class ExchangeProvider implements CalendarProvider {
from: Date, from: Date,
to: Date, to: Date,
): Promise<CalendarEvent[]> { ): Promise<CalendarEvent[]> {
// Dynamic import — ews-javascript-api is JS-only, no .d.ts const fromIso = from.toISOString();
const ews: any = await import('ews-javascript-api'); const toIso = to.toISOString();
const service = new ews.ExchangeService(ews.ExchangeVersion.Exchange2013); const findSoap = soapEnvelope(`
service.Url = new ews.Uri(source.url); <m:FindItem Traversal="Shallow">
service.Credentials = new ews.WebCredentials( <m:ItemShape>
source.username || '', <t:BaseShape>IdOnly</t:BaseShape>
source.password || '', <t:AdditionalProperties>
source.domain || undefined, <t:FieldURI FieldURI="item:Subject"/>
); <t:FieldURI FieldURI="calendar:Start"/>
<t:FieldURI FieldURI="calendar:End"/>
<t:FieldURI FieldURI="calendar:IsAllDayEvent"/>
<t:FieldURI FieldURI="calendar:Location"/>
</t:AdditionalProperties>
</m:ItemShape>
<m:CalendarView StartDate="${escapeXml(fromIso)}" EndDate="${escapeXml(toIso)}" MaxEntriesReturned="100"/>
<m:ParentFolderIds>
<t:DistinguishedFolderId Id="calendar"/>
</m:ParentFolderIds>
</m:FindItem>`);
// CalendarView constructor accepts JS Dates in ews-javascript-api const res = await this.ewsNtlmPost(source, findSoap, 'FindItem');
const calendarView = new ews.CalendarView(from, to, 100);
const findResults = await service.FindAppointments( if (res.statusCode !== 200) {
ews.WellKnownFolderName.Calendar, this.logger.warn(`EWS FindItem calendar returned HTTP ${res.statusCode}`);
calendarView, return [];
); }
const events: CalendarEvent[] = []; 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({ events.push({
id: `${source.id}-${appointment.Id?.UniqueId || String(Date.now())}`, id: `${source.id}-${uid || String(Date.now())}`,
sourceId: source.id, sourceId: source.id,
title: appointment.Subject || 'Untitled', title,
start: appointment.Start ? new Date(String(appointment.Start)) : new Date(), start: startStr ? new Date(startStr) : new Date(),
end: appointment.End ? new Date(String(appointment.End)) : new Date(), end: endStr ? new Date(endStr) : new Date(),
allDay: appointment.IsAllDayEvent || false, allDay: allDayStr === 'true',
location: appointment.Location || undefined, location: location || undefined,
description: undefined, // Body requires separate load call description: undefined,
color: source.color ?? undefined, color: source.color ?? undefined,
}); });
} }
@@ -190,6 +264,41 @@ export class ExchangeProvider implements CalendarProvider {
return events; 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. * 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( private async testEwsConnection(
source: { url: string; username?: string; password?: string; domain?: string }, source: { url: string; username?: string; password?: string; domain?: string },
): Promise<boolean> { ): Promise<boolean> {
const ews: any = await import('ews-javascript-api'); const soap = soapEnvelope(`
<m:GetFolder>
<m:FolderShape>
<t:BaseShape>IdOnly</t:BaseShape>
</m:FolderShape>
<m:FolderIds>
<t:DistinguishedFolderId Id="calendar"/>
</m:FolderIds>
</m:GetFolder>`);
const service = new ews.ExchangeService(ews.ExchangeVersion.Exchange2013); const res = await this.ewsNtlmPost(source, soap, 'GetFolder');
service.Url = new ews.Uri(source.url); return res.statusCode === 200 && !res.body.includes('ResponseClass="Error"');
service.Credentials = new ews.WebCredentials(
source.username || '',
source.password || '',
source.domain || undefined,
);
await ews.Folder.Bind(service, ews.WellKnownFolderName.Calendar);
return true;
} }
} }
@@ -39,6 +39,7 @@ export function CalendarSettingsPanel() {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const [editSaveError, setEditSaveError] = useState<string | null>(null); const [editSaveError, setEditSaveError] = useState<string | null>(null);
const [editSaveSuccess, setEditSaveSuccess] = useState(false);
const [testResults, setTestResults] = useState<Record<string, 'success' | 'error' | 'testing'>>({}); const [testResults, setTestResults] = useState<Record<string, 'success' | 'error' | 'testing'>>({});
// Load sources on mount // Load sources on mount
@@ -234,9 +235,11 @@ export function CalendarSettingsPanel() {
<button <button
type="button" type="button"
className="text-muted-foreground hover:text-foreground transition-colors" className="text-muted-foreground hover:text-foreground transition-colors"
onClick={() => onClick={() => {
setEditingId(editingId === source.id ? null : source.id) setEditingId(editingId === source.id ? null : source.id);
} setEditSaveSuccess(false);
setEditSaveError(null);
}}
aria-label={`Edit ${source.name}`} aria-label={`Edit ${source.name}`}
> >
<svg <svg
@@ -288,6 +291,9 @@ export function CalendarSettingsPanel() {
{editSaveError && ( {editSaveError && (
<p className="mb-3 text-sm text-destructive">{editSaveError}</p> <p className="mb-3 text-sm text-destructive">{editSaveError}</p>
)} )}
{editSaveSuccess && (
<p className="mb-3 text-sm text-green-600 dark:text-green-400">{t('calendar.saveSuccess') || 'Gespeichert'}</p>
)}
<h3 className="mb-3 text-sm font-semibold text-foreground"> <h3 className="mb-3 text-sm font-semibold text-foreground">
Edit Source Edit Source
</h3> </h3>
@@ -310,12 +316,13 @@ export function CalendarSettingsPanel() {
onSave={async (payload) => { onSave={async (payload) => {
setIsSaving(true); setIsSaving(true);
setEditSaveError(null); setEditSaveError(null);
setEditSaveSuccess(false);
try { try {
const updated = await updateSource(editingId, payload); const updated = await updateSource(editingId, payload);
setSources((prev) => setSources((prev) =>
prev.map((s) => (s.id === editingId ? updated : s)), prev.map((s) => (s.id === editingId ? updated : s)),
); );
setEditingId(null); setEditSaveSuccess(true);
} catch { } catch {
setEditSaveError(t('calendar.saveError') || 'Fehler beim Speichern'); setEditSaveError(t('calendar.saveError') || 'Fehler beim Speichern');
} finally { } finally {
@@ -3,7 +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'; import { testSourceConfig, testSource } from '@/lib/calendar-api';
/** /**
* 8-color palette for calendar sources (UI-SPEC). * 8-color palette for calendar sources (UI-SPEC).
@@ -110,6 +110,12 @@ export function CalendarSourceForm({
if (!validateUrl(url)) return; if (!validateUrl(url)) return;
setTestStatus('loading'); setTestStatus('loading');
try { 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({ const result = await testSourceConfig({
type, type,
url: url.trim(), url: url.trim(),
@@ -122,7 +128,7 @@ export function CalendarSourceForm({
} catch { } catch {
setTestStatus('error'); setTestStatus('error');
} }
}, [type, url, username, password, exchangeMode, domain, isExchange, validateUrl]); }, [initialValues?.id, type, url, username, password, exchangeMode, domain, isExchange, validateUrl]);
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (e: React.FormEvent) => { async (e: React.FormEvent) => {
+1
View File
@@ -209,6 +209,7 @@
"formTestSuccess": "Verbindung erfolgreich", "formTestSuccess": "Verbindung erfolgreich",
"formTestFailed": "Verbindung fehlgeschlagen", "formTestFailed": "Verbindung fehlgeschlagen",
"saveError": "Speichern fehlgeschlagen. Bitte Eingaben prüfen.", "saveError": "Speichern fehlgeschlagen. Bitte Eingaben prüfen.",
"saveSuccess": "Erfolgreich gespeichert",
"formSave": "Speichern", "formSave": "Speichern",
"formSaving": "Wird gespeichert...", "formSaving": "Wird gespeichert...",
"formCancel": "Abbrechen", "formCancel": "Abbrechen",
+1
View File
@@ -209,6 +209,7 @@
"formTestSuccess": "Connection successful", "formTestSuccess": "Connection successful",
"formTestFailed": "Connection failed", "formTestFailed": "Connection failed",
"saveError": "Save failed. Please check your input.", "saveError": "Save failed. Please check your input.",
"saveSuccess": "Saved successfully",
"formSave": "Save", "formSave": "Save",
"formSaving": "Saving...", "formSaving": "Saving...",
"formCancel": "Cancel", "formCancel": "Cancel",