feat(260929-dzu): Einstellungen > Eigene Module fuer jeden Benutzer, Verwaltung nur gemeinsam
- gemeinsame Oberflaeche (CustomModuleManager, Formular, Loeschdialog) fuer Verwaltung und Einstellungen - Einstellungen: persoenliche Eintraege, Verwaltung sendet shared: true und zeigt nur gemeinsame - Navigation, Texte de/en, Komponententests Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,25 +7,31 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
const messages: Record<string, Record<string, unknown>> = {
|
const messages: Record<string, Record<string, unknown>> = {
|
||||||
'admin.customModules': {
|
'admin.customModules': {
|
||||||
title: 'Eigene Module',
|
title: 'Eigene Module',
|
||||||
description: 'Erklärung',
|
description: 'Erklärung gemeinsam',
|
||||||
|
create: 'Gemeinsamen Eintrag anlegen',
|
||||||
|
noModules: 'Noch keine gemeinsamen Einträge',
|
||||||
|
noModulesBody: 'Legen Sie Ihren ersten gemeinsamen Eintrag an.',
|
||||||
|
},
|
||||||
|
'customModules.manage': {
|
||||||
|
actions: 'Aktionen',
|
||||||
|
loadError: 'Laden fehlgeschlagen.',
|
||||||
|
},
|
||||||
|
'customModules.form': {
|
||||||
create: 'Eigenes Modul anlegen',
|
create: 'Eigenes Modul anlegen',
|
||||||
edit: 'Eigenes Modul bearbeiten',
|
edit: 'Eigenes Modul bearbeiten',
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
url: 'Adresse',
|
url: 'Adresse',
|
||||||
urlHint: 'Hinweis https',
|
urlHint: 'Hinweis https',
|
||||||
category: 'Kategorie',
|
category: 'Kategorie',
|
||||||
actions: 'Aktionen',
|
|
||||||
noModules: 'Noch keine eigenen Module',
|
|
||||||
noModulesBody: 'Legen Sie Ihr erstes Modul an.',
|
|
||||||
nameRequired: 'Bitte geben Sie einen Namen ein.',
|
nameRequired: 'Bitte geben Sie einen Namen ein.',
|
||||||
urlNotHttps: 'Bitte geben Sie eine Adresse ein, die mit https:// beginnt.',
|
urlNotHttps: 'Bitte geben Sie eine Adresse ein, die mit https:// beginnt.',
|
||||||
urlCredentials: 'Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.',
|
urlCredentials: 'Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.',
|
||||||
saveError: 'Das Modul konnte nicht gespeichert werden.',
|
saveError: 'Das Modul konnte nicht gespeichert werden.',
|
||||||
deleteError: 'Das Modul konnte nicht gelöscht werden.',
|
deleteError: 'Das Modul konnte nicht gelöscht werden.',
|
||||||
loadError: 'Laden fehlgeschlagen.',
|
|
||||||
deleteConfirm: {
|
deleteConfirm: {
|
||||||
title: 'Eigenes Modul löschen',
|
title: 'Eigenes Modul löschen',
|
||||||
body: 'Möchten Sie „{name}“ wirklich löschen?',
|
bodyShared: 'Möchten Sie „{name}“ wirklich löschen? Für alle Benutzer.',
|
||||||
|
bodyPersonal: 'Möchten Sie „{name}“ wirklich löschen? Nur bei Ihnen.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
@@ -107,6 +113,7 @@ const wiki = {
|
|||||||
name: 'Wiki',
|
name: 'Wiki',
|
||||||
url: 'https://wiki.example.com',
|
url: 'https://wiki.example.com',
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
|
personal: false,
|
||||||
createdAt: '2026-09-29T00:00:00.000Z',
|
createdAt: '2026-09-29T00:00:00.000Z',
|
||||||
updatedAt: '2026-09-29T00:00:00.000Z',
|
updatedAt: '2026-09-29T00:00:00.000Z',
|
||||||
};
|
};
|
||||||
@@ -127,9 +134,9 @@ afterEach(() => {
|
|||||||
describe('AdminCustomModulesPage', () => {
|
describe('AdminCustomModulesPage', () => {
|
||||||
it('zeigt ohne Einträge den Leer-Zustand mit Anlegen-Knopf', async () => {
|
it('zeigt ohne Einträge den Leer-Zustand mit Anlegen-Knopf', async () => {
|
||||||
render(<AdminCustomModulesPage />);
|
render(<AdminCustomModulesPage />);
|
||||||
expect(await screen.findByText('Noch keine eigenen Module')).toBeInTheDocument();
|
expect(await screen.findByText('Noch keine gemeinsamen Einträge')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Legen Sie Ihr erstes Modul an.')).toBeInTheDocument();
|
expect(screen.getByText('Legen Sie Ihren ersten gemeinsamen Eintrag an.')).toBeInTheDocument();
|
||||||
expect(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length).toBeGreaterThan(0);
|
expect(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' }).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('zeigt mit Einträgen die Tabelle mit Link, Adresse und Kategorie-Anzeigename', async () => {
|
it('zeigt mit Einträgen die Tabelle mit Link, Adresse und Kategorie-Anzeigename', async () => {
|
||||||
@@ -146,8 +153,8 @@ describe('AdminCustomModulesPage', () => {
|
|||||||
|
|
||||||
it('Anlegen: http-Adresse zeigt die Meldung und ruft createCustomModule nicht auf', async () => {
|
it('Anlegen: http-Adresse zeigt die Meldung und ruft createCustomModule nicht auf', async () => {
|
||||||
render(<AdminCustomModulesPage />);
|
render(<AdminCustomModulesPage />);
|
||||||
await screen.findByText('Noch keine eigenen Module');
|
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||||
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
|
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
||||||
await userEvent.type(screen.getByLabelText('Adresse'), 'http://example.com');
|
await userEvent.type(screen.getByLabelText('Adresse'), 'http://example.com');
|
||||||
@@ -160,8 +167,8 @@ describe('AdminCustomModulesPage', () => {
|
|||||||
|
|
||||||
it('Anlegen: Adresse mit Zugangsdaten zeigt die passende Meldung', async () => {
|
it('Anlegen: Adresse mit Zugangsdaten zeigt die passende Meldung', async () => {
|
||||||
render(<AdminCustomModulesPage />);
|
render(<AdminCustomModulesPage />);
|
||||||
await screen.findByText('Noch keine eigenen Module');
|
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||||
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
|
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
||||||
await userEvent.type(screen.getByLabelText('Adresse'), 'https://user:pw@example.com');
|
await userEvent.type(screen.getByLabelText('Adresse'), 'https://user:pw@example.com');
|
||||||
@@ -175,8 +182,8 @@ describe('AdminCustomModulesPage', () => {
|
|||||||
|
|
||||||
it('Anlegen: leerer Name zeigt die Meldung', async () => {
|
it('Anlegen: leerer Name zeigt die Meldung', async () => {
|
||||||
render(<AdminCustomModulesPage />);
|
render(<AdminCustomModulesPage />);
|
||||||
await screen.findByText('Noch keine eigenen Module');
|
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||||
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
|
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText('Name'), ' ');
|
await userEvent.type(screen.getByLabelText('Name'), ' ');
|
||||||
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||||
@@ -188,8 +195,8 @@ describe('AdminCustomModulesPage', () => {
|
|||||||
|
|
||||||
it('Anlegen: gültige Eingabe ruft create mit getrimmtem Namen, lädt neu und aktualisiert die Seitenleiste genau einmal', async () => {
|
it('Anlegen: gültige Eingabe ruft create mit getrimmtem Namen, lädt neu und aktualisiert die Seitenleiste genau einmal', async () => {
|
||||||
render(<AdminCustomModulesPage />);
|
render(<AdminCustomModulesPage />);
|
||||||
await screen.findByText('Noch keine eigenen Module');
|
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||||
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
|
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText('Name'), ' Beispielseite ');
|
await userEvent.type(screen.getByLabelText('Name'), ' Beispielseite ');
|
||||||
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||||
@@ -201,6 +208,7 @@ describe('AdminCustomModulesPage', () => {
|
|||||||
name: 'Beispielseite',
|
name: 'Beispielseite',
|
||||||
url: 'https://example.com',
|
url: 'https://example.com',
|
||||||
category: 'security-tools',
|
category: 'security-tools',
|
||||||
|
shared: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1));
|
||||||
@@ -211,8 +219,8 @@ describe('AdminCustomModulesPage', () => {
|
|||||||
|
|
||||||
it('Anlegen: die Kategorie ist vorbelegt mit Infrastruktur', async () => {
|
it('Anlegen: die Kategorie ist vorbelegt mit Infrastruktur', async () => {
|
||||||
render(<AdminCustomModulesPage />);
|
render(<AdminCustomModulesPage />);
|
||||||
await screen.findByText('Noch keine eigenen Module');
|
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||||
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
|
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
|
||||||
expect(screen.getByLabelText('Kategorie')).toHaveValue('infrastructure');
|
expect(screen.getByLabelText('Kategorie')).toHaveValue('infrastructure');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -245,8 +253,8 @@ describe('AdminCustomModulesPage', () => {
|
|||||||
it('Serverfehler beim Speichern bleibt im Dialog sichtbar, der Dialog bleibt offen', async () => {
|
it('Serverfehler beim Speichern bleibt im Dialog sichtbar, der Dialog bleibt offen', async () => {
|
||||||
mockCreate.mockRejectedValue(new Error('500'));
|
mockCreate.mockRejectedValue(new Error('500'));
|
||||||
render(<AdminCustomModulesPage />);
|
render(<AdminCustomModulesPage />);
|
||||||
await screen.findByText('Noch keine eigenen Module');
|
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||||
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
|
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
||||||
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||||
@@ -263,7 +271,7 @@ describe('AdminCustomModulesPage', () => {
|
|||||||
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
|
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
|
||||||
|
|
||||||
const dialog = screen.getByRole('dialog');
|
const dialog = screen.getByRole('dialog');
|
||||||
expect(within(dialog).getByText('Möchten Sie „Wiki“ wirklich löschen?')).toBeInTheDocument();
|
expect(within(dialog).getByText('Möchten Sie „Wiki“ wirklich löschen? Für alle Benutzer.')).toBeInTheDocument();
|
||||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Löschen' }));
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Löschen' }));
|
||||||
|
|
||||||
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('cm-1'));
|
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('cm-1'));
|
||||||
@@ -294,6 +302,22 @@ describe('AdminCustomModulesPage', () => {
|
|||||||
expect(mockBump).not.toHaveBeenCalled();
|
expect(mockBump).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('zeigt nur gemeinsame Einträge, nie persönliche (Filter über personal)', async () => {
|
||||||
|
mockList.mockResolvedValue([
|
||||||
|
wiki,
|
||||||
|
{ ...wiki, id: 'cm-2', name: 'Mein Privates', personal: true },
|
||||||
|
]);
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
expect(await screen.findByRole('link', { name: 'Wiki' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Mein Privates')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nur persönliche Einträge vorhanden: Leer-Zustand der Verwaltung', async () => {
|
||||||
|
mockList.mockResolvedValue([{ ...wiki, personal: true }]);
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
expect(await screen.findByText('Noch keine gemeinsamen Einträge')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('Benutzer mit Rolle USER sieht den Zugriff-verweigert-Text', async () => {
|
it('Benutzer mit Rolle USER sieht den Zugriff-verweigert-Text', async () => {
|
||||||
mockRole = 'USER';
|
mockRole = 'USER';
|
||||||
render(<AdminCustomModulesPage />);
|
render(<AdminCustomModulesPage />);
|
||||||
|
|||||||
@@ -1,74 +1,24 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
|
import { CustomModuleManager } from '@/components/custom-modules/custom-module-manager';
|
||||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
|
||||||
import { useAuthStore } from '@/lib/stores/auth-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
|
* Verwaltung „Eigene Module“ (quick-260929-9wc, D-07, Vorbild
|
||||||
* `admin/groups`). ADMIN und SUPER_ADMIN; die Rollenpruefung hier ist nur
|
* `admin/groups`; quick-260929-dzu: nur die GEMEINSAMEN Eintraege). ADMIN und
|
||||||
* Anzeige — durchgesetzt wird sie serverseitig durch `@Roles` an POST, PATCH
|
* SUPER_ADMIN; die Rollenpruefung hier ist nur Anzeige — durchgesetzt wird sie
|
||||||
* und DELETE von `/custom-modules`. Nach jedem erfolgreichen Anlegen,
|
* serverseitig: die API verlangt fuer gemeinsame Eintraege (`shared: true`,
|
||||||
* Aendern oder Loeschen zieht die Seitenleiste ueber `bumpSidebarRefresh`
|
* Aendern, Loeschen) die Administrator-Rolle. Persoenliche Eintraege eines
|
||||||
* ohne Neuladen nach (D-05).
|
* Administrators stehen wie bei jedem Benutzer unter Einstellungen.
|
||||||
*/
|
*/
|
||||||
export default function AdminCustomModulesPage() {
|
export default function AdminCustomModulesPage() {
|
||||||
const t = useTranslations('admin.customModules');
|
const t = useTranslations('admin.customModules');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const categoryLabel = useCategoryLabel();
|
|
||||||
const currentUser = useAuthStore((s) => s.user);
|
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 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) {
|
if (!hasAccess) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[60vh] items-center justify-center">
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
@@ -78,97 +28,15 @@ export default function AdminCustomModulesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<CustomModuleManager
|
||||||
<div className="flex items-center justify-between">
|
scope="shared"
|
||||||
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
texts={{
|
||||||
<button type="button" onClick={openCreate} className="btn btn-primary">
|
title: t('title'),
|
||||||
{t('create')}
|
description: t('description'),
|
||||||
</button>
|
create: t('create'),
|
||||||
</div>
|
noModules: t('noModules'),
|
||||||
|
noModulesBody: t('noModulesBody'),
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Namensraumfaehiger next-intl-Mock (Muster admin/custom-modules-page.test.tsx).
|
||||||
|
const messages: Record<string, Record<string, unknown>> = {
|
||||||
|
'settings.customModules': {
|
||||||
|
title: 'Eigene Module',
|
||||||
|
description: 'Diese Einträge sehen nur Sie.',
|
||||||
|
create: 'Eigenes Modul anlegen',
|
||||||
|
noModules: 'Noch keine eigenen Module',
|
||||||
|
noModulesBody: 'Legen Sie Ihr erstes Modul an.',
|
||||||
|
},
|
||||||
|
'customModules.manage': { actions: 'Aktionen', loadError: 'Laden fehlgeschlagen.' },
|
||||||
|
'customModules.form': {
|
||||||
|
create: 'Eigenes Modul anlegen',
|
||||||
|
edit: 'Eigenes Modul bearbeiten',
|
||||||
|
name: 'Name',
|
||||||
|
url: 'Adresse',
|
||||||
|
urlHint: 'Hinweis https',
|
||||||
|
category: 'Kategorie',
|
||||||
|
nameRequired: 'Bitte geben Sie einen Namen ein.',
|
||||||
|
urlNotHttps: 'Bitte geben Sie eine Adresse ein, die mit https:// beginnt.',
|
||||||
|
urlCredentials: 'Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.',
|
||||||
|
saveError: 'Das Modul konnte nicht gespeichert werden.',
|
||||||
|
deleteError: 'Das Modul konnte nicht gelöscht werden.',
|
||||||
|
deleteConfirm: {
|
||||||
|
title: 'Eigenes Modul löschen',
|
||||||
|
bodyShared: 'Möchten Sie „{name}“ wirklich löschen? Für alle Benutzer.',
|
||||||
|
bodyPersonal: 'Möchten Sie „{name}“ wirklich löschen? Nur bei Ihnen.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
loading: 'Laden...',
|
||||||
|
cancel: 'Abbrechen',
|
||||||
|
save: 'Speichern',
|
||||||
|
delete: 'Löschen',
|
||||||
|
edit: 'Bearbeiten',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) => {
|
||||||
|
let val: unknown = messages[ns];
|
||||||
|
for (const part of key.split('.')) {
|
||||||
|
val = (val as Record<string, unknown> | undefined)?.[part];
|
||||||
|
}
|
||||||
|
if (typeof val !== 'string') return key;
|
||||||
|
return params
|
||||||
|
? val.replace(/\{(\w+)\}/g, (_m, name: string) => String(params[name] ?? ''))
|
||||||
|
: val;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/link', () => ({
|
||||||
|
default: ({
|
||||||
|
children,
|
||||||
|
href,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
href: string;
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
|
<a href={href} className={className}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/use-category-label', () => ({
|
||||||
|
useCategoryLabel: () => (slug: string) =>
|
||||||
|
({ infrastructure: 'Infrastruktur', 'security-tools': 'Sicherheit' })[slug] ?? slug,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockList, mockCreate, mockUpdate, mockDelete, mockBump } = vi.hoisted(() => ({
|
||||||
|
mockList: vi.fn(),
|
||||||
|
mockCreate: vi.fn(),
|
||||||
|
mockUpdate: vi.fn(),
|
||||||
|
mockDelete: vi.fn(),
|
||||||
|
mockBump: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/custom-modules-api', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/lib/custom-modules-api')>(
|
||||||
|
'@/lib/custom-modules-api',
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
listCustomModules: mockList,
|
||||||
|
createCustomModule: mockCreate,
|
||||||
|
updateCustomModule: mockUpdate,
|
||||||
|
deleteCustomModule: mockDelete,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/lib/stores/marketplace-store', () => ({
|
||||||
|
useMarketplaceStore: (selector: (s: { bumpSidebarRefresh: () => void }) => unknown) =>
|
||||||
|
selector({ bumpSidebarRefresh: mockBump }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import CustomModulesSettingsPage from './page';
|
||||||
|
|
||||||
|
const mine = {
|
||||||
|
id: 'cm-1',
|
||||||
|
name: 'Mein Wiki',
|
||||||
|
url: 'https://wiki.example.com',
|
||||||
|
category: 'infrastructure',
|
||||||
|
personal: true,
|
||||||
|
createdAt: '2026-09-29T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-09-29T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
const shared = { ...mine, id: 'cm-2', name: 'Firmenseite', personal: false };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockList.mockReset().mockResolvedValue([]);
|
||||||
|
mockCreate.mockReset().mockResolvedValue(mine);
|
||||||
|
mockUpdate.mockReset().mockResolvedValue(mine);
|
||||||
|
mockDelete.mockReset().mockResolvedValue(undefined);
|
||||||
|
mockBump.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CustomModulesSettingsPage', () => {
|
||||||
|
it('zeigt Einleitung und ohne Einträge den Leer-Zustand mit Anlegen-Knopf', async () => {
|
||||||
|
render(<CustomModulesSettingsPage />);
|
||||||
|
expect(await screen.findByText('Noch keine eigenen Module')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Diese Einträge sehen nur Sie.')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length,
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listet nur die persönlichen Einträge, gemeinsame der Verwaltung nicht', async () => {
|
||||||
|
mockList.mockResolvedValue([shared, mine]);
|
||||||
|
render(<CustomModulesSettingsPage />);
|
||||||
|
expect(await screen.findByRole('link', { name: 'Mein Wiki' })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/modules/custom/cm-1',
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('Firmenseite')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole('button', { name: 'Bearbeiten' })).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anlegen sendet KEIN shared (persönlicher Eintrag), lädt neu und aktualisiert die Seitenleiste', async () => {
|
||||||
|
render(<CustomModulesSettingsPage />);
|
||||||
|
await screen.findByText('Noch keine eigenen Module');
|
||||||
|
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText('Name'), ' Mein Wiki ');
|
||||||
|
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith({
|
||||||
|
name: 'Mein Wiki',
|
||||||
|
url: 'https://example.com',
|
||||||
|
category: 'infrastructure',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('shared');
|
||||||
|
await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mockList).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anlegen: http-Adresse wird vor dem Senden abgelehnt', async () => {
|
||||||
|
render(<CustomModulesSettingsPage />);
|
||||||
|
await screen.findByText('Noch keine eigenen Module');
|
||||||
|
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
|
||||||
|
await userEvent.type(screen.getByLabelText('Name'), 'X');
|
||||||
|
await userEvent.type(screen.getByLabelText('Adresse'), 'http://example.com');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText('Bitte geben Sie eine Adresse ein, die mit https:// beginnt.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(mockCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Bearbeiten: ruft update ohne shared und aktualisiert die Seitenleiste', async () => {
|
||||||
|
mockList.mockResolvedValue([mine]);
|
||||||
|
render(<CustomModulesSettingsPage />);
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Bearbeiten' }));
|
||||||
|
|
||||||
|
const dialog = screen.getByRole('dialog');
|
||||||
|
const nameInput = within(dialog).getByLabelText('Name');
|
||||||
|
expect(nameInput).toHaveValue('Mein Wiki');
|
||||||
|
await userEvent.clear(nameInput);
|
||||||
|
await userEvent.type(nameInput, 'Wiki neu');
|
||||||
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Speichern' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith('cm-1', {
|
||||||
|
name: 'Wiki neu',
|
||||||
|
url: 'https://wiki.example.com',
|
||||||
|
category: 'infrastructure',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(mockCreate).not.toHaveBeenCalled();
|
||||||
|
await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Löschen: Rückfrage nennt den persönlichen Text, Bestätigen ruft delete und aktualisiert die Seitenleiste', async () => {
|
||||||
|
mockList.mockResolvedValue([mine]);
|
||||||
|
render(<CustomModulesSettingsPage />);
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
|
||||||
|
|
||||||
|
const dialog = screen.getByRole('dialog');
|
||||||
|
expect(
|
||||||
|
within(dialog).getByText('Möchten Sie „Mein Wiki“ wirklich löschen? Nur bei Ihnen.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Löschen' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('cm-1'));
|
||||||
|
await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mockList).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Serverfehler beim Speichern bleibt im Dialog sichtbar', async () => {
|
||||||
|
mockCreate.mockRejectedValue(new Error('500'));
|
||||||
|
render(<CustomModulesSettingsPage />);
|
||||||
|
await screen.findByText('Noch keine eigenen Module');
|
||||||
|
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
|
||||||
|
await userEvent.type(screen.getByLabelText('Name'), 'X');
|
||||||
|
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
|
||||||
|
expect(await screen.findByText('Das Modul konnte nicht gespeichert werden.')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
|
expect(mockBump).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { CustomModuleManager } from '@/components/custom-modules/custom-module-manager';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einstellungen > Eigene Module (quick-260929-dzu) — /settings/custom-modules.
|
||||||
|
* Jeder angemeldete Benutzer pflegt hier seine PERSOENLICHEN Seitenleisten-
|
||||||
|
* Eintraege; nur er selbst sieht sie. Die Trennung von den gemeinsamen
|
||||||
|
* Eintraegen der Verwaltung erzwingt die API, nicht diese Seite.
|
||||||
|
*/
|
||||||
|
export default function CustomModulesSettingsPage() {
|
||||||
|
const t = useTranslations('settings.customModules');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomModuleManager
|
||||||
|
scope="personal"
|
||||||
|
texts={{
|
||||||
|
title: t('title'),
|
||||||
|
description: t('description'),
|
||||||
|
create: t('create'),
|
||||||
|
noModules: t('noModules'),
|
||||||
|
noModulesBody: t('noModulesBody'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
+17
-4
@@ -14,6 +14,12 @@ import { useCategoryLabel } from '@/lib/use-category-label';
|
|||||||
interface CustomModuleFormModalProps {
|
interface CustomModuleFormModalProps {
|
||||||
/** `null` = neues Modul anlegen, sonst bearbeiten. */
|
/** `null` = neues Modul anlegen, sonst bearbeiten. */
|
||||||
mod: CustomModule | null;
|
mod: CustomModule | null;
|
||||||
|
/**
|
||||||
|
* Beim Anlegen: `true` = gemeinsamer Eintrag fuer alle Benutzer (Verwaltung),
|
||||||
|
* sonst persoenlicher Eintrag (Einstellungen). Beim Bearbeiten ohne Wirkung —
|
||||||
|
* ob ein Eintrag gemeinsam ist, aendert sich nie.
|
||||||
|
*/
|
||||||
|
shared?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
}
|
}
|
||||||
@@ -22,13 +28,20 @@ const NAME_MAX = 100;
|
|||||||
const URL_MAX = 2048;
|
const URL_MAX = 2048;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formular „Eigenes Modul“ (quick-260929-9wc, Vorbild `GroupFormModal`).
|
* Formular „Eigenes Modul“ (quick-260929-9wc, Vorbild `GroupFormModal`;
|
||||||
|
* quick-260929-dzu: ein Formular, zwei Aufrufer — Verwaltung legt gemeinsame,
|
||||||
|
* Einstellungen persoenliche Eintraege an).
|
||||||
* Die Adresse wird VOR dem Senden geprueft (`checkCustomModuleUrl`, dieselbe
|
* Die Adresse wird VOR dem Senden geprueft (`checkCustomModuleUrl`, dieselbe
|
||||||
* Regel wie die API): nur https, keine Zugangsdaten. `noValidate`, damit die
|
* Regel wie die API): nur https, keine Zugangsdaten. `noValidate`, damit die
|
||||||
* eigenen, uebersetzten Meldungen erscheinen statt der des Browsers.
|
* eigenen, uebersetzten Meldungen erscheinen statt der des Browsers.
|
||||||
*/
|
*/
|
||||||
export function CustomModuleFormModal({ mod, onClose, onSaved }: CustomModuleFormModalProps) {
|
export function CustomModuleFormModal({
|
||||||
const t = useTranslations('admin.customModules');
|
mod,
|
||||||
|
shared = false,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: CustomModuleFormModalProps) {
|
||||||
|
const t = useTranslations('customModules.form');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const categoryLabel = useCategoryLabel();
|
const categoryLabel = useCategoryLabel();
|
||||||
|
|
||||||
@@ -64,7 +77,7 @@ export function CustomModuleFormModal({ mod, onClose, onSaved }: CustomModuleFor
|
|||||||
if (mod) {
|
if (mod) {
|
||||||
await updateCustomModule(mod.id, input);
|
await updateCustomModule(mod.id, input);
|
||||||
} else {
|
} else {
|
||||||
await createCustomModule(input);
|
await createCustomModule(shared ? { ...input, shared: true } : input);
|
||||||
}
|
}
|
||||||
onSaved();
|
onSaved();
|
||||||
onClose();
|
onClose();
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
|
||||||
|
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||||
|
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||||
|
import { CustomModuleFormModal } from './custom-module-form-modal';
|
||||||
|
import { DeleteCustomModuleDialog } from './delete-custom-module-dialog';
|
||||||
|
|
||||||
|
/** Seitenspezifische, bereits uebersetzte Texte der beiden Aufrufer. */
|
||||||
|
export interface CustomModuleManagerTexts {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
create: string;
|
||||||
|
noModules: string;
|
||||||
|
noModulesBody: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomModuleManagerProps {
|
||||||
|
/**
|
||||||
|
* `shared`: Verwaltung — zeigt und pflegt nur die gemeinsamen Eintraege (fuer
|
||||||
|
* alle Benutzer sichtbar). `personal`: Einstellungen — zeigt und pflegt nur
|
||||||
|
* die eigenen persoenlichen Eintraege des angemeldeten Benutzers.
|
||||||
|
*/
|
||||||
|
scope: 'shared' | 'personal';
|
||||||
|
texts: CustomModuleManagerTexts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste mit Anlegen, Bearbeiten und Loeschen eigener Module (quick-260929-dzu).
|
||||||
|
* Eine Oberflaeche fuer zwei Aufrufer: `admin/custom-modules` (`scope="shared"`)
|
||||||
|
* und `settings/custom-modules` (`scope="personal"`). Die API liefert gemeinsame
|
||||||
|
* PLUS eigene Eintraege; die Seite filtert ueber `personal` auf ihren Bereich.
|
||||||
|
* Nach jedem erfolgreichen Anlegen, Aendern oder Loeschen zieht die Seitenleiste
|
||||||
|
* ueber `bumpSidebarRefresh` ohne Neuladen nach.
|
||||||
|
*/
|
||||||
|
export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps) {
|
||||||
|
const t = useTranslations('customModules.form');
|
||||||
|
const tManage = useTranslations('customModules.manage');
|
||||||
|
const tCommon = useTranslations('common');
|
||||||
|
const categoryLabel = useCategoryLabel();
|
||||||
|
const bumpSidebarRefresh = useMarketplaceStore((s) => s.bumpSidebarRefresh);
|
||||||
|
const shared = scope === 'shared';
|
||||||
|
|
||||||
|
const [modules, setModules] = useState<CustomModule[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
|
|
||||||
|
const [showFormModal, setShowFormModal] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<CustomModule | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<CustomModule | null>(null);
|
||||||
|
|
||||||
|
const fetchModules = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const all = await listCustomModules();
|
||||||
|
setModules(all.filter((m) => m.personal !== shared));
|
||||||
|
setLoadFailed(false);
|
||||||
|
} catch {
|
||||||
|
setLoadFailed(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [shared]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchModules();
|
||||||
|
}, [fetchModules]);
|
||||||
|
|
||||||
|
const handleChanged = () => {
|
||||||
|
fetchModules();
|
||||||
|
bumpSidebarRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setShowFormModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (mod: CustomModule) => {
|
||||||
|
setEditing(mod);
|
||||||
|
setShowFormModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1
|
||||||
|
className={`${shared ? 'text-2xl' : 'text-lg'} font-semibold text-foreground`}
|
||||||
|
>
|
||||||
|
{texts.title}
|
||||||
|
</h1>
|
||||||
|
<button type="button" onClick={openCreate} className="btn btn-primary">
|
||||||
|
{texts.create}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">{texts.description}</p>
|
||||||
|
|
||||||
|
{loadFailed && (
|
||||||
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{tManage('loadError')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted-foreground">{tCommon('loading')}</p>
|
||||||
|
) : modules.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<h2 className="mb-2 text-lg font-semibold text-foreground">{texts.noModules}</h2>
|
||||||
|
<p className="mb-6 text-sm text-muted-foreground">{texts.noModulesBody}</p>
|
||||||
|
<button type="button" onClick={openCreate} className="btn btn-primary">
|
||||||
|
{texts.create}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-md border border-border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('name')}</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('url')}</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('category')}</th>
|
||||||
|
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
|
||||||
|
{tManage('actions')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{modules.map((mod) => (
|
||||||
|
<tr key={mod.id} className="transition-colors hover:bg-muted/30">
|
||||||
|
<td className="px-4 py-3 font-medium text-foreground">
|
||||||
|
<Link href={`/modules/custom/${mod.id}`} className="link">
|
||||||
|
{mod.name}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="max-w-[24rem] px-4 py-3 text-muted-foreground">
|
||||||
|
<span className="block truncate" title={mod.url}>
|
||||||
|
{mod.url}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">{categoryLabel(mod.category)}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEdit(mod)}
|
||||||
|
className="rounded px-2 py-1 text-xs text-foreground transition-colors hover:bg-muted"
|
||||||
|
>
|
||||||
|
{tCommon('edit')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleteTarget(mod)}
|
||||||
|
className="rounded px-2 py-1 text-xs text-destructive transition-colors hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
{tCommon('delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showFormModal && (
|
||||||
|
<CustomModuleFormModal
|
||||||
|
mod={editing}
|
||||||
|
shared={shared}
|
||||||
|
onClose={() => setShowFormModal(false)}
|
||||||
|
onSaved={handleChanged}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleteTarget && (
|
||||||
|
<DeleteCustomModuleDialog
|
||||||
|
mod={deleteTarget}
|
||||||
|
shared={shared}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
onDeleted={handleChanged}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+13
-5
@@ -6,6 +6,8 @@ import { type CustomModule, deleteCustomModule } from '@/lib/custom-modules-api'
|
|||||||
|
|
||||||
interface DeleteCustomModuleDialogProps {
|
interface DeleteCustomModuleDialogProps {
|
||||||
mod: CustomModule;
|
mod: CustomModule;
|
||||||
|
/** Gemeinsamer Eintrag (verschwindet fuer alle) oder persoenlicher (nur fuer den Besitzer). */
|
||||||
|
shared?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDeleted: () => void;
|
onDeleted: () => void;
|
||||||
}
|
}
|
||||||
@@ -13,11 +15,17 @@ interface DeleteCustomModuleDialogProps {
|
|||||||
/**
|
/**
|
||||||
* Rueckfrage vor dem Loeschen eines eigenen Moduls (quick-260929-9wc,
|
* Rueckfrage vor dem Loeschen eines eigenen Moduls (quick-260929-9wc,
|
||||||
* Vorbild `DeleteGroupDialog`). Bleibt bei einem Fehler offen und zeigt ihn —
|
* Vorbild `DeleteGroupDialog`). Bleibt bei einem Fehler offen und zeigt ihn —
|
||||||
* der Eintrag verschwindet fuer ALLE Benutzer aus der Seitenleiste, ein
|
* ein stilles Scheitern wuerde ein Loeschen vortaeuschen. Der Text nennt, fuer
|
||||||
* stilles Scheitern wuerde ein Loeschen vortaeuschen.
|
* wen der Eintrag verschwindet (quick-260929-dzu: gemeinsam = alle, persoenlich
|
||||||
|
* = nur der Besitzer).
|
||||||
*/
|
*/
|
||||||
export function DeleteCustomModuleDialog({ mod, onClose, onDeleted }: DeleteCustomModuleDialogProps) {
|
export function DeleteCustomModuleDialog({
|
||||||
const t = useTranslations('admin.customModules');
|
mod,
|
||||||
|
shared = false,
|
||||||
|
onClose,
|
||||||
|
onDeleted,
|
||||||
|
}: DeleteCustomModuleDialogProps) {
|
||||||
|
const t = useTranslations('customModules.form');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -47,7 +55,7 @@ export function DeleteCustomModuleDialog({ mod, onClose, onDeleted }: DeleteCust
|
|||||||
<h2 id="delete-custom-module-title" className="mb-4 text-lg font-semibold text-foreground">
|
<h2 id="delete-custom-module-title" className="mb-4 text-lg font-semibold text-foreground">
|
||||||
{t('deleteConfirm.title')}
|
{t('deleteConfirm.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-4 text-sm text-foreground">{t('deleteConfirm.body', { name: mod.name })}</p>
|
<p className="mb-4 text-sm text-foreground">{t(shared ? 'deleteConfirm.bodyShared' : 'deleteConfirm.bodyPersonal', { name: mod.name })}</p>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div
|
<div
|
||||||
@@ -71,6 +71,19 @@ export function SettingsSidebar() {
|
|||||||
>
|
>
|
||||||
{t('categoryDesktopApp')}
|
{t('categoryDesktopApp')}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/settings/custom-modules"
|
||||||
|
className={`nav-item flex h-9 items-center rounded-lg px-2.5 text-sm transition-colors ${
|
||||||
|
isActive('/settings/custom-modules')
|
||||||
|
? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold'
|
||||||
|
: 'text-sidebar-foreground hover:bg-sidebar-hover'
|
||||||
|
}`}
|
||||||
|
aria-current={
|
||||||
|
isActive('/settings/custom-modules') ? 'page' : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('categoryCustomModules')}
|
||||||
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Dashboard category — existing items unchanged */}
|
{/* Dashboard category — existing items unchanged */}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface CustomModule {
|
|||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
/** `true` = persoenlicher Eintrag des angemeldeten Benutzers, `false` = gemeinsam (vom Administrator). */
|
||||||
|
personal: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -23,6 +25,14 @@ export interface CustomModuleInput {
|
|||||||
category: string;
|
category: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beim Anlegen: `shared: true` legt einen gemeinsamen Eintrag fuer alle an
|
||||||
|
* (nur Administratoren, sonst 403); ohne das Feld ist der Eintrag persoenlich.
|
||||||
|
*/
|
||||||
|
export interface CustomModuleCreateInput extends CustomModuleInput {
|
||||||
|
shared?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** Fehler mit HTTP-Status und Servermeldung (falls vorhanden). */
|
/** Fehler mit HTTP-Status und Servermeldung (falls vorhanden). */
|
||||||
export class CustomModuleRequestError extends Error {
|
export class CustomModuleRequestError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -83,7 +93,7 @@ export async function getCustomModule(id: string): Promise<CustomModule | null>
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createCustomModule(input: CustomModuleInput): Promise<CustomModule> {
|
export async function createCustomModule(input: CustomModuleCreateInput): Promise<CustomModule> {
|
||||||
const res = await fetch(`${API_URL}/custom-modules`, {
|
const res = await fetch(`${API_URL}/custom-modules`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
|||||||
@@ -165,6 +165,7 @@
|
|||||||
"categoryGeneral": "Allgemein",
|
"categoryGeneral": "Allgemein",
|
||||||
"categoryAccount": "Konto",
|
"categoryAccount": "Konto",
|
||||||
"categoryDesktopApp": "Desktop-App",
|
"categoryDesktopApp": "Desktop-App",
|
||||||
|
"categoryCustomModules": "Eigene Module",
|
||||||
"categorySmtp": "SMTP",
|
"categorySmtp": "SMTP",
|
||||||
"account": {
|
"account": {
|
||||||
"title": "Konto",
|
"title": "Konto",
|
||||||
@@ -228,6 +229,13 @@
|
|||||||
"testFailed": "Verbindung fehlgeschlagen",
|
"testFailed": "Verbindung fehlgeschlagen",
|
||||||
"bugReportRecipient": "Fehlermeldungen an",
|
"bugReportRecipient": "Fehlermeldungen an",
|
||||||
"bugReportRecipientHelp": "Optional – Postfach, an das Anwender über den Knopf „Fehler melden\" ihre Meldungen mit Bildschirmfoto schicken. Leer lassen, wenn der Knopf keine E-Mails senden soll."
|
"bugReportRecipientHelp": "Optional – Postfach, an das Anwender über den Knopf „Fehler melden\" ihre Meldungen mit Bildschirmfoto schicken. Leer lassen, wenn der Knopf keine E-Mails senden soll."
|
||||||
|
},
|
||||||
|
"customModules": {
|
||||||
|
"title": "Eigene Module",
|
||||||
|
"description": "Nehmen Sie Webseiten, die Sie oft brauchen, als eigene Einträge in Ihre Seitenleiste auf. Diese Einträge sehen nur Sie. Ein Klick zeigt die Seite direkt in Tessera.",
|
||||||
|
"create": "Eigenes Modul anlegen",
|
||||||
|
"noModules": "Noch keine eigenen Module",
|
||||||
|
"noModulesBody": "Legen Sie Ihr erstes Modul an, um eine Webseite in Ihrer Seitenleiste zu zeigen."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"widgets": {
|
"widgets": {
|
||||||
@@ -672,26 +680,10 @@
|
|||||||
},
|
},
|
||||||
"customModules": {
|
"customModules": {
|
||||||
"title": "Eigene Module",
|
"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.",
|
"description": "Nehmen Sie andere Webseiten als Einträge in die Seitenleiste auf. Diese gemeinsamen Einträge sehen alle Benutzer unter der gewählten Kategorie, ein Klick zeigt die Seite direkt in Tessera. Persönliche Einträge nur für Sie legen Sie unter „Einstellungen → Eigene Module“ an.",
|
||||||
"create": "Eigenes Modul anlegen",
|
"create": "Gemeinsamen Eintrag anlegen",
|
||||||
"edit": "Eigenes Modul bearbeiten",
|
"noModules": "Noch keine gemeinsamen Einträge",
|
||||||
"name": "Name",
|
"noModulesBody": "Legen Sie Ihren ersten gemeinsamen Eintrag an, um eine Webseite für alle Benutzer in der Seitenleiste zu zeigen."
|
||||||
"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": {
|
||||||
@@ -1375,7 +1367,29 @@
|
|||||||
"embedHint": "Manche Seiten lassen sich nicht einbetten. Öffnen Sie die Seite dann in einem neuen Tab.",
|
"embedHint": "Manche Seiten lassen sich nicht einbetten. Öffnen Sie die Seite dann in einem neuen Tab.",
|
||||||
"notFound": "Dieses Modul gibt es nicht mehr.",
|
"notFound": "Dieses Modul gibt es nicht mehr.",
|
||||||
"invalidUrl": "Die Adresse dieses Moduls ist keine gültige https-Adresse und wird deshalb nicht angezeigt.",
|
"invalidUrl": "Die Adresse dieses Moduls ist keine gültige https-Adresse und wird deshalb nicht angezeigt.",
|
||||||
"loading": "Wird geladen …"
|
"loading": "Wird geladen …",
|
||||||
|
"form": {
|
||||||
|
"create": "Eigenes Modul anlegen",
|
||||||
|
"edit": "Eigenes Modul bearbeiten",
|
||||||
|
"name": "Name",
|
||||||
|
"url": "Adresse",
|
||||||
|
"urlHint": "Die Adresse muss mit https:// beginnen.",
|
||||||
|
"category": "Kategorie",
|
||||||
|
"nameRequired": "Bitte geben Sie einen Namen ein.",
|
||||||
|
"urlNotHttps": "Bitte geben Sie eine Adresse ein, die mit https:// beginnt.",
|
||||||
|
"urlCredentials": "Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.",
|
||||||
|
"saveError": "Das Modul konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.",
|
||||||
|
"deleteError": "Das Modul konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
|
||||||
|
"deleteConfirm": {
|
||||||
|
"title": "Eigenes Modul löschen",
|
||||||
|
"bodyShared": "Möchten Sie „{name}“ wirklich löschen? Der Eintrag verschwindet für alle Benutzer aus der Seitenleiste.",
|
||||||
|
"bodyPersonal": "Möchten Sie „{name}“ wirklich löschen? Der Eintrag verschwindet aus Ihrer Seitenleiste."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"manage": {
|
||||||
|
"actions": "Aktionen",
|
||||||
|
"loadError": "Die eigenen Module konnten nicht geladen werden."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"moduleCategories": {
|
"moduleCategories": {
|
||||||
"domain-tools": "Domains",
|
"domain-tools": "Domains",
|
||||||
|
|||||||
@@ -165,6 +165,7 @@
|
|||||||
"categoryGeneral": "General",
|
"categoryGeneral": "General",
|
||||||
"categoryAccount": "Account",
|
"categoryAccount": "Account",
|
||||||
"categoryDesktopApp": "Desktop App",
|
"categoryDesktopApp": "Desktop App",
|
||||||
|
"categoryCustomModules": "Custom modules",
|
||||||
"categorySmtp": "SMTP",
|
"categorySmtp": "SMTP",
|
||||||
"account": {
|
"account": {
|
||||||
"title": "Account",
|
"title": "Account",
|
||||||
@@ -228,6 +229,13 @@
|
|||||||
"testFailed": "Connection failed",
|
"testFailed": "Connection failed",
|
||||||
"bugReportRecipient": "Bug reports to",
|
"bugReportRecipient": "Bug reports to",
|
||||||
"bugReportRecipientHelp": "Optional – mailbox that receives the reports users send via the \"Report a problem\" button, including the screenshot. Leave empty if the button should not send e-mails."
|
"bugReportRecipientHelp": "Optional – mailbox that receives the reports users send via the \"Report a problem\" button, including the screenshot. Leave empty if the button should not send e-mails."
|
||||||
|
},
|
||||||
|
"customModules": {
|
||||||
|
"title": "Custom modules",
|
||||||
|
"description": "Add websites you use often as your own entries in your sidebar. Only you can see these entries. A click shows the page right inside Tessera.",
|
||||||
|
"create": "Add custom module",
|
||||||
|
"noModules": "No custom modules yet",
|
||||||
|
"noModulesBody": "Add your first module to show a website in your sidebar."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"widgets": {
|
"widgets": {
|
||||||
@@ -672,26 +680,10 @@
|
|||||||
},
|
},
|
||||||
"customModules": {
|
"customModules": {
|
||||||
"title": "Custom modules",
|
"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.",
|
"description": "Add other websites as entries in the sidebar. All users see these shared entries under the chosen category, and a click shows the page right inside Tessera. Personal entries only you can see are added under “Settings → Custom modules”.",
|
||||||
"create": "Add custom module",
|
"create": "Add shared entry",
|
||||||
"edit": "Edit custom module",
|
"noModules": "No shared entries yet",
|
||||||
"name": "Name",
|
"noModulesBody": "Add your first shared entry to show a website in the sidebar for all users."
|
||||||
"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": {
|
||||||
@@ -1375,7 +1367,29 @@
|
|||||||
"embedHint": "Some pages cannot be embedded. If this one stays blank, open it in a new tab.",
|
"embedHint": "Some pages cannot be embedded. If this one stays blank, open it in a new tab.",
|
||||||
"notFound": "This module no longer exists.",
|
"notFound": "This module no longer exists.",
|
||||||
"invalidUrl": "This module's address is not a valid https address and is therefore not shown.",
|
"invalidUrl": "This module's address is not a valid https address and is therefore not shown.",
|
||||||
"loading": "Loading …"
|
"loading": "Loading …",
|
||||||
|
"form": {
|
||||||
|
"create": "Add custom module",
|
||||||
|
"edit": "Edit custom module",
|
||||||
|
"name": "Name",
|
||||||
|
"url": "Address",
|
||||||
|
"urlHint": "The address must start with https://.",
|
||||||
|
"category": "Category",
|
||||||
|
"nameRequired": "Please enter a name.",
|
||||||
|
"urlNotHttps": "Please enter an address that starts with https://.",
|
||||||
|
"urlCredentials": "The address must not contain a user name or a password.",
|
||||||
|
"saveError": "The module could not be saved. Please try again.",
|
||||||
|
"deleteError": "The module could not be deleted. Please try again.",
|
||||||
|
"deleteConfirm": {
|
||||||
|
"title": "Delete custom module",
|
||||||
|
"bodyShared": "Do you really want to delete “{name}”? The entry disappears from the sidebar for all users.",
|
||||||
|
"bodyPersonal": "Do you really want to delete “{name}”? The entry disappears from your sidebar."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"manage": {
|
||||||
|
"actions": "Actions",
|
||||||
|
"loadError": "The custom modules could not be loaded."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"moduleCategories": {
|
"moduleCategories": {
|
||||||
"domain-tools": "Domains",
|
"domain-tools": "Domains",
|
||||||
|
|||||||
Reference in New Issue
Block a user