feat(domains): Modul Domains mit AutoDNS-Zugang, Verbindungstest und Einstellungen

- Migration mit vier Tabellen (Einstellungen, Kunden, Kontaktzuordnung, Bestellungen), Zeilenschutz je Organisation
- AutoDNS-Client mit festen Demo-/Live-Adressen, Takt-Begrenzer, 20 s Zeitlimit, ohne Wiederholung
- Zugang je System verschluesselt gespeichert, Live-Wechsel nur mit Bestaetigung, Verbindungstest
- Modulseite mit Systemkennzeichnung und Reiter Einstellungen (nur Verwalten)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 10:35:21 +02:00
parent 6b0f840c1c
commit 1f1c984184
29 changed files with 3123 additions and 2 deletions
@@ -0,0 +1,34 @@
import { useTranslations } from 'next-intl';
import type { DomainsStatus } from '@/lib/domains-api';
/**
* Dauerhafte Kennzeichnung des aktiven AutoDNS-Systems im Seitenkopf (D-E):
* Demo (Testbetrieb), Live (kostet Geld) oder "nicht eingerichtet". Die
* Klassen stehen als vollstaendige Zeichenketten, damit Tailwind sie findet.
*/
export function EnvironmentBadge({ status }: { status: DomainsStatus | null }) {
const t = useTranslations('domains.environment');
if (!status) return null;
let label: string;
let classes: string;
if (!status.configured) {
label = t('notConfigured');
classes = 'bg-status-idle/12 text-status-idle-fg';
} else if (status.environment === 'LIVE') {
label = t('live');
classes = 'bg-status-down/12 text-status-down-fg';
} else {
label = t('demo');
classes = 'bg-status-warn/12 text-status-warn-fg';
}
return (
<span
data-testid="domains-environment-badge"
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold ${classes}`}
>
{label}
</span>
);
}
@@ -0,0 +1,509 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { SettingsSection } from '@/components/control-center/settings-section';
import {
type ConnectionTestResult,
type DomainsEnvironment,
type DomainsEnvironmentAccess,
DomainsRequestError,
type DomainsSettings,
getDomainsSettings,
type SaveDomainsSettingsInput,
saveDomainsSettings,
testDomainsConnection,
} from '@/lib/domains-api';
const INPUT_CLASS =
'w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring';
const PRIMARY_BUTTON =
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:cursor-not-allowed disabled:opacity-50';
const SECONDARY_BUTTON =
'rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground hover:bg-accent transition-colors disabled:cursor-not-allowed disabled:opacity-50';
/** Der Live-Kontext von AutoDNS ist in der Regel 4; das Demo-System hat keinen Vorschlag. */
const LIVE_DEFAULT_CONTEXT = 4;
const MIN_NAMESERVER_ROWS = 2;
const MAX_NAMESERVER_ROWS = 6;
type Message = { kind: 'ok' | 'error'; text: string };
function MessageLine({ message }: { message: Message | null }) {
if (!message) return null;
return (
<p
role={message.kind === 'error' ? 'alert' : 'status'}
className={`text-sm ${message.kind === 'error' ? 'text-destructive' : 'text-status-ok-fg'}`}
>
{message.text}
</p>
);
}
function useErrorText() {
const t = useTranslations('domains');
return (error: unknown) =>
error instanceof DomainsRequestError ? error.message : t('errors.request');
}
// --- System (Demo/Live) -------------------------------------------------------
function SystemCard({
settings,
onSaved,
}: {
settings: DomainsSettings;
onSaved: (s: DomainsSettings) => void;
}) {
const t = useTranslations('domains.settings');
const errorText = useErrorText();
const [selected, setSelected] = useState<DomainsEnvironment>(settings.environment);
const [confirmingLive, setConfirmingLive] = useState(false);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<Message | null>(null);
useEffect(() => {
setSelected(settings.environment);
setConfirmingLive(false);
}, [settings.environment]);
const choose = (environment: DomainsEnvironment) => {
setMessage(null);
if (environment === 'LIVE' && settings.environment !== 'LIVE') {
// Der Wechsel auf das kostenpflichtige System braucht eine ausdrueckliche Bestaetigung.
setSelected('LIVE');
setConfirmingLive(true);
return;
}
setConfirmingLive(false);
setSelected(environment);
};
const save = async (input: SaveDomainsSettingsInput) => {
setSaving(true);
setMessage(null);
try {
onSaved(await saveDomainsSettings(input));
setConfirmingLive(false);
setMessage({ kind: 'ok', text: t('saved') });
} catch (error) {
setMessage({ kind: 'error', text: errorText(error) });
} finally {
setSaving(false);
}
};
const cancelLive = () => {
setConfirmingLive(false);
setSelected(settings.environment);
};
return (
<SettingsSection
id="domains-system"
title={t('system.title')}
description={t('system.description')}
footer={
<>
<MessageLine message={message} />
<button
type="button"
className={PRIMARY_BUTTON}
disabled={saving || confirmingLive || selected === settings.environment}
onClick={() => save({ environment: selected })}
>
{saving ? t('saving') : t('save')}
</button>
</>
}
>
<fieldset className="space-y-3">
{(['DEMO', 'LIVE'] as const).map((env) => (
<label key={env} className="flex cursor-pointer items-start gap-3">
<input
type="radio"
name="domains-environment"
value={env}
checked={selected === env}
onChange={() => choose(env)}
className="mt-1"
/>
<span>
<span className="block text-sm font-medium text-foreground">
{env === 'DEMO' ? t('system.demo') : t('system.live')}
</span>
<span className="block text-xs text-muted-foreground">
{env === 'DEMO' ? t('system.demoHelp') : t('system.liveHelp')}
</span>
</span>
</label>
))}
</fieldset>
{confirmingLive && (
<div
role="alertdialog"
aria-label={t('system.confirmText')}
className="mt-4 space-y-3 rounded-md border border-status-down/40 bg-status-down/10 p-4"
>
<p className="text-sm font-medium text-foreground">{t('system.confirmText')}</p>
<div className="flex gap-2">
<button
type="button"
className={PRIMARY_BUTTON}
disabled={saving}
onClick={() => save({ environment: 'LIVE', confirmLive: true })}
>
{t('system.confirm')}
</button>
<button
type="button"
className={SECONDARY_BUTTON}
disabled={saving}
onClick={cancelLive}
>
{t('system.cancel')}
</button>
</div>
</div>
)}
</SettingsSection>
);
}
// --- Zugang je System ---------------------------------------------------------
function parseContext(text: string): number | null | 'invalid' {
const trimmed = text.trim();
if (trimmed === '') return null;
if (!/^\d{1,10}$/.test(trimmed)) return 'invalid';
const n = Number.parseInt(trimmed, 10);
return n >= 1 && n <= 2147483647 ? n : 'invalid';
}
function AccessCard({
environment,
access,
onSaved,
}: {
environment: DomainsEnvironment;
access: DomainsEnvironmentAccess;
onSaved: (s: DomainsSettings) => void;
}) {
const t = useTranslations('domains.settings');
const errorText = useErrorText();
const prefix = environment === 'DEMO' ? 'demo' : 'live';
const initialContext =
access.context !== null
? String(access.context)
: environment === 'LIVE'
? String(LIVE_DEFAULT_CONTEXT)
: '';
const [user, setUser] = useState(access.user ?? '');
const [password, setPassword] = useState('');
const [context, setContext] = useState(initialContext);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [message, setMessage] = useState<Message | null>(null);
const [testResult, setTestResult] = useState<ConnectionTestResult | null>(null);
useEffect(() => {
setUser(access.user ?? '');
setPassword('');
setContext(
access.context !== null
? String(access.context)
: environment === 'LIVE'
? String(LIVE_DEFAULT_CONTEXT)
: '',
);
}, [access.user, access.context, environment]);
const contextValue = parseContext(context);
const dirty =
user.trim() !== (access.user ?? '') || password !== '' || context.trim() !== initialContext;
const save = async () => {
if (contextValue === 'invalid') return;
setSaving(true);
setMessage(null);
const input: SaveDomainsSettingsInput = {
[`${prefix}User`]: user.trim(),
[`${prefix}Context`]: contextValue,
...(password !== '' ? { [`${prefix}Password`]: password } : {}),
};
try {
onSaved(await saveDomainsSettings(input));
setPassword('');
setMessage({ kind: 'ok', text: t('saved') });
} catch (error) {
setMessage({ kind: 'error', text: errorText(error) });
} finally {
setSaving(false);
}
};
const runTest = async () => {
setTesting(true);
setTestResult(null);
try {
setTestResult(await testDomainsConnection(environment));
} catch (error) {
setTestResult({ ok: false, message: errorText(error) });
} finally {
setTesting(false);
}
};
const idBase = `domains-${prefix}`;
return (
<SettingsSection
id={idBase}
title={environment === 'DEMO' ? t('access.demoTitle') : t('access.liveTitle')}
description={t('access.description')}
footer={
<>
<div className="mr-auto space-y-1">
<MessageLine message={message} />
{testResult && (
<p
role={testResult.ok ? 'status' : 'alert'}
className={`text-sm ${testResult.ok ? 'text-status-ok-fg' : 'text-destructive'}`}
>
{testResult.message}
</p>
)}
{dirty && <p className="text-xs text-muted-foreground">{t('access.saveFirst')}</p>}
</div>
<button
type="button"
className={SECONDARY_BUTTON}
disabled={testing || dirty}
onClick={runTest}
>
{testing ? t('access.testing') : t('access.test')}
</button>
<button
type="button"
className={PRIMARY_BUTTON}
disabled={saving || !dirty || contextValue === 'invalid'}
onClick={save}
>
{saving ? t('saving') : t('save')}
</button>
</>
}
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<label htmlFor={`${idBase}-user`} className="text-sm font-medium text-foreground">
{t('access.user')}
</label>
<input
id={`${idBase}-user`}
type="text"
autoComplete="off"
maxLength={100}
value={user}
onChange={(e) => setUser(e.target.value)}
className={INPUT_CLASS}
/>
</div>
<div className="space-y-1">
<label htmlFor={`${idBase}-password`} className="text-sm font-medium text-foreground">
{t('access.password')}
</label>
<input
id={`${idBase}-password`}
type="password"
autoComplete="new-password"
maxLength={200}
value={password}
placeholder={access.hasPassword ? t('access.passwordStored') : ''}
onChange={(e) => setPassword(e.target.value)}
className={INPUT_CLASS}
/>
</div>
<div className="space-y-1">
<label htmlFor={`${idBase}-context`} className="text-sm font-medium text-foreground">
{t('access.context')}
</label>
<input
id={`${idBase}-context`}
type="number"
inputMode="numeric"
min={1}
value={context}
aria-invalid={contextValue === 'invalid'}
onChange={(e) => setContext(e.target.value)}
className={INPUT_CLASS}
/>
{contextValue === 'invalid' && (
<p className="text-xs text-destructive">{t('access.contextInvalid')}</p>
)}
</div>
</div>
<p className="mt-3 text-xs text-muted-foreground">{t('access.contextHelp')}</p>
</SettingsSection>
);
}
// --- Standard-Nameserver --------------------------------------------------------
function padNameServers(list: string[]): string[] {
const rows = [...list];
while (rows.length < MIN_NAMESERVER_ROWS) rows.push('');
return rows;
}
function NameServersCard({
settings,
onSaved,
}: {
settings: DomainsSettings;
onSaved: (s: DomainsSettings) => void;
}) {
const t = useTranslations('domains.settings');
const errorText = useErrorText();
const [rows, setRows] = useState<string[]>(padNameServers(settings.defaultNameServers));
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<Message | null>(null);
useEffect(() => {
setRows(padNameServers(settings.defaultNameServers));
}, [settings.defaultNameServers]);
const cleaned = rows.map((r) => r.trim().toLowerCase()).filter((r) => r.length > 0);
const dirty = cleaned.join('\n') !== settings.defaultNameServers.join('\n');
const save = async () => {
setSaving(true);
setMessage(null);
try {
onSaved(await saveDomainsSettings({ defaultNameServers: cleaned }));
setMessage({ kind: 'ok', text: t('saved') });
} catch (error) {
setMessage({ kind: 'error', text: errorText(error) });
} finally {
setSaving(false);
}
};
return (
<SettingsSection
id="domains-nameservers"
title={t('nameServers.title')}
description={t('nameServers.description')}
footer={
<>
<div className="mr-auto">
<MessageLine message={message} />
</div>
<button
type="button"
className={PRIMARY_BUTTON}
disabled={saving || !dirty}
onClick={save}
>
{saving ? t('saving') : t('save')}
</button>
</>
}
>
<div className="space-y-3">
{rows.map((value, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: Zeilen sind positionsgebunden und haben keine Kennung
<div key={index} className="flex items-end gap-2">
<div className="flex-1 space-y-1">
<label
htmlFor={`domains-ns-${index}`}
className="text-sm font-medium text-foreground"
>
{t('nameServers.label', { number: index + 1 })}
</label>
<input
id={`domains-ns-${index}`}
type="text"
autoComplete="off"
maxLength={253}
placeholder="ns1.example.com"
value={value}
onChange={(e) => setRows(rows.map((r, i) => (i === index ? e.target.value : r)))}
className={INPUT_CLASS}
/>
</div>
{rows.length > MIN_NAMESERVER_ROWS && (
<button
type="button"
className={SECONDARY_BUTTON}
onClick={() => setRows(rows.filter((_, i) => i !== index))}
>
{t('nameServers.remove')}
</button>
)}
</div>
))}
{rows.length < MAX_NAMESERVER_ROWS && (
<button type="button" className={SECONDARY_BUTTON} onClick={() => setRows([...rows, ''])}>
{t('nameServers.add')}
</button>
)}
</div>
</SettingsSection>
);
}
// --- Reiter ---------------------------------------------------------------------
/**
* Einstellungen des Moduls Domains (nur fuer Verwalter). Jede Karte speichert
* nur ihre eigenen Felder (Teil-PUT); nach jedem Speichern wird der Zustand
* der Seite (Kennzeichnung im Kopf) neu geladen.
*/
export function SettingsTab({ onChanged }: { onChanged: () => void }) {
const t = useTranslations('domains');
const [settings, setSettings] = useState<DomainsSettings | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
getDomainsSettings()
.then((s) => {
if (!cancelled) setSettings(s);
})
.catch((error) => {
if (!cancelled) {
setLoadError(error instanceof DomainsRequestError ? error.message : t('errors.request'));
}
});
return () => {
cancelled = true;
};
}, [t]);
const handleSaved = (next: DomainsSettings) => {
setSettings(next);
onChanged();
};
if (loadError) {
return (
<p role="alert" className="text-sm text-destructive">
{loadError}
</p>
);
}
if (!settings) {
return <p className="text-sm text-muted-foreground">{t('settings.loading')}</p>;
}
return (
<div className="space-y-6">
<SystemCard settings={settings} onSaved={handleSaved} />
<AccessCard environment="DEMO" access={settings.demo} onSaved={handleSaved} />
<AccessCard environment="LIVE" access={settings.live} onSaved={handleSaved} />
<NameServersCard settings={settings} onSaved={handleSaved} />
</div>
);
}
@@ -0,0 +1,243 @@
import {
cleanup,
fireEvent,
render as rtlRender,
screen,
waitFor,
within,
} from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { DomainsSettings, DomainsStatus } from '@/lib/domains-api';
import de from '@/messages/de.json';
import DomainsPage from './page';
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
const mockGetStatus = vi.fn();
const mockGetSettings = vi.fn();
const mockSaveSettings = vi.fn();
const mockTestConnection = vi.fn();
vi.mock('@/lib/domains-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/domains-api')>();
return {
...actual,
getDomainsStatus: (...args: unknown[]) => mockGetStatus(...args),
getDomainsSettings: (...args: unknown[]) => mockGetSettings(...args),
saveDomainsSettings: (...args: unknown[]) => mockSaveSettings(...args),
testDomainsConnection: (...args: unknown[]) => mockTestConnection(...args),
};
});
let mockCanManage: boolean | null = true;
vi.mock('@/lib/use-module-capability', () => ({
useCanManageModule: () => mockCanManage,
}));
function status(over: Partial<DomainsStatus> = {}): DomainsStatus {
return {
environment: 'DEMO',
configured: true,
demoConfigured: true,
liveConfigured: false,
defaultNameServers: [],
...over,
};
}
function settings(over: Partial<DomainsSettings> = {}): DomainsSettings {
return {
environment: 'DEMO',
demo: { user: 'api-user', hasPassword: true, context: 1 },
live: { user: null, hasPassword: false, context: null },
defaultNameServers: [],
configured: { demo: true, live: false },
...over,
};
}
beforeEach(() => {
mockCanManage = true;
mockGetStatus.mockReset().mockResolvedValue(status());
mockGetSettings.mockReset().mockResolvedValue(settings());
mockSaveSettings.mockReset().mockImplementation(async () => settings());
mockTestConnection
.mockReset()
.mockResolvedValue({ ok: true, message: 'Verbindung erfolgreich.' });
});
afterEach(() => {
cleanup();
});
describe('Domains-Seite — Kopf und Reiter', () => {
it('Verwalter sieht den Reiter Einstellungen und die Demo-Kennzeichnung', async () => {
render(<DomainsPage />);
expect(await screen.findByRole('button', { name: 'Einstellungen' })).toBeTruthy();
expect((await screen.findByTestId('domains-environment-badge')).textContent).toBe(
'Demo-System (Testbetrieb)',
);
});
it('Live-System zeigt die Kostenwarnung', async () => {
mockGetStatus.mockResolvedValue(status({ environment: 'LIVE', liveConfigured: true }));
render(<DomainsPage />);
await waitFor(() =>
expect(screen.getByTestId('domains-environment-badge').textContent).toBe(
'Live-System – Registrierungen kosten Geld',
),
);
});
it('nicht eingerichtet: Kennzeichnung und Hinweis', async () => {
mockGetStatus.mockResolvedValue(status({ configured: false, demoConfigured: false }));
render(<DomainsPage />);
await waitFor(() =>
expect(screen.getByTestId('domains-environment-badge').textContent).toBe(
'AutoDNS nicht eingerichtet',
),
);
expect(screen.getByText('AutoDNS ist noch nicht eingerichtet.')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Zu den Einstellungen' })).toBeTruthy();
});
it('nicht eingerichtet, ohne Verwalten: Hinweis auf einen Administrator, kein Einstellungen-Reiter', async () => {
mockCanManage = false;
mockGetStatus.mockResolvedValue(status({ configured: false, demoConfigured: false }));
render(<DomainsPage />);
expect(await screen.findByText(/Bitte wenden Sie sich an einen Administrator/)).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Zu den Einstellungen' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
});
it('Benutzer ohne Verwalten sieht den Reiter Einstellungen nicht', async () => {
mockCanManage = false;
render(<DomainsPage />);
await screen.findByTestId('domains-environment-badge');
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
expect(mockGetSettings).not.toHaveBeenCalled();
});
});
describe('Einstellungen', () => {
it('Passwortfelder beginnen leer, mit Hinweis auf das gespeicherte Passwort', async () => {
render(<DomainsPage />);
const demoPassword = (await screen.findByLabelText('Passwort', {
selector: '#domains-demo-password',
})) as HTMLInputElement;
expect(demoPassword.value).toBe('');
expect(demoPassword.placeholder).toBe('Gespeichert – leer lassen, um es beizubehalten');
const livePassword = document.getElementById('domains-live-password') as HTMLInputElement;
expect(livePassword.placeholder).toBe('');
});
it('der Live-Kontext ist mit 4 vorbelegt, der Demo-Kontext zeigt den gespeicherten Wert', async () => {
render(<DomainsPage />);
await screen.findByLabelText('Passwort', { selector: '#domains-demo-password' });
expect((document.getElementById('domains-live-context') as HTMLInputElement).value).toBe('4');
expect((document.getElementById('domains-demo-context') as HTMLInputElement).value).toBe('1');
});
it('Wechsel auf Live verlangt Bestaetigung; erst die bestaetigte Speicherung sendet confirmLive', async () => {
mockSaveSettings.mockResolvedValue(settings({ environment: 'LIVE' }));
render(<DomainsPage />);
const liveRadio = await screen.findByRole('radio', { name: /Live-System \(kostenpflichtig\)/ });
fireEvent.click(liveRadio);
const dialog = await screen.findByRole('alertdialog');
expect(within(dialog).getByText(/kosten Geld/)).toBeTruthy();
expect(mockSaveSettings).not.toHaveBeenCalled();
// Abbrechen sendet nichts und stellt die Auswahl zurueck.
fireEvent.click(within(dialog).getByRole('button', { name: 'Abbrechen' }));
expect(mockSaveSettings).not.toHaveBeenCalled();
expect(screen.queryByRole('alertdialog')).toBeNull();
expect(
(screen.getByRole('radio', { name: /Demo-System \(Testbetrieb\)/ }) as HTMLInputElement)
.checked,
).toBe(true);
fireEvent.click(screen.getByRole('radio', { name: /Live-System \(kostenpflichtig\)/ }));
fireEvent.click(
within(await screen.findByRole('alertdialog')).getByRole('button', {
name: 'Live-System verwenden',
}),
);
await waitFor(() => expect(mockSaveSettings).toHaveBeenCalledTimes(1));
expect(mockSaveSettings).toHaveBeenCalledWith({ environment: 'LIVE', confirmLive: true });
});
it('Speichern des Zugangs sendet nur die eigenen Felder; ohne Eingabe kein Passwort', async () => {
render(<DomainsPage />);
const user = (await screen.findByLabelText('Benutzername', {
selector: '#domains-demo-user',
})) as HTMLInputElement;
fireEvent.change(user, { target: { value: 'neuer-user' } });
const demoCard = document.getElementById('domains-demo') as HTMLElement;
fireEvent.click(within(demoCard).getByRole('button', { name: 'Speichern' }));
await waitFor(() => expect(mockSaveSettings).toHaveBeenCalledTimes(1));
expect(mockSaveSettings).toHaveBeenCalledWith({ demoUser: 'neuer-user', demoContext: 1 });
});
it('Verbindung testen: ein Aufruf je Klick, gesperrt waehrend des Laufs, zeigt das Ergebnis', async () => {
let resolveTest: (v: { ok: boolean; message: string }) => void = () => undefined;
mockTestConnection.mockImplementation(
() =>
new Promise((resolve) => {
resolveTest = resolve;
}),
);
render(<DomainsPage />);
await screen.findByLabelText('Passwort', { selector: '#domains-demo-password' });
const demoCard = document.getElementById('domains-demo') as HTMLElement;
const button = within(demoCard).getByRole('button', { name: 'Verbindung testen' });
fireEvent.click(button);
await waitFor(() => expect(mockTestConnection).toHaveBeenCalledTimes(1));
expect(mockTestConnection).toHaveBeenCalledWith('DEMO');
const running = within(demoCard).getByRole('button', { name: 'Verbindung wird getestet …' });
expect((running as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(running);
expect(mockTestConnection).toHaveBeenCalledTimes(1);
resolveTest({ ok: false, message: 'Anmeldung bei AutoDNS fehlgeschlagen.' });
expect(await within(demoCard).findByText('Anmeldung bei AutoDNS fehlgeschlagen.')).toBeTruthy();
});
it('der Verbindungstest ist bei ungespeicherten Aenderungen gesperrt', async () => {
render(<DomainsPage />);
const password = (await screen.findByLabelText('Passwort', {
selector: '#domains-demo-password',
})) as HTMLInputElement;
fireEvent.change(password, { target: { value: 'neu' } });
const demoCard = document.getElementById('domains-demo') as HTMLElement;
expect(
(within(demoCard).getByRole('button', { name: 'Verbindung testen' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(within(demoCard).getByText('Bitte zuerst speichern')).toBeTruthy();
});
it('Nameserver: speichert die bereinigte Liste', async () => {
render(<DomainsPage />);
const first = (await screen.findByLabelText('Nameserver 1')) as HTMLInputElement;
fireEvent.change(first, { target: { value: ' NS1.Example.com ' } });
fireEvent.change(screen.getByLabelText('Nameserver 2'), {
target: { value: 'ns2.example.com' },
});
const card = document.getElementById('domains-nameservers') as HTMLElement;
fireEvent.click(within(card).getByRole('button', { name: 'Speichern' }));
await waitFor(() => expect(mockSaveSettings).toHaveBeenCalledTimes(1));
expect(mockSaveSettings).toHaveBeenCalledWith({
defaultNameServers: ['ns1.example.com', 'ns2.example.com'],
});
});
});
@@ -0,0 +1,6 @@
import type { ReactNode } from 'react';
import { ModuleAccessGate } from '@/components/modules/module-access-gate';
export default function DomainsLayout({ children }: { children: ReactNode }) {
return <ModuleAccessGate moduleSlug="domains">{children}</ModuleAccessGate>;
}
@@ -0,0 +1,84 @@
'use client';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { TabBar } from '@/components/accounting/tab-bar';
import { SettingsSection } from '@/components/control-center/settings-section';
import { PageHeader } from '@/components/layout/page-header';
import { type DomainsStatus, getDomainsStatus } from '@/lib/domains-api';
import { useCanManageModule } from '@/lib/use-module-capability';
import { EnvironmentBadge } from './components/EnvironmentBadge';
import { SettingsTab } from './components/SettingsTab';
type TabId = 'settings';
/**
* Domains (quick-261008-dts): Domains bei AutoDNS registrieren, Kontakte und
* Kunden zuordnen. Der Seitenkopf traegt dauerhaft die Kennzeichnung des
* aktiven Systems (Demo/Live). Der Reiter "Einstellungen" erscheint nur fuer
* Administratoren und Benutzer mit der Freigabestufe Verwalten — bindend ist
* allein die API.
*/
export default function DomainsPage() {
const t = useTranslations('domains');
const canManage = useCanManageModule('domains') === true;
const [status, setStatus] = useState<DomainsStatus | null>(null);
const [statusError, setStatusError] = useState(false);
const [tab, setTab] = useState<TabId>('settings');
const reloadStatus = useCallback(async () => {
try {
setStatus(await getDomainsStatus());
setStatusError(false);
} catch {
setStatusError(true);
}
}, []);
useEffect(() => {
void reloadStatus();
}, [reloadStatus]);
const tabs: { id: TabId; label: string }[] = [];
if (canManage) tabs.push({ id: 'settings', label: t('tabs.settings') });
const activeTab = tabs.some((x) => x.id === tab) ? tab : tabs[0]?.id;
return (
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
<PageHeader
moduleSlug="domains"
title={t('title')}
description={t('description')}
actions={<EnvironmentBadge status={status} />}
/>
{statusError && (
<p role="alert" className="text-sm text-destructive">
{t('errors.loadStatus')}
</p>
)}
{status && !status.configured && (
<SettingsSection title={t('notConfigured.title')}>
<p className="text-sm text-muted-foreground">
{canManage ? t('notConfigured.managerHint') : t('notConfigured.userHint')}
</p>
{canManage && (
<button
type="button"
onClick={() => setTab('settings')}
className="mt-3 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
>
{t('notConfigured.goToSettings')}
</button>
)}
</SettingsSection>
)}
{tabs.length > 0 && activeTab && (
<>
<TabBar tabs={tabs} active={activeTab} onChange={setTab} />
{activeTab === 'settings' && <SettingsTab onChanged={reloadStatus} />}
</>
)}
</div>
);
}
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest';
import CertManagerLayout from './cert-manager/layout';
import DkvFleetLayout from './dkv-fleet/layout';
import DomaincheckLayout from './domaincheck/layout';
import DomainsLayout from './domains/layout';
import HandelswareDatevLayout from './handelsware-datev/layout';
import KantineDatevLayout from './kantine-datev/layout';
import NextcloudStatusLayout from './nextcloud-status/layout';
@@ -44,6 +45,7 @@ describe('module layouts — ModuleAccessGate slug wiring (T-e8k-01, T-e8k-03)',
['kantine-datev', KantineDatevLayout],
['handelsware-datev', HandelswareDatevLayout],
['nextcloud-status', NextcloudStatusLayout],
['domains', DomainsLayout],
] as const)('%s/layout.tsx passes moduleSlug="%s" and forwards children', (expectedSlug, Layout) => {
const element = Layout({ children: placeholderChild });