diff --git a/apps/web/src/app/(portal)/modules/domains/components/RegisterTab.test.tsx b/apps/web/src/app/(portal)/modules/domains/components/RegisterTab.test.tsx index 0eb5594..f6ad0be 100644 --- a/apps/web/src/app/(portal)/modules/domains/components/RegisterTab.test.tsx +++ b/apps/web/src/app/(portal)/modules/domains/components/RegisterTab.test.tsx @@ -27,6 +27,7 @@ const mockCreate = vi.fn(); const mockSubmit = vi.fn(); const mockCancel = vi.fn(); const mockListContacts = vi.fn(); +const mockNameServers = vi.fn(); vi.mock('@/lib/domains-api', async (importOriginal) => { const actual = await importOriginal(); @@ -37,6 +38,7 @@ vi.mock('@/lib/domains-api', async (importOriginal) => { submitOrder: (...a: unknown[]) => mockSubmit(...a), cancelOrder: (...a: unknown[]) => mockCancel(...a), listContacts: (...a: unknown[]) => mockListContacts(...a), + getNameServers: (...a: unknown[]) => mockNameServers(...a), }; }); @@ -46,7 +48,7 @@ function status(over: Partial = {}): DomainsStatus { configured: true, demoConfigured: true, liveConfigured: true, - defaultNameServers: ['ns1.example.com', 'ns2.example.com'], + defaultNameServers: [], ...over, }; } @@ -122,7 +124,7 @@ function summary(over: Partial = {}): OrderSummary { tech: { id: 22, displayName: 'Unsere Firma GmbH' }, zone: { id: 22, displayName: 'Unsere Firma GmbH' }, }, - nameServers: ['ns1.example.com', 'ns2.example.com'], + nameServers: ['z.example.net', 'b.example.org', 'a.example.org'], periodYears: 1, version: '2026-10-08T10:00:00.000Z', ...over, @@ -156,6 +158,10 @@ beforeEach(() => { mockCreate.mockReset().mockResolvedValue(summary()); mockSubmit.mockReset().mockResolvedValue(order({ status: 'SUBMITTED', jobStatus: 'RUNNING' })); mockCancel.mockReset().mockResolvedValue(order({ status: 'CANCELED' })); + mockNameServers.mockReset().mockResolvedValue({ + environment: 'DEMO', + nameServers: ['z.example.net', 'b.example.org', 'a.example.org'], + }); mockListContacts.mockReset().mockResolvedValue({ environment: 'DEMO', contacts: CONTACTS, @@ -207,7 +213,7 @@ describe('RegisterTab — Verfuegbarkeit', () => { }); describe('RegisterTab — Kontakte und Nameserver', () => { - it('Voreinstellung: Admin = Inhaber, Technik/Zone = Kontakt der eigenen Firma; Nameserver aus den Einstellungen', async () => { + it('Voreinstellung: Admin = Inhaber, Technik/Zone = Kontakt der eigenen Firma', async () => { renderTab(); await checkFree(); const owner = (await screen.findByLabelText('Inhaber')) as HTMLSelectElement; @@ -217,12 +223,6 @@ describe('RegisterTab — Kontakte und Nameserver', () => { 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 () => { @@ -245,18 +245,67 @@ describe('RegisterTab — Kontakte und Nameserver', () => { ).toBe(true); }); - it('Nameserver lassen sich zwischen 2 und 6 hinzufuegen und entfernen', async () => { - renderTab({ defaultNameServers: [] }); + it('zeigt die AutoDNS-Nameserver schreibgeschuetzt in AutoDNS-Reihenfolge', async () => { + renderTab(); 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' })); - } + const items = await screen.findAllByRole('listitem'); + expect(items.map((li) => li.textContent)).toEqual([ + 'Nameserver 1z.example.net', + 'Nameserver 2b.example.org', + 'Nameserver 3a.example.org', + ]); + expect(screen.queryByLabelText('Nameserver 1')).toBeNull(); + expect(screen.queryByRole('textbox', { name: /Nameserver/ })).toBeNull(); 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(); + }); + + it('keine Nameserver in AutoDNS: Hinweis, Zusammenfassung gesperrt, kein Entwurf', async () => { + mockNameServers.mockRejectedValue( + new DomainsRequestError(409, 'noDefaultNameServers', 'Serverseitiger Text.'), + ); + renderTab(); + expect((await screen.findByRole('alert')).textContent).toContain( + 'In AutoDNS sind keine Standard-Nameserver hinterlegt', + ); + await checkFree(); + const owner = await screen.findByLabelText('Inhaber'); + await waitFor(() => expect(mockListContacts).toHaveBeenCalled()); + fireEvent.change(owner, { target: { value: '11' } }); + const button = screen.getByRole('button', { + name: 'Zusammenfassung anzeigen', + }) as HTMLButtonElement; + expect(button.disabled).toBe(true); + fireEvent.click(button); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it('AutoDNS nicht lesbar: Hinweis mit Servertext; "Erneut aus AutoDNS lesen" schaltet frei', async () => { + mockNameServers.mockRejectedValueOnce( + new DomainsRequestError(502, 'autodnsAuth', 'Anmeldung bei AutoDNS fehlgeschlagen.'), + ); + renderTab(); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain( + 'Tessera konnte die Standard-Nameserver nicht aus AutoDNS lesen', + ); + expect(alert.textContent).toContain('Anmeldung bei AutoDNS fehlgeschlagen.'); + await checkFree(); + const owner = await screen.findByLabelText('Inhaber'); + await waitFor(() => expect(mockListContacts).toHaveBeenCalled()); + fireEvent.change(owner, { target: { value: '11' } }); + expect( + (screen.getByRole('button', { name: 'Zusammenfassung anzeigen' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + fireEvent.click(screen.getByRole('button', { name: 'Erneut aus AutoDNS lesen' })); + await waitFor(() => expect(mockNameServers).toHaveBeenCalledTimes(2)); + await screen.findByText('z.example.net'); + expect(screen.queryByRole('alert')).toBeNull(); + expect( + (screen.getByRole('button', { name: 'Zusammenfassung anzeigen' }) as HTMLButtonElement) + .disabled, + ).toBe(false); }); }); @@ -270,14 +319,13 @@ describe('RegisterTab — Zusammenfassung und verbindliche Bestaetigung', () => 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('z.example.net, b.example.org, a.example.org')).toBeTruthy(); expect( screen.getByText('Ich bestätige die Registrierung im Demo-System von AutoDNS (Testbetrieb).'), ).toBeTruthy(); diff --git a/apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx b/apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx index baf0b9e..9cf6309 100644 --- a/apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx +++ b/apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx @@ -16,6 +16,7 @@ import { type DomainsOrder, DomainsRequestError, type DomainsStatus, + getNameServers, listContacts, type OrderPrice, type OrderSummary, @@ -24,7 +25,13 @@ import { import { EnvironmentBadge } from './EnvironmentBadge'; const MIN_NAMESERVERS = 2; -const MAX_NAMESERVERS = 6; + +/** Standard-Nameserver aus AutoDNS: wird gelesen, ist da, fehlt dort oder liess sich nicht lesen. */ +type NameServerState = + | { kind: 'loading' } + | { kind: 'ok'; list: string[] } + | { kind: 'missing' } + | { kind: 'unreadable'; detail: string }; type Role = 'owner' | 'admin' | 'tech' | 'zone'; const ROLES: Role[] = ['owner', 'admin', 'tech', 'zone']; @@ -39,15 +46,12 @@ export function formatPrice(price: OrderPrice, locale: string): string { } } -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 + * Reiter "Registrieren" (L-04): Verfuegbarkeit -> Kontakte (Nameserver nur zur + * Kontrolle) -> Zusammenfassung -> verbindliche Bestaetigung. Die Nameserver + * stammen aus dem AutoDNS-Benutzerprofil, werden schreibgeschuetzt gezeigt und + * vom Server beim Entwurf selbst gelesen; fehlen sie, ist die Registrierung + * gesperrt (h3t). 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, @@ -75,9 +79,8 @@ export function RegisterTab({ const [contacts, setContacts] = useState(null); const [contactsLoading, setContactsLoading] = useState(false); const [chosen, setChosen] = useState>>({}); - const [nameServers, setNameServers] = useState(() => - initialNameServers(status.defaultNameServers), - ); + const [nameServerState, setNameServerState] = useState({ kind: 'loading' }); + const [nameServerReload, setNameServerReload] = useState(0); const [creating, setCreating] = useState(false); const [summary, setSummary] = useState(null); @@ -110,6 +113,31 @@ export function RegisterTab({ }; }, [available, contacts, tc]); + // Standard-Nameserver aus AutoDNS lesen: beim Oeffnen, beim Systemwechsel und auf Wunsch. + // biome-ignore lint/correctness/useExhaustiveDependencies: Systemwechsel und Neuladen loesen das Lesen bewusst aus + useEffect(() => { + let alive = true; + setNameServerState({ kind: 'loading' }); + getNameServers() + .then((res) => { + if (alive) setNameServerState({ kind: 'ok', list: res.nameServers }); + }) + .catch((e) => { + if (!alive) return; + if (e instanceof DomainsRequestError && e.code === 'noDefaultNameServers') { + setNameServerState({ kind: 'missing' }); + } else { + setNameServerState({ + kind: 'unreadable', + detail: e instanceof DomainsRequestError ? e.message : tc('requestFailed'), + }); + } + }); + return () => { + alive = false; + }; + }, [status.environment, nameServerReload, tc]); + const ownCompanyId = customers.find((c) => c.isOwnCompany)?.id ?? null; const ownCompanyContact = useMemo( () => (ownCompanyId ? contacts?.find((c) => c.customerId === ownCompanyId) : undefined), @@ -127,12 +155,12 @@ export function RegisterTab({ 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 && + nameServerState.kind === 'ok' && + nameServerState.list.length >= MIN_NAMESERVERS && !creating; function resetAll() { @@ -141,7 +169,6 @@ export function RegisterTab({ setAvailability(null); setError(null); setChosen({}); - setNameServers(initialNameServers(status.defaultNameServers)); setSummary(null); setConfirmed(false); setSubmitting(false); @@ -176,7 +203,6 @@ export function RegisterTab({ adminContactId, techContactId, zoneContactId, - nameServers: filledNameServers, }), ); setConfirmed(false); @@ -348,6 +374,25 @@ export function RegisterTab({ return (
+ {(nameServerState.kind === 'missing' || nameServerState.kind === 'unreadable') && ( +
+

+ {nameServerState.kind === 'missing' + ? t('nameServersMissing') + : t('nameServersUnreadable')} +

+ {nameServerState.kind === 'unreadable' && ( +

{nameServerState.detail}

+ )} + +
+ )}
@@ -472,45 +517,24 @@ export function RegisterTab({ } > -
- {nameServers.map((value, index) => { - const label = t('nameServer', { number: index + 1 }); - return ( - // biome-ignore lint/suspicious/noArrayIndexKey: feste, nummerierte Eingabefelder -
- - setNameServers((prev) => - prev.map((n, i) => (i === index ? e.target.value : n)), - ) - } - /> - {nameServers.length > MIN_NAMESERVERS && ( - - )} -
- ); - })} - {nameServers.length < MAX_NAMESERVERS && ( - - )} -
+ {nameServerState.kind === 'loading' && ( +

{t('nameServersLoading')}

+ )} + {nameServerState.kind === 'ok' && ( +
    + {nameServerState.list.map((host, index) => ( +
  1. + + {t('nameServer', { number: index + 1 })} + + {host} +
  2. + ))} +
+ )} + {(nameServerState.kind === 'missing' || nameServerState.kind === 'unreadable') && ( +

{t('nameServersBlocked')}

+ )} )} diff --git a/apps/web/src/lib/domains-api.ts b/apps/web/src/lib/domains-api.ts index 5a3557b..43a1928 100644 --- a/apps/web/src/lib/domains-api.ts +++ b/apps/web/src/lib/domains-api.ts @@ -314,9 +314,20 @@ export interface CreateOrderInput { adminContactId: number; techContactId: number; zoneContactId: number; + // Die Nameserver liest der Server selbst aus dem AutoDNS-Profil (h3t). +} + +/** Standard-Nameserver aus dem AutoDNS-Benutzerprofil, in der Reihenfolge von AutoDNS. */ +export interface DomainsNameServers { + environment: DomainsEnvironment; nameServers: string[]; } +/** Liest die Standard-Nameserver aus AutoDNS (nur zur Anzeige; der Server liest sie beim Entwurf neu). */ +export function getNameServers(): Promise { + return request('/name-servers'); +} + export function checkAvailability(domain: string): Promise { return request('/availability', { method: 'POST', json: { domain } }); } diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index c4b6662..942fd49 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -2209,7 +2209,7 @@ "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.", + "intro": "Prüfen Sie, ob eine Domain frei ist, wählen Sie die Kontakte und registrieren Sie die Domain verbindlich bei AutoDNS. Die Nameserver übernimmt Tessera aus AutoDNS.", "domainLabel": "Domain", "domainPlaceholder": "beispiel.de", "check": "Verfügbarkeit prüfen", @@ -2231,10 +2231,13 @@ "zone": "Zonenkontakt" }, "nameServersTitle": "Nameserver", - "nameServersDescription": "Mindestens zwei, höchstens sechs. Die Nameserver müssen bei Ihrem Anbieter bereits eingerichtet sein, sonst scheitert die Registrierung.", + "nameServersDescription": "Diese Standard-Nameserver sind in AutoDNS hinterlegt. Tessera verwendet sie unverändert und in dieser Reihenfolge; ändern lassen sie sich nur in AutoDNS.", "nameServer": "Nameserver {number}", - "addNameServer": "Nameserver hinzufügen", - "removeNameServer": "Entfernen", + "nameServersLoading": "Nameserver werden aus AutoDNS gelesen …", + "nameServersMissing": "In AutoDNS sind keine Standard-Nameserver hinterlegt. Bitte hinterlegen Sie mindestens zwei Nameserver als Standard in AutoDNS. Bis dahin ist keine Registrierung möglich.", + "nameServersUnreadable": "Tessera konnte die Standard-Nameserver nicht aus AutoDNS lesen. Bis das gelingt, ist keine Registrierung möglich.", + "nameServersRetry": "Erneut aus AutoDNS lesen", + "nameServersBlocked": "Ohne Standard-Nameserver aus AutoDNS ist keine Registrierung möglich – siehe Hinweis oben.", "showSummary": "Zusammenfassung anzeigen", "creating": "Wird vorbereitet …", "summary": { diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 5225f39..8ddac1c 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -2209,7 +2209,7 @@ "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.", + "intro": "Check whether a domain is available, choose the contacts, and register the domain with AutoDNS as a binding order. Tessera takes the name servers from AutoDNS.", "domainLabel": "Domain", "domainPlaceholder": "example.com", "check": "Check availability", @@ -2231,10 +2231,13 @@ "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.", + "nameServersDescription": "These default name servers are stored in AutoDNS. Tessera uses them unchanged and in this order; they can only be changed in AutoDNS.", "nameServer": "Name server {number}", - "addNameServer": "Add name server", - "removeNameServer": "Remove", + "nameServersLoading": "Reading name servers from AutoDNS …", + "nameServersMissing": "No default name servers are stored in AutoDNS. Please store at least two name servers as the default in AutoDNS. Until then, registration is not possible.", + "nameServersUnreadable": "Tessera could not read the default name servers from AutoDNS. Until that works, registration is not possible.", + "nameServersRetry": "Read from AutoDNS again", + "nameServersBlocked": "Without default name servers from AutoDNS, registration is not possible – see the notice above.", "showSummary": "Show summary", "creating": "Preparing …", "summary": {