feat(domains): Registrieren zeigt Nameserver aus AutoDNS nur zur Kontrolle (h3t)

- Nameserver schreibgeschuetzt in AutoDNS-Reihenfolge, ohne Eingabe
- Hinweis mit Neu-lesen-Knopf, Zusammenfassung gesperrt ohne Nameserver
- createOrder sendet keine Nameserver mehr

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 12:34:32 +02:00
parent 473738db9e
commit 1b20b84e2f
5 changed files with 172 additions and 83 deletions
@@ -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<typeof import('@/lib/domains-api')>();
@@ -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> = {}): 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> = {}): 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();
@@ -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<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 [nameServerState, setNameServerState] = useState<NameServerState>({ kind: 'loading' });
const [nameServerReload, setNameServerReload] = useState(0);
const [creating, setCreating] = useState(false);
const [summary, setSummary] = useState<OrderSummary | null>(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 (
<div className="space-y-4">
{(nameServerState.kind === 'missing' || nameServerState.kind === 'unreadable') && (
<div role="alert" className="rounded-lg border border-destructive/40 p-4 text-sm">
<p className="text-foreground">
{nameServerState.kind === 'missing'
? t('nameServersMissing')
: t('nameServersUnreadable')}
</p>
{nameServerState.kind === 'unreadable' && (
<p className="mt-1 text-muted-foreground">{nameServerState.detail}</p>
)}
<button
type="button"
className={`${SECONDARY_BUTTON} mt-3`}
onClick={() => setNameServerReload((n) => n + 1)}
>
{t('nameServersRetry')}
</button>
</div>
)}
<SettingsSection title={t('domainLabel')} description={t('intro')}>
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-56 flex-1">
@@ -472,45 +517,24 @@ export function RegisterTab({
</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>
{nameServerState.kind === 'loading' && (
<p className="text-sm text-muted-foreground">{t('nameServersLoading')}</p>
)}
{nameServerState.kind === 'ok' && (
<ol className="space-y-1 text-sm">
{nameServerState.list.map((host, index) => (
<li key={host} className="flex gap-3">
<span className="w-28 text-muted-foreground">
{t('nameServer', { number: index + 1 })}
</span>
<span className="text-foreground">{host}</span>
</li>
))}
</ol>
)}
{(nameServerState.kind === 'missing' || nameServerState.kind === 'unreadable') && (
<p className="text-sm text-muted-foreground">{t('nameServersBlocked')}</p>
)}
</SettingsSection>
</>
)}
+11
View File
@@ -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<DomainsNameServers> {
return request<DomainsNameServers>('/name-servers');
}
export function checkAvailability(domain: string): Promise<AvailabilityResult> {
return request<AvailabilityResult>('/availability', { method: 'POST', json: { domain } });
}
+7 -4
View File
@@ -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": {
+7 -4
View File
@@ -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": {