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>
|
</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'),
|
label: t('admin.ldap'),
|
||||||
href: '/admin/ldap',
|
href: '/admin/ldap',
|
||||||
|
|||||||
@@ -85,6 +85,7 @@
|
|||||||
"users": "Benutzer",
|
"users": "Benutzer",
|
||||||
"tenants": "Mandanten",
|
"tenants": "Mandanten",
|
||||||
"modules": "Module",
|
"modules": "Module",
|
||||||
|
"customModules": "Eigene Module",
|
||||||
"ldap": "LDAP",
|
"ldap": "LDAP",
|
||||||
"smtp": "SMTP",
|
"smtp": "SMTP",
|
||||||
"groups": "Gruppen"
|
"groups": "Gruppen"
|
||||||
@@ -668,6 +669,29 @@
|
|||||||
"grants": {
|
"grants": {
|
||||||
"matrixCheckboxLabel": "{module} für Gruppe {group} {granted, select, true {freigeben} other {entziehen}}"
|
"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": {
|
"adminModules": {
|
||||||
|
|||||||
@@ -85,6 +85,7 @@
|
|||||||
"users": "Users",
|
"users": "Users",
|
||||||
"tenants": "Tenants",
|
"tenants": "Tenants",
|
||||||
"modules": "Modules",
|
"modules": "Modules",
|
||||||
|
"customModules": "Custom modules",
|
||||||
"ldap": "LDAP",
|
"ldap": "LDAP",
|
||||||
"smtp": "SMTP",
|
"smtp": "SMTP",
|
||||||
"groups": "Groups"
|
"groups": "Groups"
|
||||||
@@ -668,6 +669,29 @@
|
|||||||
"grants": {
|
"grants": {
|
||||||
"matrixCheckboxLabel": "{granted, select, true {Grant} other {Revoke}} {module} for group {group}"
|
"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": {
|
"adminModules": {
|
||||||
|
|||||||
Reference in New Issue
Block a user