feat(web): Verwaltung „Eigene Module“
- Liste, Anlegen, Bearbeiten und Löschen unter Verwaltung, Adresse wird im Formular geprüft - Seitenleiste zieht nach jedem Speichern oder Löschen ohne Neuladen nach - Texte deutsch und englisch, Eintrag in der Admin-Leiste hinter „Module“ Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
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: Punktschluessel werden gegen den
|
||||
// Namensraum aufgeloest, {param} wird eingesetzt (Muster groups-page.test.tsx).
|
||||
const messages: Record<string, Record<string, unknown>> = {
|
||||
'admin.customModules': {
|
||||
title: 'Eigene Module',
|
||||
description: 'Erklärung',
|
||||
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?',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
loading: 'Laden...',
|
||||
cancel: 'Abbrechen',
|
||||
save: 'Speichern',
|
||||
delete: 'Löschen',
|
||||
edit: 'Bearbeiten',
|
||||
accessDenied: 'Zugriff verweigert',
|
||||
},
|
||||
};
|
||||
|
||||
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 }),
|
||||
}));
|
||||
|
||||
let mockRole = 'ADMIN';
|
||||
vi.mock('@/lib/stores/auth-store', () => ({
|
||||
useAuthStore: (selector: (s: { user: { role: string } }) => unknown) =>
|
||||
selector({ user: { role: mockRole } }),
|
||||
}));
|
||||
|
||||
import AdminCustomModulesPage from './page';
|
||||
|
||||
const wiki = {
|
||||
id: 'cm-1',
|
||||
name: 'Wiki',
|
||||
url: 'https://wiki.example.com',
|
||||
category: 'infrastructure',
|
||||
createdAt: '2026-09-29T00:00:00.000Z',
|
||||
updatedAt: '2026-09-29T00:00:00.000Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockRole = 'ADMIN';
|
||||
mockList.mockReset().mockResolvedValue([]);
|
||||
mockCreate.mockReset().mockResolvedValue(wiki);
|
||||
mockUpdate.mockReset().mockResolvedValue(wiki);
|
||||
mockDelete.mockReset().mockResolvedValue(undefined);
|
||||
mockBump.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('zeigt mit Einträgen die Tabelle mit Link, Adresse und Kategorie-Anzeigename', async () => {
|
||||
mockList.mockResolvedValue([wiki]);
|
||||
render(<AdminCustomModulesPage />);
|
||||
|
||||
const link = await screen.findByRole('link', { name: 'Wiki' });
|
||||
expect(link).toHaveAttribute('href', '/modules/custom/cm-1');
|
||||
expect(screen.getByText('https://wiki.example.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('Infrastruktur')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Bearbeiten' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Löschen' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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 userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
||||
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();
|
||||
expect(mockBump).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
||||
await userEvent.type(screen.getByLabelText('Adresse'), 'https://user:pw@example.com');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.'),
|
||||
).toBeInTheDocument();
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 userEvent.type(screen.getByLabelText('Name'), ' ');
|
||||
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
|
||||
expect(await screen.findByText('Bitte geben Sie einen Namen ein.')).toBeInTheDocument();
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 userEvent.type(screen.getByLabelText('Name'), ' Beispielseite ');
|
||||
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||
await userEvent.selectOptions(screen.getByLabelText('Kategorie'), 'security-tools');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
name: 'Beispielseite',
|
||||
url: 'https://example.com',
|
||||
category: 'security-tools',
|
||||
});
|
||||
});
|
||||
await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1));
|
||||
// Anfangsladen + Neuladen nach dem Speichern
|
||||
expect(mockList).toHaveBeenCalledTimes(2);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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]);
|
||||
expect(screen.getByLabelText('Kategorie')).toHaveValue('infrastructure');
|
||||
});
|
||||
|
||||
it('Bearbeiten: Formular ist vorbelegt, Speichern ruft update und aktualisiert die Seitenleiste', async () => {
|
||||
mockList.mockResolvedValue([wiki]);
|
||||
render(<AdminCustomModulesPage />);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Bearbeiten' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(within(dialog).getByLabelText('Name')).toHaveValue('Wiki');
|
||||
expect(within(dialog).getByLabelText('Adresse')).toHaveValue('https://wiki.example.com');
|
||||
expect(within(dialog).getByLabelText('Kategorie')).toHaveValue('infrastructure');
|
||||
|
||||
const nameInput = within(dialog).getByLabelText('Name');
|
||||
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('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 userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
||||
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();
|
||||
});
|
||||
|
||||
it('Löschen: Rückfrage nennt den Namen, Bestätigen ruft delete, lädt neu und aktualisiert die Seitenleiste', async () => {
|
||||
mockList.mockResolvedValue([wiki]);
|
||||
render(<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();
|
||||
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('Löschen: Abbrechen ruft nichts auf', async () => {
|
||||
mockList.mockResolvedValue([wiki]);
|
||||
render(<AdminCustomModulesPage />);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
|
||||
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Abbrechen' }));
|
||||
|
||||
expect(mockDelete).not.toHaveBeenCalled();
|
||||
expect(mockBump).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Löschfehler bleibt im Dialog sichtbar', async () => {
|
||||
mockList.mockResolvedValue([wiki]);
|
||||
mockDelete.mockRejectedValue(new Error('500'));
|
||||
render(<AdminCustomModulesPage />);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
|
||||
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' }));
|
||||
|
||||
expect(await screen.findByText('Das Modul konnte nicht gelöscht werden.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(mockBump).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Benutzer mit Rolle USER sieht den Zugriff-verweigert-Text', async () => {
|
||||
mockRole = 'USER';
|
||||
render(<AdminCustomModulesPage />);
|
||||
expect(await screen.findByText('Zugriff verweigert')).toBeInTheDocument();
|
||||
expect(mockList).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
'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 { 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).
|
||||
*/
|
||||
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">
|
||||
<p className="text-lg text-muted-foreground">{tCommon('accessDenied')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,18 @@ export function AdminSidebar() {
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t('admin.customModules'),
|
||||
href: '/admin/custom-modules',
|
||||
show: true,
|
||||
icon: (
|
||||
<svg aria-hidden="true" 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">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" y1="14" x2="21" y2="3" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t('admin.ldap'),
|
||||
href: '/admin/ldap',
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"users": "Benutzer",
|
||||
"tenants": "Mandanten",
|
||||
"modules": "Module",
|
||||
"customModules": "Eigene Module",
|
||||
"ldap": "LDAP",
|
||||
"smtp": "SMTP",
|
||||
"groups": "Gruppen"
|
||||
@@ -668,6 +669,29 @@
|
||||
"grants": {
|
||||
"matrixCheckboxLabel": "{module} für Gruppe {group} {granted, select, true {freigeben} other {entziehen}}"
|
||||
}
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
},
|
||||
"adminModules": {
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"users": "Users",
|
||||
"tenants": "Tenants",
|
||||
"modules": "Modules",
|
||||
"customModules": "Custom modules",
|
||||
"ldap": "LDAP",
|
||||
"smtp": "SMTP",
|
||||
"groups": "Groups"
|
||||
@@ -668,6 +669,29 @@
|
||||
"grants": {
|
||||
"matrixCheckboxLabel": "{granted, select, true {Grant} other {Revoke}} {module} for group {group}"
|
||||
}
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
},
|
||||
"adminModules": {
|
||||
|
||||
Reference in New Issue
Block a user