From e7fc4de4301b12c23cdc3d6f8539ab8ed9d45850 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 29 Sep 2026 07:27:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20Verwaltung=20=E2=80=9EEigene=20Mod?= =?UTF-8?q?ule=E2=80=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../components/CustomModuleFormModal.tsx | 161 ++++++++++ .../components/DeleteCustomModuleDialog.tsx | 77 +++++ .../custom-modules-page.test.tsx | 303 ++++++++++++++++++ .../(portal)/admin/custom-modules/page.tsx | 174 ++++++++++ .../src/components/admin/admin-sidebar.tsx | 12 + apps/web/src/messages/de.json | 24 ++ apps/web/src/messages/en.json | 24 ++ 7 files changed, 775 insertions(+) create mode 100644 apps/web/src/app/(portal)/admin/custom-modules/components/CustomModuleFormModal.tsx create mode 100644 apps/web/src/app/(portal)/admin/custom-modules/components/DeleteCustomModuleDialog.tsx create mode 100644 apps/web/src/app/(portal)/admin/custom-modules/custom-modules-page.test.tsx create mode 100644 apps/web/src/app/(portal)/admin/custom-modules/page.tsx diff --git a/apps/web/src/app/(portal)/admin/custom-modules/components/CustomModuleFormModal.tsx b/apps/web/src/app/(portal)/admin/custom-modules/components/CustomModuleFormModal.tsx new file mode 100644 index 0000000..4239bf8 --- /dev/null +++ b/apps/web/src/app/(portal)/admin/custom-modules/components/CustomModuleFormModal.tsx @@ -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(mod?.category ?? 'infrastructure'); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(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 ( +
+
+

+ {mod ? t('edit') : t('create')} +

+
+
+ + setName(e.target.value)} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" + /> +
+ +
+ + setUrl(e.target.value)} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" + /> +

{t('urlHint')}

+
+ +
+ + +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(portal)/admin/custom-modules/components/DeleteCustomModuleDialog.tsx b/apps/web/src/app/(portal)/admin/custom-modules/components/DeleteCustomModuleDialog.tsx new file mode 100644 index 0000000..f67d128 --- /dev/null +++ b/apps/web/src/app/(portal)/admin/custom-modules/components/DeleteCustomModuleDialog.tsx @@ -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(null); + + const handleDelete = async () => { + setDeleting(true); + setError(null); + try { + await deleteCustomModule(mod.id); + onDeleted(); + onClose(); + } catch { + setError(t('deleteError')); + } finally { + setDeleting(false); + } + }; + + return ( +
+
+

+ {t('deleteConfirm.title')} +

+

{t('deleteConfirm.body', { name: mod.name })}

+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/app/(portal)/admin/custom-modules/custom-modules-page.test.tsx b/apps/web/src/app/(portal)/admin/custom-modules/custom-modules-page.test.tsx new file mode 100644 index 0000000..f23666d --- /dev/null +++ b/apps/web/src/app/(portal)/admin/custom-modules/custom-modules-page.test.tsx @@ -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> = { + '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) => { + let val: unknown = messages[ns]; + for (const part of key.split('.')) { + val = (val as Record | 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 }) => ( + {children} + ), +})); + +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( + '@/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(); + 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(); + + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(await screen.findByText('Zugriff verweigert')).toBeInTheDocument(); + expect(mockList).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/(portal)/admin/custom-modules/page.tsx b/apps/web/src/app/(portal)/admin/custom-modules/page.tsx new file mode 100644 index 0000000..b040d99 --- /dev/null +++ b/apps/web/src/app/(portal)/admin/custom-modules/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [loadFailed, setLoadFailed] = useState(false); + + const [showFormModal, setShowFormModal] = useState(false); + const [editing, setEditing] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(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 ( +
+

{tCommon('accessDenied')}

+
+ ); + } + + return ( +
+
+

{t('title')}

+ +
+ +

{t('description')}

+ + {loadFailed && ( +
+ {t('loadError')} +
+ )} + + {loading ? ( +

{tCommon('loading')}

+ ) : modules.length === 0 ? ( +
+

{t('noModules')}

+

{t('noModulesBody')}

+ +
+ ) : ( +
+ + + + + + + + + + + {modules.map((mod) => ( + + + + + + + ))} + +
{t('name')}{t('url')}{t('category')}{t('actions')}
+ + {mod.name} + + + + {mod.url} + + {categoryLabel(mod.category)} +
+ + +
+
+
+ )} + + {showFormModal && ( + setShowFormModal(false)} + onSaved={handleChanged} + /> + )} + + {deleteTarget && ( + setDeleteTarget(null)} + onDeleted={handleChanged} + /> + )} +
+ ); +} diff --git a/apps/web/src/components/admin/admin-sidebar.tsx b/apps/web/src/components/admin/admin-sidebar.tsx index f4d32aa..590d3ab 100644 --- a/apps/web/src/components/admin/admin-sidebar.tsx +++ b/apps/web/src/components/admin/admin-sidebar.tsx @@ -48,6 +48,18 @@ export function AdminSidebar() { ), }, + { + label: t('admin.customModules'), + href: '/admin/custom-modules', + show: true, + icon: ( + + ), + }, { label: t('admin.ldap'), href: '/admin/ldap', diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 506f2e2..ab21c67 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -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": { diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 9ba97b0..86e1f44 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -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": {