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:
2026-09-29 07:27:16 +02:00
parent b9d87be360
commit e7fc4de430
7 changed files with 775 additions and 0 deletions
@@ -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>
);
}
@@ -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',
+24
View File
@@ -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": {
+24
View File
@@ -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": {