feat(calendar): add domain field and test-connection button to Exchange sources

- Prisma: domain String? added to CalendarSource model (db push applied)
- DTOs: domain in CreateCalendarSourceDto, UpdateCalendarSourceDto, new TestCalendarSourceConfigDto
- Service: domain in SOURCE_SAFE_SELECT, addSource, updateSource; new testConnectionFromConfig method
- Controller: POST /calendar/sources/test-config (before :id routes to avoid collision)
- ExchangeProvider: domain in all source interfaces; passed as 3rd arg to EWS WebCredentials
- Frontend: domain in CalendarSource/CreateSourcePayload/UpdateSourcePayload; testSourceConfig API fn
- Form: domain field (Exchange-only), "Test connection" button with idle/loading/success/error states
- i18n: de+en keys for formFieldDomain, formFieldDomainHint, formTestConnection, formTesting, formTestSuccess, formTestFailed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 13:15:13 +02:00
parent 2e0e7290ba
commit 42daa87e5e
12 changed files with 214 additions and 6 deletions
@@ -3,6 +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';
/**
* 8-color palette for calendar sources (UI-SPEC).
@@ -69,6 +70,8 @@ export function CalendarSourceForm({
const [exchangeMode, setExchangeMode] = useState(
initialValues?.exchangeMode ?? 'graph',
);
const [domain, setDomain] = useState(initialValues?.domain ?? '');
const [testStatus, setTestStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [color, setColor] = useState(
initialValues?.color ?? SOURCE_COLOR_PALETTE[0].value,
);
@@ -103,6 +106,24 @@ export function CalendarSourceForm({
url.trim() !== '' &&
!urlError;
const handleTest = useCallback(async () => {
if (!validateUrl(url)) return;
setTestStatus('loading');
try {
const result = await testSourceConfig({
type,
url: url.trim(),
username: username.trim() || undefined,
password: password || undefined,
exchangeMode: isExchange ? exchangeMode : undefined,
domain: isExchange && domain.trim() ? domain.trim() : undefined,
});
setTestStatus(result.success ? 'success' : 'error');
} catch {
setTestStatus('error');
}
}, [type, url, username, password, exchangeMode, domain, isExchange, validateUrl]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
@@ -122,14 +143,15 @@ export function CalendarSourceForm({
if (password) payload.password = password;
}
// Include exchangeMode for Exchange type
// Include exchangeMode and domain for Exchange type
if (isExchange) {
payload.exchangeMode = exchangeMode;
if (domain.trim()) payload.domain = domain.trim();
}
await onSave(payload);
},
[name, type, url, username, password, exchangeMode, color, isICS, isExchange, onSave, validateUrl],
[name, type, url, username, password, exchangeMode, domain, color, isICS, isExchange, onSave, validateUrl],
);
return (
@@ -194,6 +216,23 @@ export function CalendarSourceForm({
</div>
)}
{/* Domain (Exchange EWS only) */}
{isExchange && (
<div>
<label htmlFor="exchange-domain" className="mb-1 block text-sm text-foreground">
{t('calendar.formFieldDomain')}
</label>
<input
id="exchange-domain"
type="text"
placeholder={t('calendar.formFieldDomainHint')}
className="h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm text-foreground"
value={domain}
onChange={(e) => setDomain(e.target.value)}
/>
</div>
)}
{/* URL */}
<div>
<label htmlFor="source-url" className="mb-1 block text-sm text-foreground">
@@ -282,7 +321,7 @@ export function CalendarSourceForm({
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<div className="flex flex-wrap gap-3 pt-2">
<button
type="submit"
data-testid="source-form-submit"
@@ -291,6 +330,15 @@ export function CalendarSourceForm({
>
{isSaving ? t('calendar.formSaving') : t('calendar.formSave')}
</button>
<button
type="button"
data-testid="source-form-test"
disabled={!isFormValid || testStatus === 'loading'}
onClick={handleTest}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
{testStatus === 'loading' ? t('calendar.formTesting') : t('calendar.formTestConnection')}
</button>
<button
type="button"
onClick={onCancel}
@@ -299,6 +347,12 @@ export function CalendarSourceForm({
{t('calendar.formCancel')}
</button>
</div>
{testStatus === 'success' && (
<p className="text-sm text-green-600 dark:text-green-400">{t('calendar.formTestSuccess')}</p>
)}
{testStatus === 'error' && (
<p className="text-sm text-destructive">{t('calendar.formTestFailed')}</p>
)}
</form>
);
}
+25
View File
@@ -22,6 +22,7 @@ export interface CalendarSource {
name: string;
type: 'caldav' | 'ics' | 'exchange';
exchangeMode?: string | null;
domain?: string | null;
url: string;
username?: string | null;
color?: string | null;
@@ -59,6 +60,7 @@ export interface CreateSourcePayload {
username?: string;
password?: string;
exchangeMode?: string;
domain?: string;
color?: string;
}
@@ -73,6 +75,7 @@ export interface UpdateSourcePayload {
username?: string;
password?: string;
exchangeMode?: string;
domain?: string;
color?: string;
isVisible?: boolean;
}
@@ -138,6 +141,28 @@ export async function deleteSource(id: string): Promise<void> {
if (!res.ok) throw new Error('Failed to delete calendar source');
}
/**
* Test a calendar configuration without saving it.
* POST /calendar/sources/test-config
*/
export async function testSourceConfig(config: {
type: string;
url: string;
username?: string;
password?: string;
exchangeMode?: string;
domain?: string;
}): Promise<{ success: boolean; error?: string }> {
const res = await fetch(`${API_URL}/calendar/sources/test-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(config),
});
if (!res.ok) throw new Error('Test request failed');
return res.json();
}
/**
* Test connection to a calendar source.
* POST /calendar/sources/:id/test
+6
View File
@@ -202,6 +202,12 @@
"formFieldPassword": "Passwort",
"formFieldColor": "Farbe",
"formFieldExchangeMode": "Exchange-Modus",
"formFieldDomain": "Domain",
"formFieldDomainHint": "Windows-Domain (z.B. FIRMA)",
"formTestConnection": "Verbindung testen",
"formTesting": "Teste...",
"formTestSuccess": "Verbindung erfolgreich",
"formTestFailed": "Verbindung fehlgeschlagen",
"formSave": "Speichern",
"formSaving": "Wird gespeichert...",
"formCancel": "Abbrechen",
+6
View File
@@ -202,6 +202,12 @@
"formFieldPassword": "Password",
"formFieldColor": "Color",
"formFieldExchangeMode": "Exchange Mode",
"formFieldDomain": "Domain",
"formFieldDomainHint": "Windows domain (e.g. COMPANY)",
"formTestConnection": "Test connection",
"formTesting": "Testing...",
"formTestSuccess": "Connection successful",
"formTestFailed": "Connection failed",
"formSave": "Save",
"formSaving": "Saving...",
"formCancel": "Cancel",