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>
</>
)}