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:
@@ -0,0 +1,34 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { DomainsStatus } from '@/lib/domains-api';
|
||||
|
||||
/**
|
||||
* Dauerhafte Kennzeichnung des aktiven AutoDNS-Systems im Seitenkopf (D-E):
|
||||
* Demo (Testbetrieb), Live (kostet Geld) oder "nicht eingerichtet". Die
|
||||
* Klassen stehen als vollstaendige Zeichenketten, damit Tailwind sie findet.
|
||||
*/
|
||||
export function EnvironmentBadge({ status }: { status: DomainsStatus | null }) {
|
||||
const t = useTranslations('domains.environment');
|
||||
if (!status) return null;
|
||||
|
||||
let label: string;
|
||||
let classes: string;
|
||||
if (!status.configured) {
|
||||
label = t('notConfigured');
|
||||
classes = 'bg-status-idle/12 text-status-idle-fg';
|
||||
} else if (status.environment === 'LIVE') {
|
||||
label = t('live');
|
||||
classes = 'bg-status-down/12 text-status-down-fg';
|
||||
} else {
|
||||
label = t('demo');
|
||||
classes = 'bg-status-warn/12 text-status-warn-fg';
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid="domains-environment-badge"
|
||||
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold ${classes}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SettingsSection } from '@/components/control-center/settings-section';
|
||||
import {
|
||||
type ConnectionTestResult,
|
||||
type DomainsEnvironment,
|
||||
type DomainsEnvironmentAccess,
|
||||
DomainsRequestError,
|
||||
type DomainsSettings,
|
||||
getDomainsSettings,
|
||||
type SaveDomainsSettingsInput,
|
||||
saveDomainsSettings,
|
||||
testDomainsConnection,
|
||||
} from '@/lib/domains-api';
|
||||
|
||||
const INPUT_CLASS =
|
||||
'w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring';
|
||||
const PRIMARY_BUTTON =
|
||||
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:cursor-not-allowed disabled:opacity-50';
|
||||
const SECONDARY_BUTTON =
|
||||
'rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground hover:bg-accent transition-colors disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
/** Der Live-Kontext von AutoDNS ist in der Regel 4; das Demo-System hat keinen Vorschlag. */
|
||||
const LIVE_DEFAULT_CONTEXT = 4;
|
||||
const MIN_NAMESERVER_ROWS = 2;
|
||||
const MAX_NAMESERVER_ROWS = 6;
|
||||
|
||||
type Message = { kind: 'ok' | 'error'; text: string };
|
||||
|
||||
function MessageLine({ message }: { message: Message | null }) {
|
||||
if (!message) return null;
|
||||
return (
|
||||
<p
|
||||
role={message.kind === 'error' ? 'alert' : 'status'}
|
||||
className={`text-sm ${message.kind === 'error' ? 'text-destructive' : 'text-status-ok-fg'}`}
|
||||
>
|
||||
{message.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function useErrorText() {
|
||||
const t = useTranslations('domains');
|
||||
return (error: unknown) =>
|
||||
error instanceof DomainsRequestError ? error.message : t('errors.request');
|
||||
}
|
||||
|
||||
// --- System (Demo/Live) -------------------------------------------------------
|
||||
|
||||
function SystemCard({
|
||||
settings,
|
||||
onSaved,
|
||||
}: {
|
||||
settings: DomainsSettings;
|
||||
onSaved: (s: DomainsSettings) => void;
|
||||
}) {
|
||||
const t = useTranslations('domains.settings');
|
||||
const errorText = useErrorText();
|
||||
const [selected, setSelected] = useState<DomainsEnvironment>(settings.environment);
|
||||
const [confirmingLive, setConfirmingLive] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<Message | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(settings.environment);
|
||||
setConfirmingLive(false);
|
||||
}, [settings.environment]);
|
||||
|
||||
const choose = (environment: DomainsEnvironment) => {
|
||||
setMessage(null);
|
||||
if (environment === 'LIVE' && settings.environment !== 'LIVE') {
|
||||
// Der Wechsel auf das kostenpflichtige System braucht eine ausdrueckliche Bestaetigung.
|
||||
setSelected('LIVE');
|
||||
setConfirmingLive(true);
|
||||
return;
|
||||
}
|
||||
setConfirmingLive(false);
|
||||
setSelected(environment);
|
||||
};
|
||||
|
||||
const save = async (input: SaveDomainsSettingsInput) => {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
onSaved(await saveDomainsSettings(input));
|
||||
setConfirmingLive(false);
|
||||
setMessage({ kind: 'ok', text: t('saved') });
|
||||
} catch (error) {
|
||||
setMessage({ kind: 'error', text: errorText(error) });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelLive = () => {
|
||||
setConfirmingLive(false);
|
||||
setSelected(settings.environment);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="domains-system"
|
||||
title={t('system.title')}
|
||||
description={t('system.description')}
|
||||
footer={
|
||||
<>
|
||||
<MessageLine message={message} />
|
||||
<button
|
||||
type="button"
|
||||
className={PRIMARY_BUTTON}
|
||||
disabled={saving || confirmingLive || selected === settings.environment}
|
||||
onClick={() => save({ environment: selected })}
|
||||
>
|
||||
{saving ? t('saving') : t('save')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<fieldset className="space-y-3">
|
||||
{(['DEMO', 'LIVE'] as const).map((env) => (
|
||||
<label key={env} className="flex cursor-pointer items-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="domains-environment"
|
||||
value={env}
|
||||
checked={selected === env}
|
||||
onChange={() => choose(env)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-foreground">
|
||||
{env === 'DEMO' ? t('system.demo') : t('system.live')}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{env === 'DEMO' ? t('system.demoHelp') : t('system.liveHelp')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
{confirmingLive && (
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-label={t('system.confirmText')}
|
||||
className="mt-4 space-y-3 rounded-md border border-status-down/40 bg-status-down/10 p-4"
|
||||
>
|
||||
<p className="text-sm font-medium text-foreground">{t('system.confirmText')}</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={PRIMARY_BUTTON}
|
||||
disabled={saving}
|
||||
onClick={() => save({ environment: 'LIVE', confirmLive: true })}
|
||||
>
|
||||
{t('system.confirm')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={SECONDARY_BUTTON}
|
||||
disabled={saving}
|
||||
onClick={cancelLive}
|
||||
>
|
||||
{t('system.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Zugang je System ---------------------------------------------------------
|
||||
|
||||
function parseContext(text: string): number | null | 'invalid' {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed === '') return null;
|
||||
if (!/^\d{1,10}$/.test(trimmed)) return 'invalid';
|
||||
const n = Number.parseInt(trimmed, 10);
|
||||
return n >= 1 && n <= 2147483647 ? n : 'invalid';
|
||||
}
|
||||
|
||||
function AccessCard({
|
||||
environment,
|
||||
access,
|
||||
onSaved,
|
||||
}: {
|
||||
environment: DomainsEnvironment;
|
||||
access: DomainsEnvironmentAccess;
|
||||
onSaved: (s: DomainsSettings) => void;
|
||||
}) {
|
||||
const t = useTranslations('domains.settings');
|
||||
const errorText = useErrorText();
|
||||
const prefix = environment === 'DEMO' ? 'demo' : 'live';
|
||||
const initialContext =
|
||||
access.context !== null
|
||||
? String(access.context)
|
||||
: environment === 'LIVE'
|
||||
? String(LIVE_DEFAULT_CONTEXT)
|
||||
: '';
|
||||
|
||||
const [user, setUser] = useState(access.user ?? '');
|
||||
const [password, setPassword] = useState('');
|
||||
const [context, setContext] = useState(initialContext);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [message, setMessage] = useState<Message | null>(null);
|
||||
const [testResult, setTestResult] = useState<ConnectionTestResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setUser(access.user ?? '');
|
||||
setPassword('');
|
||||
setContext(
|
||||
access.context !== null
|
||||
? String(access.context)
|
||||
: environment === 'LIVE'
|
||||
? String(LIVE_DEFAULT_CONTEXT)
|
||||
: '',
|
||||
);
|
||||
}, [access.user, access.context, environment]);
|
||||
|
||||
const contextValue = parseContext(context);
|
||||
const dirty =
|
||||
user.trim() !== (access.user ?? '') || password !== '' || context.trim() !== initialContext;
|
||||
|
||||
const save = async () => {
|
||||
if (contextValue === 'invalid') return;
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
const input: SaveDomainsSettingsInput = {
|
||||
[`${prefix}User`]: user.trim(),
|
||||
[`${prefix}Context`]: contextValue,
|
||||
...(password !== '' ? { [`${prefix}Password`]: password } : {}),
|
||||
};
|
||||
try {
|
||||
onSaved(await saveDomainsSettings(input));
|
||||
setPassword('');
|
||||
setMessage({ kind: 'ok', text: t('saved') });
|
||||
} catch (error) {
|
||||
setMessage({ kind: 'error', text: errorText(error) });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runTest = async () => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
setTestResult(await testDomainsConnection(environment));
|
||||
} catch (error) {
|
||||
setTestResult({ ok: false, message: errorText(error) });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const idBase = `domains-${prefix}`;
|
||||
return (
|
||||
<SettingsSection
|
||||
id={idBase}
|
||||
title={environment === 'DEMO' ? t('access.demoTitle') : t('access.liveTitle')}
|
||||
description={t('access.description')}
|
||||
footer={
|
||||
<>
|
||||
<div className="mr-auto space-y-1">
|
||||
<MessageLine message={message} />
|
||||
{testResult && (
|
||||
<p
|
||||
role={testResult.ok ? 'status' : 'alert'}
|
||||
className={`text-sm ${testResult.ok ? 'text-status-ok-fg' : 'text-destructive'}`}
|
||||
>
|
||||
{testResult.message}
|
||||
</p>
|
||||
)}
|
||||
{dirty && <p className="text-xs text-muted-foreground">{t('access.saveFirst')}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={SECONDARY_BUTTON}
|
||||
disabled={testing || dirty}
|
||||
onClick={runTest}
|
||||
>
|
||||
{testing ? t('access.testing') : t('access.test')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={PRIMARY_BUTTON}
|
||||
disabled={saving || !dirty || contextValue === 'invalid'}
|
||||
onClick={save}
|
||||
>
|
||||
{saving ? t('saving') : t('save')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor={`${idBase}-user`} className="text-sm font-medium text-foreground">
|
||||
{t('access.user')}
|
||||
</label>
|
||||
<input
|
||||
id={`${idBase}-user`}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
maxLength={100}
|
||||
value={user}
|
||||
onChange={(e) => setUser(e.target.value)}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label htmlFor={`${idBase}-password`} className="text-sm font-medium text-foreground">
|
||||
{t('access.password')}
|
||||
</label>
|
||||
<input
|
||||
id={`${idBase}-password`}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
maxLength={200}
|
||||
value={password}
|
||||
placeholder={access.hasPassword ? t('access.passwordStored') : ''}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label htmlFor={`${idBase}-context`} className="text-sm font-medium text-foreground">
|
||||
{t('access.context')}
|
||||
</label>
|
||||
<input
|
||||
id={`${idBase}-context`}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
value={context}
|
||||
aria-invalid={contextValue === 'invalid'}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
{contextValue === 'invalid' && (
|
||||
<p className="text-xs text-destructive">{t('access.contextInvalid')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">{t('access.contextHelp')}</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Standard-Nameserver --------------------------------------------------------
|
||||
|
||||
function padNameServers(list: string[]): string[] {
|
||||
const rows = [...list];
|
||||
while (rows.length < MIN_NAMESERVER_ROWS) rows.push('');
|
||||
return rows;
|
||||
}
|
||||
|
||||
function NameServersCard({
|
||||
settings,
|
||||
onSaved,
|
||||
}: {
|
||||
settings: DomainsSettings;
|
||||
onSaved: (s: DomainsSettings) => void;
|
||||
}) {
|
||||
const t = useTranslations('domains.settings');
|
||||
const errorText = useErrorText();
|
||||
const [rows, setRows] = useState<string[]>(padNameServers(settings.defaultNameServers));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<Message | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setRows(padNameServers(settings.defaultNameServers));
|
||||
}, [settings.defaultNameServers]);
|
||||
|
||||
const cleaned = rows.map((r) => r.trim().toLowerCase()).filter((r) => r.length > 0);
|
||||
const dirty = cleaned.join('\n') !== settings.defaultNameServers.join('\n');
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
onSaved(await saveDomainsSettings({ defaultNameServers: cleaned }));
|
||||
setMessage({ kind: 'ok', text: t('saved') });
|
||||
} catch (error) {
|
||||
setMessage({ kind: 'error', text: errorText(error) });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="domains-nameservers"
|
||||
title={t('nameServers.title')}
|
||||
description={t('nameServers.description')}
|
||||
footer={
|
||||
<>
|
||||
<div className="mr-auto">
|
||||
<MessageLine message={message} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={PRIMARY_BUTTON}
|
||||
disabled={saving || !dirty}
|
||||
onClick={save}
|
||||
>
|
||||
{saving ? t('saving') : t('save')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{rows.map((value, index) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: Zeilen sind positionsgebunden und haben keine Kennung
|
||||
<div key={index} className="flex items-end gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<label
|
||||
htmlFor={`domains-ns-${index}`}
|
||||
className="text-sm font-medium text-foreground"
|
||||
>
|
||||
{t('nameServers.label', { number: index + 1 })}
|
||||
</label>
|
||||
<input
|
||||
id={`domains-ns-${index}`}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
maxLength={253}
|
||||
placeholder="ns1.example.com"
|
||||
value={value}
|
||||
onChange={(e) => setRows(rows.map((r, i) => (i === index ? e.target.value : r)))}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
{rows.length > MIN_NAMESERVER_ROWS && (
|
||||
<button
|
||||
type="button"
|
||||
className={SECONDARY_BUTTON}
|
||||
onClick={() => setRows(rows.filter((_, i) => i !== index))}
|
||||
>
|
||||
{t('nameServers.remove')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{rows.length < MAX_NAMESERVER_ROWS && (
|
||||
<button type="button" className={SECONDARY_BUTTON} onClick={() => setRows([...rows, ''])}>
|
||||
{t('nameServers.add')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Reiter ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Einstellungen des Moduls Domains (nur fuer Verwalter). Jede Karte speichert
|
||||
* nur ihre eigenen Felder (Teil-PUT); nach jedem Speichern wird der Zustand
|
||||
* der Seite (Kennzeichnung im Kopf) neu geladen.
|
||||
*/
|
||||
export function SettingsTab({ onChanged }: { onChanged: () => void }) {
|
||||
const t = useTranslations('domains');
|
||||
const [settings, setSettings] = useState<DomainsSettings | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getDomainsSettings()
|
||||
.then((s) => {
|
||||
if (!cancelled) setSettings(s);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
setLoadError(error instanceof DomainsRequestError ? error.message : t('errors.request'));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const handleSaved = (next: DomainsSettings) => {
|
||||
setSettings(next);
|
||||
onChanged();
|
||||
};
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{loadError}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (!settings) {
|
||||
return <p className="text-sm text-muted-foreground">{t('settings.loading')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SystemCard settings={settings} onSaved={handleSaved} />
|
||||
<AccessCard environment="DEMO" access={settings.demo} onSaved={handleSaved} />
|
||||
<AccessCard environment="LIVE" access={settings.live} onSaved={handleSaved} />
|
||||
<NameServersCard settings={settings} onSaved={handleSaved} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render as rtlRender,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} 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 { DomainsSettings, DomainsStatus } from '@/lib/domains-api';
|
||||
import de from '@/messages/de.json';
|
||||
import DomainsPage from './page';
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
{ui}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const mockGetStatus = vi.fn();
|
||||
const mockGetSettings = vi.fn();
|
||||
const mockSaveSettings = vi.fn();
|
||||
const mockTestConnection = vi.fn();
|
||||
|
||||
vi.mock('@/lib/domains-api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/domains-api')>();
|
||||
return {
|
||||
...actual,
|
||||
getDomainsStatus: (...args: unknown[]) => mockGetStatus(...args),
|
||||
getDomainsSettings: (...args: unknown[]) => mockGetSettings(...args),
|
||||
saveDomainsSettings: (...args: unknown[]) => mockSaveSettings(...args),
|
||||
testDomainsConnection: (...args: unknown[]) => mockTestConnection(...args),
|
||||
};
|
||||
});
|
||||
|
||||
let mockCanManage: boolean | null = true;
|
||||
vi.mock('@/lib/use-module-capability', () => ({
|
||||
useCanManageModule: () => mockCanManage,
|
||||
}));
|
||||
|
||||
function status(over: Partial<DomainsStatus> = {}): DomainsStatus {
|
||||
return {
|
||||
environment: 'DEMO',
|
||||
configured: true,
|
||||
demoConfigured: true,
|
||||
liveConfigured: false,
|
||||
defaultNameServers: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function settings(over: Partial<DomainsSettings> = {}): DomainsSettings {
|
||||
return {
|
||||
environment: 'DEMO',
|
||||
demo: { user: 'api-user', hasPassword: true, context: 1 },
|
||||
live: { user: null, hasPassword: false, context: null },
|
||||
defaultNameServers: [],
|
||||
configured: { demo: true, live: false },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCanManage = true;
|
||||
mockGetStatus.mockReset().mockResolvedValue(status());
|
||||
mockGetSettings.mockReset().mockResolvedValue(settings());
|
||||
mockSaveSettings.mockReset().mockImplementation(async () => settings());
|
||||
mockTestConnection
|
||||
.mockReset()
|
||||
.mockResolvedValue({ ok: true, message: 'Verbindung erfolgreich.' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('Domains-Seite — Kopf und Reiter', () => {
|
||||
it('Verwalter sieht den Reiter Einstellungen und die Demo-Kennzeichnung', async () => {
|
||||
render(<DomainsPage />);
|
||||
expect(await screen.findByRole('button', { name: 'Einstellungen' })).toBeTruthy();
|
||||
expect((await screen.findByTestId('domains-environment-badge')).textContent).toBe(
|
||||
'Demo-System (Testbetrieb)',
|
||||
);
|
||||
});
|
||||
|
||||
it('Live-System zeigt die Kostenwarnung', async () => {
|
||||
mockGetStatus.mockResolvedValue(status({ environment: 'LIVE', liveConfigured: true }));
|
||||
render(<DomainsPage />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('domains-environment-badge').textContent).toBe(
|
||||
'Live-System – Registrierungen kosten Geld',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('nicht eingerichtet: Kennzeichnung und Hinweis', async () => {
|
||||
mockGetStatus.mockResolvedValue(status({ configured: false, demoConfigured: false }));
|
||||
render(<DomainsPage />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('domains-environment-badge').textContent).toBe(
|
||||
'AutoDNS nicht eingerichtet',
|
||||
),
|
||||
);
|
||||
expect(screen.getByText('AutoDNS ist noch nicht eingerichtet.')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'Zu den Einstellungen' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('nicht eingerichtet, ohne Verwalten: Hinweis auf einen Administrator, kein Einstellungen-Reiter', async () => {
|
||||
mockCanManage = false;
|
||||
mockGetStatus.mockResolvedValue(status({ configured: false, demoConfigured: false }));
|
||||
render(<DomainsPage />);
|
||||
expect(await screen.findByText(/Bitte wenden Sie sich an einen Administrator/)).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: 'Zu den Einstellungen' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
|
||||
});
|
||||
|
||||
it('Benutzer ohne Verwalten sieht den Reiter Einstellungen nicht', async () => {
|
||||
mockCanManage = false;
|
||||
render(<DomainsPage />);
|
||||
await screen.findByTestId('domains-environment-badge');
|
||||
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
|
||||
expect(mockGetSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Einstellungen', () => {
|
||||
it('Passwortfelder beginnen leer, mit Hinweis auf das gespeicherte Passwort', async () => {
|
||||
render(<DomainsPage />);
|
||||
const demoPassword = (await screen.findByLabelText('Passwort', {
|
||||
selector: '#domains-demo-password',
|
||||
})) as HTMLInputElement;
|
||||
expect(demoPassword.value).toBe('');
|
||||
expect(demoPassword.placeholder).toBe('Gespeichert – leer lassen, um es beizubehalten');
|
||||
const livePassword = document.getElementById('domains-live-password') as HTMLInputElement;
|
||||
expect(livePassword.placeholder).toBe('');
|
||||
});
|
||||
|
||||
it('der Live-Kontext ist mit 4 vorbelegt, der Demo-Kontext zeigt den gespeicherten Wert', async () => {
|
||||
render(<DomainsPage />);
|
||||
await screen.findByLabelText('Passwort', { selector: '#domains-demo-password' });
|
||||
expect((document.getElementById('domains-live-context') as HTMLInputElement).value).toBe('4');
|
||||
expect((document.getElementById('domains-demo-context') as HTMLInputElement).value).toBe('1');
|
||||
});
|
||||
|
||||
it('Wechsel auf Live verlangt Bestaetigung; erst die bestaetigte Speicherung sendet confirmLive', async () => {
|
||||
mockSaveSettings.mockResolvedValue(settings({ environment: 'LIVE' }));
|
||||
render(<DomainsPage />);
|
||||
const liveRadio = await screen.findByRole('radio', { name: /Live-System \(kostenpflichtig\)/ });
|
||||
fireEvent.click(liveRadio);
|
||||
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
expect(within(dialog).getByText(/kosten Geld/)).toBeTruthy();
|
||||
expect(mockSaveSettings).not.toHaveBeenCalled();
|
||||
|
||||
// Abbrechen sendet nichts und stellt die Auswahl zurueck.
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Abbrechen' }));
|
||||
expect(mockSaveSettings).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
expect(
|
||||
(screen.getByRole('radio', { name: /Demo-System \(Testbetrieb\)/ }) as HTMLInputElement)
|
||||
.checked,
|
||||
).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: /Live-System \(kostenpflichtig\)/ }));
|
||||
fireEvent.click(
|
||||
within(await screen.findByRole('alertdialog')).getByRole('button', {
|
||||
name: 'Live-System verwenden',
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(mockSaveSettings).toHaveBeenCalledTimes(1));
|
||||
expect(mockSaveSettings).toHaveBeenCalledWith({ environment: 'LIVE', confirmLive: true });
|
||||
});
|
||||
|
||||
it('Speichern des Zugangs sendet nur die eigenen Felder; ohne Eingabe kein Passwort', async () => {
|
||||
render(<DomainsPage />);
|
||||
const user = (await screen.findByLabelText('Benutzername', {
|
||||
selector: '#domains-demo-user',
|
||||
})) as HTMLInputElement;
|
||||
fireEvent.change(user, { target: { value: 'neuer-user' } });
|
||||
const demoCard = document.getElementById('domains-demo') as HTMLElement;
|
||||
fireEvent.click(within(demoCard).getByRole('button', { name: 'Speichern' }));
|
||||
await waitFor(() => expect(mockSaveSettings).toHaveBeenCalledTimes(1));
|
||||
expect(mockSaveSettings).toHaveBeenCalledWith({ demoUser: 'neuer-user', demoContext: 1 });
|
||||
});
|
||||
|
||||
it('Verbindung testen: ein Aufruf je Klick, gesperrt waehrend des Laufs, zeigt das Ergebnis', async () => {
|
||||
let resolveTest: (v: { ok: boolean; message: string }) => void = () => undefined;
|
||||
mockTestConnection.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveTest = resolve;
|
||||
}),
|
||||
);
|
||||
render(<DomainsPage />);
|
||||
await screen.findByLabelText('Passwort', { selector: '#domains-demo-password' });
|
||||
const demoCard = document.getElementById('domains-demo') as HTMLElement;
|
||||
const button = within(demoCard).getByRole('button', { name: 'Verbindung testen' });
|
||||
|
||||
fireEvent.click(button);
|
||||
await waitFor(() => expect(mockTestConnection).toHaveBeenCalledTimes(1));
|
||||
expect(mockTestConnection).toHaveBeenCalledWith('DEMO');
|
||||
const running = within(demoCard).getByRole('button', { name: 'Verbindung wird getestet …' });
|
||||
expect((running as HTMLButtonElement).disabled).toBe(true);
|
||||
fireEvent.click(running);
|
||||
expect(mockTestConnection).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveTest({ ok: false, message: 'Anmeldung bei AutoDNS fehlgeschlagen.' });
|
||||
expect(await within(demoCard).findByText('Anmeldung bei AutoDNS fehlgeschlagen.')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('der Verbindungstest ist bei ungespeicherten Aenderungen gesperrt', async () => {
|
||||
render(<DomainsPage />);
|
||||
const password = (await screen.findByLabelText('Passwort', {
|
||||
selector: '#domains-demo-password',
|
||||
})) as HTMLInputElement;
|
||||
fireEvent.change(password, { target: { value: 'neu' } });
|
||||
const demoCard = document.getElementById('domains-demo') as HTMLElement;
|
||||
expect(
|
||||
(within(demoCard).getByRole('button', { name: 'Verbindung testen' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
expect(within(demoCard).getByText('Bitte zuerst speichern')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Nameserver: speichert die bereinigte Liste', async () => {
|
||||
render(<DomainsPage />);
|
||||
const first = (await screen.findByLabelText('Nameserver 1')) as HTMLInputElement;
|
||||
fireEvent.change(first, { target: { value: ' NS1.Example.com ' } });
|
||||
fireEvent.change(screen.getByLabelText('Nameserver 2'), {
|
||||
target: { value: 'ns2.example.com' },
|
||||
});
|
||||
const card = document.getElementById('domains-nameservers') as HTMLElement;
|
||||
fireEvent.click(within(card).getByRole('button', { name: 'Speichern' }));
|
||||
await waitFor(() => expect(mockSaveSettings).toHaveBeenCalledTimes(1));
|
||||
expect(mockSaveSettings).toHaveBeenCalledWith({
|
||||
defaultNameServers: ['ns1.example.com', 'ns2.example.com'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { ModuleAccessGate } from '@/components/modules/module-access-gate';
|
||||
|
||||
export default function DomainsLayout({ children }: { children: ReactNode }) {
|
||||
return <ModuleAccessGate moduleSlug="domains">{children}</ModuleAccessGate>;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { TabBar } from '@/components/accounting/tab-bar';
|
||||
import { SettingsSection } from '@/components/control-center/settings-section';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { type DomainsStatus, getDomainsStatus } from '@/lib/domains-api';
|
||||
import { useCanManageModule } from '@/lib/use-module-capability';
|
||||
import { EnvironmentBadge } from './components/EnvironmentBadge';
|
||||
import { SettingsTab } from './components/SettingsTab';
|
||||
|
||||
type TabId = 'settings';
|
||||
|
||||
/**
|
||||
* Domains (quick-261008-dts): Domains bei AutoDNS registrieren, Kontakte und
|
||||
* Kunden zuordnen. Der Seitenkopf traegt dauerhaft die Kennzeichnung des
|
||||
* aktiven Systems (Demo/Live). Der Reiter "Einstellungen" erscheint nur fuer
|
||||
* Administratoren und Benutzer mit der Freigabestufe Verwalten — bindend ist
|
||||
* allein die API.
|
||||
*/
|
||||
export default function DomainsPage() {
|
||||
const t = useTranslations('domains');
|
||||
const canManage = useCanManageModule('domains') === true;
|
||||
|
||||
const [status, setStatus] = useState<DomainsStatus | null>(null);
|
||||
const [statusError, setStatusError] = useState(false);
|
||||
const [tab, setTab] = useState<TabId>('settings');
|
||||
|
||||
const reloadStatus = useCallback(async () => {
|
||||
try {
|
||||
setStatus(await getDomainsStatus());
|
||||
setStatusError(false);
|
||||
} catch {
|
||||
setStatusError(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reloadStatus();
|
||||
}, [reloadStatus]);
|
||||
|
||||
const tabs: { id: TabId; label: string }[] = [];
|
||||
if (canManage) tabs.push({ id: 'settings', label: t('tabs.settings') });
|
||||
const activeTab = tabs.some((x) => x.id === tab) ? tab : tabs[0]?.id;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
|
||||
<PageHeader
|
||||
moduleSlug="domains"
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
actions={<EnvironmentBadge status={status} />}
|
||||
/>
|
||||
{statusError && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{t('errors.loadStatus')}
|
||||
</p>
|
||||
)}
|
||||
{status && !status.configured && (
|
||||
<SettingsSection title={t('notConfigured.title')}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{canManage ? t('notConfigured.managerHint') : t('notConfigured.userHint')}
|
||||
</p>
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('settings')}
|
||||
className="mt-3 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
|
||||
>
|
||||
{t('notConfigured.goToSettings')}
|
||||
</button>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
{tabs.length > 0 && activeTab && (
|
||||
<>
|
||||
<TabBar tabs={tabs} active={activeTab} onChange={setTab} />
|
||||
{activeTab === 'settings' && <SettingsTab onChanged={reloadStatus} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import CertManagerLayout from './cert-manager/layout';
|
||||
import DkvFleetLayout from './dkv-fleet/layout';
|
||||
import DomaincheckLayout from './domaincheck/layout';
|
||||
import DomainsLayout from './domains/layout';
|
||||
import HandelswareDatevLayout from './handelsware-datev/layout';
|
||||
import KantineDatevLayout from './kantine-datev/layout';
|
||||
import NextcloudStatusLayout from './nextcloud-status/layout';
|
||||
@@ -44,6 +45,7 @@ describe('module layouts — ModuleAccessGate slug wiring (T-e8k-01, T-e8k-03)',
|
||||
['kantine-datev', KantineDatevLayout],
|
||||
['handelsware-datev', HandelswareDatevLayout],
|
||||
['nextcloud-status', NextcloudStatusLayout],
|
||||
['domains', DomainsLayout],
|
||||
] as const)('%s/layout.tsx passes moduleSlug="%s" and forwards children', (expectedSlug, Layout) => {
|
||||
const element = Layout({ children: placeholderChild });
|
||||
|
||||
|
||||
@@ -58,6 +58,14 @@ const GLYPHS: Record<ModuleIconId, ReactNode> = {
|
||||
</>
|
||||
),
|
||||
cloud: <path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />,
|
||||
earth: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M21.54 15H17a2 2 0 0 0-2 2v4.54" />
|
||||
<path d="M7 3.34V5a3 3 0 0 0 3 3a2 2 0 0 1 2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2c0-1.1.9-2 2-2h3.17" />
|
||||
<path d="M11 21.95V18a2 2 0 0 0-2-2a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05" />
|
||||
</>
|
||||
),
|
||||
tile: (
|
||||
<>
|
||||
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -77,6 +77,12 @@ export const MODULE_REGISTRY: Record<string, ModuleRegistryEntry> = {
|
||||
{ ssr: false },
|
||||
),
|
||||
},
|
||||
domains: {
|
||||
component: dynamic(
|
||||
() => import('@/app/(portal)/modules/domains/page'),
|
||||
{ ssr: false },
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2048,5 +2048,65 @@
|
||||
"calendar": {
|
||||
"sourcesTitle": "Kalenderquellen"
|
||||
}
|
||||
},
|
||||
"domains": {
|
||||
"title": "Domains",
|
||||
"description": "Domains bei AutoDNS registrieren, Kontakte und Kunden zuordnen",
|
||||
"environment": {
|
||||
"demo": "Demo-System (Testbetrieb)",
|
||||
"live": "Live-System – Registrierungen kosten Geld",
|
||||
"notConfigured": "AutoDNS nicht eingerichtet"
|
||||
},
|
||||
"tabs": {
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"notConfigured": {
|
||||
"title": "AutoDNS ist noch nicht eingerichtet.",
|
||||
"managerHint": "Hinterlegen Sie Benutzername, Passwort und Kontext des gewählten Systems in den Einstellungen.",
|
||||
"goToSettings": "Zu den Einstellungen",
|
||||
"userHint": "Bitte wenden Sie sich an einen Administrator oder an jemanden mit der Freigabestufe Verwalten."
|
||||
},
|
||||
"settings": {
|
||||
"loading": "Einstellungen werden geladen …",
|
||||
"save": "Speichern",
|
||||
"saving": "Speichern …",
|
||||
"saved": "Gespeichert.",
|
||||
"system": {
|
||||
"title": "System",
|
||||
"description": "Legen Sie fest, ob Tessera mit dem Testsystem oder mit dem echten System von AutoDNS arbeitet.",
|
||||
"demo": "Demo-System (Testbetrieb)",
|
||||
"demoHelp": "Zum Ausprobieren. Hier entstehen keine echten Registrierungen.",
|
||||
"live": "Live-System (kostenpflichtig)",
|
||||
"liveHelp": "Registrierungen sind verbindlich und kosten Geld.",
|
||||
"confirmText": "Ab jetzt laufen Registrierungen über das Live-System von AutoDNS und kosten Geld.",
|
||||
"confirm": "Live-System verwenden",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"access": {
|
||||
"demoTitle": "Zugang Demo-System",
|
||||
"liveTitle": "Zugang Live-System",
|
||||
"description": "Diese Angaben erhalten Sie von AutoDNS. Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt.",
|
||||
"user": "Benutzername",
|
||||
"password": "Passwort",
|
||||
"passwordStored": "Gespeichert – leer lassen, um es beizubehalten",
|
||||
"context": "Kontext",
|
||||
"contextHelp": "Verwenden Sie einen eigenen AutoDNS-Benutzer für Tessera ohne Zwei-Faktor-Anmeldung.",
|
||||
"contextInvalid": "Der Kontext muss eine ganze Zahl größer als 0 sein.",
|
||||
"test": "Verbindung testen",
|
||||
"testing": "Verbindung wird getestet …",
|
||||
"saveFirst": "Bitte zuerst speichern"
|
||||
},
|
||||
"nameServers": {
|
||||
"title": "Standard-Nameserver",
|
||||
"description": "Diese Nameserver werden bei jeder Registrierung vorausgefüllt. Sie müssen bei Ihrem Anbieter bereits eingerichtet sein, sonst scheitert die Registrierung.",
|
||||
"label": "Nameserver {number}",
|
||||
"add": "Nameserver hinzufügen",
|
||||
"remove": "Entfernen"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"request": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
"loadStatus": "Der Zustand der AutoDNS-Anbindung konnte nicht geladen werden."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2048,5 +2048,65 @@
|
||||
"calendar": {
|
||||
"sourcesTitle": "Calendar sources"
|
||||
}
|
||||
},
|
||||
"domains": {
|
||||
"title": "Domains",
|
||||
"description": "Register domains with AutoDNS, assign contacts and customers",
|
||||
"environment": {
|
||||
"demo": "Demo system (test mode)",
|
||||
"live": "Live system – registrations cost money",
|
||||
"notConfigured": "AutoDNS not set up"
|
||||
},
|
||||
"tabs": {
|
||||
"settings": "Settings"
|
||||
},
|
||||
"notConfigured": {
|
||||
"title": "AutoDNS is not set up yet.",
|
||||
"managerHint": "Enter the user name, password and context of the selected system in the settings.",
|
||||
"goToSettings": "Go to settings",
|
||||
"userHint": "Please ask an administrator or someone with the Manage permission level."
|
||||
},
|
||||
"settings": {
|
||||
"loading": "Loading settings …",
|
||||
"save": "Save",
|
||||
"saving": "Saving …",
|
||||
"saved": "Saved.",
|
||||
"system": {
|
||||
"title": "System",
|
||||
"description": "Choose whether Tessera works with the AutoDNS test system or with the real AutoDNS system.",
|
||||
"demo": "Demo system (test mode)",
|
||||
"demoHelp": "For trying things out. No real registrations are made here.",
|
||||
"live": "Live system (billable)",
|
||||
"liveHelp": "Registrations are binding and cost money.",
|
||||
"confirmText": "From now on, registrations run through the AutoDNS live system and cost money.",
|
||||
"confirm": "Use live system",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"access": {
|
||||
"demoTitle": "Demo system access",
|
||||
"liveTitle": "Live system access",
|
||||
"description": "You receive these details from AutoDNS. The password is stored encrypted and is never shown again.",
|
||||
"user": "User name",
|
||||
"password": "Password",
|
||||
"passwordStored": "Stored – leave empty to keep it",
|
||||
"context": "Context",
|
||||
"contextHelp": "Use a dedicated AutoDNS user for Tessera without two-factor sign-in.",
|
||||
"contextInvalid": "The context must be a whole number greater than 0.",
|
||||
"test": "Test connection",
|
||||
"testing": "Testing connection …",
|
||||
"saveFirst": "Please save first"
|
||||
},
|
||||
"nameServers": {
|
||||
"title": "Default name servers",
|
||||
"description": "These name servers are prefilled for every registration. They must already be set up with your provider, otherwise the registration fails.",
|
||||
"label": "Name server {number}",
|
||||
"add": "Add name server",
|
||||
"remove": "Remove"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"request": "The request failed. Please try again.",
|
||||
"loadStatus": "The state of the AutoDNS connection could not be loaded."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,8 @@ export const UMLAUT_REPLACEMENTS: Record<string, string> = {
|
||||
* and must never be touched by the replacement or flagged by the guard.
|
||||
*/
|
||||
export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
// quick-261008-dts: Modul Domains (korrektes Deutsch)
|
||||
'verschlüsselt',
|
||||
// quick-261003-387: Kategorienverwaltung (korrektes Deutsch)
|
||||
'Neuer',
|
||||
// quick-261002-k67: Nextcloud-Status (korrektes Deutsch)
|
||||
|
||||
Reference in New Issue
Block a user