feat(260929-dzu): Einstellungen > Eigene Module fuer jeden Benutzer, Verwaltung nur gemeinsam

- gemeinsame Oberflaeche (CustomModuleManager, Formular, Loeschdialog) fuer Verwaltung und Einstellungen
- Einstellungen: persoenliche Eintraege, Verwaltung sendet shared: true und zeigt nur gemeinsame
- Navigation, Texte de/en, Komponententests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 10:17:22 +02:00
parent bc4c0119de
commit ee97b4ed9f
11 changed files with 636 additions and 222 deletions
@@ -1,161 +0,0 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { MODULE_CATEGORIES } from '@tessera/shared';
import {
type CustomModule,
checkCustomModuleUrl,
createCustomModule,
updateCustomModule,
} from '@/lib/custom-modules-api';
import { useCategoryLabel } from '@/lib/use-category-label';
interface CustomModuleFormModalProps {
/** `null` = neues Modul anlegen, sonst bearbeiten. */
mod: CustomModule | null;
onClose: () => void;
onSaved: () => void;
}
const NAME_MAX = 100;
const URL_MAX = 2048;
/**
* Formular „Eigenes Modul“ (quick-260929-9wc, Vorbild `GroupFormModal`).
* Die Adresse wird VOR dem Senden geprueft (`checkCustomModuleUrl`, dieselbe
* Regel wie die API): nur https, keine Zugangsdaten. `noValidate`, damit die
* eigenen, uebersetzten Meldungen erscheinen statt der des Browsers.
*/
export function CustomModuleFormModal({ mod, onClose, onSaved }: CustomModuleFormModalProps) {
const t = useTranslations('admin.customModules');
const tCommon = useTranslations('common');
const categoryLabel = useCategoryLabel();
const [name, setName] = useState(mod?.name ?? '');
const [url, setUrl] = useState(mod?.url ?? '');
const [category, setCategory] = useState<string>(mod?.category ?? 'infrastructure');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const trimmedName = name.trim();
if (trimmedName === '') {
setError(t('nameRequired'));
return;
}
const trimmedUrl = url.trim();
const check = checkCustomModuleUrl(trimmedUrl);
if (check === 'notHttps') {
setError(t('urlNotHttps'));
return;
}
if (check === 'credentials') {
setError(t('urlCredentials'));
return;
}
setSaving(true);
try {
const input = { name: trimmedName, url: trimmedUrl, category };
if (mod) {
await updateCustomModule(mod.id, input);
} else {
await createCustomModule(input);
}
onSaved();
onClose();
} catch {
setError(t('saveError'));
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
role="dialog"
aria-modal="true"
aria-labelledby="custom-module-form-title"
className="w-full max-w-md rounded-lg bg-card p-6 shadow-lg dark:border dark:border-border"
>
<h2 id="custom-module-form-title" className="mb-4 text-lg font-semibold text-foreground">
{mod ? t('edit') : t('create')}
</h2>
<form onSubmit={handleSubmit} noValidate className="space-y-4">
<div className="space-y-2">
<label htmlFor="custom-module-name" className="text-sm font-medium text-foreground">
{t('name')}
</label>
<input
id="custom-module-name"
type="text"
required
maxLength={NAME_MAX}
value={name}
onChange={(e) => setName(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
<div className="space-y-2">
<label htmlFor="custom-module-url" className="text-sm font-medium text-foreground">
{t('url')}
</label>
<input
id="custom-module-url"
type="url"
required
maxLength={URL_MAX}
placeholder="https://…"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
<p className="text-xs text-muted-foreground">{t('urlHint')}</p>
</div>
<div className="space-y-2">
<label htmlFor="custom-module-category" className="text-sm font-medium text-foreground">
{t('category')}
</label>
<select
id="custom-module-category"
value={category}
onChange={(e) => setCategory(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{MODULE_CATEGORIES.map((c) => (
<option key={c} value={c}>
{categoryLabel(c)}
</option>
))}
</select>
</div>
{error && (
<div
role="alert"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{error}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<button type="button" onClick={onClose} className="btn btn-secondary">
{tCommon('cancel')}
</button>
<button type="submit" disabled={saving} className="btn btn-primary">
{saving ? tCommon('loading') : tCommon('save')}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -1,77 +0,0 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { type CustomModule, deleteCustomModule } from '@/lib/custom-modules-api';
interface DeleteCustomModuleDialogProps {
mod: CustomModule;
onClose: () => void;
onDeleted: () => void;
}
/**
* Rueckfrage vor dem Loeschen eines eigenen Moduls (quick-260929-9wc,
* Vorbild `DeleteGroupDialog`). Bleibt bei einem Fehler offen und zeigt ihn —
* der Eintrag verschwindet fuer ALLE Benutzer aus der Seitenleiste, ein
* stilles Scheitern wuerde ein Loeschen vortaeuschen.
*/
export function DeleteCustomModuleDialog({ mod, onClose, onDeleted }: DeleteCustomModuleDialogProps) {
const t = useTranslations('admin.customModules');
const tCommon = useTranslations('common');
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleDelete = async () => {
setDeleting(true);
setError(null);
try {
await deleteCustomModule(mod.id);
onDeleted();
onClose();
} catch {
setError(t('deleteError'));
} finally {
setDeleting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
role="dialog"
aria-modal="true"
aria-labelledby="delete-custom-module-title"
className="w-full max-w-sm rounded-lg bg-card p-6 shadow-lg dark:border dark:border-border"
>
<h2 id="delete-custom-module-title" className="mb-4 text-lg font-semibold text-foreground">
{t('deleteConfirm.title')}
</h2>
<p className="mb-4 text-sm text-foreground">{t('deleteConfirm.body', { name: mod.name })}</p>
{error && (
<div
role="alert"
className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{error}
</div>
)}
<div className="flex justify-end gap-3">
<button type="button" onClick={onClose} className="btn btn-secondary">
{tCommon('cancel')}
</button>
<button
type="button"
onClick={handleDelete}
disabled={deleting}
className="rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-opacity hover:opacity-90 disabled:opacity-50"
>
{deleting ? tCommon('loading') : tCommon('delete')}
</button>
</div>
</div>
</div>
);
}
@@ -7,25 +7,31 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const messages: Record<string, Record<string, unknown>> = {
'admin.customModules': {
title: 'Eigene Module',
description: 'Erklärung',
description: 'Erklärung gemeinsam',
create: 'Gemeinsamen Eintrag anlegen',
noModules: 'Noch keine gemeinsamen Einträge',
noModulesBody: 'Legen Sie Ihren ersten gemeinsamen Eintrag an.',
},
'customModules.manage': {
actions: 'Aktionen',
loadError: 'Laden fehlgeschlagen.',
},
'customModules.form': {
create: 'Eigenes Modul anlegen',
edit: 'Eigenes Modul bearbeiten',
name: 'Name',
url: 'Adresse',
urlHint: 'Hinweis https',
category: 'Kategorie',
actions: 'Aktionen',
noModules: 'Noch keine eigenen Module',
noModulesBody: 'Legen Sie Ihr erstes Modul an.',
nameRequired: 'Bitte geben Sie einen Namen ein.',
urlNotHttps: 'Bitte geben Sie eine Adresse ein, die mit https:// beginnt.',
urlCredentials: 'Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.',
saveError: 'Das Modul konnte nicht gespeichert werden.',
deleteError: 'Das Modul konnte nicht gelöscht werden.',
loadError: 'Laden fehlgeschlagen.',
deleteConfirm: {
title: 'Eigenes Modul löschen',
body: 'Möchten Sie „{name}“ wirklich löschen?',
bodyShared: 'Möchten Sie „{name}“ wirklich löschen? Für alle Benutzer.',
bodyPersonal: 'Möchten Sie „{name}“ wirklich löschen? Nur bei Ihnen.',
},
},
common: {
@@ -107,6 +113,7 @@ const wiki = {
name: 'Wiki',
url: 'https://wiki.example.com',
category: 'infrastructure',
personal: false,
createdAt: '2026-09-29T00:00:00.000Z',
updatedAt: '2026-09-29T00:00:00.000Z',
};
@@ -127,9 +134,9 @@ afterEach(() => {
describe('AdminCustomModulesPage', () => {
it('zeigt ohne Einträge den Leer-Zustand mit Anlegen-Knopf', async () => {
render(<AdminCustomModulesPage />);
expect(await screen.findByText('Noch keine eigenen Module')).toBeInTheDocument();
expect(screen.getByText('Legen Sie Ihr erstes Modul an.')).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length).toBeGreaterThan(0);
expect(await screen.findByText('Noch keine gemeinsamen Einträge')).toBeInTheDocument();
expect(screen.getByText('Legen Sie Ihren ersten gemeinsamen Eintrag an.')).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' }).length).toBeGreaterThan(0);
});
it('zeigt mit Einträgen die Tabelle mit Link, Adresse und Kategorie-Anzeigename', async () => {
@@ -146,8 +153,8 @@ describe('AdminCustomModulesPage', () => {
it('Anlegen: http-Adresse zeigt die Meldung und ruft createCustomModule nicht auf', async () => {
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine eigenen Module');
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'http://example.com');
@@ -160,8 +167,8 @@ describe('AdminCustomModulesPage', () => {
it('Anlegen: Adresse mit Zugangsdaten zeigt die passende Meldung', async () => {
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine eigenen Module');
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://user:pw@example.com');
@@ -175,8 +182,8 @@ describe('AdminCustomModulesPage', () => {
it('Anlegen: leerer Name zeigt die Meldung', async () => {
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine eigenen Module');
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
await userEvent.type(screen.getByLabelText('Name'), ' ');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
@@ -188,8 +195,8 @@ describe('AdminCustomModulesPage', () => {
it('Anlegen: gültige Eingabe ruft create mit getrimmtem Namen, lädt neu und aktualisiert die Seitenleiste genau einmal', async () => {
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine eigenen Module');
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
await userEvent.type(screen.getByLabelText('Name'), ' Beispielseite ');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
@@ -201,6 +208,7 @@ describe('AdminCustomModulesPage', () => {
name: 'Beispielseite',
url: 'https://example.com',
category: 'security-tools',
shared: true,
});
});
await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1));
@@ -211,8 +219,8 @@ describe('AdminCustomModulesPage', () => {
it('Anlegen: die Kategorie ist vorbelegt mit Infrastruktur', async () => {
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine eigenen Module');
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
expect(screen.getByLabelText('Kategorie')).toHaveValue('infrastructure');
});
@@ -245,8 +253,8 @@ describe('AdminCustomModulesPage', () => {
it('Serverfehler beim Speichern bleibt im Dialog sichtbar, der Dialog bleibt offen', async () => {
mockCreate.mockRejectedValue(new Error('500'));
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine eigenen Module');
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
@@ -263,7 +271,7 @@ describe('AdminCustomModulesPage', () => {
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
const dialog = screen.getByRole('dialog');
expect(within(dialog).getByText('Möchten Sie „Wiki“ wirklich löschen?')).toBeInTheDocument();
expect(within(dialog).getByText('Möchten Sie „Wiki“ wirklich löschen? Für alle Benutzer.')).toBeInTheDocument();
await userEvent.click(within(dialog).getByRole('button', { name: 'Löschen' }));
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('cm-1'));
@@ -294,6 +302,22 @@ describe('AdminCustomModulesPage', () => {
expect(mockBump).not.toHaveBeenCalled();
});
it('zeigt nur gemeinsame Einträge, nie persönliche (Filter über personal)', async () => {
mockList.mockResolvedValue([
wiki,
{ ...wiki, id: 'cm-2', name: 'Mein Privates', personal: true },
]);
render(<AdminCustomModulesPage />);
expect(await screen.findByRole('link', { name: 'Wiki' })).toBeInTheDocument();
expect(screen.queryByText('Mein Privates')).not.toBeInTheDocument();
});
it('nur persönliche Einträge vorhanden: Leer-Zustand der Verwaltung', async () => {
mockList.mockResolvedValue([{ ...wiki, personal: true }]);
render(<AdminCustomModulesPage />);
expect(await screen.findByText('Noch keine gemeinsamen Einträge')).toBeInTheDocument();
});
it('Benutzer mit Rolle USER sieht den Zugriff-verweigert-Text', async () => {
mockRole = 'USER';
render(<AdminCustomModulesPage />);
@@ -1,74 +1,24 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { CustomModuleManager } from '@/components/custom-modules/custom-module-manager';
import { useAuthStore } from '@/lib/stores/auth-store';
import { useCategoryLabel } from '@/lib/use-category-label';
import { CustomModuleFormModal } from './components/CustomModuleFormModal';
import { DeleteCustomModuleDialog } from './components/DeleteCustomModuleDialog';
/**
* Verwaltung „Eigene Module“ (quick-260929-9wc, D-07, Vorbild
* `admin/groups`). ADMIN und SUPER_ADMIN; die Rollenpruefung hier ist nur
* Anzeige — durchgesetzt wird sie serverseitig durch `@Roles` an POST, PATCH
* und DELETE von `/custom-modules`. Nach jedem erfolgreichen Anlegen,
* Aendern oder Loeschen zieht die Seitenleiste ueber `bumpSidebarRefresh`
* ohne Neuladen nach (D-05).
* `admin/groups`; quick-260929-dzu: nur die GEMEINSAMEN Eintraege). ADMIN und
* SUPER_ADMIN; die Rollenpruefung hier ist nur Anzeige — durchgesetzt wird sie
* serverseitig: die API verlangt fuer gemeinsame Eintraege (`shared: true`,
* Aendern, Loeschen) die Administrator-Rolle. Persoenliche Eintraege eines
* Administrators stehen wie bei jedem Benutzer unter Einstellungen.
*/
export default function AdminCustomModulesPage() {
const t = useTranslations('admin.customModules');
const tCommon = useTranslations('common');
const categoryLabel = useCategoryLabel();
const currentUser = useAuthStore((s) => s.user);
const bumpSidebarRefresh = useMarketplaceStore((s) => s.bumpSidebarRefresh);
const [modules, setModules] = useState<CustomModule[]>([]);
const [loading, setLoading] = useState(true);
const [loadFailed, setLoadFailed] = useState(false);
const [showFormModal, setShowFormModal] = useState(false);
const [editing, setEditing] = useState<CustomModule | null>(null);
const [deleteTarget, setDeleteTarget] = useState<CustomModule | null>(null);
const hasAccess = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
const fetchModules = useCallback(async () => {
try {
setModules(await listCustomModules());
setLoadFailed(false);
} catch {
setLoadFailed(true);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (hasAccess) {
fetchModules();
} else {
setLoading(false);
}
}, [hasAccess, fetchModules]);
const handleChanged = () => {
fetchModules();
bumpSidebarRefresh();
};
const openCreate = () => {
setEditing(null);
setShowFormModal(true);
};
const openEdit = (mod: CustomModule) => {
setEditing(mod);
setShowFormModal(true);
};
if (!hasAccess) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
@@ -78,97 +28,15 @@ export default function AdminCustomModulesPage() {
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<button type="button" onClick={openCreate} className="btn btn-primary">
{t('create')}
</button>
</div>
<p className="text-sm text-muted-foreground">{t('description')}</p>
{loadFailed && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{t('loadError')}
</div>
)}
{loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p>
) : modules.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<h2 className="mb-2 text-lg font-semibold text-foreground">{t('noModules')}</h2>
<p className="mb-6 text-sm text-muted-foreground">{t('noModulesBody')}</p>
<button type="button" onClick={openCreate} className="btn btn-primary">
{t('create')}
</button>
</div>
) : (
<div className="overflow-x-auto rounded-md border border-border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('name')}</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('url')}</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('category')}</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">{t('actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{modules.map((mod) => (
<tr key={mod.id} className="transition-colors hover:bg-muted/30">
<td className="px-4 py-3 font-medium text-foreground">
<Link href={`/modules/custom/${mod.id}`} className="link">
{mod.name}
</Link>
</td>
<td className="max-w-[24rem] px-4 py-3 text-muted-foreground">
<span className="block truncate" title={mod.url}>
{mod.url}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground">{categoryLabel(mod.category)}</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => openEdit(mod)}
className="rounded px-2 py-1 text-xs text-foreground transition-colors hover:bg-muted"
>
{tCommon('edit')}
</button>
<button
type="button"
onClick={() => setDeleteTarget(mod)}
className="rounded px-2 py-1 text-xs text-destructive transition-colors hover:bg-destructive/10"
>
{tCommon('delete')}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{showFormModal && (
<CustomModuleFormModal
mod={editing}
onClose={() => setShowFormModal(false)}
onSaved={handleChanged}
/>
)}
{deleteTarget && (
<DeleteCustomModuleDialog
mod={deleteTarget}
onClose={() => setDeleteTarget(null)}
onDeleted={handleChanged}
/>
)}
</div>
<CustomModuleManager
scope="shared"
texts={{
title: t('title'),
description: t('description'),
create: t('create'),
noModules: t('noModules'),
noModulesBody: t('noModulesBody'),
}}
/>
);
}
@@ -0,0 +1,235 @@
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Namensraumfaehiger next-intl-Mock (Muster admin/custom-modules-page.test.tsx).
const messages: Record<string, Record<string, unknown>> = {
'settings.customModules': {
title: 'Eigene Module',
description: 'Diese Einträge sehen nur Sie.',
create: 'Eigenes Modul anlegen',
noModules: 'Noch keine eigenen Module',
noModulesBody: 'Legen Sie Ihr erstes Modul an.',
},
'customModules.manage': { actions: 'Aktionen', loadError: 'Laden fehlgeschlagen.' },
'customModules.form': {
create: 'Eigenes Modul anlegen',
edit: 'Eigenes Modul bearbeiten',
name: 'Name',
url: 'Adresse',
urlHint: 'Hinweis https',
category: 'Kategorie',
nameRequired: 'Bitte geben Sie einen Namen ein.',
urlNotHttps: 'Bitte geben Sie eine Adresse ein, die mit https:// beginnt.',
urlCredentials: 'Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.',
saveError: 'Das Modul konnte nicht gespeichert werden.',
deleteError: 'Das Modul konnte nicht gelöscht werden.',
deleteConfirm: {
title: 'Eigenes Modul löschen',
bodyShared: 'Möchten Sie „{name}“ wirklich löschen? Für alle Benutzer.',
bodyPersonal: 'Möchten Sie „{name}“ wirklich löschen? Nur bei Ihnen.',
},
},
common: {
loading: 'Laden...',
cancel: 'Abbrechen',
save: 'Speichern',
delete: 'Löschen',
edit: 'Bearbeiten',
},
};
vi.mock('next-intl', () => ({
useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) => {
let val: unknown = messages[ns];
for (const part of key.split('.')) {
val = (val as Record<string, unknown> | undefined)?.[part];
}
if (typeof val !== 'string') return key;
return params
? val.replace(/\{(\w+)\}/g, (_m, name: string) => String(params[name] ?? ''))
: val;
},
}));
vi.mock('next/link', () => ({
default: ({
children,
href,
className,
}: {
children: React.ReactNode;
href: string;
className?: string;
}) => (
<a href={href} className={className}>
{children}
</a>
),
}));
vi.mock('@/lib/use-category-label', () => ({
useCategoryLabel: () => (slug: string) =>
({ infrastructure: 'Infrastruktur', 'security-tools': 'Sicherheit' })[slug] ?? slug,
}));
const { mockList, mockCreate, mockUpdate, mockDelete, mockBump } = vi.hoisted(() => ({
mockList: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockDelete: vi.fn(),
mockBump: vi.fn(),
}));
vi.mock('@/lib/custom-modules-api', async () => {
const actual = await vi.importActual<typeof import('@/lib/custom-modules-api')>(
'@/lib/custom-modules-api',
);
return {
...actual,
listCustomModules: mockList,
createCustomModule: mockCreate,
updateCustomModule: mockUpdate,
deleteCustomModule: mockDelete,
};
});
vi.mock('@/lib/stores/marketplace-store', () => ({
useMarketplaceStore: (selector: (s: { bumpSidebarRefresh: () => void }) => unknown) =>
selector({ bumpSidebarRefresh: mockBump }),
}));
import CustomModulesSettingsPage from './page';
const mine = {
id: 'cm-1',
name: 'Mein Wiki',
url: 'https://wiki.example.com',
category: 'infrastructure',
personal: true,
createdAt: '2026-09-29T00:00:00.000Z',
updatedAt: '2026-09-29T00:00:00.000Z',
};
const shared = { ...mine, id: 'cm-2', name: 'Firmenseite', personal: false };
beforeEach(() => {
mockList.mockReset().mockResolvedValue([]);
mockCreate.mockReset().mockResolvedValue(mine);
mockUpdate.mockReset().mockResolvedValue(mine);
mockDelete.mockReset().mockResolvedValue(undefined);
mockBump.mockReset();
});
afterEach(() => {
cleanup();
});
describe('CustomModulesSettingsPage', () => {
it('zeigt Einleitung und ohne Einträge den Leer-Zustand mit Anlegen-Knopf', async () => {
render(<CustomModulesSettingsPage />);
expect(await screen.findByText('Noch keine eigenen Module')).toBeInTheDocument();
expect(screen.getByText('Diese Einträge sehen nur Sie.')).toBeInTheDocument();
expect(
screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length,
).toBeGreaterThan(0);
});
it('listet nur die persönlichen Einträge, gemeinsame der Verwaltung nicht', async () => {
mockList.mockResolvedValue([shared, mine]);
render(<CustomModulesSettingsPage />);
expect(await screen.findByRole('link', { name: 'Mein Wiki' })).toHaveAttribute(
'href',
'/modules/custom/cm-1',
);
expect(screen.queryByText('Firmenseite')).not.toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Bearbeiten' })).toHaveLength(1);
});
it('Anlegen sendet KEIN shared (persönlicher Eintrag), lädt neu und aktualisiert die Seitenleiste', async () => {
render(<CustomModulesSettingsPage />);
await screen.findByText('Noch keine eigenen Module');
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
await userEvent.type(screen.getByLabelText('Name'), ' Mein Wiki ');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
await waitFor(() => {
expect(mockCreate).toHaveBeenCalledWith({
name: 'Mein Wiki',
url: 'https://example.com',
category: 'infrastructure',
});
});
expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('shared');
await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1));
expect(mockList).toHaveBeenCalledTimes(2);
});
it('Anlegen: http-Adresse wird vor dem Senden abgelehnt', async () => {
render(<CustomModulesSettingsPage />);
await screen.findByText('Noch keine eigenen Module');
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
await userEvent.type(screen.getByLabelText('Name'), 'X');
await userEvent.type(screen.getByLabelText('Adresse'), 'http://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(
await screen.findByText('Bitte geben Sie eine Adresse ein, die mit https:// beginnt.'),
).toBeInTheDocument();
expect(mockCreate).not.toHaveBeenCalled();
});
it('Bearbeiten: ruft update ohne shared und aktualisiert die Seitenleiste', async () => {
mockList.mockResolvedValue([mine]);
render(<CustomModulesSettingsPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Bearbeiten' }));
const dialog = screen.getByRole('dialog');
const nameInput = within(dialog).getByLabelText('Name');
expect(nameInput).toHaveValue('Mein Wiki');
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'Wiki neu');
await userEvent.click(within(dialog).getByRole('button', { name: 'Speichern' }));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith('cm-1', {
name: 'Wiki neu',
url: 'https://wiki.example.com',
category: 'infrastructure',
});
});
expect(mockCreate).not.toHaveBeenCalled();
await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1));
});
it('Löschen: Rückfrage nennt den persönlichen Text, Bestätigen ruft delete und aktualisiert die Seitenleiste', async () => {
mockList.mockResolvedValue([mine]);
render(<CustomModulesSettingsPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
const dialog = screen.getByRole('dialog');
expect(
within(dialog).getByText('Möchten Sie „Mein Wiki“ wirklich löschen? Nur bei Ihnen.'),
).toBeInTheDocument();
await userEvent.click(within(dialog).getByRole('button', { name: 'Löschen' }));
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('cm-1'));
await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1));
expect(mockList).toHaveBeenCalledTimes(2);
});
it('Serverfehler beim Speichern bleibt im Dialog sichtbar', async () => {
mockCreate.mockRejectedValue(new Error('500'));
render(<CustomModulesSettingsPage />);
await screen.findByText('Noch keine eigenen Module');
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
await userEvent.type(screen.getByLabelText('Name'), 'X');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByText('Das Modul konnte nicht gespeichert werden.')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(mockBump).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,27 @@
'use client';
import { useTranslations } from 'next-intl';
import { CustomModuleManager } from '@/components/custom-modules/custom-module-manager';
/**
* Einstellungen > Eigene Module (quick-260929-dzu) — /settings/custom-modules.
* Jeder angemeldete Benutzer pflegt hier seine PERSOENLICHEN Seitenleisten-
* Eintraege; nur er selbst sieht sie. Die Trennung von den gemeinsamen
* Eintraegen der Verwaltung erzwingt die API, nicht diese Seite.
*/
export default function CustomModulesSettingsPage() {
const t = useTranslations('settings.customModules');
return (
<CustomModuleManager
scope="personal"
texts={{
title: t('title'),
description: t('description'),
create: t('create'),
noModules: t('noModules'),
noModulesBody: t('noModulesBody'),
}}
/>
);
}