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
+8
View File
@@ -4,6 +4,14 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
## Unveröffentlicht
### Neu
- Neues Modul „Domains“ in der Gruppe „Domains“: Domains bei AutoDNS registrieren, Kontakte und Kunden zuordnen. Ein Administrator aktiviert das Modul im Marktplatz; wer es nutzen soll, bekommt zusätzlich die Freigabe. Mit „Benutzen“ sehen Sie die Domainliste, die Kontakte, die Kunden und die Aufträge; mit „Verwalten“ (und als Administrator) registrieren Sie Domains, legen Kontakte und Kunden an und richten die Anbindung ein.
- Domains, Anbindung an AutoDNS: Unter „Einstellungen“ tragen Sie Benutzername, Passwort und Kontext getrennt für das Demo-System (zum Ausprobieren) und das Live-System (echte, kostenpflichtige Registrierungen) ein. Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt. Neue Installationen starten im Demo-System; der Wechsel auf das Live-System verlangt eine ausdrückliche Bestätigung, und eine Kennzeichnung oben auf der Seite zeigt jederzeit, welches System gerade gilt. „Verbindung testen“ prüft den Zugang mit einem einzigen Aufruf. Dazu kommen die Standard-Nameserver, die bei jeder Registrierung vorausgefüllt werden.
- Domains, Kontakte und Kunden: Alle Kontakte, die bei AutoDNS vorhanden sind, erscheinen automatisch in Tessera („Aus AutoDNS neu einlesen“ holt sie auf Wunsch sofort neu). Neue Kontakte (Person oder Organisation) legen Sie über ein Formular an. Jeden Kontakt – einzeln oder viele auf einmal – ordnen Sie einem Kunden zu; Ihre eigene Firma legen Sie ebenfalls als Kunden an und markieren sie. Die Listen lassen sich nach Kunde filtern und gruppieren.
- Domains, Domainliste: Alle Domains aus AutoDNS mit Kunde (über den Inhaber-Kontakt), Inhaber, Ablaufdatum und Status, ebenfalls filterbar und nach Kunde gruppierbar.
- Domains, Registrieren: Verfügbarkeit prüfen, Kontakte und Nameserver wählen, Zusammenfassung mit System und Preis ansehen und mit „Jetzt verbindlich registrieren“ bestätigen. Tessera schützt vor Doppelbestellungen: Jede Registrierung wird höchstens ein einziges Mal an AutoDNS geschickt, auch bei Doppelklick oder zwei geöffneten Fenstern, und nie automatisch wiederholt. Ist unklar, ob AutoDNS die Bestellung angenommen hat, steht der Auftrag auf „Ergebnis ungeklärt“, bis Tessera ihn mit AutoDNS abgeglichen hat. Den Stand jeder Registrierung sehen Sie im Reiter „Aufträge“; er wird beim Öffnen und danach alle 30 Sekunden mit AutoDNS abgeglichen.
### Geändert
- DKV-Rechnung: Die Modulbeschreibung im Marktplatz weist jetzt darauf hin, dass das Modul die Freigabestufe „Verwalten“ benötigt. Mit „Benutzen“ erscheint es zwar in der Seitenleiste, öffnet aber nur die Seite „Kein Zugriff“. Modulversion 1.1.0.
+145 -1
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest';
import { parseContacts, parseDomains } from './autodns-parse';
import {
extractJobFromSubmit,
parseContacts,
parseDomainStudio,
parseDomains,
parseJob,
parseJobList,
} from './autodns-parse';
import { TtlCache } from './domains-cache';
describe('parseContacts', () => {
@@ -128,3 +135,140 @@ describe('TtlCache', () => {
expect(cache.get('t2|DEMO|1|contacts')).toBe(3);
});
});
function studio(domain: string, status: string, prices?: unknown[]) {
return {
domain,
services: {
whois: { data: { status } },
...(prices ? { price: { data: { prices } } } : {}),
},
};
}
describe('parseDomainStudio', () => {
it('liest FREE und den Ein-Jahres-Preis der angefragten Domain', () => {
const result = parseDomainStudio(
[
studio('beispiel.de', 'FREE', [
{ amount: 9.9, currency: 'EUR', period: { unit: 'YEAR', period: 2 } },
{ amount: 4.9, currency: 'EUR', period: { unit: 'YEAR', period: 1 } },
]),
],
'beispiel.de',
);
expect(result).toEqual({ whoisStatus: 'FREE', price: { amount: 4.9, currency: 'EUR' } });
});
it('liest den Betrag auch unter `price` und als Zeichenkette', () => {
const result = parseDomainStudio(
[studio('beispiel.de', 'FREE', [{ price: { amount: '4,90', currency: 'EUR' } }])],
'beispiel.de',
);
expect(result.price).toEqual({ amount: 4.9, currency: 'EUR' });
});
it('nimmt den ersten Preis, wenn kein Ein-Jahres-Eintrag da ist', () => {
const result = parseDomainStudio(
[studio('beispiel.de', 'FREE', [{ amount: 7, currency: 'EUR' }])],
'beispiel.de',
);
expect(result.price).toEqual({ amount: 7, currency: 'EUR' });
});
it('ohne Preisdienst ist der Preis null', () => {
expect(parseDomainStudio([studio('beispiel.de', 'ASSIGNED')], 'beispiel.de')).toEqual({
whoisStatus: 'ASSIGNED',
price: null,
});
});
it('ignoriert Umschlaege anderer Domains', () => {
expect(parseDomainStudio([studio('beispiel.com', 'FREE')], 'beispiel.de')).toEqual({
whoisStatus: 'NO_RESULT',
price: null,
});
});
it('erkennt die Unicode-Schreibweise der angefragten Domain', () => {
expect(parseDomainStudio([studio('müller.de', 'FREE')], 'xn--mller-kva.de').whoisStatus).toBe(
'FREE',
);
});
it('ist robust gegen Unsinn', () => {
expect(parseDomainStudio(null, 'a.de').whoisStatus).toBe('NO_RESULT');
expect(parseDomainStudio([null, 3, {}], 'a.de').whoisStatus).toBe('NO_RESULT');
});
});
describe('extractJobFromSubmit', () => {
it('liest data[0].id und status', () => {
expect(extractJobFromSubmit({ data: [{ id: 987, status: 'RUNNING' }], object: null })).toEqual({
id: '987',
status: 'RUNNING',
});
});
it('faellt auf object.value zurueck, wenn object.type job ist', () => {
expect(extractJobFromSubmit({ data: [], object: { type: 'job', value: '555' } })).toEqual({
id: '555',
status: null,
});
});
it('liefert id null ohne erkennbare Nummer', () => {
expect(extractJobFromSubmit({ data: [{}], object: { type: 'domain', value: 'x' } }).id).toBe(
null,
);
});
});
describe('parseJob', () => {
it('liest data[0].job.status', () => {
expect(parseJob([{ job: { id: 987, status: 'SUCCESS', subStatus: 'X' } }])).toMatchObject({
id: '987',
status: 'SUCCESS',
subStatus: 'X',
});
});
it('liest data[0].status ohne job-Ebene', () => {
expect(parseJob([{ id: 5, status: 'failed' }])?.status).toBe('FAILED');
});
it('sammelt Fehlermeldungen aus den Ereignissen', () => {
const job = parseJob([
{
job: {
id: 1,
status: 'FAILED',
events: [
{ status: 'SUCCESS', message: 'ok' },
{ status: 'ERROR', message: 'Nameserver nicht erreichbar' },
],
},
},
]);
expect(job?.messages).toEqual(['Nameserver nicht erreichbar']);
});
it('liefert null bei leeren Daten', () => {
expect(parseJob([])).toBeNull();
expect(parseJob(undefined)).toBeNull();
});
});
describe('parseJobList', () => {
it('liest Id, Objekt und Anlagezeit', () => {
const list = parseJobList([
{ job: { id: 9, status: 'running', created: '2026-10-08T10:00:00Z' }, object: 'Beispiel.de' },
{ job: { id: 10 }, object: { value: 'x.de' } },
{ object: 'ohne-id.de' },
]);
expect(list).toHaveLength(2);
expect(list[0]).toMatchObject({ id: '9', status: 'RUNNING', object: 'beispiel.de' });
expect(list[0].created?.toISOString()).toBe('2026-10-08T10:00:00.000Z');
expect(list[1]).toMatchObject({ id: '10', object: 'x.de', created: null });
});
});
+184
View File
@@ -1,3 +1,5 @@
import { domainToASCII } from 'node:url';
/**
* Reine Auswertung der AutoDNS-Antwortdaten (quick-261008-dts) — kein Nest,
* kein Netz. Alles defensiv: unbekannte Felder werden ignoriert, Zeichenketten
@@ -120,3 +122,185 @@ export function parseDomains(data: unknown): ParsedDomain[] {
}
return result;
}
// --- Verfuegbarkeit (DomainStudio), Bestellung, Auftrag (Aufgabe 3) --------------
export interface ParsedPrice {
amount: number;
currency: string;
}
export interface ParsedAvailability {
/** WHOIS-Status der angefragten Domain, `NO_RESULT` wenn DomainStudio sie nicht lieferte. */
whoisStatus: string;
price: ParsedPrice | null;
}
function pickRecord(value: unknown, ...path: string[]): Record<string, unknown> | null {
let current: unknown = value;
for (const key of path) {
if (!isRecord(current)) return null;
current = current[key];
}
return isRecord(current) ? current : null;
}
function toNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && /^\d+(?:[.,]\d+)?$/.test(value.trim())) {
return Number.parseFloat(value.trim().replace(',', '.'));
}
return null;
}
/** Ein Jahr: `{unit:'YEAR',period:1}`, `{unit:'MONTH',period:12}` oder die nackte Zahl 1. */
function isOneYear(period: unknown): boolean {
if (isRecord(period)) {
const unit = typeof period.unit === 'string' ? period.unit.toUpperCase() : '';
const n = toNumber(period.period);
return (unit === 'YEAR' && n === 1) || (unit === 'MONTH' && n === 12);
}
return toNumber(period) === 1;
}
function parsePriceEntry(entry: unknown): ParsedPrice | null {
if (!isRecord(entry)) return null;
// Betrag direkt am Eintrag oder darunter in `price` (Form laut Recherche unklar).
const source = isRecord(entry.price) ? entry.price : entry;
const amount = toNumber(source.amount);
const currency = text(source.currency);
if (amount === null || currency === null) return null;
return { amount, currency };
}
/**
* Wertet die Antwort von `POST /domainstudio` aus. Nur der Umschlag, dessen
* `domain` der angefragten Domain entspricht, zaehlt (D-K) — alles andere
* ist `NO_RESULT`. Als Preis gilt der Ein-Jahres-Eintrag, sonst der erste.
*/
export function parseDomainStudio(data: unknown, wantedAscii: string): ParsedAvailability {
const wanted = wantedAscii.toLowerCase();
if (Array.isArray(data)) {
for (const envelope of data) {
if (!isRecord(envelope)) continue;
const name = text(envelope.domain)?.toLowerCase();
if (!name) continue;
if (name !== wanted && domainToAsciiSafe(name) !== wanted) continue;
const whois = pickRecord(envelope, 'services', 'whois', 'data');
const status = whois ? text(whois.status) : null;
const prices = pickRecord(envelope, 'services', 'price', 'data')?.prices;
let price: ParsedPrice | null = null;
if (Array.isArray(prices)) {
const oneYear = prices.find((p) => isRecord(p) && isOneYear(p.period));
price = parsePriceEntry(oneYear) ?? parsePriceEntry(prices[0]);
}
return { whoisStatus: status ? status.toUpperCase() : 'NO_RESULT', price };
}
}
return { whoisStatus: 'NO_RESULT', price: null };
}
function domainToAsciiSafe(name: string): string {
try {
return domainToASCII(name);
} catch {
return name;
}
}
/** Auftragsnummern sind Dezimalzahlen; als Zeichenkette gefuehrt (keine int32-Grenze). */
function toJobId(value: unknown): string | null {
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return String(value);
if (typeof value === 'string' && /^\d{1,20}$/.test(value.trim())) return value.trim();
return null;
}
/**
* Auftrag aus der Antwort von `POST /domain`: `data[0].id` / `data[0].status`,
* ersatzweise `object.value`, wenn `object.type === 'job'` (Recherche A4).
*/
export function extractJobFromSubmit(result: {
data: unknown[];
object: { type: string | null; value: string | null } | null;
}): { id: string | null; status: string | null } {
const first = result.data[0];
let id: string | null = null;
let status: string | null = null;
if (isRecord(first)) {
id = toJobId(first.id);
status = text(first.status);
}
if (id === null && result.object?.type === 'job') id = toJobId(result.object.value);
return { id, status };
}
export interface ParsedJob {
id: string | null;
status: string | null;
subStatus: string | null;
messages: string[];
}
/** `GET /job/{id}`: `data[0].job ?? data[0]`. */
export function parseJob(data: unknown): ParsedJob | null {
if (!Array.isArray(data) || data.length === 0) return null;
const first = data[0];
if (!isRecord(first)) return null;
const job = isRecord(first.job) ? first.job : first;
const status = text(job.status);
const messages: string[] = [];
const events = job.events;
if (Array.isArray(events)) {
for (const event of events) {
if (!isRecord(event)) continue;
const msg = text(event.message) ?? text(event.text);
if (msg && String(event.status ?? '').toUpperCase() !== 'SUCCESS') messages.push(msg);
if (messages.length >= 5) break;
}
}
return {
id: toJobId(job.id),
status: status ? status.toUpperCase() : null,
subStatus: text(job.subStatus),
messages,
};
}
export interface ParsedJobListEntry {
id: string;
status: string | null;
/** Domainname, auf den sich der Auftrag bezieht. */
object: string | null;
/** Zeitpunkt der Anlage; `null`, wenn AutoDNS keinen lesbaren liefert. */
created: Date | null;
}
function dateOrNull(value: unknown): Date | null {
const raw = text(value);
if (!raw) return null;
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? null : d;
}
/** `POST /job/_search`: Auftragsliste mit Objekt (Domain) und Anlagezeit. */
export function parseJobList(data: unknown): ParsedJobListEntry[] {
if (!Array.isArray(data)) return [];
const result: ParsedJobListEntry[] = [];
for (const entry of data) {
if (!isRecord(entry)) continue;
const job = isRecord(entry.job) ? entry.job : entry;
const id = toJobId(job.id);
if (id === null) continue;
const rawObject = entry.object ?? job.object;
const object = isRecord(rawObject) ? text(rawObject.value) : text(rawObject);
const status = text(job.status);
result.push({
id,
status: status ? status.toUpperCase() : null,
object: object ? object.toLowerCase() : null,
created: dateOrNull(job.created) ?? dateOrNull(job.started) ?? dateOrNull(job.added),
});
}
return result;
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { normalizeDomainName, splitDomain } from './domain-name';
describe('normalizeDomainName', () => {
it('entfernt Protokoll, Pfad, Leerraum und Grossschreibung', () => {
expect(normalizeDomainName(' https://Beispiel.DE/pfad ')?.ascii).toBe('beispiel.de');
});
it('entfernt einen abschliessenden Punkt', () => {
expect(normalizeDomainName('beispiel.de.')?.ascii).toBe('beispiel.de');
});
it('wandelt Umlaute in Punycode und behaelt die Unicode-Form zur Anzeige', () => {
expect(normalizeDomainName('müller.de')).toEqual({
ascii: 'xn--mller-kva.de',
unicode: 'müller.de',
});
});
it.each([
'beispiel',
'bei spiel.de',
'-a.de',
'a-.de',
`${'a'.repeat(64)}.de`,
'',
' ',
'beispiel..de',
'bei"spiel.de',
])('lehnt %j ab', (input) => {
expect(normalizeDomainName(input)).toBeNull();
});
it('lehnt Nicht-Zeichenketten ab', () => {
expect(normalizeDomainName(undefined)).toBeNull();
expect(normalizeDomainName(42)).toBeNull();
});
it('nimmt ein 63 Zeichen langes Label noch an', () => {
expect(normalizeDomainName(`${'a'.repeat(63)}.de`)?.ascii).toBe(`${'a'.repeat(63)}.de`);
});
});
describe('splitDomain', () => {
it('trennt Namensteil und Endung, auch bei Mehrteil-Endungen', () => {
expect(splitDomain('beispiel.de')).toEqual({ label: 'beispiel', tld: 'de' });
expect(splitDomain('beispiel.co.uk')).toEqual({ label: 'beispiel', tld: 'co.uk' });
});
});
+47
View File
@@ -0,0 +1,47 @@
import { domainToASCII, domainToUnicode } from 'node:url';
/**
* Domainnamen aus Benutzereingaben (quick-261008-dts, D-K): normalisieren,
* in die ASCII-Form (Punycode) umwandeln und mit einem verankerten Muster
* vorpruefen. Die Gueltigkeit im Sinne der Registry entscheidet am Ende
* AutoDNS/DomainStudio; dies hier ist nur der Vorfilter, damit nichts
* Unsinniges (Pfade, Leerzeichen, Anfuehrungszeichen) ins Netz geht.
*/
/** Labels 1-63 Zeichen aus Buchstaben/Ziffern/Bindestrich, kein Bindestrich am Rand. */
const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
const DOMAIN_PATTERN = new RegExp(`^(?:${LABEL}\\.)+${LABEL}$`);
const MAX_TOTAL_LENGTH = 253;
export interface NormalizedDomain {
/** ASCII-/Punycode-Form — das geht an AutoDNS. */
ascii: string;
/** Unicode-Form fuer die Anzeige. */
unicode: string;
}
export function normalizeDomainName(raw: unknown): NormalizedDomain | null {
if (typeof raw !== 'string') return null;
let value = raw.trim().toLowerCase();
if (value === '') return null;
value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//, '');
// Pfad, Query, Fragment und Port abschneiden.
value = value.split(/[/?#]/)[0] ?? '';
value = value.replace(/:\d+$/, '');
value = value.replace(/\.+$/, '');
if (value === '' || /\s/.test(value)) return null;
const ascii = domainToASCII(value);
if (ascii === '' || ascii.length > MAX_TOTAL_LENGTH) return null;
if (!DOMAIN_PATTERN.test(ascii)) return null;
return { ascii, unicode: domainToUnicode(ascii) || ascii };
}
/**
* Trennt den ersten Namensteil vom Rest. `beispiel.co.uk` -> label `beispiel`,
* tld `co.uk` (DomainStudio sucht je Endung, ohne Mehrteil-Endungen zu kennen).
*/
export function splitDomain(ascii: string): { label: string; tld: string } {
const dot = ascii.indexOf('.');
return { label: ascii.slice(0, dot), tld: ascii.slice(dot + 1) };
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,782 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import {
type AutodnsCredentials,
type AutodnsFailure,
type AutodnsResult,
type AutodnsSuccess,
autodnsRequest,
} from './autodns-client';
import {
extractJobFromSubmit,
type ParsedPrice,
parseDomainStudio,
parseJob,
parseJobList,
} from './autodns-parse';
import { normalizeDomainName, splitDomain } from './domain-name';
import { autodnsFailureToHttp, type DomainsEnvironment } from './domains.types';
import { DomainsDirectoryService } from './domains-directory.service';
import { DomainsSettingsService, HOSTNAME_PATTERN } from './domains-settings.service';
import type { CreateDomainsOrderDto } from './dto/domains-order.dto';
export type DomainOrderStatus =
| 'DRAFT'
| 'SUBMITTING'
| 'SUBMITTED'
| 'SUCCESS'
| 'FAILED'
| 'UNKNOWN'
| 'CANCELED';
/** Hoechstens so viele offene Auftraege pro Abgleich (D-N, Ratenbegrenzung). */
export const REFRESH_MAX_ORDERS = 20;
/** Ein Auftrag, der laenger als das in SUBMITTING steht, gilt als unklar (Prozess starb mitten im Aufruf). */
export const SUBMITTING_STALE_MS = 120_000;
/** Ein Auftrag aus der Auftragssuche zaehlt ab dieser Toleranz vor der Bestaetigung. */
const JOB_SEARCH_TOLERANCE_MS = 5 * 60_000;
const LIST_MAX_ORDERS = 200;
const MIN_NAMESERVERS = 2;
const MAX_NAMESERVERS = 6;
interface OrderPayload {
ownerContactId: number;
adminContactId: number;
techContactId: number;
zoneContactId: number;
nameServers: string[];
periodYears: 1;
price: ParsedPrice | null;
availabilityCheckedAt: string;
}
interface OrderRow {
id: string;
tenantId: string;
environment: DomainsEnvironment;
domainName: string;
openKey: string | null;
status: DomainOrderStatus;
payload: unknown;
jobId: string | null;
jobStatus: string | null;
errorText: string | null;
createdByUserId: string;
confirmedByUserId: string | null;
confirmedByUsername: string | null;
confirmedAt: Date | null;
lastCheckedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface DomainsOrderView {
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: ParsedPrice | null;
}
export interface AvailabilityView {
environment: DomainsEnvironment;
domain: string;
domainUnicode: string;
available: boolean;
whoisStatus: string;
price: ParsedPrice | null;
}
export interface OrderContactView {
id: number;
displayName: string;
}
export interface OrderSummaryView {
order: DomainsOrderView;
contacts: {
owner: OrderContactView;
admin: OrderContactView;
tech: OrderContactView;
zone: OrderContactView;
};
nameServers: string[];
periodYears: 1;
}
const ERR = {
notAvailable: {
code: 'notAvailable',
message: 'Die Domain ist nicht frei und kann nicht registriert werden.',
},
orderOpen: {
code: 'orderOpen',
message:
'Für diese Domain gibt es bereits einen offenen Auftrag. Bitte sehen Sie unter „Aufträge“ nach.',
},
alreadySubmitted: {
code: 'alreadySubmitted',
message: 'Dieser Auftrag wurde bereits abgeschickt.',
},
environmentChanged: {
code: 'environmentChanged',
message: 'Das System wurde inzwischen gewechselt. Bitte prüfen Sie die Verfügbarkeit erneut.',
},
checkFirst: {
code: 'checkFirst',
message:
'Bitte aktualisieren Sie den Auftrag zuerst, damit Tessera bei AutoDNS nachsehen kann.',
},
notCancelable: {
code: 'notCancelable',
message: 'Dieser Auftrag kann nicht mehr verworfen werden.',
},
orderNotFound: { code: 'orderNotFound', message: 'Der Auftrag wurde nicht gefunden.' },
invalidDomain: {
code: 'invalidDomain',
message: 'Bitte geben Sie einen gültigen Domainnamen an, zum Beispiel beispiel.de.',
},
contactNotFound: {
code: 'contactNotFound',
message:
'Einer der gewählten Kontakte wurde in AutoDNS nicht gefunden. Bitte lesen Sie die Kontakte neu ein.',
},
};
function isUniqueViolation(error: unknown): boolean {
return (
typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'P2002'
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** Meldungstext eines abgelehnten AutoDNS-Aufrufs, wie ihn der Browser auch sonst sieht. */
function failureText(failure: AutodnsFailure): string {
const response = autodnsFailureToHttp(failure).getResponse();
return isRecord(response) && typeof response.message === 'string'
? response.message
: 'AutoDNS hat die Anfrage nicht angenommen.';
}
function readPayload(raw: unknown): OrderPayload | null {
if (!isRecord(raw)) return null;
const ids = [raw.ownerContactId, raw.adminContactId, raw.techContactId, raw.zoneContactId];
if (!ids.every((v) => typeof v === 'number' && Number.isInteger(v) && v > 0)) return null;
if (!Array.isArray(raw.nameServers) || !raw.nameServers.every((n) => typeof n === 'string')) {
return null;
}
const price =
isRecord(raw.price) &&
typeof raw.price.amount === 'number' &&
typeof raw.price.currency === 'string'
? { amount: raw.price.amount, currency: raw.price.currency }
: null;
return {
ownerContactId: raw.ownerContactId as number,
adminContactId: raw.adminContactId as number,
techContactId: raw.techContactId as number,
zoneContactId: raw.zoneContactId as number,
nameServers: raw.nameServers as string[],
periodYears: 1,
price,
availabilityCheckedAt:
typeof raw.availabilityCheckedAt === 'string' ? raw.availabilityCheckedAt : '',
};
}
function toView(row: OrderRow): DomainsOrderView {
const normalized = normalizeDomainName(row.domainName);
return {
id: row.id,
environment: row.environment,
domainName: row.domainName,
domainNameUnicode: normalized?.unicode ?? row.domainName,
status: row.status,
jobStatus: row.jobStatus,
errorText: row.errorText,
confirmedByUsername: row.confirmedByUsername,
confirmedAt: row.confirmedAt ? row.confirmedAt.toISOString() : null,
createdAt: row.createdAt.toISOString(),
lastCheckedAt: row.lastCheckedAt ? row.lastCheckedAt.toISOString() : null,
price: readPayload(row.payload)?.price ?? null,
};
}
/**
* Verfuegbarkeit, Registrierung und Auftragsverfolgung (quick-261008-dts, L-04).
*
* Eine Registrierung kostet Geld; deshalb gilt in diesem Dienst durchgehend:
* HOECHSTENS EIN `POST /domain` je Auftrag — ohne Wiederholung, auch nicht nach
* einem Zeitablauf. Der Auftrag steht lokal in `DomainsOrder`; der Zustand
* wechselt atomar, bevor das Netz angefasst wird (siehe `submitOrder`).
*
* Gesamter Zugriff auf `domainsOrder` liegt ausschliesslich hier; jede Methode
* bindet ihren eigenen `forTenant`-Klienten, jede `where`-Klausel nennt die
* Organisation. Kein `include:`/relationales `select:`.
*/
@Injectable()
export class DomainsOrdersService {
private readonly logger = new Logger(DomainsOrdersService.name);
constructor(
private readonly prisma: PrismaService,
private readonly settings: DomainsSettingsService,
private readonly directory: DomainsDirectoryService,
) {}
// --- AutoDNS-Aufruf ----------------------------------------------------------
private callRaw(
credentials: AutodnsCredentials,
method: 'GET' | 'POST',
path: string,
body?: unknown,
): Promise<AutodnsResult> {
const transport = this.settings.transport;
return autodnsRequest(credentials, method, path, {
...(body !== undefined ? { body } : {}),
fetchImpl: transport?.fetchImpl,
limiter: transport?.limiter,
timeoutMs: transport?.timeoutMs,
});
}
private async callOrThrow(
credentials: AutodnsCredentials,
method: 'GET' | 'POST',
path: string,
body?: unknown,
): Promise<AutodnsSuccess> {
const result = await this.callRaw(credentials, method, path, body);
if (!result.ok) throw autodnsFailureToHttp(result);
return result;
}
// --- Verfuegbarkeit ----------------------------------------------------------
private async availabilityFor(
credentials: AutodnsCredentials,
ascii: string,
unicode: string,
): Promise<AvailabilityView> {
const { label, tld } = splitDomain(ascii);
const result = await this.callOrThrow(credentials, 'POST', '/domainstudio', {
searchToken: label,
currency: 'EUR',
sources: { initial: { tlds: [tld], services: ['WHOIS', 'PRICE'] } },
});
const parsed = parseDomainStudio(result.data, ascii);
return {
environment: credentials.environment,
domain: ascii,
domainUnicode: unicode,
// Nur FREE ist bestellbar; alles andere (auch ERROR/TIMEOUT) ist es nicht.
available: parsed.whoisStatus === 'FREE',
whoisStatus: parsed.whoisStatus,
price: parsed.price,
};
}
async checkAvailability(tenantId: string, rawDomain: string): Promise<AvailabilityView> {
const normalized = normalizeDomainName(rawDomain);
if (!normalized) throw new BadRequestException(ERR.invalidDomain);
const { credentials } = await this.settings.getActiveCredentials(tenantId);
return this.availabilityFor(credentials, normalized.ascii, normalized.unicode);
}
// --- Entwurf -----------------------------------------------------------------
private normalizeNameServers(raw: string[]): string[] {
const list = [...new Set((raw ?? []).map((n) => String(n).trim().toLowerCase()))].filter(
(n) => n.length > 0,
);
if (list.length < MIN_NAMESERVERS) {
throw new BadRequestException({
code: 'tooFewNameServers',
message: 'Bitte geben Sie mindestens zwei Nameserver an.',
});
}
if (list.length > MAX_NAMESERVERS) {
throw new BadRequestException({
code: 'tooManyNameServers',
message: 'Höchstens sechs Nameserver sind möglich.',
});
}
for (const name of list) {
if (!HOSTNAME_PATTERN.test(name)) {
throw new BadRequestException({
code: 'invalidNameServer',
message: `Der Nameserver „${name}“ ist kein gültiger Rechnername.`,
});
}
}
return list;
}
async createOrder(
tenantId: string,
userId: string,
dto: CreateDomainsOrderDto,
): Promise<OrderSummaryView> {
const normalized = normalizeDomainName(dto.domain);
if (!normalized) throw new BadRequestException(ERR.invalidDomain);
const nameServers = this.normalizeNameServers(dto.nameServers);
const contactIds = [
dto.ownerContactId,
dto.adminContactId,
dto.techContactId,
dto.zoneContactId,
];
if (!contactIds.every((id) => Number.isInteger(id) && id > 0)) {
throw new BadRequestException({
code: 'contactsRequired',
message: 'Bitte wählen Sie Inhaber, Admin-, Technik- und Zonenkontakt aus.',
});
}
const { environment, credentials } = await this.settings.getActiveCredentials(tenantId);
const tenantPrisma = forTenant(this.prisma, tenantId);
const domain = normalized.ascii;
// Offener Auftrag? Nur ein Entwurf darf ueberschrieben werden.
const existing = (await tenantPrisma.domainsOrder.findFirst({
where: { tenantId, environment, openKey: domain },
})) as OrderRow | null;
if (existing && existing.status !== 'DRAFT') throw new ConflictException(ERR.orderOpen);
// Die Verfuegbarkeit wird serverseitig neu geprueft — dem Browser traut hier niemand.
const availability = await this.availabilityFor(credentials, domain, normalized.unicode);
if (!availability.available) throw new ConflictException(ERR.notAvailable);
const found = await this.directory.findContactsByIds(tenantId, contactIds);
const byId = new Map(found.map((c) => [c.id, c]));
if (!contactIds.every((id) => byId.has(id))) {
throw new BadRequestException(ERR.contactNotFound);
}
const payload: OrderPayload = {
ownerContactId: dto.ownerContactId,
adminContactId: dto.adminContactId,
techContactId: dto.techContactId,
zoneContactId: dto.zoneContactId,
nameServers,
periodYears: 1,
price: availability.price,
availabilityCheckedAt: new Date().toISOString(),
};
let row: OrderRow;
if (existing) {
const { count } = await tenantPrisma.domainsOrder.updateMany({
where: { id: existing.id, tenantId, status: 'DRAFT', environment },
data: { payload: payload as never },
});
// Zwischenzeitlich abgeschickt: nicht mehr anfassen.
if (count !== 1) throw new ConflictException(ERR.orderOpen);
row = { ...existing, payload };
} else {
try {
row = (await tenantPrisma.domainsOrder.create({
data: {
tenantId,
environment,
domainName: domain,
openKey: domain,
status: 'DRAFT',
payload: payload as never,
createdByUserId: userId,
},
})) as OrderRow;
} catch (error) {
if (isUniqueViolation(error)) throw new ConflictException(ERR.orderOpen);
throw error;
}
}
const contact = (id: number): OrderContactView => ({
id,
displayName: byId.get(id)?.displayName ?? `#${id}`,
});
return {
order: toView(row),
contacts: {
owner: contact(payload.ownerContactId),
admin: contact(payload.adminContactId),
tech: contact(payload.techContactId),
zone: contact(payload.zoneContactId),
},
nameServers: payload.nameServers,
periodYears: 1,
};
}
// --- Verbindlich abschicken --------------------------------------------------
/**
* Schickt die Registrierung genau EINMAL an AutoDNS.
*
* Warum so (L-04, Recherche Pitfalls 1-3):
*
* - ATOMISCHER ANSPRUCH: `updateMany where { id, tenantId, status: 'DRAFT',
* environment: <aktiv> }` setzt DRAFT -> SUBMITTING. Postgres prueft die
* Bedingung beim zweiten, gleichzeitigen UPDATE nach dem Commit des ersten
* erneut. Genau EIN Aufruf sieht `count === 1`; wer `count !== 1` erhaelt,
* erreicht das Netz nie. Der Knopf im Browser ist nur Komfort — dieser
* Zaehler ist die Sperre (zweiter Tab, Doppelklick, erneuter Aufruf).
* - ZAEHLER VOR DEM NETZ: Der Anspruch wird festgeschrieben, BEVOR
* `POST /domain` startet. Stuerzt der Prozess danach ab, bleibt der
* Auftrag in SUBMITTING und wird beim Abgleich zu UNKNOWN — nie wieder
* zu DRAFT, denn der Aufruf kann schon draussen gewesen sein.
* - SYSTEM IM ANSPRUCH: Das aktive System steht in der `where`-Klausel. Ein
* Entwurf aus dem Demo-System kann nach einem Wechsel nie im Live-System
* (oder umgekehrt) bestellt werden; stattdessen 409 `environmentChanged`.
* - KEINE WIEDERHOLUNG: Es gibt genau einen `autodnsRequest`-Aufruf, keine
* Schleife, keinen zweiten Versuch. Ein Zeitablauf heisst NICHT "nicht
* bestellt". Ein unklarer Ausgang (Zeitablauf, Netzfehler, unlesbare oder
* gatewayartige Antwort) wird als UNKNOWN gespeichert und spaeter gegen
* AutoDNS abgeglichen — es wird nie geraten.
*/
async submitOrder(
tenantId: string,
user: { id: string; username: string },
id: string,
): Promise<DomainsOrderView> {
// Zugang zuerst: scheitert er, wurde noch nichts angespruchen.
const { environment, credentials } = await this.settings.getActiveCredentials(tenantId);
const tenantPrisma = forTenant(this.prisma, tenantId);
// Lesen VOR dem Anspruch: der Inhalt, den der Benutzer bestaetigt hat, geht raus.
const before = (await tenantPrisma.domainsOrder.findFirst({
where: { id, tenantId },
})) as OrderRow | null;
if (!before) throw new NotFoundException(ERR.orderNotFound);
if (before.environment !== environment) throw new ConflictException(ERR.environmentChanged);
if (before.status !== 'DRAFT') throw new ConflictException(ERR.alreadySubmitted);
const payload = readPayload(before.payload);
if (!payload) {
throw new BadRequestException({
code: 'orderInvalid',
message: 'Der Auftrag ist unvollständig. Bitte legen Sie ihn neu an.',
});
}
const claim = await tenantPrisma.domainsOrder.updateMany({
where: { id, tenantId, status: 'DRAFT', environment },
data: {
status: 'SUBMITTING',
confirmedAt: new Date(),
confirmedByUserId: user.id,
confirmedByUsername: user.username,
},
});
if (claim.count !== 1) {
// Verloren: jemand war schneller. Kein Netzaufruf.
const current = (await tenantPrisma.domainsOrder.findFirst({
where: { id, tenantId },
})) as OrderRow | null;
if (!current) throw new NotFoundException(ERR.orderNotFound);
if (current.environment !== environment) throw new ConflictException(ERR.environmentChanged);
throw new ConflictException(ERR.alreadySubmitted);
}
// --- Ab hier gibt es kein Zurueck zu DRAFT. Genau ein Aufruf, ohne Wiederholung. ---
const body = {
name: before.domainName,
period: { unit: 'YEAR', period: 1 },
ownerc: { id: payload.ownerContactId },
adminc: { id: payload.adminContactId },
techc: { id: payload.techContactId },
zonec: { id: payload.zoneContactId },
nameServers: payload.nameServers.map((name) => ({ name })),
};
let outcome: Record<string, unknown>;
try {
const result = await this.callRaw(credentials, 'POST', '/domain', body);
outcome = this.outcomeOfSubmit(result);
} catch (error) {
this.logger.error(
`POST /domain für Auftrag ${id} warf einen Fehler: ${error instanceof Error ? error.name : 'unbekannt'}`,
);
outcome = { status: 'UNKNOWN' };
}
// Bewusst OHNE `lastCheckedAt`: bei UNKNOWN heisst das "noch nicht bei AutoDNS
// nachgesehen" und sperrt das Verwerfen, bis ein Abgleich stattgefunden hat.
await tenantPrisma.domainsOrder.updateMany({
where: { id, tenantId, status: 'SUBMITTING' },
data: outcome,
});
this.logger.log(`Auftrag ${id}: Registrierung abgeschickt, Ergebnis ${String(outcome.status)}`);
if (outcome.status === 'SUBMITTED') this.directory.invalidate(tenantId);
const after = (await tenantPrisma.domainsOrder.findFirst({
where: { id, tenantId },
})) as OrderRow | null;
if (!after) throw new NotFoundException(ERR.orderNotFound);
return toView(after);
}
/** Abbildung der Antwort von `POST /domain` auf die zu speichernden Felder (D-M). */
private outcomeOfSubmit(result: AutodnsResult): Record<string, unknown> {
if (result.ok) {
const job = extractJobFromSubmit(result);
return {
status: 'SUBMITTED',
jobId: job.id,
jobStatus: job.status ? job.status.toUpperCase() : null,
};
}
switch (result.kind) {
// AutoDNS hat ausdruecklich abgelehnt (Umschlag bzw. Anmeldefehler): nichts wurde bestellt.
case 'auth':
case 'forbidden':
case 'rate-limit':
case 'business':
return { status: 'FAILED', errorText: failureText(result), openKey: null };
// Zeitablauf, Netz, TLS, unlesbare oder umschlaglose Antwort: Ausgang unbekannt.
default:
return { status: 'UNKNOWN' };
}
}
// --- Abgleich ----------------------------------------------------------------
/**
* Gleicht offene Auftraege des aktiven Systems mit AutoDNS ab (hoechstens 20,
* der am laengsten ungeprueften zuerst). Wird beim Oeffnen des Reiters, bei
* "Aktualisieren" und alle 30 s bei offenen Auftraegen aufgerufen (D-N, ohne
* Cron). Auftraege des anderen Systems bleiben unveraendert, bis dieses
* wieder aktiv ist — ihre Zugangsdaten sind gerade nicht massgeblich.
*/
async refreshOpenOrders(tenantId: string): Promise<DomainsOrderView[]> {
const tenantPrisma = forTenant(this.prisma, tenantId);
// 1. Haengende Ansprueche -> UNKNOWN (alle Systeme; braucht kein Netz).
const stale = (await tenantPrisma.domainsOrder.findMany({
where: { tenantId, status: 'SUBMITTING' },
})) as OrderRow[];
const now = Date.now();
for (const row of stale) {
const since = (row.confirmedAt ?? row.updatedAt).getTime();
if (now - since > SUBMITTING_STALE_MS) {
await tenantPrisma.domainsOrder.updateMany({
where: { id: row.id, tenantId, status: 'SUBMITTING' },
data: { status: 'UNKNOWN' },
});
}
}
// 2. Offene Auftraege des aktiven Systems.
let active: { environment: DomainsEnvironment; credentials: AutodnsCredentials } | null = null;
try {
active = await this.settings.getActiveCredentials(tenantId);
} catch (error) {
if (!(error instanceof ConflictException)) throw error;
}
if (active) {
const open = (await tenantPrisma.domainsOrder.findMany({
where: {
tenantId,
environment: active.environment,
status: { in: ['SUBMITTED', 'UNKNOWN'] },
},
orderBy: { lastCheckedAt: { sort: 'asc', nulls: 'first' } },
take: REFRESH_MAX_ORDERS,
})) as OrderRow[];
for (const row of open) {
try {
await this.refreshOrder(tenantId, active.credentials, row);
} catch (error) {
// Ein Fehler bei einem Auftrag haelt die anderen nicht auf.
this.logger.warn(
`Abgleich von Auftrag ${row.id} fehlgeschlagen: ${error instanceof Error ? error.name : 'unbekannt'}`,
);
}
}
}
return this.listOrders(tenantId);
}
/** Einziger Einstieg fuer den Abgleich eines Auftrags (D-N). */
private async refreshOrder(
tenantId: string,
credentials: AutodnsCredentials,
row: OrderRow,
): Promise<void> {
if (row.status === 'SUBMITTED' && row.jobId) {
await this.refreshFromJob(tenantId, credentials, row);
} else if (row.status === 'UNKNOWN' || row.status === 'SUBMITTED') {
await this.reconcile(tenantId, credentials, row);
}
}
/** Schreibt nur, solange der Auftrag noch im erwarteten Zustand ist. */
private async writeIf(
tenantId: string,
row: OrderRow,
data: Record<string, unknown>,
): Promise<void> {
const tenantPrisma = forTenant(this.prisma, tenantId);
await tenantPrisma.domainsOrder.updateMany({
where: { id: row.id, tenantId, status: row.status },
data,
});
if (data.status === 'SUCCESS') this.directory.invalidate(tenantId);
}
private async refreshFromJob(
tenantId: string,
credentials: AutodnsCredentials,
row: OrderRow,
): Promise<void> {
const result = await this.callRaw(
credentials,
'GET',
`/job/${encodeURIComponent(row.jobId as string)}`,
);
if (!result.ok) return; // nicht gepruefte Lage bleibt, wie sie ist
const job = parseJob(result.data);
const checkedAt = new Date();
if (!job?.status) {
await this.writeIf(tenantId, row, { lastCheckedAt: checkedAt });
return;
}
switch (job.status) {
case 'SUCCESS':
await this.writeIf(tenantId, row, {
status: 'SUCCESS',
jobStatus: 'SUCCESS',
lastCheckedAt: checkedAt,
});
return;
case 'FAILED':
await this.writeIf(tenantId, row, {
status: 'FAILED',
jobStatus: 'FAILED',
errorText: job.messages.length
? `AutoDNS meldet: ${job.messages.join(' ')}`
: 'AutoDNS hat die Registrierung nicht ausgeführt.',
openKey: null,
lastCheckedAt: checkedAt,
});
return;
case 'CANCELED':
await this.writeIf(tenantId, row, {
status: 'CANCELED',
jobStatus: 'CANCELED',
errorText: 'Der Auftrag wurde bei AutoDNS abgebrochen.',
openKey: null,
lastCheckedAt: checkedAt,
});
return;
default:
// RUNNING, WAIT, DEFERRED, NOT_SET, SUPPORT: bleibt offen.
await this.writeIf(tenantId, row, { jobStatus: job.status, lastCheckedAt: checkedAt });
}
}
/**
* UNKNOWN (oder SUBMITTED ohne Auftragsnummer): erst `GET /domain/{name}` —
* gibt es die Domain, ist sie registriert. Sonst Auftragssuche nach dem
* Domainnamen, nur Auftraege ab 5 Minuten vor der Bestaetigung. Nur ein
* ERGEBNIS ("nicht gefunden") setzt `lastCheckedAt`; ein Fehler beim Nachsehen
* (Zeitablauf, Anmeldung, Gateway) laesst den Auftrag unveraendert, damit
* "Verwerfen" nie auf einer Pruefung beruht, die gar nichts geprueft hat.
*/
private async reconcile(
tenantId: string,
credentials: AutodnsCredentials,
row: OrderRow,
): Promise<void> {
const domain = await this.callRaw(
credentials,
'GET',
`/domain/${encodeURIComponent(row.domainName)}`,
);
if (domain.ok && domain.data.length > 0) {
await this.writeIf(tenantId, row, { status: 'SUCCESS', lastCheckedAt: new Date() });
return;
}
// Nur eine ausdrueckliche AutoDNS-Antwort "gibt es nicht" ist ein Ergebnis.
if (!domain.ok && domain.kind !== 'business') return;
const search = await this.callRaw(credentials, 'POST', '/job/_search', {
filters: [{ key: 'object', operator: 'EQUAL', value: row.domainName }],
view: { limit: 10, offset: 0 },
orders: [{ key: 'created', type: 'DESC' }],
});
if (!search.ok) return;
const since = (row.confirmedAt ?? row.createdAt).getTime() - JOB_SEARCH_TOLERANCE_MS;
const candidates = parseJobList(search.data)
.filter((j) => j.object === row.domainName && j.created && j.created.getTime() >= since)
.sort((a, b) => (b.created?.getTime() ?? 0) - (a.created?.getTime() ?? 0));
const checkedAt = new Date();
const job = candidates[0];
if (job) {
await this.writeIf(tenantId, row, {
status: 'SUBMITTED',
jobId: job.id,
jobStatus: job.status,
lastCheckedAt: checkedAt,
});
return;
}
await this.writeIf(tenantId, row, { lastCheckedAt: checkedAt });
}
// --- Verwerfen ---------------------------------------------------------------
async cancelOrder(tenantId: string, id: string): Promise<DomainsOrderView> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const row = (await tenantPrisma.domainsOrder.findFirst({
where: { id, tenantId },
})) as OrderRow | null;
if (!row) throw new NotFoundException(ERR.orderNotFound);
if (row.status === 'UNKNOWN' && !row.lastCheckedAt) throw new ConflictException(ERR.checkFirst);
if (row.status !== 'DRAFT' && row.status !== 'UNKNOWN') {
throw new ConflictException(ERR.notCancelable);
}
const { count } = await tenantPrisma.domainsOrder.updateMany({
where: {
id,
tenantId,
status: row.status,
...(row.status === 'UNKNOWN' ? { lastCheckedAt: { not: null } } : {}),
},
data: { status: 'CANCELED', openKey: null },
});
if (count !== 1) throw new ConflictException(ERR.notCancelable);
return toView({ ...row, status: 'CANCELED', openKey: null });
}
// --- Liste -------------------------------------------------------------------
async listOrders(tenantId: string): Promise<DomainsOrderView[]> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const rows = (await tenantPrisma.domainsOrder.findMany({
where: { tenantId },
orderBy: { createdAt: 'desc' },
take: LIST_MAX_ORDERS,
})) as OrderRow[];
return rows.map(toView);
}
}
@@ -41,7 +41,7 @@ const MIN_NAMESERVERS = 2;
const MAX_NAMESERVERS = 6;
/** Rechnername: Labels aus Buchstaben, Ziffern, Bindestrich; mindestens zwei Labels. */
const HOSTNAME_PATTERN =
export const HOSTNAME_PATTERN =
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])$/;
interface ConfigRow {
@@ -82,7 +82,11 @@ export class DomainsSettingsService {
* Nur fuer Tests: ersetzt den Netzzugang und den Takt-Begrenzer. Im
* Betrieb bleibt das leer (echter `undiciFetch`, prozessweiter Begrenzer).
*/
transport: { fetchImpl?: AutodnsFetch; limiter?: AutodnsRateLimiter } = {};
transport: {
fetchImpl?: AutodnsFetch;
limiter?: AutodnsRateLimiter;
timeoutMs?: number;
} = {};
constructor(
private readonly prisma: PrismaService,
@@ -17,8 +17,19 @@ const MANAGE_HANDLERS = [
'deleteCustomer',
'createContact',
'assignContacts',
'checkAvailability',
'createOrder',
'submitOrder',
'cancelOrder',
];
const USE_HANDLERS = [
'getStatus',
'listCustomers',
'listContacts',
'listDomains',
'listOrders',
'refreshOrders',
];
const USE_HANDLERS = ['getStatus', 'listCustomers', 'listContacts', 'listDomains'];
function makeDirectory() {
return {
@@ -33,6 +44,17 @@ function makeDirectory() {
};
}
function makeOrders() {
return {
checkAvailability: vi.fn(async (..._a: unknown[]) => ({})),
listOrders: vi.fn(async (..._a: unknown[]) => []),
createOrder: vi.fn(async (..._a: unknown[]) => ({})),
refreshOpenOrders: vi.fn(async (..._a: unknown[]) => []),
submitOrder: vi.fn(async (..._a: unknown[]) => ({})),
cancelOrder: vi.fn(async (..._a: unknown[]) => ({})),
};
}
function makeSettings() {
return {
getStatus: vi.fn(async (..._a: unknown[]) => ({})),
@@ -80,6 +102,12 @@ describe('DomainsController — Metadaten', () => {
expect(route('listDomains')).toEqual([0, 'domains']);
expect(route('updateCustomer')).toEqual([2, 'customers/:id']);
expect(route('deleteCustomer')).toEqual([3, 'customers/:id']);
expect(route('checkAvailability')).toEqual([1, 'availability']);
expect(route('listOrders')).toEqual([0, 'orders']);
expect(route('createOrder')).toEqual([1, 'orders']);
expect(route('refreshOrders')).toEqual([1, 'orders/refresh']);
expect(route('submitOrder')).toEqual([1, 'orders/:id/submit']);
expect(route('cancelOrder')).toEqual([1, 'orders/:id/cancel']);
});
});
@@ -97,11 +125,22 @@ describe('DomainsController — Routen-Reihenfolge (statisch vor :id)', () => {
});
});
describe('DomainsController — Auftraege: Reihenfolge', () => {
it("'orders/refresh' steht vor jedem :id-Handler", () => {
const names = Object.getOwnPropertyNames(DomainsController.prototype);
const refresh = names.indexOf('refreshOrders');
expect(refresh).toBeGreaterThan(-1);
for (const n of ['updateCustomer', 'deleteCustomer', 'submitOrder', 'cancelOrder']) {
expect(refresh, n).toBeLessThan(names.indexOf(n));
}
});
});
describe('DomainsController — Verhalten', () => {
it('reicht req.tenantId weiter', async () => {
const settings = makeSettings();
const directory = makeDirectory();
const c = new DomainsController(settings as any, directory as any);
const c = new DomainsController(settings as any, directory as any, makeOrders() as any);
await c.getStatus(req('t1'));
await c.getSettings(req('t1'));
await c.saveSettings(req('t1'), { demoUser: 'x' } as any);
@@ -115,7 +154,7 @@ describe('DomainsController — Verhalten', () => {
it('Kunden-, Kontakt- und Domainwege reichen req.tenantId weiter; refresh nur bei 1/true', async () => {
const settings = makeSettings();
const directory = makeDirectory();
const c = new DomainsController(settings as any, directory as any);
const c = new DomainsController(settings as any, directory as any, makeOrders() as any);
const id = '11111111-1111-4111-8111-111111111111';
await c.listCustomers(req('t1'));
await c.createCustomer(req('t1'), { name: 'A' } as any);
@@ -147,7 +186,44 @@ describe('DomainsController — Verhalten', () => {
});
it('ohne Mandantenkontext 403', async () => {
const c = new DomainsController(makeSettings() as any, makeDirectory() as any);
const c = new DomainsController(
makeSettings() as any,
makeDirectory() as any,
makeOrders() as any,
);
await expect(c.getStatus(req(undefined))).rejects.toBeInstanceOf(ForbiddenException);
});
});
describe('DomainsController — Auftraege: Verhalten', () => {
const user = { id: 'u1', username: 'anna' } as any;
const id = '22222222-2222-4222-8222-222222222222';
it('reicht Organisation, Benutzer und Auftragsnummer weiter', async () => {
const orders = makeOrders();
const c = new DomainsController(makeSettings() as any, makeDirectory() as any, orders as any);
await c.checkAvailability(req('t1'), { domain: 'beispiel.de' });
await c.listOrders(req('t1'));
await c.createOrder(req('t1'), user, { domain: 'beispiel.de' } as any);
await c.refreshOrders(req('t1'));
await c.submitOrder(req('t1'), user, id);
await c.cancelOrder(req('t1'), id);
expect(orders.checkAvailability).toHaveBeenCalledWith('t1', 'beispiel.de');
expect(orders.listOrders).toHaveBeenCalledWith('t1');
expect(orders.createOrder).toHaveBeenCalledWith('t1', 'u1', { domain: 'beispiel.de' });
expect(orders.refreshOpenOrders).toHaveBeenCalledWith('t1');
expect(orders.submitOrder).toHaveBeenCalledWith('t1', { id: 'u1', username: 'anna' }, id);
expect(orders.cancelOrder).toHaveBeenCalledWith('t1', id);
});
it('ohne Mandantenkontext 403', async () => {
const c = new DomainsController(
makeSettings() as any,
makeDirectory() as any,
makeOrders() as any,
);
await expect(c.submitOrder(req(undefined), user, id)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
});
+55 -1
View File
@@ -11,12 +11,15 @@ import {
Query,
Req,
} from '@nestjs/common';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedRequest, AuthUser } from '../auth/types/auth-user';
import { ModuleManage, UseModule } from '../module-registry/module.guard';
import { DomainsDirectoryService } from './domains-directory.service';
import { DomainsOrdersService } from './domains-orders.service';
import { DomainsSettingsService } from './domains-settings.service';
import { AssignDomainsContactsDto, CreateDomainsContactDto } from './dto/domains-contact.dto';
import { DomainsCustomerDto } from './dto/domains-customer.dto';
import { CheckAvailabilityDto, CreateDomainsOrderDto } from './dto/domains-order.dto';
import { SaveDomainsSettingsDto, TestDomainsConnectionDto } from './dto/domains-settings.dto';
/** `?refresh=1` oder `?refresh=true` umgeht den Zwischenspeicher. */
@@ -51,6 +54,7 @@ export class DomainsController {
constructor(
private readonly settings: DomainsSettingsService,
private readonly directory: DomainsDirectoryService,
private readonly orders: DomainsOrdersService,
) {}
private requireTenantId(req: AuthenticatedRequest): string {
@@ -124,6 +128,36 @@ export class DomainsController {
});
}
// --- Registrierung und Auftraege (Aufgabe 3) ---------------------------------
// `orders/refresh` steht VOR jeder `orders/:id/...`-Route.
@Post('availability')
@ModuleManage('domains')
async checkAvailability(@Req() req: AuthenticatedRequest, @Body() dto: CheckAvailabilityDto) {
return this.orders.checkAvailability(this.requireTenantId(req), dto.domain);
}
@Get('orders')
async listOrders(@Req() req: AuthenticatedRequest) {
return this.orders.listOrders(this.requireTenantId(req));
}
@Post('orders')
@ModuleManage('domains')
async createOrder(
@Req() req: AuthenticatedRequest,
@CurrentUser() user: AuthUser,
@Body() dto: CreateDomainsOrderDto,
) {
return this.orders.createOrder(this.requireTenantId(req), user.id, dto);
}
/** Gleicht nur den Zustand mit AutoDNS ab und aendert dort nichts: Benutzen genuegt. */
@Post('orders/refresh')
async refreshOrders(@Req() req: AuthenticatedRequest) {
return this.orders.refreshOpenOrders(this.requireTenantId(req));
}
// --- Routen mit :id (immer ganz am Ende) -------------------------------------
@Put('customers/:id')
@@ -141,4 +175,24 @@ export class DomainsController {
async deleteCustomer(@Req() req: AuthenticatedRequest, @Param('id', ParseUUIDPipe) id: string) {
return this.directory.deleteCustomer(this.requireTenantId(req), id);
}
@Post('orders/:id/submit')
@ModuleManage('domains')
async submitOrder(
@Req() req: AuthenticatedRequest,
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.orders.submitOrder(
this.requireTenantId(req),
{ id: user.id, username: user.username },
id,
);
}
@Post('orders/:id/cancel')
@ModuleManage('domains')
async cancelOrder(@Req() req: AuthenticatedRequest, @Param('id', ParseUUIDPipe) id: string) {
return this.orders.cancelOrder(this.requireTenantId(req), id);
}
}
+2 -1
View File
@@ -4,6 +4,7 @@ import { ModuleRegistryService } from '../module-registry/module-registry.servic
import { DomainsController } from './domains.controller';
import { seedDomainsModule } from './domains.seed';
import { DomainsDirectoryService } from './domains-directory.service';
import { DomainsOrdersService } from './domains-orders.service';
import { DomainsSettingsService } from './domains-settings.service';
/**
@@ -14,7 +15,7 @@ import { DomainsSettingsService } from './domains-settings.service';
@Module({
imports: [ModuleRegistryModule],
controllers: [DomainsController],
providers: [DomainsSettingsService, DomainsDirectoryService],
providers: [DomainsSettingsService, DomainsDirectoryService, DomainsOrdersService],
})
export class DomainsModule implements OnModuleInit {
private readonly logger = new Logger(DomainsModule.name);
@@ -0,0 +1,52 @@
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsInt,
IsNotEmpty,
IsString,
MaxLength,
Min,
} from 'class-validator';
/** Verfuegbarkeitspruefung einer Domain (quick-261008-dts, D-K). */
export class CheckAvailabilityDto {
@IsString()
@IsNotEmpty()
@MaxLength(300)
domain!: string;
}
/**
* Entwurf einer Registrierung (D-O): alle vier Kontakte sind Pflicht, die
* Laufzeit steht fest bei einem Jahr und ist deshalb kein Feld.
*/
export class CreateDomainsOrderDto {
@IsString()
@IsNotEmpty()
@MaxLength(300)
domain!: string;
@IsInt()
@Min(1)
ownerContactId!: number;
@IsInt()
@Min(1)
adminContactId!: number;
@IsInt()
@Min(1)
techContactId!: number;
@IsInt()
@Min(1)
zoneContactId!: number;
@IsArray()
@ArrayMinSize(2)
@ArrayMaxSize(6)
@IsString({ each: true })
@MaxLength(253, { each: true })
nameServers!: string[];
}
@@ -94,6 +94,10 @@ describe('Umgestellte Handler (Verwalten)', () => {
'deleteCustomer',
'createContact',
'assignContacts',
'checkAvailability',
'createOrder',
'submitOrder',
'cancelOrder',
])('DomainsController.%s verlangt Verwalten für domains (quick-261008-dts)', (name) => {
expectManage(DomainsController, name, 'domains');
});
@@ -103,6 +107,8 @@ describe('Umgestellte Handler (Verwalten)', () => {
'listCustomers',
'listContacts',
'listDomains',
'listOrders',
'refreshOrders',
])('DomainsController.%s bleibt auf Benutzen-Ebene', (name) => {
const fn = handler(DomainsController, name);
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
@@ -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',
];
+13
View File
@@ -356,6 +356,19 @@ Das Modul „Nextcloud-Status“ (Gruppe Infrastruktur) aktivieren Sie wie jedes
- **Benachrichtigungen:** Jeder Benutzer mit Zugriff auf das Modul kann über die Glocke auf einer Kachel für sich Benachrichtigungen einschalten. Tessera schickt dann eine E-Mail und, solange Tessera geöffnet ist, eine Meldung auf dem Bildschirm, wenn die Cloud rot wird und wenn sie wieder in Ordnung ist (eine Nachricht je Änderung). Für die E-Mails muss der Mailversand eingerichtet sein (Kapitel 6) und der Empfänger eine Adresse im Profil haben. Ob das Konto aktiv ist und das Modul noch genutzt werden darf, prüft Tessera erst beim Senden; sonst wird nichts verschickt. Scheitert der Versand, versucht Tessera es bis zu dreimal. Ändern Sie die Adresse einer roten Cloud und die neue Adresse antwortet in Ordnung, geht an die Abonnenten die Nachricht „wieder in Ordnung“.
- **Logo:** Entweder ein hochgeladenes Bild (PNG, JPEG, GIF oder WebP, höchstens 1 MB; SVG wird bewusst nicht angenommen) oder die https-Adresse eines Bildes, das der Browser des Betrachters direkt lädt – der Server ruft diese Adresse nie ab. Beides zugleich gibt es nicht: ein Upload ersetzt die Adresse und umgekehrt.
### Domains: AutoDNS anbinden
Das Modul „Domains“ (Gruppe Domains) aktivieren Sie wie jedes Modul im Marktplatz; wer es nutzen soll, bekommt die Freigabe „Benutzen“. **Die Anbindung einrichten, Kunden und Kontakte anlegen und Domains registrieren** dürfen Administratoren und Benutzer mit der Freigabestufe „Verwalten“ – vergeben Sie diese Stufe nur an Personen, denen Sie Bestellungen auf Ihre Kosten zutrauen, denn eine Registrierung im Live-System ist verbindlich und kostenpflichtig.
- **Eigener AutoDNS-Benutzer:** Legen Sie bei AutoDNS einen eigenen Benutzer für Tessera an, **ohne Zwei-Faktor-Anmeldung** – Tessera meldet sich mit Benutzername, Passwort und der Kontextnummer an, einen Zusatzcode kann es nicht eingeben. Der Benutzer braucht die Rechte, Domains und Kontakte zu lesen sowie Kontakte anzulegen und Domains zu registrieren.
- **Kontext je System:** Tragen Sie unter Domains → Einstellungen für das Demo-System und das Live-System je Benutzername, Passwort und Kontext ein. Beim Live-System ist der Kontext in der Regel **4** (das Feld ist damit vorbelegt). Für das Demo-System nennt InterNetX den Wert; er steht nicht fest und ist deshalb frei eintragbar. Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt; lassen Sie das Feld später leer, um es zu behalten.
- **Mit dem Demo-System beginnen:** Neue Installationen laufen im Demo-System, dort entstehen keine echten Registrierungen. Probieren Sie zuerst dort alles aus. Der Wechsel auf das Live-System (Einstellungen → System) verlangt eine ausdrückliche Bestätigung; danach zeigt die Kennzeichnung oben auf der Seite „Live-System – Registrierungen kosten Geld“. Für jedes System gelten eigene Zugangsdaten, eigene Kontakt-Zuordnungen und eigene Aufträge – ein Entwurf aus dem Demo-System kann nie im Live-System bestellt werden.
- **Verbindung testen:** Der Knopf schickt genau einen Anmeldeversuch an AutoDNS. Klicken Sie bei einer Fehlermeldung nicht wiederholt: **Mehrere falsche Anmeldungen hintereinander können den AutoDNS-Benutzer sperren.** Prüfen Sie erst Benutzername, Passwort und Kontext, speichern Sie, und testen Sie dann einmal.
- **Standard-Nameserver:** Unter „Standard-Nameserver“ (zwei bis sechs) hinterlegen Sie die Nameserver, die bei jeder Registrierung vorausgefüllt werden. Sie **müssen bei Ihrem Anbieter bereits eingerichtet sein**: Für `.de`-Domains prüft die Registry (DENIC) die Nameserver bei der Registrierung, und eine Registrierung mit nicht eingerichteten Nameservern scheitert („Fehlgeschlagen“).
- **Netzwerk:** Der API-Container braucht ausgehenden Zugriff (https) auf `api.autodns.com` (Live) und `api.demo.autodns.com` (Demo). Die Adressen sind fest eingebaut und nicht änderbar; Zertifikate werden geprüft, Weiterleitungen nicht befolgt.
- **Abfragegrenze:** AutoDNS erlaubt etwa drei Anfragen pro Sekunde und IP-Adresse. Tessera hält das ein (höchstens eine Anfrage alle 350 Millisekunden, Listen seitenweise, ein Zwischenspeicher von einer Minute) und wiederholt nie selbstständig eine Anfrage. Listen zeigen höchstens die ersten 2000 Einträge.
- **Keine Doppelbestellung:** Jede Registrierung wird höchstens einmal an AutoDNS geschickt. Bei Zeitüberschreitung oder Verbindungsabbruch steht der Auftrag auf „Ergebnis ungeklärt“ und wird beim nächsten Abgleich (Reiter „Aufträge“) mit AutoDNS verglichen. Ein unklarer Auftrag lässt sich erst verwerfen, nachdem dieser Abgleich ohne Fund durchgelaufen ist.
## 6. SMTP
Unter **Administration → E-Mail-Versand (SMTP)** wird der Mailversand konfiguriert: Host, Port, Verschlüsselung (Keine, STARTTLS oder SSL-TLS), Benutzername, Passwort, die Absenderadresse und optional das Feld „Fehlermeldungen an“ (siehe unten). Das Passwortfeld wird aus Sicherheitsgründen nie mit dem gespeicherten Wert vorbefüllt – es bleibt beim Laden immer leer und wird nur mitgesendet, wenn tatsächlich ein neuer Wert eingegeben wurde.
+35
View File
@@ -15,6 +15,7 @@ Diese Anleitung richtet sich an alle Kolleginnen und Kollegen, die Tessera im Ar
- [DKV-Rechnung](#dkv-rechnung)
- [Zertifikat-Manager](#zertifikat-manager)
- [Domaincheck](#domaincheck)
- [Domains](#domains)
- [Proxmox](#proxmox)
- [Nextcloud-Status](#nextcloud-status)
- [Kantinenabrechnung](#kantinenabrechnung)
@@ -160,6 +161,40 @@ Unterstützte Dateiformate sind unter anderem `.pem`, `.crt`, `.cer`, `.der`, `.
Ein einfaches Werkzeug, um zu prüfen, ob eine Internet-Domain verfügbar ist. Geben Sie einen Domain-Namen ein (z. B. `beispiel.de`) und klicken Sie auf „Prüfen" — das Ergebnis zeigt für die geprüften Endungen jeweils „Verfügbar" oder „Registriert" an, ergänzt um alternative Vorschläge.
### Domains
Mit dem Modul „Domains“ (Gruppe **Domains**) verwalten Sie Ihre Domains bei AutoDNS: Sie sehen alle vorhandenen Domains, ordnen Kontakte Kunden zu und registrieren neue Domains. Ein Administrator aktiviert das Modul im Marktplatz; Sie brauchen außerdem die Freigabe. Mit **„Benutzen“** sehen Sie die Reiter **Domains**, **Kontakte**, **Kunden** und **Aufträge**. Mit **„Verwalten“** (und als Administrator) kommen **Registrieren** und **Einstellungen** hinzu, und Sie können Kontakte und Kunden anlegen und zuordnen.
**Das System oben rechts:** Eine Kennzeichnung zeigt immer, womit Tessera gerade arbeitet. **„Demo-System (Testbetrieb)“** ist ein Übungssystem von AutoDNS, dort entstehen keine echten Registrierungen. **„Live-System – Registrierungen kosten Geld“** ist das echte System. Steht dort „AutoDNS nicht eingerichtet“, hinterlegt zuerst ein Administrator oder jemand mit „Verwalten“ den Zugang in den Einstellungen.
**Domains:** Alle Domains des gewählten Systems mit Kunde, Inhaber, Ablaufdatum und Status. Der Kunde ergibt sich aus dem Inhaber-Kontakt; ist dieser keinem Kunden zugeordnet, steht dort „Nicht zugeordnet“. Über das Suchfeld, die Kundenauswahl und „Nach Kunde gruppieren“ grenzen Sie die Liste ein. „Aus AutoDNS neu laden“ holt den aktuellen Stand; sonst merkt sich Tessera die Liste für eine Minute, damit AutoDNS nicht überlastet wird.
**Kontakte und Kunden:** Alle Kontakte, die bei AutoDNS im gewählten System vorhanden sind, erscheinen automatisch im Reiter „Kontakte“. Wer „Verwalten“ hat, ordnet sie einem **Kunden** zu: Kontakte ankreuzen, bei „Ausgewählte zuordnen“ den Kunden wählen, „Zuordnen“ klicken – auch viele auf einmal. „Zuordnung entfernen“ löst sie wieder. Mit „Neuer Kontakt“ legen Sie einen neuen Kontakt bei AutoDNS an (Person oder Organisation; die Telefonnummer in internationaler Schreibweise, zum Beispiel +49 30 123456). Im Reiter „Kunden“ legen Sie Kunden an; Ihre eigene Firma legen Sie ebenfalls als Kunden an und haken „Das ist unsere eigene Firma“ an. Ein Kunde, dem noch Kontakte zugeordnet sind, lässt sich nicht löschen. Kontakte selbst ändert oder löscht Tessera bei AutoDNS nicht.
**Eine Domain registrieren** (Reiter „Registrieren“, nur mit „Verwalten“):
1. Domain eingeben und **„Verfügbarkeit prüfen“**. Nur „frei“ lässt sich registrieren; der Preis für ein Jahr wird angezeigt, sofern AutoDNS ihn nennt.
2. Für **Inhaber, Admin-Kontakt, technischen Kontakt und Zonenkontakt** je einen Kontakt wählen. Admin-, Technik- und Zonenkontakt sind vorausgewählt (Admin wie der Inhaber, Technik und Zone wie der erste Kontakt Ihrer eigenen Firma, sonst wie der Inhaber) und lassen sich ändern. Die **Nameserver** sind aus den Einstellungen vorausgefüllt; es sind zwei bis sechs nötig.
3. **„Zusammenfassung anzeigen“** und alles prüfen: System, Domain, Laufzeit (ein Jahr), Preis, Kontakte, Nameserver.
4. Das Häkchen zur Bestätigung setzen und **„Jetzt verbindlich registrieren“** klicken. Im Live-System kostet das Geld.
Tessera schickt eine Registrierung **höchstens ein einziges Mal** an AutoDNS – auch bei Doppelklick, bei zwei geöffneten Fenstern oder nach einem Wechsel des Systems zwischen Zusammenfassung und Bestätigung (dann erscheint eine Meldung, und es wird nichts bestellt). Eine Wiederholung gibt es nie von selbst.
**Aufträge:** Jede Registrierung erscheint hier mit ihrem Stand. Tessera gleicht die offenen Aufträge beim Öffnen des Reiters, nach „Aktualisieren“ und danach alle 30 Sekunden mit AutoDNS ab, solange der Reiter offen und sichtbar ist. Die Stände bedeuten:
| Stand | Bedeutung |
|---|---|
| Entwurf | Zusammenfassung erstellt, noch nichts bestellt. |
| Wird übermittelt | Die Bestätigung läuft gerade. |
| In Bearbeitung | AutoDNS hat die Bestellung angenommen und bearbeitet sie. |
| Rückfrage nötig | AutoDNS beziehungsweise InterNetX braucht eine Rückmeldung; wenden Sie sich dorthin. |
| Registriert | Die Domain ist registriert. |
| Fehlgeschlagen | AutoDNS hat die Registrierung abgelehnt oder nicht ausgeführt; der Grund steht dabei. Sie dürfen es erneut versuchen. |
| Ergebnis ungeklärt | Tessera weiß nicht, ob AutoDNS die Bestellung angenommen hat (zum Beispiel wegen einer Verbindungsstörung). |
| Verworfen | Ein Entwurf oder ein geprüfter unklarer Auftrag, den jemand mit „Verwalten“ verworfen hat. |
**„Ergebnis ungeklärt“ – bestellen Sie nicht erneut.** Die Bestellung kann bei AutoDNS angekommen sein, auch wenn die Antwort fehlte. Klicken Sie unter „Aufträge“ auf „Aktualisieren“: Tessera fragt dann bei AutoDNS nach, ob es die Domain oder einen passenden Auftrag gibt, und stellt den Auftrag entsprechend richtig. Findet Tessera weder die Domain noch einen Auftrag, dürfen Sie den Auftrag verwerfen – prüfen Sie zur Sicherheit vorher in AutoDNS selbst, dass die Domain nicht bestellt wurde. Verwerfen und neu bestellen ist erst danach sicher.
### Proxmox
Das Modul zeigt den Zustand Ihrer Proxmox-Server auf einen Blick — für die drei Proxmox-Produkte PVE (Virtualisierung), PBS (Backup) und PMG (Mail-Gateway). Wichtig zu wissen: **Tessera verändert bei Proxmox nichts, es schaut nur zu.** Es gibt in diesem Modul keinen Weg, einen Server zu starten, zu stoppen, eine Sicherung auszulösen oder irgendeine Proxmox-Einstellung zu ändern.
File diff suppressed because one or more lines are too long