feat(domains): Modul Domains mit AutoDNS-Zugang, Verbindungstest und Einstellungen

- Migration mit vier Tabellen (Einstellungen, Kunden, Kontaktzuordnung, Bestellungen), Zeilenschutz je Organisation
- AutoDNS-Client mit festen Demo-/Live-Adressen, Takt-Begrenzer, 20 s Zeitlimit, ohne Wiederholung
- Zugang je System verschluesselt gespeichert, Live-Wechsel nur mit Bestaetigung, Verbindungstest
- Modulseite mit Systemkennzeichnung und Reiter Einstellungen (nur Verwalten)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 10:35:21 +02:00
parent 6b0f840c1c
commit 1f1c984184
29 changed files with 3123 additions and 2 deletions
+124
View File
@@ -0,0 +1,124 @@
/**
* Domains — API-Client (quick-261008-dts). Konsumiert `/modules/domains/*`.
* `credentials: 'include'` fuer Cookie-Auth, `NEXT_PUBLIC_API_URL` als Basis
* (Muster `nextcloud-status-api.ts`). Der Browser spricht nie mit AutoDNS;
* Zugangsdaten kommen hier nie zurueck (nur `hasPassword`).
*/
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
const BASE = '/modules/domains';
export type DomainsEnvironment = 'DEMO' | 'LIVE';
export interface DomainsStatus {
environment: DomainsEnvironment;
/** Ist das AKTIVE System eingerichtet? */
configured: boolean;
demoConfigured: boolean;
liveConfigured: boolean;
defaultNameServers: string[];
}
export interface DomainsEnvironmentAccess {
user: string | null;
hasPassword: boolean;
context: number | null;
}
export interface DomainsSettings {
environment: DomainsEnvironment;
demo: DomainsEnvironmentAccess;
live: DomainsEnvironmentAccess;
defaultNameServers: string[];
configured: { demo: boolean; live: boolean };
}
/** Teilspeichern: nur mitgeschickte Felder aendern sich; leeres Passwort = unveraendert. */
export interface SaveDomainsSettingsInput {
environment?: DomainsEnvironment;
confirmLive?: boolean;
demoUser?: string;
demoPassword?: string;
demoContext?: number | null;
liveUser?: string;
livePassword?: string;
liveContext?: number | null;
defaultNameServers?: string[];
}
export interface ConnectionTestResult {
ok: boolean;
kind?: string;
message: string;
}
/** Fehler mit HTTP-Status, maschinenlesbarer Kennung und deutscher Servermeldung. */
export class DomainsRequestError extends Error {
constructor(
readonly status: number,
readonly code: string | null,
message: string,
) {
super(message);
this.name = 'DomainsRequestError';
}
}
async function failure(res: Response): Promise<DomainsRequestError> {
let message = `Request failed (${res.status})`;
let code: string | null = null;
try {
const body = await res.json();
const raw = body?.message;
if (Array.isArray(raw)) message = raw.join(' ');
else if (typeof raw === 'string') message = raw;
if (typeof body?.code === 'string') code = body.code;
} catch {
// Antwort ohne JSON-Koerper — Standardmeldung bleibt.
}
return new DomainsRequestError(res.status, code, message);
}
async function request<T>(
path: string,
init: { method?: string; json?: unknown } = {},
): Promise<T> {
const method = init.method ?? 'GET';
const headers: Record<string, string> = {};
let body: string | undefined;
if (init.json !== undefined) {
headers['Content-Type'] = 'application/json';
body = JSON.stringify(init.json);
}
const res = await fetch(`${API_URL}${BASE}${path}`, {
method,
credentials: 'include',
headers,
body,
...(method === 'GET' ? { cache: 'no-store' as const } : {}),
});
if (!res.ok) throw await failure(res);
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export function getDomainsStatus(): Promise<DomainsStatus> {
return request<DomainsStatus>('/status');
}
export function getDomainsSettings(): Promise<DomainsSettings> {
return request<DomainsSettings>('/settings');
}
export function saveDomainsSettings(input: SaveDomainsSettingsInput): Promise<DomainsSettings> {
return request<DomainsSettings>('/settings', { method: 'PUT', json: input });
}
export function testDomainsConnection(
environment: DomainsEnvironment,
): Promise<ConnectionTestResult> {
return request<ConnectionTestResult>('/connection-test', {
method: 'POST',
json: { environment },
});
}
+2 -1
View File
@@ -7,7 +7,7 @@
* (`--tile`, `--tile-foreground`, `--primary`, `--primary-foreground`).
*/
export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'utensils' | 'shopping-bag' | 'cloud' | 'tile';
export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'utensils' | 'shopping-bag' | 'cloud' | 'earth' | 'tile';
const ICONS: Record<string, ModuleIconId> = {
'tender-radar': 'radar',
@@ -18,6 +18,7 @@ const ICONS: Record<string, ModuleIconId> = {
'nextcloud-status': 'cloud',
'kantine-datev': 'utensils',
'handelsware-datev': 'shopping-bag',
domains: 'earth',
};
/** Symbol eines Moduls; unbekannte Module bekommen das allgemeine Kachel-Symbol. */
+6
View File
@@ -77,6 +77,12 @@ export const MODULE_REGISTRY: Record<string, ModuleRegistryEntry> = {
{ ssr: false },
),
},
domains: {
component: dynamic(
() => import('@/app/(portal)/modules/domains/page'),
{ ssr: false },
),
},
};
/**
+1
View File
@@ -32,6 +32,7 @@ const MODULE_TITLE_KEYS: Record<string, string> = {
'tender-radar': 'tenderRadar.page.title',
'kantine-datev': 'kantineDatev.title',
'handelsware-datev': 'handelswareDatev.title',
domains: 'domains.title',
};
/**