feat(domains): Domain registrieren mit Schutz vor Doppelbestellung, Aufträge, Changelog und Anleitung

- Verfügbarkeitsprüfung über DomainStudio, Entwurf mit Kontakten und Nameservern, Zusammenfassung mit System und Preis
- Verbindliches Abschicken: atomarer Anspruch DRAFT -> SUBMITTING vor dem Netzaufruf, genau ein POST /domain ohne Wiederholung, unklarer Ausgang als UNKNOWN, Bindung an das System
- Aufträge: Stand aus dem AutoDNS-Auftrag, Abgleich unklarer Aufträge, Verwerfen erst nach Abgleich
- Reiter Registrieren und Aufträge, Changelog, Anwender- und Administrationshandbuch, Mandantenschutz-Inventar

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 11:10:03 +02:00
parent 450218fe77
commit d332246bde
28 changed files with 4330 additions and 15 deletions
@@ -0,0 +1,234 @@
import {
act,
cleanup,
fireEvent,
render as rtlRender,
screen,
waitFor,
} 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 { DomainsOrder } from '@/lib/domains-api';
import de from '@/messages/de.json';
import { ORDERS_POLL_MS, OrdersTab } from './OrdersTab';
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
const mockList = vi.fn();
const mockRefresh = vi.fn();
const mockCancel = vi.fn();
vi.mock('@/lib/domains-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/domains-api')>();
return {
...actual,
listOrders: (...a: unknown[]) => mockList(...a),
refreshOrders: (...a: unknown[]) => mockRefresh(...a),
cancelOrder: (...a: unknown[]) => mockCancel(...a),
};
});
function order(over: Partial<DomainsOrder> = {}): DomainsOrder {
return {
id: 'o1',
environment: 'DEMO',
domainName: 'beispiel.de',
domainNameUnicode: 'beispiel.de',
status: 'DRAFT',
jobStatus: null,
errorText: null,
confirmedByUsername: null,
confirmedAt: null,
createdAt: '2026-10-08T10:00:00.000Z',
lastCheckedAt: null,
price: null,
...over,
};
}
beforeEach(() => {
mockList.mockReset().mockResolvedValue([]);
mockRefresh.mockReset().mockResolvedValue([]);
mockCancel.mockReset().mockResolvedValue(order({ status: 'CANCELED' }));
});
afterEach(() => {
vi.useRealTimers();
cleanup();
});
describe('OrdersTab — Anzeige', () => {
it('ruft beim Oeffnen den Abgleich auf', async () => {
render(<OrdersTab canManage />);
await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
expect(await screen.findByText('Es gibt noch keine Aufträge.')).toBeTruthy();
});
it('zeigt Domain, System, Status, Bestaetigung und Fehlertext', async () => {
mockRefresh.mockResolvedValue([
order({
id: 'a',
domainName: 'xn--mller-kva.de',
domainNameUnicode: 'müller.de',
environment: 'LIVE',
status: 'SUCCESS',
confirmedByUsername: 'anna',
confirmedAt: '2026-10-08T09:30:00.000Z',
}),
order({
id: 'b',
domainName: 'fehler.de',
domainNameUnicode: 'fehler.de',
status: 'FAILED',
errorText: 'AutoDNS meldet: Nameserver nicht erreichbar',
}),
]);
render(<OrdersTab canManage={false} />);
expect(await screen.findByText('müller.de')).toBeTruthy();
expect(screen.getByText('Live')).toBeTruthy();
expect(screen.getByText('Registriert')).toBeTruthy();
expect(screen.getByText('anna, 08.10.2026, 11:30')).toBeTruthy();
expect(screen.getByText('Fehlgeschlagen')).toBeTruthy();
expect(screen.getByText('AutoDNS meldet: Nameserver nicht erreichbar')).toBeTruthy();
});
it.each([
[{ status: 'DRAFT' }, 'Entwurf'],
[{ status: 'SUBMITTING' }, 'Wird übermittelt'],
[{ status: 'SUBMITTED', jobStatus: 'RUNNING' }, 'In Bearbeitung'],
[{ status: 'SUBMITTED', jobStatus: 'SUPPORT' }, 'Rückfrage nötig'],
[{ status: 'UNKNOWN' }, 'Ergebnis ungeklärt'],
[{ status: 'CANCELED' }, 'Verworfen'],
] as [Partial<DomainsOrder>, string][])('Status %j heisst "%s"', async (over, label) => {
mockRefresh.mockResolvedValue([order(over)]);
render(<OrdersTab canManage={false} />);
expect(await screen.findByText(label)).toBeTruthy();
});
});
describe('OrdersTab — Abgleich im Takt', () => {
it('mit offenem Auftrag laeuft alle 30 s ein Abgleich und stoppt, wenn keiner mehr offen ist', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockRefresh.mockResolvedValue([order({ status: 'SUBMITTED', jobStatus: 'RUNNING' })]);
render(<OrdersTab canManage />);
await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
await screen.findByText('In Bearbeitung');
await act(async () => {
await vi.advanceTimersByTimeAsync(ORDERS_POLL_MS);
});
expect(mockRefresh).toHaveBeenCalledTimes(2);
mockRefresh.mockResolvedValue([order({ status: 'SUCCESS' })]);
await act(async () => {
await vi.advanceTimersByTimeAsync(ORDERS_POLL_MS);
});
expect(mockRefresh).toHaveBeenCalledTimes(3);
await screen.findByText('Registriert');
await act(async () => {
await vi.advanceTimersByTimeAsync(ORDERS_POLL_MS * 3);
});
expect(mockRefresh).toHaveBeenCalledTimes(3);
});
it('ohne offene Auftraege gibt es keinen Takt', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockRefresh.mockResolvedValue([order({ status: 'FAILED' })]);
render(<OrdersTab canManage />);
await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
await act(async () => {
await vi.advanceTimersByTimeAsync(ORDERS_POLL_MS * 2);
});
expect(mockRefresh).toHaveBeenCalledTimes(1);
});
it('bei unsichtbarer Seite wird nicht abgeglichen', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockRefresh.mockResolvedValue([order({ status: 'SUBMITTED' })]);
render(<OrdersTab canManage />);
await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
const spy = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden');
await act(async () => {
await vi.advanceTimersByTimeAsync(ORDERS_POLL_MS);
});
expect(mockRefresh).toHaveBeenCalledTimes(1);
spy.mockRestore();
});
it('"Aktualisieren" gleicht sofort ab', async () => {
render(<OrdersTab canManage />);
await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
fireEvent.click(await screen.findByRole('button', { name: 'Aktualisieren' }));
await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(2));
});
});
describe('OrdersTab — Verwerfen', () => {
it('erscheint nur fuer Verwalter, nur bei Entwurf und bei geprueftem unklarem Auftrag', async () => {
mockRefresh.mockResolvedValue([
order({
id: 'd',
domainName: 'entwurf.de',
domainNameUnicode: 'entwurf.de',
status: 'DRAFT',
}),
order({
id: 'u1',
domainName: 'u1.de',
domainNameUnicode: 'u1.de',
status: 'UNKNOWN',
lastCheckedAt: '2026-10-08T10:00:00.000Z',
}),
order({
id: 'u2',
domainName: 'u2.de',
domainNameUnicode: 'u2.de',
status: 'UNKNOWN',
lastCheckedAt: null,
}),
order({ id: 's', domainName: 's.de', domainNameUnicode: 's.de', status: 'SUBMITTED' }),
]);
render(<OrdersTab canManage />);
await screen.findByText('entwurf.de');
expect(screen.getAllByRole('button', { name: 'Verwerfen' })).toHaveLength(2);
cleanup();
render(<OrdersTab canManage={false} />);
await screen.findByText('entwurf.de');
expect(screen.queryByRole('button', { name: 'Verwerfen' })).toBeNull();
});
it('fragt nach; bei "unklar" mit dem Hinweis, erst in AutoDNS zu pruefen; dann cancelOrder', async () => {
mockRefresh.mockResolvedValue([
order({ id: 'u1', status: 'UNKNOWN', lastCheckedAt: '2026-10-08T10:00:00.000Z' }),
]);
render(<OrdersTab canManage />);
fireEvent.click(await screen.findByRole('button', { name: 'Verwerfen' }));
expect(mockCancel).not.toHaveBeenCalled();
expect(
screen.getByText(
/nur, nachdem Sie in AutoDNS geprüft haben, dass die Domain nicht bestellt wurde/,
),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Ja, verwerfen' }));
await waitFor(() => expect(mockCancel).toHaveBeenCalledWith('u1'));
});
it('"Abbrechen" im Dialog verwirft nichts', async () => {
mockRefresh.mockResolvedValue([order({ id: 'd', status: 'DRAFT' })]);
render(<OrdersTab canManage />);
fireEvent.click(await screen.findByRole('button', { name: 'Verwerfen' }));
expect(screen.getByText('Diesen Entwurf wirklich verwerfen?')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Abbrechen' }));
expect(mockCancel).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Verwerfen' })).toBeTruthy();
});
});
@@ -0,0 +1,235 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useCallback, useEffect, useRef, useState } from 'react';
import { SettingsSection } from '@/components/control-center/settings-section';
import { isDiscardable, isOpen, orderStatusInfo } from '@/components/domains/order-status';
import {
SECONDARY_BUTTON,
TABLE_CELL,
TABLE_HEAD_CELL,
TABLE_HEAD_CLASS,
} from '@/components/domains/ui-classes';
import {
cancelOrder,
type DomainsOrder,
DomainsRequestError,
listOrders,
refreshOrders,
} from '@/lib/domains-api';
/** Abgleich mit AutoDNS, solange offene Auftraege existieren und die Seite sichtbar ist (D-N). */
export const ORDERS_POLL_MS = 30_000;
function formatDateTime(iso: string | null, locale: string): string {
if (!iso) return '–';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '–';
return new Intl.DateTimeFormat(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Berlin',
}).format(date);
}
/**
* Reiter "Aufträge": Stand jeder Registrierung. Beim Oeffnen wird die Liste
* sofort gezeigt und danach mit AutoDNS abgeglichen; solange ein Auftrag offen
* ist, wiederholt sich der Abgleich alle 30 Sekunden (nur bei sichtbarer Seite).
* Ein unklarer Auftrag laesst sich erst verwerfen, nachdem Tessera bei AutoDNS
* nachgesehen hat — und nur nach ausdruecklicher Bestaetigung.
*/
export function OrdersTab({ canManage }: { canManage: boolean }) {
const t = useTranslations('domains.orders');
const tc = useTranslations('domains.common');
const locale = useLocale();
const [orders, setOrders] = useState<DomainsOrder[] | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [discardId, setDiscardId] = useState<string | null>(null);
const [discardError, setDiscardError] = useState<string | null>(null);
const inFlight = useRef(false);
const refresh = useCallback(async () => {
if (inFlight.current) return;
inFlight.current = true;
setRefreshing(true);
try {
setOrders(await refreshOrders());
setError(null);
} catch (e) {
setError(e instanceof DomainsRequestError ? e.message : tc('requestFailed'));
} finally {
inFlight.current = false;
setRefreshing(false);
}
}, [tc]);
// Beim Oeffnen: erst die gespeicherte Liste, dann der Abgleich.
useEffect(() => {
let alive = true;
listOrders()
.then((list) => {
if (alive) setOrders((current) => current ?? list);
})
.catch((e) => {
if (alive) setError(e instanceof DomainsRequestError ? e.message : tc('requestFailed'));
});
void refresh();
return () => {
alive = false;
};
}, [refresh, tc]);
const hasOpen = (orders ?? []).some(isOpen);
useEffect(() => {
if (!hasOpen) return;
const timer = setInterval(() => {
if (document.visibilityState === 'visible') void refresh();
}, ORDERS_POLL_MS);
return () => clearInterval(timer);
}, [hasOpen, refresh]);
async function onDiscard(order: DomainsOrder) {
setDiscardError(null);
try {
await cancelOrder(order.id);
setDiscardId(null);
await refresh();
} catch (e) {
setDiscardError(e instanceof DomainsRequestError ? e.message : tc('requestFailed'));
}
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="max-w-2xl text-sm text-muted-foreground">{t('intro')}</p>
<button
type="button"
className={SECONDARY_BUTTON}
disabled={refreshing}
onClick={() => void refresh()}
>
{refreshing ? t('refreshing') : t('refresh')}
</button>
</div>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
{orders === null && !error && <p className="text-sm text-muted-foreground">{t('loading')}</p>}
{orders !== null && orders.length === 0 && (
<p className="text-sm text-muted-foreground">{t('empty')}</p>
)}
{orders !== null && orders.length > 0 && (
<SettingsSection flush>
<table className="w-full text-left text-sm">
<thead className={TABLE_HEAD_CLASS}>
<tr>
<th className={TABLE_HEAD_CELL}>{t('columns.domain')}</th>
<th className={TABLE_HEAD_CELL}>{t('columns.system')}</th>
<th className={TABLE_HEAD_CELL}>{t('columns.status')}</th>
<th className={TABLE_HEAD_CELL}>{t('columns.confirmed')}</th>
{canManage && <th className={TABLE_HEAD_CELL} />}
</tr>
</thead>
<tbody>
{orders.map((order) => {
const info = orderStatusInfo(order);
const discardable = canManage && isDiscardable(order);
return (
<tr key={order.id} className="border-t border-border align-top">
<td className={`${TABLE_CELL} font-medium text-foreground`}>
{order.domainNameUnicode}
</td>
<td className={TABLE_CELL}>{t(`environment.${order.environment}`)}</td>
<td className={TABLE_CELL}>
<span
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${info.className}`}
>
{t(`status.${info.labelKey}`)}
</span>
{order.status === 'FAILED' && order.errorText && (
<p className="mt-1 text-xs text-destructive">{order.errorText}</p>
)}
{order.status === 'SUBMITTED' && order.jobStatus === 'SUPPORT' && (
<p className="mt-1 text-xs text-muted-foreground">{t('supportHint')}</p>
)}
{order.status === 'UNKNOWN' && (
<p className="mt-1 text-xs text-muted-foreground">
{order.lastCheckedAt ? t('unknownChecked') : t('unknownUnchecked')}
</p>
)}
</td>
<td className={`${TABLE_CELL} tabular-nums`}>
{order.confirmedByUsername && order.confirmedAt
? t('confirmedBy', {
user: order.confirmedByUsername,
date: formatDateTime(order.confirmedAt, locale),
})
: t('notConfirmed')}
</td>
{canManage && (
<td className={TABLE_CELL}>
{discardable && discardId !== order.id && (
<button
type="button"
className={SECONDARY_BUTTON}
onClick={() => {
setDiscardError(null);
setDiscardId(order.id);
}}
>
{t('discard')}
</button>
)}
{discardable && discardId === order.id && (
<div role="alertdialog" className="max-w-xs space-y-2 text-xs">
<p className="text-foreground">
{order.status === 'UNKNOWN'
? t('confirmDiscardUnknown')
: t('confirmDiscard')}
</p>
{discardError && (
<p role="alert" className="text-destructive">
{discardError}
</p>
)}
<div className="flex gap-2">
<button
type="button"
className={SECONDARY_BUTTON}
onClick={() => void onDiscard(order)}
>
{t('discardYes')}
</button>
<button
type="button"
className={SECONDARY_BUTTON}
onClick={() => setDiscardId(null)}
>
{t('discardNo')}
</button>
</div>
</div>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</SettingsSection>
)}
</div>
);
}
@@ -0,0 +1,396 @@
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } 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 AvailabilityResult,
type DomainsContact,
type DomainsCustomer,
type DomainsOrder,
DomainsRequestError,
type DomainsStatus,
type OrderSummary,
} from '@/lib/domains-api';
import de from '@/messages/de.json';
import { RegisterTab } from './RegisterTab';
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
const mockCheck = vi.fn();
const mockCreate = vi.fn();
const mockSubmit = vi.fn();
const mockCancel = vi.fn();
const mockListContacts = vi.fn();
vi.mock('@/lib/domains-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/domains-api')>();
return {
...actual,
checkAvailability: (...a: unknown[]) => mockCheck(...a),
createOrder: (...a: unknown[]) => mockCreate(...a),
submitOrder: (...a: unknown[]) => mockSubmit(...a),
cancelOrder: (...a: unknown[]) => mockCancel(...a),
listContacts: (...a: unknown[]) => mockListContacts(...a),
};
});
function status(over: Partial<DomainsStatus> = {}): DomainsStatus {
return {
environment: 'DEMO',
configured: true,
demoConfigured: true,
liveConfigured: true,
defaultNameServers: ['ns1.example.com', 'ns2.example.com'],
...over,
};
}
const CUSTOMERS: DomainsCustomer[] = [
{ id: 'c-own', name: 'Unsere Firma', isOwnCompany: true, contactCount: 1 },
{ id: 'c-a', name: 'Alpha GmbH', isOwnCompany: false, contactCount: 1 },
];
function contact(id: number, name: string, customerId: string | null): DomainsContact {
return {
id,
type: 'ORG',
displayName: name,
fname: null,
lname: null,
organization: name,
address: [],
pcode: null,
city: 'Berlin',
country: 'DE',
email: null,
phone: null,
alias: null,
customerId,
customerName:
customerId === 'c-own' ? 'Unsere Firma' : customerId === 'c-a' ? 'Alpha GmbH' : null,
};
}
const CONTACTS = [
contact(11, 'Alpha GmbH', 'c-a'),
contact(22, 'Unsere Firma GmbH', 'c-own'),
contact(33, 'Frei Fritz', null),
];
function free(over: Partial<AvailabilityResult> = {}): AvailabilityResult {
return {
environment: 'DEMO',
domain: 'beispiel.de',
domainUnicode: 'beispiel.de',
available: true,
whoisStatus: 'FREE',
price: { amount: 4.9, currency: 'EUR' },
...over,
};
}
function order(over: Partial<DomainsOrder> = {}): DomainsOrder {
return {
id: 'o1',
environment: 'DEMO',
domainName: 'beispiel.de',
domainNameUnicode: 'beispiel.de',
status: 'DRAFT',
jobStatus: null,
errorText: null,
confirmedByUsername: null,
confirmedAt: null,
createdAt: '2026-10-08T10:00:00.000Z',
lastCheckedAt: null,
price: { amount: 4.9, currency: 'EUR' },
...over,
};
}
function summary(over: Partial<OrderSummary> = {}): OrderSummary {
return {
order: order(),
contacts: {
owner: { id: 11, displayName: 'Alpha GmbH' },
admin: { id: 11, displayName: 'Alpha GmbH' },
tech: { id: 22, displayName: 'Unsere Firma GmbH' },
zone: { id: 22, displayName: 'Unsere Firma GmbH' },
},
nameServers: ['ns1.example.com', 'ns2.example.com'],
periodYears: 1,
...over,
};
}
function renderTab(statusOver: Partial<DomainsStatus> = {}, onOpenOrders = vi.fn()) {
render(
<RegisterTab status={status(statusOver)} customers={CUSTOMERS} onOpenOrders={onOpenOrders} />,
);
return { onOpenOrders };
}
async function checkFree() {
fireEvent.change(screen.getByLabelText('Domain'), { target: { value: 'beispiel.de' } });
fireEvent.click(screen.getByRole('button', { name: 'Verfügbarkeit prüfen' }));
await screen.findByText('beispiel.de ist frei.');
}
async function reachSummary() {
await checkFree();
const owner = await screen.findByLabelText('Inhaber');
await waitFor(() => expect(mockListContacts).toHaveBeenCalled());
fireEvent.change(owner, { target: { value: '11' } });
fireEvent.click(screen.getByRole('button', { name: 'Zusammenfassung anzeigen' }));
await screen.findByRole('button', { name: 'Jetzt verbindlich registrieren' });
}
beforeEach(() => {
mockCheck.mockReset().mockResolvedValue(free());
mockCreate.mockReset().mockResolvedValue(summary());
mockSubmit.mockReset().mockResolvedValue(order({ status: 'SUBMITTED', jobStatus: 'RUNNING' }));
mockCancel.mockReset().mockResolvedValue(order({ status: 'CANCELED' }));
mockListContacts.mockReset().mockResolvedValue({
environment: 'DEMO',
contacts: CONTACTS,
truncated: false,
fetchedAt: '2026-10-08T10:00:00.000Z',
});
});
afterEach(() => {
cleanup();
});
describe('RegisterTab — Verfuegbarkeit', () => {
it('zeigt "frei" mit Preis; Kontakte erscheinen erst danach', async () => {
renderTab();
expect(screen.queryByLabelText('Inhaber')).toBeNull();
await checkFree();
expect(mockCheck).toHaveBeenCalledWith('beispiel.de');
expect(screen.getByText(/Preis für ein Jahr: 4,90/)).toBeTruthy();
expect(await screen.findByLabelText('Inhaber')).toBeTruthy();
});
it('nicht frei: Hinweis, keine Kontaktauswahl', async () => {
mockCheck.mockResolvedValue(free({ available: false, whoisStatus: 'ASSIGNED' }));
renderTab();
fireEvent.change(screen.getByLabelText('Domain'), { target: { value: 'beispiel.de' } });
fireEvent.click(screen.getByRole('button', { name: 'Verfügbarkeit prüfen' }));
expect(
await screen.findByText('beispiel.de ist nicht frei und kann nicht registriert werden.'),
).toBeTruthy();
expect(screen.queryByLabelText('Inhaber')).toBeNull();
expect(mockListContacts).not.toHaveBeenCalled();
});
it('frei ohne Preis: "Preis nicht ermittelbar"', async () => {
mockCheck.mockResolvedValue(free({ price: null }));
renderTab();
await checkFree();
expect(screen.getByText('Preis nicht ermittelbar')).toBeTruthy();
});
it('zeigt den Fehlertext der API', async () => {
mockCheck.mockRejectedValue(new DomainsRequestError(400, 'invalidDomain', 'Ungültiger Name.'));
renderTab();
fireEvent.change(screen.getByLabelText('Domain'), { target: { value: 'x' } });
fireEvent.click(screen.getByRole('button', { name: 'Verfügbarkeit prüfen' }));
expect((await screen.findByRole('alert')).textContent).toBe('Ungültiger Name.');
});
});
describe('RegisterTab — Kontakte und Nameserver', () => {
it('Voreinstellung: Admin = Inhaber, Technik/Zone = Kontakt der eigenen Firma; Nameserver aus den Einstellungen', async () => {
renderTab();
await checkFree();
const owner = (await screen.findByLabelText('Inhaber')) as HTMLSelectElement;
await waitFor(() => expect(owner.options.length).toBeGreaterThan(1));
expect(owner.value).toBe('');
fireEvent.change(owner, { target: { value: '33' } });
expect((screen.getByLabelText('Admin-Kontakt') as HTMLSelectElement).value).toBe('33');
expect((screen.getByLabelText('Technischer Kontakt') as HTMLSelectElement).value).toBe('22');
expect((screen.getByLabelText('Zonenkontakt') as HTMLSelectElement).value).toBe('22');
expect((screen.getByLabelText('Nameserver 1') as HTMLInputElement).value).toBe(
'ns1.example.com',
);
expect((screen.getByLabelText('Nameserver 2') as HTMLInputElement).value).toBe(
'ns2.example.com',
);
});
it('ohne Kontakt der eigenen Firma folgen Technik und Zone dem Inhaber', async () => {
render(<RegisterTab status={status()} customers={[CUSTOMERS[1]]} onOpenOrders={vi.fn()} />);
await checkFree();
const owner = (await screen.findByLabelText('Inhaber')) as HTMLSelectElement;
await waitFor(() => expect(owner.options.length).toBeGreaterThan(1));
fireEvent.change(owner, { target: { value: '11' } });
expect((screen.getByLabelText('Technischer Kontakt') as HTMLSelectElement).value).toBe('11');
expect((screen.getByLabelText('Zonenkontakt') as HTMLSelectElement).value).toBe('11');
});
it('"Zusammenfassung anzeigen" ist ohne Inhaber gesperrt', async () => {
renderTab();
await checkFree();
await screen.findByLabelText('Inhaber');
expect(
(screen.getByRole('button', { name: 'Zusammenfassung anzeigen' }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
it('Nameserver lassen sich zwischen 2 und 6 hinzufuegen und entfernen', async () => {
renderTab({ defaultNameServers: [] });
await checkFree();
await screen.findByLabelText('Inhaber');
expect(screen.queryByRole('button', { name: 'Entfernen' })).toBeNull();
for (let i = 0; i < 4; i += 1) {
fireEvent.click(screen.getByRole('button', { name: 'Nameserver hinzufügen' }));
}
expect(screen.queryByRole('button', { name: 'Nameserver hinzufügen' })).toBeNull();
expect(screen.getByLabelText('Nameserver 6')).toBeTruthy();
fireEvent.click(screen.getAllByRole('button', { name: 'Entfernen' })[0]);
expect(screen.queryByLabelText('Nameserver 6')).toBeNull();
});
});
describe('RegisterTab — Zusammenfassung und verbindliche Bestaetigung', () => {
it('createOrder bekommt die Auswahl; die Zusammenfassung nennt System, Domain, Preis, Kontakte, Nameserver', async () => {
renderTab();
await reachSummary();
expect(mockCreate).toHaveBeenCalledWith({
domain: 'beispiel.de',
ownerContactId: 11,
adminContactId: 11,
techContactId: 22,
zoneContactId: 22,
nameServers: ['ns1.example.com', 'ns2.example.com'],
});
expect(screen.getByTestId('domains-environment-badge').textContent).toBe(
'Demo-System (Testbetrieb)',
);
expect(screen.getByText('1 Jahr')).toBeTruthy();
expect(screen.getByText(/4,90/)).toBeTruthy();
expect(screen.getByText('ns1.example.com, ns2.example.com')).toBeTruthy();
expect(
screen.getByText('Ich bestätige die Registrierung im Demo-System von AutoDNS (Testbetrieb).'),
).toBeTruthy();
});
it('Live: Kennzeichnung und kostenpflichtiger Bestaetigungstext; ohne Preis "Preis nicht ermittelbar"', async () => {
mockCreate.mockResolvedValue(summary({ order: order({ environment: 'LIVE', price: null }) }));
renderTab({ environment: 'LIVE' });
await reachSummary();
expect(screen.getByTestId('domains-environment-badge').textContent).toBe(
'Live-System – Registrierungen kosten Geld',
);
expect(
screen.getByText(
'Ich bestätige die verbindliche und kostenpflichtige Registrierung bei AutoDNS.',
),
).toBeTruthy();
expect(screen.getByText('Preis nicht ermittelbar')).toBeTruthy();
});
it('der Knopf ist gesperrt, bis das Haekchen gesetzt ist', async () => {
renderTab();
await reachSummary();
const button = screen.getByRole('button', {
name: 'Jetzt verbindlich registrieren',
}) as HTMLButtonElement;
expect(button.disabled).toBe(true);
fireEvent.click(screen.getByRole('checkbox'));
expect(button.disabled).toBe(false);
expect(mockSubmit).not.toHaveBeenCalled();
});
it('zwei schnelle Klicks rufen submitOrder genau einmal auf, der Knopf zeigt "Wird übermittelt …"', async () => {
let resolveSubmit!: (o: DomainsOrder) => void;
mockSubmit.mockImplementation(
() =>
new Promise<DomainsOrder>((resolve) => {
resolveSubmit = resolve;
}),
);
renderTab();
await reachSummary();
fireEvent.click(screen.getByRole('checkbox'));
const button = screen.getByRole('button', { name: 'Jetzt verbindlich registrieren' });
fireEvent.click(button);
fireEvent.click(button);
expect(mockSubmit).toHaveBeenCalledTimes(1);
expect(mockSubmit).toHaveBeenCalledWith('o1');
expect(screen.getByRole('button', { name: 'Wird übermittelt …' })).toBeTruthy();
resolveSubmit(order({ status: 'SUBMITTED', jobStatus: 'RUNNING' }));
await screen.findByText(/wird bearbeitet/);
expect(mockSubmit).toHaveBeenCalledTimes(1);
});
it('SUBMITTED zeigt den Fortschrittstext und fuehrt zu den Auftraegen', async () => {
const { onOpenOrders } = renderTab();
await reachSummary();
fireEvent.click(screen.getByRole('checkbox'));
fireEvent.click(screen.getByRole('button', { name: 'Jetzt verbindlich registrieren' }));
await screen.findByText(/Die Registrierung wurde an AutoDNS übergeben und wird bearbeitet/);
fireEvent.click(screen.getByRole('button', { name: 'Zu den Aufträgen' }));
expect(onOpenOrders).toHaveBeenCalledTimes(1);
});
it('FAILED zeigt den Fehler von AutoDNS', async () => {
mockSubmit.mockResolvedValue(
order({ status: 'FAILED', errorText: 'AutoDNS meldet: Domain not available' }),
);
renderTab();
await reachSummary();
fireEvent.click(screen.getByRole('checkbox'));
fireEvent.click(screen.getByRole('button', { name: 'Jetzt verbindlich registrieren' }));
expect(await screen.findByText('AutoDNS meldet: Domain not available')).toBeTruthy();
expect(screen.getByText('AutoDNS hat die Registrierung nicht ausgeführt.')).toBeTruthy();
});
it('UNKNOWN zeigt "Ergebnis ungeklärt" mit dem Rat, nicht erneut zu bestellen', async () => {
mockSubmit.mockResolvedValue(order({ status: 'UNKNOWN' }));
renderTab();
await reachSummary();
fireEvent.click(screen.getByRole('checkbox'));
fireEvent.click(screen.getByRole('button', { name: 'Jetzt verbindlich registrieren' }));
expect(await screen.findByText('Ergebnis ungeklärt')).toBeTruthy();
expect(screen.getByText(/Bestellen Sie die Domain bitte nicht erneut/)).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Weitere Domain registrieren' })).toBeNull();
});
it('ein Fehler beim Abschicken laesst den Knopf gesperrt, bis "Zurück" gewaehlt wird', async () => {
mockSubmit.mockRejectedValue(
new DomainsRequestError(409, 'alreadySubmitted', 'Dieser Auftrag wurde bereits abgeschickt.'),
);
renderTab();
await reachSummary();
fireEvent.click(screen.getByRole('checkbox'));
const button = screen.getByRole('button', {
name: 'Jetzt verbindlich registrieren',
}) as HTMLButtonElement;
fireEvent.click(button);
expect(await screen.findByText('Dieser Auftrag wurde bereits abgeschickt.')).toBeTruthy();
expect(button.disabled).toBe(true);
fireEvent.click(button);
expect(mockSubmit).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'Zurück' }));
expect(screen.queryByRole('button', { name: 'Jetzt verbindlich registrieren' })).toBeNull();
});
it('"Abbrechen" ruft cancelOrder auf und beginnt neu', async () => {
renderTab();
await reachSummary();
fireEvent.click(screen.getByRole('button', { name: 'Abbrechen' }));
await waitFor(() => expect(mockCancel).toHaveBeenCalledWith('o1'));
await waitFor(() =>
expect((screen.getByLabelText('Domain') as HTMLInputElement).value).toBe(''),
);
expect(mockSubmit).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,526 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useEffect, useMemo, useRef, useState } from 'react';
import { SettingsSection } from '@/components/control-center/settings-section';
import { groupByCustomer } from '@/components/domains/group-by-customer';
import { orderStatusInfo } from '@/components/domains/order-status';
import { INPUT_CLASS, PRIMARY_BUTTON, SECONDARY_BUTTON } from '@/components/domains/ui-classes';
import {
type AvailabilityResult,
cancelOrder,
checkAvailability,
createOrder,
type DomainsContact,
type DomainsCustomer,
type DomainsOrder,
DomainsRequestError,
type DomainsStatus,
listContacts,
type OrderPrice,
type OrderSummary,
submitOrder,
} from '@/lib/domains-api';
import { EnvironmentBadge } from './EnvironmentBadge';
const MIN_NAMESERVERS = 2;
const MAX_NAMESERVERS = 6;
type Role = 'owner' | 'admin' | 'tech' | 'zone';
const ROLES: Role[] = ['owner', 'admin', 'tech', 'zone'];
export function formatPrice(price: OrderPrice, locale: string): string {
try {
return new Intl.NumberFormat(locale, { style: 'currency', currency: price.currency }).format(
price.amount,
);
} catch {
return `${price.amount} ${price.currency}`;
}
}
function initialNameServers(defaults: string[]): string[] {
const list = defaults.slice(0, MAX_NAMESERVERS);
while (list.length < MIN_NAMESERVERS) list.push('');
return list;
}
/**
* Reiter "Registrieren" (L-04): Verfuegbarkeit -> Kontakte und Nameserver ->
* Zusammenfassung -> verbindliche Bestaetigung. Der Knopf "Jetzt verbindlich
* registrieren" ist nur Komfort-Schutz gegen Doppelklick (`useRef` sperrt noch
* vor dem naechsten Zeichnen); die eigentliche Sperre ist der atomare Anspruch
* auf dem Server. Nach einem Fehler beim Abschicken bleibt der Knopf gesperrt,
* bis der Benutzer bewusst "Zurueck" waehlt — der Auftrag koennte schon
* angekommen sein.
*/
export function RegisterTab({
status,
customers,
onOpenOrders,
}: {
status: DomainsStatus;
customers: DomainsCustomer[];
onOpenOrders: () => void;
}) {
const t = useTranslations('domains.register');
const tc = useTranslations('domains.common');
const locale = useLocale();
const [domainInput, setDomainInput] = useState('');
const [checking, setChecking] = useState(false);
const [availability, setAvailability] = useState<AvailabilityResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [contacts, setContacts] = useState<DomainsContact[] | null>(null);
const [contactsLoading, setContactsLoading] = useState(false);
const [chosen, setChosen] = useState<Partial<Record<Role, number>>>({});
const [nameServers, setNameServers] = useState<string[]>(() =>
initialNameServers(status.defaultNameServers),
);
const [creating, setCreating] = useState(false);
const [summary, setSummary] = useState<OrderSummary | null>(null);
const [confirmed, setConfirmed] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [canceling, setCanceling] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [result, setResult] = useState<DomainsOrder | null>(null);
const submittingRef = useRef(false);
const available = availability?.available === true;
// Kontakte erst laden, wenn die Domain frei ist.
useEffect(() => {
if (!available || contacts !== null) return;
let alive = true;
setContactsLoading(true);
listContacts()
.then((list) => {
if (alive) setContacts(list.contacts);
})
.catch((e) => {
if (alive) setError(e instanceof DomainsRequestError ? e.message : tc('requestFailed'));
})
.finally(() => {
if (alive) setContactsLoading(false);
});
return () => {
alive = false;
};
}, [available, contacts, tc]);
const ownCompanyId = customers.find((c) => c.isOwnCompany)?.id ?? null;
const ownCompanyContact = useMemo(
() => (ownCompanyId ? contacts?.find((c) => c.customerId === ownCompanyId) : undefined),
[contacts, ownCompanyId],
);
/** Vorbelegung (D-O): Admin = Inhaber; Technik/Zone = Kontakt der eigenen Firma, sonst Inhaber. */
const effective = (role: Role): number | null => {
const picked = chosen[role];
if (picked !== undefined) return picked;
if (role === 'owner') return null;
if (role === 'admin') return chosen.owner ?? null;
return ownCompanyContact?.id ?? chosen.owner ?? null;
};
const groups = useMemo(() => groupByCustomer(contacts ?? [], customers), [contacts, customers]);
const filledNameServers = nameServers.map((n) => n.trim()).filter((n) => n.length > 0);
const contactIds = ROLES.map(effective);
const canSummarize =
available &&
contactIds.every((id) => id !== null) &&
filledNameServers.length >= MIN_NAMESERVERS &&
!creating;
function resetAll() {
submittingRef.current = false;
setDomainInput('');
setAvailability(null);
setError(null);
setChosen({});
setNameServers(initialNameServers(status.defaultNameServers));
setSummary(null);
setConfirmed(false);
setSubmitting(false);
setSubmitError(null);
setResult(null);
}
async function onCheck() {
setError(null);
setChecking(true);
setAvailability(null);
setSummary(null);
try {
setAvailability(await checkAvailability(domainInput));
} catch (e) {
setError(e instanceof DomainsRequestError ? e.message : tc('requestFailed'));
} finally {
setChecking(false);
}
}
async function onSummary() {
if (!availability || contactIds.some((id) => id === null)) return;
setError(null);
setCreating(true);
try {
const [ownerContactId, adminContactId, techContactId, zoneContactId] = contactIds as number[];
setSummary(
await createOrder({
domain: availability.domain,
ownerContactId,
adminContactId,
techContactId,
zoneContactId,
nameServers: filledNameServers,
}),
);
setConfirmed(false);
} catch (e) {
setError(e instanceof DomainsRequestError ? e.message : tc('requestFailed'));
} finally {
setCreating(false);
}
}
async function onSubmit() {
// Sperre noch VOR dem naechsten Zeichnen: ein zweiter Klick kommt hier nicht weiter.
if (submittingRef.current || !summary || !confirmed) return;
submittingRef.current = true;
setSubmitting(true);
setSubmitError(null);
try {
setResult(await submitOrder(summary.order.id));
} catch (e) {
// Die Sperre bleibt: der Auftrag koennte angekommen sein. Nur "Zurueck" hebt sie auf.
setSubmitError(e instanceof DomainsRequestError ? e.message : t('submitFailed'));
setSubmitting(false);
}
}
function onBack() {
submittingRef.current = false;
setSubmitError(null);
setSubmitting(false);
setSummary(null);
setConfirmed(false);
}
async function onCancel() {
if (!summary || canceling) return;
setCanceling(true);
try {
await cancelOrder(summary.order.id);
resetAll();
} catch (e) {
setSubmitError(e instanceof DomainsRequestError ? e.message : tc('requestFailed'));
} finally {
setCanceling(false);
}
}
// --- Ergebnis ---------------------------------------------------------------
if (result) {
const info = orderStatusInfo(result);
return (
<SettingsSection title={t('result.title')}>
<div className="space-y-3">
<p className="text-sm font-medium text-foreground">{result.domainNameUnicode}</p>
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold ${info.className}`}
>
{result.status === 'UNKNOWN' ? t('result.UNKNOWN') : t(`result.${resultKey(result)}`)}
</span>
{result.status === 'UNKNOWN' && (
<p role="alert" className="text-sm text-foreground">
{t('result.unknownAdvice')}
</p>
)}
{result.status === 'FAILED' && result.errorText && (
<p role="alert" className="text-sm text-destructive">
{result.errorText}
</p>
)}
<div className="flex flex-wrap gap-3">
<button type="button" className={PRIMARY_BUTTON} onClick={onOpenOrders}>
{t('result.toOrders')}
</button>
{result.status !== 'UNKNOWN' && (
<button type="button" className={SECONDARY_BUTTON} onClick={resetAll}>
{t('result.another')}
</button>
)}
</div>
</div>
</SettingsSection>
);
}
// --- Zusammenfassung --------------------------------------------------------
if (summary) {
const live = summary.order.environment === 'LIVE';
const rows: { label: string; value: string }[] = [
{ label: t('summary.domain'), value: summary.order.domainNameUnicode },
{ label: t('summary.period'), value: t('summary.periodValue') },
{
label: t('summary.price'),
value: summary.order.price ? formatPrice(summary.order.price, locale) : t('priceUnknown'),
},
...ROLES.map((role) => ({
label: t(`roles.${role}`),
value: `${summary.contacts[role].displayName} (#${summary.contacts[role].id})`,
})),
{ label: t('summary.nameServers'), value: summary.nameServers.join(', ') },
];
return (
<SettingsSection title={t('summary.title')} description={t('summary.description')}>
<div className="space-y-4">
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">{t('summary.system')}</span>
<EnvironmentBadge
status={{ ...status, environment: summary.order.environment, configured: true }}
/>
</div>
<dl className="grid gap-2 text-sm sm:grid-cols-[12rem_1fr]">
{rows.map((row) => (
<div key={row.label} className="contents">
<dt className="text-muted-foreground">{row.label}</dt>
<dd className="text-foreground">{row.value}</dd>
</div>
))}
</dl>
<label className="flex items-start gap-2 text-sm text-foreground">
<input
type="checkbox"
className="mt-1"
checked={confirmed}
disabled={submitting || submitError !== null}
onChange={(e) => setConfirmed(e.target.checked)}
/>
{live ? t('confirmLive') : t('confirmDemo')}
</label>
{submitError && (
<p role="alert" className="text-sm text-destructive">
{submitError}
</p>
)}
<div className="flex flex-wrap gap-3">
<button
type="button"
className={PRIMARY_BUTTON}
disabled={!confirmed || submitting || submitError !== null}
onClick={() => void onSubmit()}
>
{submitting ? t('submitting') : t('submit')}
</button>
{submitError ? (
<>
<button type="button" className={SECONDARY_BUTTON} onClick={onBack}>
{t('back')}
</button>
<button type="button" className={SECONDARY_BUTTON} onClick={onOpenOrders}>
{t('result.toOrders')}
</button>
</>
) : (
<button
type="button"
className={SECONDARY_BUTTON}
disabled={submitting || canceling}
onClick={() => void onCancel()}
>
{canceling ? t('canceling') : t('cancel')}
</button>
)}
</div>
</div>
</SettingsSection>
);
}
// --- Eingabe ----------------------------------------------------------------
return (
<div className="space-y-4">
<SettingsSection title={t('domainLabel')} description={t('intro')}>
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-56 flex-1">
<label htmlFor="domains-register-domain" className="sr-only">
{t('domainLabel')}
</label>
<input
id="domains-register-domain"
type="text"
className={INPUT_CLASS}
placeholder={t('domainPlaceholder')}
value={domainInput}
autoComplete="off"
onChange={(e) => {
setDomainInput(e.target.value);
setAvailability(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && domainInput.trim() && !checking) void onCheck();
}}
/>
</div>
<button
type="button"
className={PRIMARY_BUTTON}
disabled={checking || domainInput.trim() === ''}
onClick={() => void onCheck()}
>
{checking ? t('checking') : t('check')}
</button>
</div>
{error && (
<p role="alert" className="mt-3 text-sm text-destructive">
{error}
</p>
)}
{availability && available && (
<p className="mt-3 text-sm text-foreground">
<span className="mr-2 inline-flex rounded-full bg-status-ok/12 px-2 py-0.5 text-xs font-medium text-status-ok-fg">
{t('free', { domain: availability.domainUnicode })}
</span>
{availability.price
? t('price', { price: formatPrice(availability.price, locale) })
: t('priceUnknown')}
</p>
)}
{availability && !available && (
<div className="mt-3 space-y-1 text-sm">
<p className="text-foreground">
{t('notFree', { domain: availability.domainUnicode })}
</p>
<p className="text-muted-foreground">
{t('notFreeStatus', { status: availability.whoisStatus })}
</p>
</div>
)}
</SettingsSection>
{available && (
<>
<SettingsSection title={t('contactsTitle')} description={t('contactsDescription')}>
{contactsLoading && (
<p className="text-sm text-muted-foreground">{t('contactsLoading')}</p>
)}
{contacts !== null && contacts.length === 0 && (
<p className="text-sm text-muted-foreground">{t('contactsEmpty')}</p>
)}
{contacts !== null && contacts.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2">
{ROLES.map((role) => {
const id = `domains-register-${role}`;
const value = effective(role);
return (
<div key={role}>
<label htmlFor={id} className="mb-1 block text-xs text-muted-foreground">
{t(`roles.${role}`)}
</label>
<select
id={id}
className={INPUT_CLASS}
value={value === null ? '' : String(value)}
onChange={(e) => {
const next = e.target.value;
setChosen((prev) => {
const copy = { ...prev };
if (next === '') delete copy[role];
else copy[role] = Number(next);
return copy;
});
}}
>
<option value="">{t('choose')}</option>
{groups.map((group) => (
<optgroup key={group.key} label={group.name ?? tc('unassigned')}>
{group.items.map((contact) => (
<option key={contact.id} value={String(contact.id)}>
{contact.displayName}
{contact.city ? ` – ${contact.city}` : ''} (#{contact.id})
</option>
))}
</optgroup>
))}
</select>
</div>
);
})}
</div>
)}
</SettingsSection>
<SettingsSection
title={t('nameServersTitle')}
description={t('nameServersDescription')}
footer={
<button
type="button"
className={PRIMARY_BUTTON}
disabled={!canSummarize}
onClick={() => void onSummary()}
>
{creating ? t('creating') : t('showSummary')}
</button>
}
>
<div className="space-y-2">
{nameServers.map((value, index) => {
const label = t('nameServer', { number: index + 1 });
return (
// biome-ignore lint/suspicious/noArrayIndexKey: feste, nummerierte Eingabefelder
<div key={index} className="flex items-center gap-2">
<input
type="text"
aria-label={label}
className={INPUT_CLASS}
value={value}
onChange={(e) =>
setNameServers((prev) =>
prev.map((n, i) => (i === index ? e.target.value : n)),
)
}
/>
{nameServers.length > MIN_NAMESERVERS && (
<button
type="button"
className={SECONDARY_BUTTON}
onClick={() => setNameServers((prev) => prev.filter((_, i) => i !== index))}
>
{t('removeNameServer')}
</button>
)}
</div>
);
})}
{nameServers.length < MAX_NAMESERVERS && (
<button
type="button"
className={SECONDARY_BUTTON}
onClick={() => setNameServers((prev) => [...prev, ''])}
>
{t('addNameServer')}
</button>
)}
</div>
</SettingsSection>
</>
)}
</div>
);
}
/** Schluessel unter `domains.register.result.*` fuer einen Auftrag nach dem Abschicken. */
function resultKey(order: DomainsOrder): 'SUBMITTED' | 'SUCCESS' | 'FAILED' {
if (order.status === 'FAILED') return 'FAILED';
if (order.status === 'SUCCESS') return 'SUCCESS';
return 'SUBMITTED';
}
@@ -37,6 +37,8 @@ const mockListDomains = vi.fn();
const mockAssignContacts = vi.fn();
const mockCreateCustomer = vi.fn();
const mockDeleteCustomer = vi.fn();
const mockListOrders = vi.fn();
const mockRefreshOrders = vi.fn();
vi.mock('@/lib/domains-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/domains-api')>();
@@ -52,6 +54,8 @@ vi.mock('@/lib/domains-api', async (importOriginal) => {
assignContacts: (...args: unknown[]) => mockAssignContacts(...args),
createCustomer: (...args: unknown[]) => mockCreateCustomer(...args),
deleteCustomer: (...args: unknown[]) => mockDeleteCustomer(...args),
listOrders: (...args: unknown[]) => mockListOrders(...args),
refreshOrders: (...args: unknown[]) => mockRefreshOrders(...args),
};
});
@@ -185,6 +189,8 @@ beforeEach(() => {
mockAssignContacts.mockReset().mockResolvedValue({ assigned: 1, removed: 0 });
mockCreateCustomer.mockReset().mockResolvedValue(CUSTOMERS[1]);
mockDeleteCustomer.mockReset().mockResolvedValue({ deleted: true });
mockListOrders.mockReset().mockResolvedValue([]);
mockRefreshOrders.mockReset().mockResolvedValue([]);
mockGetStatus.mockReset().mockResolvedValue(status());
mockGetSettings.mockReset().mockResolvedValue(settings());
mockSaveSettings.mockReset().mockImplementation(async () => settings());
@@ -517,3 +523,38 @@ describe('Reiter Domains, Kontakte und Kunden', () => {
expect(mockListDomains).not.toHaveBeenCalled();
});
});
describe('Reiter Registrieren und Aufträge', () => {
it('Verwalter sieht beide Reiter in der festen Reihenfolge', async () => {
render(<DomainsPage />);
await screen.findByRole('button', { name: 'Einstellungen' });
const wanted = ['Domains', 'Kontakte', 'Kunden', 'Registrieren', 'Aufträge', 'Einstellungen'];
const names = screen
.getAllByRole('button')
.map((b) => b.textContent ?? '')
.filter((x) => wanted.includes(x));
expect(names).toEqual(wanted);
});
it('Benutzer ohne Verwalten sieht Aufträge, aber weder Registrieren noch Einstellungen', async () => {
mockCanManage = false;
render(<DomainsPage />);
await screen.findByTestId('domains-environment-badge');
expect(screen.getByRole('button', { name: 'Aufträge' })).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Registrieren' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
});
it('der Reiter Aufträge gleicht beim Öffnen mit AutoDNS ab', async () => {
mockCanManage = false;
render(<DomainsPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Aufträge' }));
await waitFor(() => expect(mockRefreshOrders).toHaveBeenCalledTimes(1));
});
it('der Reiter Registrieren zeigt die Domain-Eingabe', async () => {
render(<DomainsPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Registrieren' }));
expect(await screen.findByRole('button', { name: 'Verfügbarkeit prüfen' })).toBeTruthy();
});
});
@@ -16,16 +16,18 @@ import { ContactsTab } from './components/ContactsTab';
import { CustomersTab } from './components/CustomersTab';
import { DomainsTab } from './components/DomainsTab';
import { EnvironmentBadge } from './components/EnvironmentBadge';
import { OrdersTab } from './components/OrdersTab';
import { RegisterTab } from './components/RegisterTab';
import { SettingsTab } from './components/SettingsTab';
type TabId = 'domains' | 'contacts' | 'customers' | 'settings';
type TabId = 'domains' | 'contacts' | 'customers' | 'register' | 'orders' | 'settings';
/**
* Domains (quick-261008-dts): Domains bei AutoDNS registrieren, Kontakte und
* Kunden zuordnen. Der Seitenkopf traegt dauerhaft die Kennzeichnung des
* aktiven Systems (Demo/Live). Die Reiter Domains, Kontakte und Kunden sehen alle
* mit "Benutzen"; "Einstellungen" und die Schreibknoepfe erscheinen nur fuer
* Administratoren und Benutzer mit der Freigabestufe Verwalten — bindend ist
* aktiven Systems (Demo/Live). Die Reiter Domains, Kontakte, Kunden und Auftraege
* sehen alle mit "Benutzen"; "Registrieren", "Einstellungen" und die Schreibknoepfe
* erscheinen nur fuer Administratoren und Benutzer mit der Freigabestufe Verwalten — bindend ist
* allein die API. Die Kundenliste wird einmal hier geladen und nach unten
* gereicht (Filter, Zuordnung, Formular).
*/
@@ -65,6 +67,8 @@ export default function DomainsPage() {
{ id: 'contacts', label: t('tabs.contacts') },
{ id: 'customers', label: t('tabs.customers') },
];
if (canManage) tabs.push({ id: 'register', label: t('tabs.register') });
tabs.push({ id: 'orders', label: t('tabs.orders') });
if (canManage) tabs.push({ id: 'settings', label: t('tabs.settings') });
const activeTab = tabs.some((x) => x.id === tab) ? tab : tabs[0]?.id;
@@ -111,6 +115,15 @@ export default function DomainsPage() {
{activeTab === 'customers' && (
<CustomersTab customers={customers} canManage={canManage} onChanged={reloadCustomers} />
)}
{activeTab === 'register' && canManage && status?.configured && (
<RegisterTab
key={status.environment}
status={status}
customers={customers}
onOpenOrders={() => setTab('orders')}
/>
)}
{activeTab === 'orders' && <OrdersTab canManage={canManage} />}
{activeTab === 'settings' && (
<SettingsTab
onChanged={() => {
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { isDiscardable, isOpen, orderStatusInfo } from './order-status';
const base = { jobStatus: null, lastCheckedAt: null };
describe('orderStatusInfo', () => {
it('SUBMITTED mit jobStatus SUPPORT heisst "Rückfrage nötig"', () => {
expect(orderStatusInfo({ ...base, status: 'SUBMITTED', jobStatus: 'SUPPORT' }).labelKey).toBe(
'SUPPORT',
);
expect(orderStatusInfo({ ...base, status: 'SUBMITTED', jobStatus: 'RUNNING' }).labelKey).toBe(
'SUBMITTED',
);
});
it('Farben: Erfolg ok, Fehler down, unklar warn, Entwurf/Verworfen idle', () => {
expect(orderStatusInfo({ ...base, status: 'SUCCESS' }).className).toContain('status-ok');
expect(orderStatusInfo({ ...base, status: 'FAILED' }).className).toContain('status-down');
expect(orderStatusInfo({ ...base, status: 'UNKNOWN' }).className).toContain('status-warn');
expect(orderStatusInfo({ ...base, status: 'DRAFT' }).className).toContain('status-idle');
expect(orderStatusInfo({ ...base, status: 'CANCELED' }).className).toContain('status-idle');
});
});
describe('isOpen / isDiscardable', () => {
it('offen sind laufende Auftraege und ungeprueft unklare', () => {
expect(isOpen({ ...base, status: 'SUBMITTING' })).toBe(true);
expect(isOpen({ ...base, status: 'SUBMITTED' })).toBe(true);
expect(isOpen({ ...base, status: 'UNKNOWN' })).toBe(true);
expect(isOpen({ ...base, status: 'UNKNOWN', lastCheckedAt: '2026-10-08T10:00:00Z' })).toBe(
false,
);
for (const status of ['DRAFT', 'SUCCESS', 'FAILED', 'CANCELED'] as const) {
expect(isOpen({ ...base, status })).toBe(false);
}
});
it('verworfen werden nur Entwuerfe und geprueft unklare Auftraege', () => {
expect(isDiscardable({ ...base, status: 'DRAFT' })).toBe(true);
expect(isDiscardable({ ...base, status: 'UNKNOWN' })).toBe(false);
expect(
isDiscardable({ ...base, status: 'UNKNOWN', lastCheckedAt: '2026-10-08T10:00:00Z' }),
).toBe(true);
expect(isDiscardable({ ...base, status: 'SUBMITTED' })).toBe(false);
expect(isDiscardable({ ...base, status: 'SUCCESS' })).toBe(false);
});
});
@@ -0,0 +1,62 @@
import type { DomainOrderStatus } from '@/lib/domains-api';
/**
* Darstellung des Auftragszustands (quick-261008-dts): Beschriftungsschluessel
* und Pillen-Klassen. Die Klassen stehen als vollstaendige Zeichenketten,
* damit Tailwind sie findet.
*/
export interface OrderLike {
status: DomainOrderStatus;
jobStatus: string | null;
lastCheckedAt: string | null;
}
export interface OrderStatusInfo {
/** Schluessel unter `domains.orders.status.*`. */
labelKey: string;
className: string;
}
const OK = 'bg-status-ok/12 text-status-ok-fg';
const WARN = 'bg-status-warn/12 text-status-warn-fg';
const DOWN = 'bg-status-down/12 text-status-down-fg';
const IDLE = 'bg-status-idle/12 text-status-idle-fg';
export function orderStatusInfo(order: OrderLike): OrderStatusInfo {
switch (order.status) {
case 'SUCCESS':
return { labelKey: 'SUCCESS', className: OK };
case 'FAILED':
return { labelKey: 'FAILED', className: DOWN };
case 'UNKNOWN':
return { labelKey: 'UNKNOWN', className: WARN };
case 'SUBMITTING':
return { labelKey: 'SUBMITTING', className: WARN };
case 'SUBMITTED':
// Eingriff bei InterNetX noetig: nicht "laeuft", sondern "Rueckfrage".
return order.jobStatus === 'SUPPORT'
? { labelKey: 'SUPPORT', className: WARN }
: { labelKey: 'SUBMITTED', className: WARN };
case 'CANCELED':
return { labelKey: 'CANCELED', className: IDLE };
default:
return { labelKey: 'DRAFT', className: IDLE };
}
}
/**
* Wird der Auftrag noch verfolgt? Dann lohnt der regelmaessige Abgleich. Ein
* unklarer Auftrag zaehlt nur, solange noch nie bei AutoDNS nachgesehen wurde —
* danach wartet er auf den Benutzer und soll nicht dauernd Anfragen ausloesen.
*/
export function isOpen(order: OrderLike): boolean {
if (order.status === 'SUBMITTING' || order.status === 'SUBMITTED') return true;
return order.status === 'UNKNOWN' && order.lastCheckedAt === null;
}
/** Darf der Auftrag verworfen werden? (Entwurf; unklar erst nach einem Abgleich.) */
export function isDiscardable(order: OrderLike): boolean {
if (order.status === 'DRAFT') return true;
return order.status === 'UNKNOWN' && order.lastCheckedAt !== null;
}
+96
View File
@@ -244,3 +244,99 @@ export function assignContacts(
export function listDomains(opts: { refresh?: boolean } = {}): Promise<DomainsDomainList> {
return request<DomainsDomainList>(`/domains${opts.refresh ? '?refresh=1' : ''}`);
}
// --- Verfuegbarkeit, Registrierung und Auftraege (quick-261008-dts, Aufgabe 3) ----
export type DomainOrderStatus =
| 'DRAFT'
| 'SUBMITTING'
| 'SUBMITTED'
| 'SUCCESS'
| 'FAILED'
| 'UNKNOWN'
| 'CANCELED';
export interface OrderPrice {
amount: number;
currency: string;
}
export interface AvailabilityResult {
environment: DomainsEnvironment;
/** ASCII-Schreibweise (Punycode) — so geht sie an AutoDNS. */
domain: string;
domainUnicode: string;
/** Nur `FREE` ist bestellbar. */
available: boolean;
whoisStatus: string;
/** Preis fuer ein Jahr; `null`, wenn AutoDNS keinen nennt. */
price: OrderPrice | null;
}
export interface DomainsOrder {
id: string;
environment: DomainsEnvironment;
domainName: string;
domainNameUnicode: string;
status: DomainOrderStatus;
jobStatus: string | null;
errorText: string | null;
confirmedByUsername: string | null;
confirmedAt: string | null;
createdAt: string;
lastCheckedAt: string | null;
price: OrderPrice | null;
}
export interface OrderContact {
id: number;
displayName: string;
}
/** Zusammenfassung eines Entwurfs — das, was der Benutzer verbindlich bestaetigt. */
export interface OrderSummary {
order: DomainsOrder;
contacts: {
owner: OrderContact;
admin: OrderContact;
tech: OrderContact;
zone: OrderContact;
};
nameServers: string[];
periodYears: 1;
}
export interface CreateOrderInput {
domain: string;
ownerContactId: number;
adminContactId: number;
techContactId: number;
zoneContactId: number;
nameServers: string[];
}
export function checkAvailability(domain: string): Promise<AvailabilityResult> {
return request<AvailabilityResult>('/availability', { method: 'POST', json: { domain } });
}
export function createOrder(input: CreateOrderInput): Promise<OrderSummary> {
return request<OrderSummary>('/orders', { method: 'POST', json: input });
}
/** Verbindlich: schickt die Registrierung an AutoDNS (kostet im Live-System Geld). */
export function submitOrder(id: string): Promise<DomainsOrder> {
return request<DomainsOrder>(`/orders/${encodeURIComponent(id)}/submit`, { method: 'POST' });
}
export function cancelOrder(id: string): Promise<DomainsOrder> {
return request<DomainsOrder>(`/orders/${encodeURIComponent(id)}/cancel`, { method: 'POST' });
}
export function listOrders(): Promise<DomainsOrder[]> {
return request<DomainsOrder[]>('/orders');
}
/** Gleicht offene Auftraege mit AutoDNS ab und liefert die aktuelle Liste. */
export function refreshOrders(): Promise<DomainsOrder[]> {
return request<DomainsOrder[]>('/orders/refresh', { method: 'POST' });
}
+97
View File
@@ -2061,6 +2061,8 @@
"domains": "Domains",
"contacts": "Kontakte",
"customers": "Kunden",
"register": "Registrieren",
"orders": "Aufträge",
"settings": "Einstellungen"
},
"notConfigured": {
@@ -2206,6 +2208,101 @@
"confirmDelete": "Kunde „{name}“ wirklich löschen?",
"confirmDeleteYes": "Endgültig löschen"
},
"register": {
"intro": "Prüfen Sie, ob eine Domain frei ist, wählen Sie die Kontakte und Nameserver und registrieren Sie die Domain verbindlich bei AutoDNS.",
"domainLabel": "Domain",
"domainPlaceholder": "beispiel.de",
"check": "Verfügbarkeit prüfen",
"checking": "Wird geprüft …",
"free": "{domain} ist frei.",
"price": "Preis für ein Jahr: {price}",
"priceUnknown": "Preis nicht ermittelbar",
"notFree": "{domain} ist nicht frei und kann nicht registriert werden.",
"notFreeStatus": "Meldung der Registry: {status}",
"contactsTitle": "Kontakte",
"contactsDescription": "Wählen Sie für jede Rolle einen Kontakt aus AutoDNS. Der Inhaber ist Pflicht; Admin-, Technik- und Zonenkontakt sind vorausgewählt und änderbar.",
"contactsLoading": "Kontakte werden geladen …",
"contactsEmpty": "Bei AutoDNS gibt es noch keine Kontakte. Legen Sie zuerst unter „Kontakte“ einen Kontakt an.",
"choose": "Bitte wählen …",
"roles": {
"owner": "Inhaber",
"admin": "Admin-Kontakt",
"tech": "Technischer Kontakt",
"zone": "Zonenkontakt"
},
"nameServersTitle": "Nameserver",
"nameServersDescription": "Mindestens zwei, höchstens sechs. Die Nameserver müssen bei Ihrem Anbieter bereits eingerichtet sein, sonst scheitert die Registrierung.",
"nameServer": "Nameserver {number}",
"addNameServer": "Nameserver hinzufügen",
"removeNameServer": "Entfernen",
"showSummary": "Zusammenfassung anzeigen",
"creating": "Wird vorbereitet …",
"summary": {
"title": "Zusammenfassung",
"description": "Bitte prüfen Sie alle Angaben. Mit dem Knopf unten wird die Registrierung verbindlich.",
"system": "System",
"domain": "Domain",
"period": "Laufzeit",
"periodValue": "1 Jahr",
"price": "Preis",
"nameServers": "Nameserver"
},
"confirmLive": "Ich bestätige die verbindliche und kostenpflichtige Registrierung bei AutoDNS.",
"confirmDemo": "Ich bestätige die Registrierung im Demo-System von AutoDNS (Testbetrieb).",
"submit": "Jetzt verbindlich registrieren",
"submitting": "Wird übermittelt …",
"cancel": "Abbrechen",
"canceling": "Wird verworfen …",
"back": "Zurück",
"submitFailed": "Die Registrierung konnte nicht abgeschickt werden. Bitte sehen Sie unter „Aufträge“ nach, bevor Sie es erneut versuchen.",
"result": {
"title": "Ergebnis",
"SUBMITTED": "Die Registrierung wurde an AutoDNS übergeben und wird bearbeitet. Den Fortschritt sehen Sie unter „Aufträge“.",
"SUCCESS": "Die Domain wurde registriert.",
"FAILED": "AutoDNS hat die Registrierung nicht ausgeführt.",
"UNKNOWN": "Ergebnis ungeklärt",
"unknownAdvice": "Tessera konnte nicht feststellen, ob AutoDNS die Registrierung angenommen hat. Bestellen Sie die Domain bitte nicht erneut. Sehen Sie unter „Aufträge“ nach: Dort gleicht Tessera den Auftrag mit AutoDNS ab.",
"toOrders": "Zu den Aufträgen",
"another": "Weitere Domain registrieren"
}
},
"orders": {
"intro": "Hier sehen Sie jede Registrierung und ihren Stand. Tessera gleicht offene Aufträge beim Öffnen und danach alle 30 Sekunden mit AutoDNS ab.",
"refresh": "Aktualisieren",
"refreshing": "Wird abgeglichen …",
"loading": "Aufträge werden geladen …",
"empty": "Es gibt noch keine Aufträge.",
"columns": {
"domain": "Domain",
"system": "System",
"status": "Status",
"confirmed": "Bestätigt"
},
"environment": {
"DEMO": "Demo",
"LIVE": "Live"
},
"status": {
"DRAFT": "Entwurf",
"SUBMITTING": "Wird übermittelt",
"SUBMITTED": "In Bearbeitung",
"SUPPORT": "Rückfrage nötig",
"SUCCESS": "Registriert",
"FAILED": "Fehlgeschlagen",
"UNKNOWN": "Ergebnis ungeklärt",
"CANCELED": "Verworfen"
},
"confirmedBy": "{user}, {date}",
"notConfirmed": "–",
"supportHint": "AutoDNS hat zu diesem Auftrag eine Rückfrage. Bitte wenden Sie sich an AutoDNS beziehungsweise InterNetX.",
"unknownUnchecked": "Tessera hat noch nicht bei AutoDNS nachgesehen. Klicken Sie auf „Aktualisieren“. Bestellen Sie die Domain bitte nicht erneut.",
"unknownChecked": "Bei AutoDNS wurden weder die Domain noch ein passender Auftrag gefunden. Prüfen Sie zur Sicherheit in AutoDNS selbst, bevor Sie den Auftrag verwerfen.",
"discard": "Verwerfen",
"confirmDiscard": "Diesen Entwurf wirklich verwerfen?",
"confirmDiscardUnknown": "Verwerfen Sie diesen Auftrag nur, nachdem Sie in AutoDNS geprüft haben, dass die Domain nicht bestellt wurde. Sonst könnte sie doppelt bestellt werden.",
"discardYes": "Ja, verwerfen",
"discardNo": "Abbrechen"
},
"errors": {
"request": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut.",
"loadStatus": "Der Zustand der AutoDNS-Anbindung konnte nicht geladen werden."
+97
View File
@@ -2061,6 +2061,8 @@
"domains": "Domains",
"contacts": "Contacts",
"customers": "Customers",
"register": "Register",
"orders": "Orders",
"settings": "Settings"
},
"notConfigured": {
@@ -2206,6 +2208,101 @@
"confirmDelete": "Really delete customer “{name}”?",
"confirmDeleteYes": "Delete permanently"
},
"register": {
"intro": "Check whether a domain is available, choose the contacts and name servers, and register the domain with AutoDNS as a binding order.",
"domainLabel": "Domain",
"domainPlaceholder": "example.com",
"check": "Check availability",
"checking": "Checking …",
"free": "{domain} is available.",
"price": "Price for one year: {price}",
"priceUnknown": "Price could not be determined",
"notFree": "{domain} is not available and cannot be registered.",
"notFreeStatus": "Registry response: {status}",
"contactsTitle": "Contacts",
"contactsDescription": "Choose a contact from AutoDNS for each role. The owner is required; the admin, technical and zone contacts are preselected and can be changed.",
"contactsLoading": "Loading contacts …",
"contactsEmpty": "There are no contacts at AutoDNS yet. Create a contact under “Contacts” first.",
"choose": "Please choose …",
"roles": {
"owner": "Owner",
"admin": "Admin contact",
"tech": "Technical contact",
"zone": "Zone contact"
},
"nameServersTitle": "Name servers",
"nameServersDescription": "At least two, at most six. The name servers must already be set up with your provider, otherwise the registration fails.",
"nameServer": "Name server {number}",
"addNameServer": "Add name server",
"removeNameServer": "Remove",
"showSummary": "Show summary",
"creating": "Preparing …",
"summary": {
"title": "Summary",
"description": "Please check all details. The button below makes the registration binding.",
"system": "System",
"domain": "Domain",
"period": "Term",
"periodValue": "1 year",
"price": "Price",
"nameServers": "Name servers"
},
"confirmLive": "I confirm the binding registration with AutoDNS, which costs money.",
"confirmDemo": "I confirm the registration in the AutoDNS demo system (test mode).",
"submit": "Register now (binding)",
"submitting": "Submitting …",
"cancel": "Cancel",
"canceling": "Discarding …",
"back": "Back",
"submitFailed": "The registration could not be submitted. Please check under “Orders” before you try again.",
"result": {
"title": "Result",
"SUBMITTED": "The registration was handed to AutoDNS and is being processed. You can follow the progress under “Orders”.",
"SUCCESS": "The domain was registered.",
"FAILED": "AutoDNS did not carry out the registration.",
"UNKNOWN": "Outcome unclear",
"unknownAdvice": "Tessera could not determine whether AutoDNS accepted the registration. Please do not order the domain again. Check under “Orders”: Tessera compares the order with AutoDNS there.",
"toOrders": "Go to orders",
"another": "Register another domain"
}
},
"orders": {
"intro": "Every registration and its state. Tessera compares open orders with AutoDNS when you open this page and every 30 seconds afterwards.",
"refresh": "Refresh",
"refreshing": "Comparing …",
"loading": "Loading orders …",
"empty": "There are no orders yet.",
"columns": {
"domain": "Domain",
"system": "System",
"status": "Status",
"confirmed": "Confirmed"
},
"environment": {
"DEMO": "Demo",
"LIVE": "Live"
},
"status": {
"DRAFT": "Draft",
"SUBMITTING": "Submitting",
"SUBMITTED": "In progress",
"SUPPORT": "Query pending",
"SUCCESS": "Registered",
"FAILED": "Failed",
"UNKNOWN": "Outcome unclear",
"CANCELED": "Discarded"
},
"confirmedBy": "{user}, {date}",
"notConfirmed": "–",
"supportHint": "AutoDNS has a query about this order. Please contact AutoDNS or InterNetX.",
"unknownUnchecked": "Tessera has not checked with AutoDNS yet. Click “Refresh”. Please do not order the domain again.",
"unknownChecked": "AutoDNS found neither the domain nor a matching order. For safety, check in AutoDNS itself before you discard the order.",
"discard": "Discard",
"confirmDiscard": "Really discard this draft?",
"confirmDiscardUnknown": "Only discard this order after you have checked in AutoDNS that the domain was not ordered. Otherwise it could be ordered twice.",
"discardYes": "Yes, discard",
"discardNo": "Cancel"
},
"errors": {
"request": "The request failed. Please try again.",
"loadStatus": "The state of the AutoDNS connection could not be loaded."
@@ -228,4 +228,8 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
// quick-261008-dts: Modul Domains, Kunden und Kontakte — korrektes Deutsch mit „ss“
'Adresszeile',
'fassen',
// quick-261008-dts (Aufgabe 3): Registrieren und Aufträge — korrektes Deutsch mit „ss“
'Zusammenfassung',
'passender',
'dass',
];