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
@@ -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}
<CustomModuleManager
scope="shared"
texts={{
title: t('title'),
description: t('description'),
create: t('create'),
noModules: t('noModules'),
noModulesBody: t('noModulesBody'),
}}
/>
)}
{deleteTarget && (
<DeleteCustomModuleDialog
mod={deleteTarget}
onClose={() => setDeleteTarget(null)}
onDeleted={handleChanged}
/>
)}
</div>
);
}
@@ -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'),
}}
/>
);
}
@@ -14,6 +14,12 @@ import { useCategoryLabel } from '@/lib/use-category-label';
interface CustomModuleFormModalProps {
/** `null` = neues Modul anlegen, sonst bearbeiten. */
mod: CustomModule | null;
/**
* Beim Anlegen: `true` = gemeinsamer Eintrag fuer alle Benutzer (Verwaltung),
* sonst persoenlicher Eintrag (Einstellungen). Beim Bearbeiten ohne Wirkung —
* ob ein Eintrag gemeinsam ist, aendert sich nie.
*/
shared?: boolean;
onClose: () => void;
onSaved: () => void;
}
@@ -22,13 +28,20 @@ const NAME_MAX = 100;
const URL_MAX = 2048;
/**
* Formular „Eigenes Modul“ (quick-260929-9wc, Vorbild `GroupFormModal`).
* Formular „Eigenes Modul“ (quick-260929-9wc, Vorbild `GroupFormModal`;
* quick-260929-dzu: ein Formular, zwei Aufrufer — Verwaltung legt gemeinsame,
* Einstellungen persoenliche Eintraege an).
* 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');
export function CustomModuleFormModal({
mod,
shared = false,
onClose,
onSaved,
}: CustomModuleFormModalProps) {
const t = useTranslations('customModules.form');
const tCommon = useTranslations('common');
const categoryLabel = useCategoryLabel();
@@ -64,7 +77,7 @@ export function CustomModuleFormModal({ mod, onClose, onSaved }: CustomModuleFor
if (mod) {
await updateCustomModule(mod.id, input);
} else {
await createCustomModule(input);
await createCustomModule(shared ? { ...input, shared: true } : input);
}
onSaved();
onClose();
@@ -0,0 +1,188 @@
'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 { useCategoryLabel } from '@/lib/use-category-label';
import { CustomModuleFormModal } from './custom-module-form-modal';
import { DeleteCustomModuleDialog } from './delete-custom-module-dialog';
/** Seitenspezifische, bereits uebersetzte Texte der beiden Aufrufer. */
export interface CustomModuleManagerTexts {
title: string;
description: string;
create: string;
noModules: string;
noModulesBody: string;
}
interface CustomModuleManagerProps {
/**
* `shared`: Verwaltung — zeigt und pflegt nur die gemeinsamen Eintraege (fuer
* alle Benutzer sichtbar). `personal`: Einstellungen — zeigt und pflegt nur
* die eigenen persoenlichen Eintraege des angemeldeten Benutzers.
*/
scope: 'shared' | 'personal';
texts: CustomModuleManagerTexts;
}
/**
* Liste mit Anlegen, Bearbeiten und Loeschen eigener Module (quick-260929-dzu).
* Eine Oberflaeche fuer zwei Aufrufer: `admin/custom-modules` (`scope="shared"`)
* und `settings/custom-modules` (`scope="personal"`). Die API liefert gemeinsame
* PLUS eigene Eintraege; die Seite filtert ueber `personal` auf ihren Bereich.
* Nach jedem erfolgreichen Anlegen, Aendern oder Loeschen zieht die Seitenleiste
* ueber `bumpSidebarRefresh` ohne Neuladen nach.
*/
export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps) {
const t = useTranslations('customModules.form');
const tManage = useTranslations('customModules.manage');
const tCommon = useTranslations('common');
const categoryLabel = useCategoryLabel();
const bumpSidebarRefresh = useMarketplaceStore((s) => s.bumpSidebarRefresh);
const shared = scope === 'shared';
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 fetchModules = useCallback(async () => {
try {
const all = await listCustomModules();
setModules(all.filter((m) => m.personal !== shared));
setLoadFailed(false);
} catch {
setLoadFailed(true);
} finally {
setLoading(false);
}
}, [shared]);
useEffect(() => {
fetchModules();
}, [fetchModules]);
const handleChanged = () => {
fetchModules();
bumpSidebarRefresh();
};
const openCreate = () => {
setEditing(null);
setShowFormModal(true);
};
const openEdit = (mod: CustomModule) => {
setEditing(mod);
setShowFormModal(true);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1
className={`${shared ? 'text-2xl' : 'text-lg'} font-semibold text-foreground`}
>
{texts.title}
</h1>
<button type="button" onClick={openCreate} className="btn btn-primary">
{texts.create}
</button>
</div>
<p className="text-sm text-muted-foreground">{texts.description}</p>
{loadFailed && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{tManage('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">{texts.noModules}</h2>
<p className="mb-6 text-sm text-muted-foreground">{texts.noModulesBody}</p>
<button type="button" onClick={openCreate} className="btn btn-primary">
{texts.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">
{tManage('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}
shared={shared}
onClose={() => setShowFormModal(false)}
onSaved={handleChanged}
/>
)}
{deleteTarget && (
<DeleteCustomModuleDialog
mod={deleteTarget}
shared={shared}
onClose={() => setDeleteTarget(null)}
onDeleted={handleChanged}
/>
)}
</div>
);
}
@@ -6,6 +6,8 @@ import { type CustomModule, deleteCustomModule } from '@/lib/custom-modules-api'
interface DeleteCustomModuleDialogProps {
mod: CustomModule;
/** Gemeinsamer Eintrag (verschwindet fuer alle) oder persoenlicher (nur fuer den Besitzer). */
shared?: boolean;
onClose: () => void;
onDeleted: () => void;
}
@@ -13,11 +15,17 @@ interface DeleteCustomModuleDialogProps {
/**
* 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.
* ein stilles Scheitern wuerde ein Loeschen vortaeuschen. Der Text nennt, fuer
* wen der Eintrag verschwindet (quick-260929-dzu: gemeinsam = alle, persoenlich
* = nur der Besitzer).
*/
export function DeleteCustomModuleDialog({ mod, onClose, onDeleted }: DeleteCustomModuleDialogProps) {
const t = useTranslations('admin.customModules');
export function DeleteCustomModuleDialog({
mod,
shared = false,
onClose,
onDeleted,
}: DeleteCustomModuleDialogProps) {
const t = useTranslations('customModules.form');
const tCommon = useTranslations('common');
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -47,7 +55,7 @@ export function DeleteCustomModuleDialog({ mod, onClose, onDeleted }: DeleteCust
<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>
<p className="mb-4 text-sm text-foreground">{t(shared ? 'deleteConfirm.bodyShared' : 'deleteConfirm.bodyPersonal', { name: mod.name })}</p>
{error && (
<div
@@ -71,6 +71,19 @@ export function SettingsSidebar() {
>
{t('categoryDesktopApp')}
</Link>
<Link
href="/settings/custom-modules"
className={`nav-item flex h-9 items-center rounded-lg px-2.5 text-sm transition-colors ${
isActive('/settings/custom-modules')
? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold'
: 'text-sidebar-foreground hover:bg-sidebar-hover'
}`}
aria-current={
isActive('/settings/custom-modules') ? 'page' : undefined
}
>
{t('categoryCustomModules')}
</Link>
</nav>
{/* Dashboard category — existing items unchanged */}
+11 -1
View File
@@ -13,6 +13,8 @@ export interface CustomModule {
name: string;
url: string;
category: string;
/** `true` = persoenlicher Eintrag des angemeldeten Benutzers, `false` = gemeinsam (vom Administrator). */
personal: boolean;
createdAt: string;
updatedAt: string;
}
@@ -23,6 +25,14 @@ export interface CustomModuleInput {
category: string;
}
/**
* Beim Anlegen: `shared: true` legt einen gemeinsamen Eintrag fuer alle an
* (nur Administratoren, sonst 403); ohne das Feld ist der Eintrag persoenlich.
*/
export interface CustomModuleCreateInput extends CustomModuleInput {
shared?: boolean;
}
/** Fehler mit HTTP-Status und Servermeldung (falls vorhanden). */
export class CustomModuleRequestError extends Error {
constructor(
@@ -83,7 +93,7 @@ export async function getCustomModule(id: string): Promise<CustomModule | null>
return res.json();
}
export async function createCustomModule(input: CustomModuleInput): Promise<CustomModule> {
export async function createCustomModule(input: CustomModuleCreateInput): Promise<CustomModule> {
const res = await fetch(`${API_URL}/custom-modules`, {
method: 'POST',
credentials: 'include',
+35 -21
View File
@@ -165,6 +165,7 @@
"categoryGeneral": "Allgemein",
"categoryAccount": "Konto",
"categoryDesktopApp": "Desktop-App",
"categoryCustomModules": "Eigene Module",
"categorySmtp": "SMTP",
"account": {
"title": "Konto",
@@ -228,6 +229,13 @@
"testFailed": "Verbindung fehlgeschlagen",
"bugReportRecipient": "Fehlermeldungen an",
"bugReportRecipientHelp": "Optional – Postfach, an das Anwender über den Knopf „Fehler melden\" ihre Meldungen mit Bildschirmfoto schicken. Leer lassen, wenn der Knopf keine E-Mails senden soll."
},
"customModules": {
"title": "Eigene Module",
"description": "Nehmen Sie Webseiten, die Sie oft brauchen, als eigene Einträge in Ihre Seitenleiste auf. Diese Einträge sehen nur Sie. Ein Klick zeigt die Seite direkt in Tessera.",
"create": "Eigenes Modul anlegen",
"noModules": "Noch keine eigenen Module",
"noModulesBody": "Legen Sie Ihr erstes Modul an, um eine Webseite in Ihrer Seitenleiste zu zeigen."
}
},
"widgets": {
@@ -672,26 +680,10 @@
},
"customModules": {
"title": "Eigene Module",
"description": "Nehmen Sie andere Webseiten als Einträge in die Seitenleiste auf. Alle Benutzer sehen diese Einträge unter der gewählten Kategorie, ein Klick zeigt die Seite direkt in Tessera.",
"create": "Eigenes Modul anlegen",
"edit": "Eigenes Modul bearbeiten",
"name": "Name",
"url": "Adresse",
"urlHint": "Die Adresse muss mit https:// beginnen.",
"category": "Kategorie",
"actions": "Aktionen",
"noModules": "Noch keine eigenen Module",
"noModulesBody": "Legen Sie Ihr erstes Modul an, um eine Webseite in der Seitenleiste zu zeigen.",
"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. Bitte versuchen Sie es erneut.",
"deleteError": "Das Modul konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
"loadError": "Die eigenen Module konnten nicht geladen werden.",
"deleteConfirm": {
"title": "Eigenes Modul löschen",
"body": "Möchten Sie „{name}“ wirklich löschen? Der Eintrag verschwindet für alle Benutzer aus der Seitenleiste."
}
"description": "Nehmen Sie andere Webseiten als Einträge in die Seitenleiste auf. Diese gemeinsamen Einträge sehen alle Benutzer unter der gewählten Kategorie, ein Klick zeigt die Seite direkt in Tessera. Persönliche Einträge nur für Sie legen Sie unter „Einstellungen → Eigene Module“ an.",
"create": "Gemeinsamen Eintrag anlegen",
"noModules": "Noch keine gemeinsamen Einträge",
"noModulesBody": "Legen Sie Ihren ersten gemeinsamen Eintrag an, um eine Webseite für alle Benutzer in der Seitenleiste zu zeigen."
}
},
"adminModules": {
@@ -1375,7 +1367,29 @@
"embedHint": "Manche Seiten lassen sich nicht einbetten. Öffnen Sie die Seite dann in einem neuen Tab.",
"notFound": "Dieses Modul gibt es nicht mehr.",
"invalidUrl": "Die Adresse dieses Moduls ist keine gültige https-Adresse und wird deshalb nicht angezeigt.",
"loading": "Wird geladen …"
"loading": "Wird geladen …",
"form": {
"create": "Eigenes Modul anlegen",
"edit": "Eigenes Modul bearbeiten",
"name": "Name",
"url": "Adresse",
"urlHint": "Die Adresse muss mit https:// beginnen.",
"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. Bitte versuchen Sie es erneut.",
"deleteError": "Das Modul konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
"deleteConfirm": {
"title": "Eigenes Modul löschen",
"bodyShared": "Möchten Sie „{name}“ wirklich löschen? Der Eintrag verschwindet für alle Benutzer aus der Seitenleiste.",
"bodyPersonal": "Möchten Sie „{name}“ wirklich löschen? Der Eintrag verschwindet aus Ihrer Seitenleiste."
}
},
"manage": {
"actions": "Aktionen",
"loadError": "Die eigenen Module konnten nicht geladen werden."
}
},
"moduleCategories": {
"domain-tools": "Domains",
+35 -21
View File
@@ -165,6 +165,7 @@
"categoryGeneral": "General",
"categoryAccount": "Account",
"categoryDesktopApp": "Desktop App",
"categoryCustomModules": "Custom modules",
"categorySmtp": "SMTP",
"account": {
"title": "Account",
@@ -228,6 +229,13 @@
"testFailed": "Connection failed",
"bugReportRecipient": "Bug reports to",
"bugReportRecipientHelp": "Optional – mailbox that receives the reports users send via the \"Report a problem\" button, including the screenshot. Leave empty if the button should not send e-mails."
},
"customModules": {
"title": "Custom modules",
"description": "Add websites you use often as your own entries in your sidebar. Only you can see these entries. A click shows the page right inside Tessera.",
"create": "Add custom module",
"noModules": "No custom modules yet",
"noModulesBody": "Add your first module to show a website in your sidebar."
}
},
"widgets": {
@@ -672,26 +680,10 @@
},
"customModules": {
"title": "Custom modules",
"description": "Add other websites as entries in the sidebar. All users see these entries under the chosen category, and a click shows the page right inside Tessera.",
"create": "Add custom module",
"edit": "Edit custom module",
"name": "Name",
"url": "Address",
"urlHint": "The address must start with https://.",
"category": "Category",
"actions": "Actions",
"noModules": "No custom modules yet",
"noModulesBody": "Add your first module to show a website in the sidebar.",
"nameRequired": "Please enter a name.",
"urlNotHttps": "Please enter an address that starts with https://.",
"urlCredentials": "The address must not contain a user name or a password.",
"saveError": "The module could not be saved. Please try again.",
"deleteError": "The module could not be deleted. Please try again.",
"loadError": "The custom modules could not be loaded.",
"deleteConfirm": {
"title": "Delete custom module",
"body": "Do you really want to delete “{name}”? The entry disappears from the sidebar for all users."
}
"description": "Add other websites as entries in the sidebar. All users see these shared entries under the chosen category, and a click shows the page right inside Tessera. Personal entries only you can see are added under “Settings → Custom modules”.",
"create": "Add shared entry",
"noModules": "No shared entries yet",
"noModulesBody": "Add your first shared entry to show a website in the sidebar for all users."
}
},
"adminModules": {
@@ -1375,7 +1367,29 @@
"embedHint": "Some pages cannot be embedded. If this one stays blank, open it in a new tab.",
"notFound": "This module no longer exists.",
"invalidUrl": "This module's address is not a valid https address and is therefore not shown.",
"loading": "Loading …"
"loading": "Loading …",
"form": {
"create": "Add custom module",
"edit": "Edit custom module",
"name": "Name",
"url": "Address",
"urlHint": "The address must start with https://.",
"category": "Category",
"nameRequired": "Please enter a name.",
"urlNotHttps": "Please enter an address that starts with https://.",
"urlCredentials": "The address must not contain a user name or a password.",
"saveError": "The module could not be saved. Please try again.",
"deleteError": "The module could not be deleted. Please try again.",
"deleteConfirm": {
"title": "Delete custom module",
"bodyShared": "Do you really want to delete “{name}”? The entry disappears from the sidebar for all users.",
"bodyPersonal": "Do you really want to delete “{name}”? The entry disappears from your sidebar."
}
},
"manage": {
"actions": "Actions",
"loadError": "The custom modules could not be loaded."
}
},
"moduleCategories": {
"domain-tools": "Domains",