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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user