refactor(admin): move SMTP settings from user settings to admin area
Tessera CI/CD / Lint & Type Check (push) Successful in 39s
Tessera CI/CD / Tests (push) Successful in 37s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m28s

SMTP configuration is an admin concern, not a per-user setting. Removed
it from the settings sidebar and relocated to /admin/smtp with a link in
the admin sidebar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 14:47:44 +02:00
parent fe4e64a0ad
commit 85cd17452c
6 changed files with 16 additions and 213 deletions
@@ -3,12 +3,7 @@
import { useTranslations } from 'next-intl';
import { SmtpSettingsForm } from '@/components/settings/smtp-settings-form';
/**
* SMTP settings page — /settings/general/smtp (Surface C, DKV-05).
* Sits inside the existing settings layout (SettingsSidebar wraps it).
* Heading follows the settings/dashboard/page.tsx pattern exactly.
*/
export default function SmtpSettingsPage() {
export default function AdminSmtpPage() {
const t = useTranslations('settings');
return (
@@ -1,192 +0,0 @@
import { cleanup, render, screen, waitFor, fireEvent } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
// Mock next-intl — returns a short readable string for each key
vi.mock('next-intl', () => ({
useTranslations: (ns: string) => (key: string) => {
const map: Record<string, Record<string, string>> = {
settings: {
'smtp.title': 'SMTP-Konfiguration',
'smtp.host': 'Host',
'smtp.port': 'Port',
'smtp.encryption': 'Verschluesselung',
'smtp.username': 'Benutzername',
'smtp.password': 'Passwort',
'smtp.fromAddress': 'Absenderadresse (Von)',
'smtp.fromAddressHelp': 'Die E-Mail-Adresse, die als Absender angezeigt wird.',
'smtp.save': 'Einstellungen speichern',
'smtp.test': 'Verbindung testen',
'smtp.showPassword': 'Passwort anzeigen',
'smtp.hidePassword': 'Passwort verbergen',
'smtp.testTesting': 'Verbindung wird getestet...',
'smtp.testSuccess': 'Verbindung erfolgreich',
'smtp.testFailed': 'Verbindung fehlgeschlagen',
},
};
return map[ns]?.[key] ?? key;
},
}));
// Mock @/lib/settings-api
const mockFetchSmtp = vi.fn();
const mockSaveSmtp = vi.fn();
const mockTestSmtp = vi.fn();
vi.mock('@/lib/settings-api', () => ({
fetchSmtp: (...args: unknown[]) => mockFetchSmtp(...args),
saveSmtp: (...args: unknown[]) => mockSaveSmtp(...args),
testSmtp: (...args: unknown[]) => mockTestSmtp(...args),
}));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe('SmtpSettingsForm', () => {
it('loads existing config and populates fields (password stays blank)', async () => {
mockFetchSmtp.mockResolvedValue({
host: 'smtp.example.com',
port: 587,
encryption: 'starttls',
username: 'user@example.com',
fromAddress: 'tessera@example.com',
hasPassword: true,
});
const { SmtpSettingsForm } = await import(
'@/components/settings/smtp-settings-form'
);
render(<SmtpSettingsForm />);
await waitFor(() => {
expect(
(screen.getByLabelText('Host') as HTMLInputElement).value,
).toBe('smtp.example.com');
});
expect((screen.getByLabelText('Port') as HTMLInputElement).value).toBe(
'587',
);
expect(
(screen.getByLabelText('Benutzername') as HTMLInputElement).value,
).toBe('user@example.com');
expect(
(screen.getByLabelText('Absenderadresse (Von)') as HTMLInputElement)
.value,
).toBe('tessera@example.com');
// T-07-17: password must never be pre-filled from server data
expect(
(screen.getByLabelText('Passwort') as HTMLInputElement).value,
).toBe('');
});
it('calls saveSmtp with form values on submit', async () => {
mockFetchSmtp.mockResolvedValue({
host: 'smtp.example.com',
port: 587,
encryption: 'starttls',
username: 'user@example.com',
fromAddress: 'tessera@example.com',
hasPassword: false,
});
mockSaveSmtp.mockResolvedValue({
host: 'smtp.example.com',
port: 587,
encryption: 'starttls',
username: 'user@example.com',
fromAddress: 'tessera@example.com',
hasPassword: false,
});
const { SmtpSettingsForm } = await import(
'@/components/settings/smtp-settings-form'
);
render(<SmtpSettingsForm />);
await waitFor(() => {
expect(
(screen.getByLabelText('Host') as HTMLInputElement).value,
).toBe('smtp.example.com');
});
fireEvent.click(screen.getByText('Einstellungen speichern'));
await waitFor(() => {
expect(mockSaveSmtp).toHaveBeenCalledWith(
expect.objectContaining({
host: 'smtp.example.com',
port: 587,
encryption: 'starttls',
}),
);
});
});
it('shows success message when testSmtp resolves { success: true }', async () => {
mockFetchSmtp.mockResolvedValue(null);
mockTestSmtp.mockResolvedValue({ success: true });
const { SmtpSettingsForm } = await import(
'@/components/settings/smtp-settings-form'
);
render(<SmtpSettingsForm />);
await waitFor(() =>
expect(screen.getByText('Verbindung testen')).toBeInTheDocument(),
);
fireEvent.click(screen.getByText('Verbindung testen'));
await waitFor(() => {
expect(
screen.getByText('Verbindung erfolgreich'),
).toBeInTheDocument();
});
});
it('shows error message when testSmtp resolves { success: false }', async () => {
mockFetchSmtp.mockResolvedValue(null);
mockTestSmtp.mockResolvedValue({ success: false });
const { SmtpSettingsForm } = await import(
'@/components/settings/smtp-settings-form'
);
render(<SmtpSettingsForm />);
await waitFor(() =>
expect(screen.getByText('Verbindung testen')).toBeInTheDocument(),
);
fireEvent.click(screen.getByText('Verbindung testen'));
await waitFor(() => {
expect(
screen.getByText('Verbindung fehlgeschlagen'),
).toBeInTheDocument();
});
});
it('toggles password input type between password and text', async () => {
mockFetchSmtp.mockResolvedValue(null);
const { SmtpSettingsForm } = await import(
'@/components/settings/smtp-settings-form'
);
render(<SmtpSettingsForm />);
await waitFor(() =>
expect(screen.getByLabelText('Passwort')).toBeInTheDocument(),
);
const passwordInput = screen.getByLabelText('Passwort') as HTMLInputElement;
expect(passwordInput.type).toBe('password');
const toggleButton = screen.getByRole('button', {
name: 'Passwort anzeigen',
});
fireEvent.click(toggleButton);
expect(passwordInput.type).toBe('text');
});
});
@@ -60,6 +60,17 @@ export function AdminSidebar() {
</svg>
),
},
{
label: t('admin.smtp'),
href: '/admin/smtp',
show: true,
icon: (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
),
},
];
return (
@@ -58,19 +58,6 @@ export function SettingsSidebar() {
>
{t('categoryAccount')}
</Link>
<Link
href="/settings/general/smtp"
className={`flex items-center rounded-md px-2 py-1.5 text-sm transition-colors ${
isActive('/settings/general/smtp')
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
: 'text-sidebar-foreground hover:bg-muted'
}`}
aria-current={
isActive('/settings/general/smtp') ? 'page' : undefined
}
>
{t('categorySmtp')}
</Link>
</nav>
{/* Dashboard category — existing items unchanged */}
+2 -1
View File
@@ -74,7 +74,8 @@
"users": "Benutzer",
"tenants": "Mandanten",
"modules": "Module",
"ldap": "LDAP"
"ldap": "LDAP",
"smtp": "SMTP"
},
"role": {
"SUPER_ADMIN": "Super-Admin",
+2 -1
View File
@@ -74,7 +74,8 @@
"users": "Users",
"tenants": "Tenants",
"modules": "Modules",
"ldap": "LDAP"
"ldap": "LDAP",
"smtp": "SMTP"
},
"role": {
"SUPER_ADMIN": "Super Admin",