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:
@@ -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, '&').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<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) => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user