diff --git a/.planning/STATE.md b/.planning/STATE.md index 566323c..8562e00 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17) Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden) Plan: 6 of 6 Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen -Last activity: 2026-10-05 - Quick 261005-bt1 Proxmox Autostart-Warnung +Last activity: 2026-10-05 - Quick 261005-d5d Kalender-Test Zeitgrenze Progress: [██████████] 99% @@ -493,7 +493,8 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests. | 261002-kxc | Nextcloud-Status: Benachrichtigung bei Rot je Benutzer (Mail + Desktop-Hinweis), Klartext-Fehler | 2026-10-02 | faed0d7..6c4bff6 | [261002-kxc-nextcloud-status-benachrichtigung-bei-ro](.planning/quick/261002-kxc-nextcloud-status-benachrichtigung-bei-ro/) | | 261003-387 | Kategorien durch Admins bearbeitbar (anlegen, umbenennen, sortieren, loeschen mit Verschieben, Module zuordnen) | 2026-10-03 | 8ec116c..f2c0a89 | [261003-387-kategorien-durch-admins-bearbeitbar-umbe](.planning/quick/261003-387-kategorien-durch-admins-bearbeitbar-umbe/) | | 261005-blw | Links in Notiz-Kacheln oeffnen in neuem Tab | 2026-10-05 | 781bc9f | [261005-blw-notiz-links-in-neuem-tab](.planning/quick/261005-blw-notiz-links-in-neuem-tab/) | -| 261005-bt1 | Proxmox: Warnung je gestopptem Gast mit aktivem Autostart (Karte, Kachel) | 2026-10-05 | (dieser Commit) | [261005-bt1-proxmox-autostart-warnung](.planning/quick/261005-bt1-proxmox-autostart-warnung/) | +| 261005-bt1 | Proxmox: Warnung je gestopptem Gast mit aktivem Autostart (Karte, Kachel) | 2026-10-05 | 873d15d | [261005-bt1-proxmox-autostart-warnung](.planning/quick/261005-bt1-proxmox-autostart-warnung/) | +| 261005-d5d | Kalender-Test: 15-s-Zeitgrenze fuer Exchange (EWS), Meldung „nicht erreichbar“ | 2026-10-05 | (dieser Commit) | [261005-d5d-kalender-test-zeitgrenze](.planning/quick/261005-d5d-kalender-test-zeitgrenze/) | ## Deferred Items diff --git a/.planning/quick/261005-d5d-kalender-test-zeitgrenze/261005-d5d-PLAN.md b/.planning/quick/261005-d5d-kalender-test-zeitgrenze/261005-d5d-PLAN.md new file mode 100644 index 0000000..129607e --- /dev/null +++ b/.planning/quick/261005-d5d-kalender-test-zeitgrenze/261005-d5d-PLAN.md @@ -0,0 +1,16 @@ +--- +quick_id: 261005-d5d +slug: kalender-test-zeitgrenze +date: 2026-10-05 +--- + +# Kalender-Test: Zeitgrenze + Meldung „nicht erreichbar“ + +Anlass 05.10.: Exchange auf alpha/live nicht erreichbar (interner DNS -> 172.16.0.3), „Verbindung testen“ hing ~2 min auf „wird geprueft“, danach „Adresse und Zugangsdaten pruefen“. + +## Task 1 — API +- EWS-Aufrufe (httpntlm) mit 15 s Zeitgrenze. +- Netzfehler (TIMEOUT, ETIMEDOUT, ECONNREFUSED, ...) im Test als `CalendarSourceUnreachableError`; Service liefert Schluessel `unreachable`. + +## Task 2 — Web +- Formular zeigt bei `unreachable` eigene Meldung (de/en). diff --git a/.planning/quick/261005-d5d-kalender-test-zeitgrenze/261005-d5d-SUMMARY.md b/.planning/quick/261005-d5d-kalender-test-zeitgrenze/261005-d5d-SUMMARY.md new file mode 100644 index 0000000..dca2ba0 --- /dev/null +++ b/.planning/quick/261005-d5d-kalender-test-zeitgrenze/261005-d5d-SUMMARY.md @@ -0,0 +1,13 @@ +--- +quick_id: 261005-d5d +status: complete +date: 2026-10-05 +--- + +# 261005-d5d: Kalender-Test Zeitgrenze — SUMMARY + +- `ntlmPost` (exchange.provider.ts): eigene 15-s-Zeitgrenze per Timer, zusaetzlich `timeout` an httpntlm. httpreqs `timeout` allein greift NICHT waehrend des Verbindungsaufbaus ueber den Keep-alive-Agenten — echter Lauf gegen 172.16.0.3 dauerte damit noch 134,5 s; erst der eigene Timer brachte 15,0 s. Unit-Test haette das nicht gefunden (Stub), deshalb echter Lauf im lokalen Container. +- `CalendarSourceUnreachableError` + `isNetworkUnreachableError` (calendar.service.ts); `testConnection`/`testConnectionFromConfig` liefern `error: 'unreachable'`, lastSyncError „Server not reachable“. +- Formular: `formTestUnreachable` statt `formTestFailed`. +- Nachweis (lokaler Container, kompilierter Provider): 172.16.0.3 -> nicht erreichbar nach 15,0 s; owa.ctl.de mit falschem Passwort -> erreichbar/abgelehnt 0,0 s. Tests api 2219+, web 1372 gruen. +- Gleiches Muster steckt in `inbox/exchange-inbox.provider.ts` (eigenes httpntlm ohne Zeitgrenze) — nicht angefasst. diff --git a/CHANGELOG.md b/CHANGELOG.md index c70648b..e20bfa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T ### Behoben +- Kalender (Exchange): Ist der Exchange-Server nicht erreichbar, bricht „Verbindung testen“ jetzt nach 15 Sekunden ab, statt rund zwei Minuten auf „Verbindung wird geprüft …“ zu stehen, und meldet „Der Kalender-Server ist nicht erreichbar“ statt des Hinweises auf Adresse und Zugangsdaten. Auch der regelmäßige Abruf im Hintergrund wartet nicht mehr länger als 15 Sekunden. - Links in Notiz-Kacheln öffnen jetzt immer in einem neuen Tab, statt das Dashboard im selben Fenster zu verlassen. In der Desktop-App öffnen sie wie gewohnt im Browser. ## 1.9.2 – 2026-10-02 diff --git a/apps/api/src/calendar/calendar.service.spec.ts b/apps/api/src/calendar/calendar.service.spec.ts index ea8af18..4c6beff 100644 --- a/apps/api/src/calendar/calendar.service.spec.ts +++ b/apps/api/src/calendar/calendar.service.spec.ts @@ -27,7 +27,7 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({ })); import { forTenant } from '../prisma/prisma-tenant.extension'; -import { CalendarService } from './calendar.service'; +import { CalendarService, CalendarSourceUnreachableError } from './calendar.service'; function _applySelect(row: any, select: Record | undefined) { if (!select) return { ...row }; @@ -533,6 +533,45 @@ describe('CalendarService — Bindung an forTenant() (260911-cwh)', () => { expect(vi.mocked(prisma.calendarSource.create)).not.toHaveBeenCalled(); }); + // ─── quick-261005: Kalender-Server nicht erreichbar ────────────────────── + + it('testConnectionFromConfig: nicht erreichbarer Server liefert den Schluessel "unreachable"', async () => { + const prisma = makeFakePrisma(); + const exchange = { + fetchEvents: vi.fn(async () => []), + testConnection: vi.fn(async () => { + throw new CalendarSourceUnreachableError(); + }), + }; + const { service } = makeCalendarService(prisma, { exchangeProvider: exchange }); + + const result = await service.testConnectionFromConfig({ + type: 'exchange', + url: 'https://owa.example.invalid/EWS/Exchange.asmx', + exchangeMode: 'ews', + } as any); + + expect(result).toEqual({ success: false, error: 'unreachable' }); + }); + + it('testConnection (gespeicherte Quelle): nicht erreichbar -> "unreachable", lastSyncError ohne Details', async () => { + const prisma = makeFakePrisma(); + prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', type: 'exchange' }); + const exchange = { + fetchEvents: vi.fn(async () => []), + testConnection: vi.fn(async () => { + throw new CalendarSourceUnreachableError(); + }), + }; + const { service } = makeCalendarService(prisma, { exchangeProvider: exchange }); + + const result = await service.testConnection('src-a1', 'user-a1', 't1'); + + expect(result).toEqual({ success: false, error: 'unreachable' }); + const updateCall = vi.mocked(prisma.calendarSource.update).mock.calls.at(-1)?.[0] as any; + expect(updateCall.data).toEqual({ lastSyncError: 'Server not reachable' }); + }); + // ─── Wachhund ──────────────────────────────────────────────────────── it('keine Methode dieses Bereichs erzeugt mehr als EINEN gebundenen Klienten je Aufruf', async () => { diff --git a/apps/api/src/calendar/calendar.service.ts b/apps/api/src/calendar/calendar.service.ts index 825b080..e5ab225 100644 --- a/apps/api/src/calendar/calendar.service.ts +++ b/apps/api/src/calendar/calendar.service.ts @@ -30,6 +30,43 @@ export interface CalendarEvent { color?: string; } +/** + * quick-261005: Ein Provider wirft diesen Fehler aus `testConnection`, wenn + * der Kalender-Server gar nicht erreichbar ist (Zeitueberschreitung, + * abgewiesene Verbindung, Name unbekannt) — im Unterschied zu „erreichbar, + * aber Anmeldung abgelehnt“ (`false`). Traegt bewusst keine Details (T-05-13). + */ +export class CalendarSourceUnreachableError extends Error { + constructor() { + super('Calendar server not reachable'); + this.name = 'CalendarSourceUnreachableError'; + } +} + +/** Fehlerkennungen von Node/httpreq, die „Server nicht erreichbar“ bedeuten. */ +const NETWORK_UNREACHABLE_CODES = new Set([ + 'TIMEOUT', // httpreq bei Ablauf von `timeout` + 'ETIMEDOUT', + 'ECONNREFUSED', + 'ECONNRESET', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'ENOTFOUND', + 'EAI_AGAIN', +]); + +export function isNetworkUnreachableError(error: unknown): boolean { + const code = (error as { code?: unknown; cause?: { code?: unknown } } | null)?.code; + const causeCode = (error as { cause?: { code?: unknown } } | null)?.cause?.code; + return ( + (typeof code === 'string' && NETWORK_UNREACHABLE_CODES.has(code)) || + (typeof causeCode === 'string' && NETWORK_UNREACHABLE_CODES.has(causeCode)) + ); +} + +/** Stabiler Fehlerschluessel fuer die Oberflaeche (dort uebersetzt). */ +export const CALENDAR_TEST_UNREACHABLE = 'unreachable'; + /** * Provider interface for calendar source integrations. * Each provider (ICS, CalDAV, Exchange) implements this contract. @@ -319,13 +356,15 @@ export class CalendarService { }); return { success }; - } catch { - const errorMsg = 'Connection failed'; // T-05-13: generic error, no credentials + } catch (error) { + const unreachable = error instanceof CalendarSourceUnreachableError; + // T-05-13: generic error, no credentials + const errorMsg = unreachable ? 'Server not reachable' : 'Connection failed'; await tenantPrisma.calendarSource.update({ where: { id }, data: { lastSyncError: errorMsg }, }); - return { success: false, error: errorMsg }; + return { success: false, error: unreachable ? CALENDAR_TEST_UNREACHABLE : errorMsg }; } } @@ -364,7 +403,10 @@ export class CalendarService { try { const success = await provider.testConnection(tempSource); return { success }; - } catch { + } catch (error) { + if (error instanceof CalendarSourceUnreachableError) { + return { success: false, error: CALENDAR_TEST_UNREACHABLE }; + } return { success: false, error: 'Connection failed' }; } } diff --git a/apps/api/src/calendar/providers/exchange.provider.spec.ts b/apps/api/src/calendar/providers/exchange.provider.spec.ts new file mode 100644 index 0000000..6133f81 --- /dev/null +++ b/apps/api/src/calendar/providers/exchange.provider.spec.ts @@ -0,0 +1,110 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CalendarSourceUnreachableError, isNetworkUnreachableError } from '../calendar.service'; +import type { ExchangeProvider as ExchangeProviderType } from './exchange.provider'; + +/** + * ExchangeProvider.testConnection (quick-261005): Zeitgrenze fuer EWS und + * getrennte Meldung „nicht erreichbar“. + * + * httpntlm wird per CommonJS-`require` geladen — `vi.mock` greift dort nicht. + * Wie in `inbox/exchange-inbox.provider.spec.ts` wird deshalb Nodes + * `require.cache` vor dem ersten Laden des Providers mit einem Stub belegt. + */ + +const httpntlmPath = require.resolve('httpntlm'); + +const httpntlmPost = vi.fn((_opts: any, cb: (err: Error | null, res: any) => void) => { + cb(new Error('httpntlmPost not configured for this test'), null); +}); + +require.cache[httpntlmPath] = { + id: httpntlmPath, + filename: httpntlmPath, + loaded: true, + exports: { post: httpntlmPost }, +} as any; + +let ExchangeProvider: typeof ExchangeProviderType; + +beforeAll(async () => { + ({ ExchangeProvider } = await import('./exchange.provider')); +}); + +beforeEach(() => { + httpntlmPost.mockReset(); +}); + +const SOURCE = { + id: 'src-1', + url: 'https://owa.example.invalid/EWS/Exchange.asmx', + username: 'kalender', + password: 'geheim', + domain: 'CONTOSO', + exchangeMode: 'ews', +}; + +function failWith(code: string) { + httpntlmPost.mockImplementation((_opts, cb) => { + const err = Object.assign(new Error(`fail ${code}`), { code }); + cb(err, null); + }); +} + +describe('ExchangeProvider.testConnection (EWS)', () => { + it('uebergibt httpntlm eine Zeitgrenze von 15 Sekunden', async () => { + httpntlmPost.mockImplementation((_opts, cb) => cb(null, { statusCode: 200, body: '' })); + + await new ExchangeProvider().testConnection(SOURCE); + + expect(httpntlmPost).toHaveBeenCalled(); + expect(httpntlmPost.mock.calls[0][0].timeout).toBe(15_000); + }); + + it.each([ + 'TIMEOUT', + 'ETIMEDOUT', + 'ECONNREFUSED', + 'ENOTFOUND', + 'EHOSTUNREACH', + ])('Netzfehler %s -> CalendarSourceUnreachableError', async (code) => { + failWith(code); + await expect(new ExchangeProvider().testConnection(SOURCE)).rejects.toBeInstanceOf( + CalendarSourceUnreachableError, + ); + }); + + it('keine Rueckmeldung von httpntlm (Verbindungsaufbau haengt) -> nach 15 s nicht erreichbar', async () => { + vi.useFakeTimers(); + try { + httpntlmPost.mockImplementation(() => { + /* ruft nie zurueck — wie ein unbeantworteter Verbindungsaufbau */ + }); + const pending = new ExchangeProvider().testConnection(SOURCE); + const assertion = expect(pending).rejects.toBeInstanceOf(CalendarSourceUnreachableError); + await vi.advanceTimersByTimeAsync(14_999); + await vi.advanceTimersByTimeAsync(1); + await assertion; + } finally { + vi.useRealTimers(); + } + }); + + it('erreichbar, aber Anmeldung abgelehnt (401) -> false, kein Wurf', async () => { + httpntlmPost.mockImplementation((_opts, cb) => cb(null, { statusCode: 401, body: '' })); + await expect(new ExchangeProvider().testConnection(SOURCE)).resolves.toBe(false); + }); + + it('sonstiger Fehler ohne Netzkennung -> false', async () => { + httpntlmPost.mockImplementation((_opts, cb) => cb(new Error('kaputt'), null)); + await expect(new ExchangeProvider().testConnection(SOURCE)).resolves.toBe(false); + }); +}); + +describe('isNetworkUnreachableError', () => { + it('erkennt Kennung direkt und in cause, sonst nicht', () => { + expect(isNetworkUnreachableError({ code: 'ETIMEDOUT' })).toBe(true); + expect(isNetworkUnreachableError({ cause: { code: 'ECONNREFUSED' } })).toBe(true); + expect(isNetworkUnreachableError(new Error('x'))).toBe(false); + expect(isNetworkUnreachableError(null)).toBe(false); + }); +}); diff --git a/apps/api/src/calendar/providers/exchange.provider.ts b/apps/api/src/calendar/providers/exchange.provider.ts index 95ebcc0..ce2adc6 100644 --- a/apps/api/src/calendar/providers/exchange.provider.ts +++ b/apps/api/src/calendar/providers/exchange.provider.ts @@ -1,6 +1,11 @@ import { Injectable, Logger } from '@nestjs/common'; import type { AuthProviderCallback } from '@microsoft/microsoft-graph-client'; -import { CalendarEvent, CalendarProvider } from '../calendar.service'; +import { + CalendarEvent, + CalendarProvider, + CalendarSourceUnreachableError, + isNetworkUnreachableError, +} from '../calendar.service'; /** Optionen, die ntlmPost() unten uebergibt — nichts darueber hinaus. */ interface NtlmOptions { @@ -11,6 +16,8 @@ interface NtlmOptions { workstation: string; body: string; headers: Record; + /** Millisekunden bis zum Abbruch mit `code: 'TIMEOUT'` (siehe ntlmPost). */ + timeout: number; } /** @@ -31,6 +38,13 @@ const httpntlm = require('httpntlm') as { post: (opts: NtlmOptions, cb: (err: Error | null, res: NtlmResponse) => void) => void; }; +/** + * quick-261005: Ohne Grenze wartete ein EWS-Aufruf auf eine nicht + * erreichbare Adresse rund zwei Minuten (TCP-Verbindungsaufbau des + * Betriebssystems), der Test-Knopf hing so lange auf „wird geprueft“. + */ +const EWS_TIMEOUT_MS = 15_000; + 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'; @@ -74,7 +88,15 @@ function extractAttr(xml: string, tag: string, attr: string): string { function ntlmPost(opts: NtlmOptions): Promise<{ statusCode: number; body: string }> { return new Promise((resolve, reject) => { + // Eigene Zeitgrenze zusaetzlich zu `opts.timeout`: httpreq setzt seine + // nur als Leerlaufgrenze am Socket, die waehrend des Verbindungsaufbaus + // ueber den Keep-alive-Agenten von httpntlm NICHT greift — gemessen + // 05.10.: 134 s bis zum Fehler trotz `timeout: 15000`. + const timer = setTimeout(() => { + reject(Object.assign(new Error('EWS request timed out'), { code: 'TIMEOUT' })); + }, opts.timeout); httpntlm.post(opts, (err, res) => { + clearTimeout(timer); if (err) return reject(err); resolve({ statusCode: res.statusCode, @@ -152,7 +174,10 @@ export class ExchangeProvider implements CalendarProvider { } else { return await this.testEwsConnection(source); } - } catch { + } catch (error) { + // quick-261005: „nicht erreichbar“ getrennt melden, damit die + // Oberflaeche nicht „Zugangsdaten pruefen“ sagt, wenn das Netz fehlt. + if (isNetworkUnreachableError(error)) throw new CalendarSourceUnreachableError(); return false; } } @@ -316,6 +341,7 @@ export class ExchangeProvider implements CalendarProvider { domain: source.domain ?? '', workstation: '', body: soap, + timeout: EWS_TIMEOUT_MS, headers: { 'Content-Type': 'text/xml; charset=utf-8', 'SOAPAction': `"http://schemas.microsoft.com/exchange/services/2006/messages/${action}"`, diff --git a/apps/web/src/components/settings/calendar-source-form.test.tsx b/apps/web/src/components/settings/calendar-source-form.test.tsx index 11303b9..a419a13 100644 --- a/apps/web/src/components/settings/calendar-source-form.test.tsx +++ b/apps/web/src/components/settings/calendar-source-form.test.tsx @@ -1,4 +1,5 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { testSourceConfig } from '@/lib/calendar-api'; import { afterEach, describe, expect, it, vi } from 'vitest'; import de from '@/messages/de.json'; import { CalendarSourceForm } from './calendar-source-form'; @@ -111,3 +112,29 @@ describe('CalendarSourceForm — URL-Platzhalter je Typ + EWS-Hinweis (quick-260 expect(fehler.compareDocumentPosition(hinweis) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); }); + +describe('CalendarSourceForm — Verbindungstest: nicht erreichbar (quick-261005)', () => { + function fillAndTest() { + const { urlInput, typeSelect } = renderForm(); + fireEvent.change(screen.getByLabelText(/^Name/), { target: { value: 'Firma' } }); + fireEvent.change(typeSelect, { target: { value: 'exchange' } }); + fireEvent.change(urlInput, { target: { value: 'https://owa.example.invalid/EWS/Exchange.asmx' } }); + fireEvent.click(screen.getByTestId('source-form-test')); + } + + it('Schluessel "unreachable" zeigt die Nicht-erreichbar-Meldung statt „Zugangsdaten pruefen“', async () => { + vi.mocked(testSourceConfig).mockResolvedValueOnce({ success: false, error: 'unreachable' }); + fillAndTest(); + + expect(await screen.findByText(t.formTestUnreachable)).toBeInTheDocument(); + expect(screen.queryByText(t.formTestFailed)).not.toBeInTheDocument(); + }); + + it('sonstiger Fehlschlag zeigt weiter die bisherige Meldung', async () => { + vi.mocked(testSourceConfig).mockResolvedValueOnce({ success: false, error: 'Connection failed' }); + fillAndTest(); + + expect(await screen.findByText(t.formTestFailed)).toBeInTheDocument(); + expect(screen.queryByText(t.formTestUnreachable)).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/settings/calendar-source-form.tsx b/apps/web/src/components/settings/calendar-source-form.tsx index 7c72ba9..82c8764 100644 --- a/apps/web/src/components/settings/calendar-source-form.tsx +++ b/apps/web/src/components/settings/calendar-source-form.tsx @@ -58,6 +58,16 @@ interface CalendarSourceFormProps { * (CalDAV / ICS / Exchange Graph / Exchange EWS), and Exchange EWS shows a * hint below the field explaining that the full EWS address is required. */ +/** + * quick-261005: „nicht erreichbar“ (Schluessel `unreachable` vom Server) + * getrennt von „Anmeldung fehlgeschlagen“ — sonst schickt die Meldung den + * Benutzer zu den Zugangsdaten, obwohl das Netz fehlt. + */ +function testOutcome(result: { success: boolean; error?: string }): 'success' | 'error' | 'unreachable' { + if (result.success) return 'success'; + return result.error === 'unreachable' ? 'unreachable' : 'error'; +} + export function CalendarSourceForm({ onSave, onCancel, @@ -75,7 +85,9 @@ export function CalendarSourceForm({ initialValues?.exchangeMode ?? 'graph', ); const [domain, setDomain] = useState(initialValues?.domain ?? ''); - const [testStatus, setTestStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); + const [testStatus, setTestStatus] = useState< + 'idle' | 'loading' | 'success' | 'error' | 'unreachable' + >('idle'); const [color, setColor] = useState( initialValues?.color ?? SOURCE_COLOR_PALETTE[0].value, ); @@ -127,7 +139,7 @@ export function CalendarSourceForm({ // 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'); + setTestStatus(testOutcome(result)); return; } const result = await testSourceConfig({ @@ -138,7 +150,7 @@ export function CalendarSourceForm({ exchangeMode: isExchange ? exchangeMode : undefined, domain: isExchange && domain.trim() ? domain.trim() : undefined, }); - setTestStatus(result.success ? 'success' : 'error'); + setTestStatus(testOutcome(result)); } catch { setTestStatus('error'); } @@ -382,6 +394,9 @@ export function CalendarSourceForm({ {testStatus === 'error' && (

{t('calendar.formTestFailed')}

)} + {testStatus === 'unreachable' && ( +

{t('calendar.formTestUnreachable')}

+ )} ); } diff --git a/apps/web/src/lib/calendar-api.ts b/apps/web/src/lib/calendar-api.ts index 2f5d347..1240e63 100644 --- a/apps/web/src/lib/calendar-api.ts +++ b/apps/web/src/lib/calendar-api.ts @@ -170,7 +170,7 @@ export async function testSourceConfig(config: { */ export async function testSource( id: string, -): Promise<{ success: boolean }> { +): Promise<{ success: boolean; error?: string }> { const res = await fetch(`${API_URL}/calendar/sources/${id}/test`, { method: 'POST', credentials: 'include', diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 59af7ae..841c6e3 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -332,6 +332,7 @@ "formTesting": "Verbindung wird geprüft …", "formTestSuccess": "Verbindung erfolgreich.", "formTestFailed": "Verbindung fehlgeschlagen. Bitte prüfen Sie Adresse und Zugangsdaten.", + "formTestUnreachable": "Der Kalender-Server ist nicht erreichbar. Bitte prüfen Sie die Adresse und ob der Server vom Tessera-Server aus erreichbar ist.", "formCancel": "Abbrechen", "formUrlErrorHttps": "Die Adresse muss mit https:// beginnen.", "formUrlErrorInvalid": "Bitte geben Sie eine gültige Adresse ein.", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index f0d60ab..91e37b3 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -332,6 +332,7 @@ "formTesting": "Checking connection …", "formTestSuccess": "Connection successful.", "formTestFailed": "Connection failed. Please check the address and credentials.", + "formTestUnreachable": "The calendar server is not reachable. Please check the address and whether the server can be reached from the Tessera server.", "formCancel": "Cancel", "formUrlErrorHttps": "The address must start with https://.", "formUrlErrorInvalid": "Please enter a valid address.",