diff --git a/apps/web/src/app/(portal)/marketplace/marketplace-filters.test.tsx b/apps/web/src/app/(portal)/marketplace/marketplace-filters.test.tsx
index fc2505b..bd77efb 100644
--- a/apps/web/src/app/(portal)/marketplace/marketplace-filters.test.tsx
+++ b/apps/web/src/app/(portal)/marketplace/marketplace-filters.test.tsx
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Statisch statt je Test dynamisch importiert: Laden/Umwandeln der Seite faellt
// in die Sammelphase, nicht in die 5-s-Frist eines Tests (quick-260924-m4n).
+import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
import Page from './page';
vi.mock('next-intl', () => ({
@@ -197,6 +198,27 @@ describe('Marketplace Filters', () => {
});
});
+ it('Kategorie-Chips folgen der eingestellten Kategorienreihenfolge (quick-261003-387)', async () => {
+ useModuleCategoryStore.setState({
+ loaded: true,
+ categories: [
+ { id: 'u', key: 'Utilities', name: null, sortOrder: 0, isSystem: false },
+ { id: 'd', key: 'Domain-Tools', name: 'Meine Domains', sortOrder: 1, isSystem: false },
+ ],
+ });
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Domaincheck')).toBeInTheDocument();
+ });
+ const chips = ['Utilities', 'Meine Domains'].map((name) =>
+ screen.getByRole('button', { name }),
+ );
+ // compareDocumentPosition: 4 = „folgt“ — der erste Chip steht vor dem zweiten.
+ expect(chips[0].compareDocumentPosition(chips[1]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ useModuleCategoryStore.setState({ categories: [], loaded: false });
+ });
+
it('search + status + category filters compose together (AND)', async () => {
render();
diff --git a/apps/web/src/app/(portal)/marketplace/page.tsx b/apps/web/src/app/(portal)/marketplace/page.tsx
index 8b05f77..17df257 100644
--- a/apps/web/src/app/(portal)/marketplace/page.tsx
+++ b/apps/web/src/app/(portal)/marketplace/page.tsx
@@ -4,7 +4,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/stores/auth-store';
+import { categoryRank } from '@/lib/module-category-order';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
+import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
import { MarketplaceCard } from './components/MarketplaceCard';
import { MarketplaceSearch } from './components/MarketplaceSearch';
import { StatusFilter, type StatusValue } from './components/StatusFilter';
@@ -43,6 +45,9 @@ export default function MarketplacePage() {
const currentUser = useAuthStore((s) => s.user);
const { selectedTenantId, bumpSidebarRefresh } = useMarketplaceStore();
const addToast = useToastStore((s) => s.addToast);
+ // Reihenfolge der Filter-Chips = eingestellte Kategorienreihenfolge (quick-261003-387).
+ const orderedCategories = useModuleCategoryStore((s) => s.categories);
+ const ensureCategories = useModuleCategoryStore((s) => s.ensureLoaded);
const [modules, setModules] = useState([]);
const [loading, setLoading] = useState(true);
@@ -85,6 +90,10 @@ export default function MarketplacePage() {
fetchModules();
}, [fetchModules]);
+ useEffect(() => {
+ ensureCategories();
+ }, [ensureCategories]);
+
const locale = typeof window !== 'undefined' ? document.documentElement.lang || 'de' : 'de';
@@ -104,9 +113,15 @@ export default function MarketplacePage() {
});
}, [modules, searchQuery, statusFilter, categoryFilter, locale]);
+ // Chips in der eingestellten Kategorienreihenfolge; unbekannte Kennungen
+ // (Speicher noch nicht geladen) stehen zuletzt in Fundreihenfolge — der
+ // Server liefert die Module schon in dieser Reihenfolge.
const categories = useMemo(
- () => [...new Set(modules.map((m) => m.category))].sort(),
- [modules],
+ () =>
+ [...new Set(modules.map((m) => m.category))].sort(
+ (a, b) => categoryRank(orderedCategories, a) - categoryRank(orderedCategories, b),
+ ),
+ [modules, orderedCategories],
);
const statusCounts = useMemo(() => ({
diff --git a/apps/web/src/app/(portal)/modules/[category]/page.tsx b/apps/web/src/app/(portal)/modules/[category]/page.tsx
index 206e5a0..d299c58 100644
--- a/apps/web/src/app/(portal)/modules/[category]/page.tsx
+++ b/apps/web/src/app/(portal)/modules/[category]/page.tsx
@@ -3,6 +3,7 @@
import { useTranslations } from 'next-intl';
import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
+import { useCategoryLabel } from '@/lib/use-category-label';
import { ModuleCard, type ModuleCardProps } from './components/ModuleCard';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
@@ -46,6 +47,7 @@ export default function CategoryPage() {
const params = useParams<{ category: string }>();
const category = params.category;
const t = useTranslations('modules');
+ const categoryLabel = useCategoryLabel();
const [modules, setModules] = useState([]);
const [isLoading, setIsLoading] = useState(true);
@@ -89,7 +91,10 @@ export default function CategoryPage() {
fetchModules();
}, [category]);
- const categoryTitle = formatCategoryName(category);
+ // Gespeicherter Name, Uebersetzung oder — fuer eine Kennung ohne beides —
+ // die lesbar gemachte Kennung (quick-261003-387).
+ const label = categoryLabel(category);
+ const categoryTitle = label === category ? formatCategoryName(category) : label;
return (
diff --git a/apps/web/src/components/custom-modules/custom-module-form-modal.tsx b/apps/web/src/components/custom-modules/custom-module-form-modal.tsx
index 6760d29..13dce0d 100644
--- a/apps/web/src/components/custom-modules/custom-module-form-modal.tsx
+++ b/apps/web/src/components/custom-modules/custom-module-form-modal.tsx
@@ -2,7 +2,7 @@
import { CUSTOM_MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import { useTranslations } from 'next-intl';
-import { useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import {
type CustomModule,
CustomModuleRequestError,
@@ -11,6 +11,7 @@ import {
updateCustomModule,
} from '@/lib/custom-modules-api';
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
+import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
import { useCategoryLabel } from '@/lib/use-category-label';
interface CustomModuleFormModalProps {
@@ -63,6 +64,8 @@ export function CustomModuleFormModal({
const t = useTranslations('customModules.form');
const tCommon = useTranslations('common');
const categoryLabel = useCategoryLabel();
+ const storeCategories = useModuleCategoryStore((s) => s.categories);
+ const ensureCategories = useModuleCategoryStore((s) => s.ensureLoaded);
const [name, setName] = useState(mod?.name ?? '');
const [url, setUrl] = useState(mod?.url ?? '');
@@ -72,6 +75,20 @@ export function CustomModuleFormModal({
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
+ // Alle Kategorien der Organisation in der eingestellten Reihenfolge
+ // (quick-261003-387). Solange der Speicher leer ist, gilt die Standardliste;
+ // ein vorhandener Wert, der (noch) nicht im Speicher steht, bleibt Option.
+ useEffect(() => {
+ ensureCategories();
+ }, [ensureCategories]);
+ const categoryOptions = useMemo(() => {
+ const keys: string[] =
+ storeCategories.length > 0
+ ? storeCategories.map((c) => c.key)
+ : [...CUSTOM_MODULE_CATEGORIES];
+ return keys.includes(category) ? keys : [...keys, category];
+ }, [storeCategories, category]);
+
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
@@ -167,7 +184,7 @@ export function CustomModuleFormModal({
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"
>
- {CUSTOM_MODULE_CATEGORIES.map((c) => (
+ {categoryOptions.map((c) => (
diff --git a/apps/web/src/lib/module-categories-api.ts b/apps/web/src/lib/module-categories-api.ts
index 5654ab9..41ca12e 100644
--- a/apps/web/src/lib/module-categories-api.ts
+++ b/apps/web/src/lib/module-categories-api.ts
@@ -17,9 +17,108 @@ export interface ModuleCategoryInfo {
isSystem: boolean;
}
+/** Ein Eintrag einer Kategorie in der Verwaltungsuebersicht. */
+export interface ModuleCategoryOverviewItem {
+ /** `module` = Marktplatz-Modul, `custom` = gemeinsames eigenes Modul. */
+ type: 'module' | 'custom';
+ id: string;
+ name: string;
+ slug?: string;
+}
+
+/** Eine Kategorie samt Eintraegen fuer die Verwaltungsseite. */
+export interface ModuleCategoryOverview extends ModuleCategoryInfo {
+ items: ModuleCategoryOverviewItem[];
+ /** Anzahl persoenlicher eigener Module von Benutzern (nie einzeln benannt). */
+ personalCount: number;
+}
+
+/** Fehler mit HTTP-Status und Servermeldung (falls vorhanden). */
+export class ModuleCategoryRequestError extends Error {
+ constructor(
+ readonly status: number,
+ message: string,
+ ) {
+ super(message);
+ this.name = 'ModuleCategoryRequestError';
+ }
+}
+
+async function failure(res: Response): Promise {
+ let message = `Request failed (${res.status})`;
+ try {
+ const body = await res.json();
+ const raw = body?.message;
+ if (Array.isArray(raw)) message = raw.join(' ');
+ else if (typeof raw === 'string') message = raw;
+ } catch {
+ // Antwort ohne JSON-Koerper — Standardmeldung bleibt.
+ }
+ return new ModuleCategoryRequestError(res.status, message);
+}
+
+async function send(path: string, method: string, body?: unknown): Promise {
+ const res = await fetch(`${API_URL}/module-categories${path}`, {
+ method,
+ credentials: 'include',
+ ...(body === undefined
+ ? {}
+ : { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
+ });
+ if (!res.ok) throw await failure(res);
+ return res.json();
+}
+
/** Alle Kategorien der Organisation in ihrer Reihenfolge (jeder Angemeldete). */
export async function listModuleCategories(): Promise {
const res = await fetch(`${API_URL}/module-categories`, { credentials: 'include' });
if (!res.ok) throw new Error(`Request failed (${res.status})`);
return res.json();
}
+
+/** Kategorien mit ihren Eintraegen (nur Administratoren). */
+export function getModuleCategoryOverview(): Promise {
+ return send('/overview', 'GET');
+}
+
+export function createModuleCategory(name: string): Promise {
+ return send('', 'POST', { name });
+}
+
+export function renameModuleCategory(key: string, name: string): Promise {
+ return send(`/${encodeURIComponent(key)}`, 'PATCH', { name });
+}
+
+/** Neue Reihenfolge ALLER Kategorien (Kennungen). */
+export function reorderModuleCategories(keys: string[]): Promise {
+ return send('/order', 'PUT', { keys });
+}
+
+/** Neue Reihenfolge ALLER nicht persoenlichen Eintraege einer Kategorie. */
+export function reorderModuleCategoryItems(
+ key: string,
+ items: Array>,
+): Promise<{ ok: true }> {
+ return send(`/${encodeURIComponent(key)}/items`, 'PUT', { items });
+}
+
+/** Einen Eintrag einer Kategorie zuordnen (hinten angehaengt). */
+export function assignModuleCategory(input: {
+ type: 'module' | 'custom';
+ id: string;
+ categoryKey: string;
+}): Promise {
+ return send('/assignment', 'PUT', input);
+}
+
+/**
+ * Kategorie loeschen. Eine nicht leere Kategorie braucht `moveTo`, sonst
+ * antwortet die API mit 409 und loescht nichts.
+ */
+export function deleteModuleCategory(
+ key: string,
+ moveTo?: string,
+): Promise<{ deleted: true; moved: number }> {
+ const query = moveTo ? `?moveTo=${encodeURIComponent(moveTo)}` : '';
+ return send(`/${encodeURIComponent(key)}${query}`, 'DELETE');
+}
diff --git a/apps/web/src/lib/stores/module-category-store.ts b/apps/web/src/lib/stores/module-category-store.ts
index 48784ad..414345b 100644
--- a/apps/web/src/lib/stores/module-category-store.ts
+++ b/apps/web/src/lib/stores/module-category-store.ts
@@ -21,7 +21,8 @@ export const useModuleCategoryStore = create()((set, get) =
load: async () => {
try {
const categories = await listModuleCategories();
- set({ categories, loaded: true });
+ // Eine unerwartete Antwort (kein Feld) laesst den bisherigen Stand stehen.
+ if (Array.isArray(categories)) set({ categories, loaded: true });
} catch {
// silently fail
}
diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json
index 0f800fd..851e5a9 100644
--- a/apps/web/src/messages/de.json
+++ b/apps/web/src/messages/de.json
@@ -864,6 +864,7 @@
"deactivateSuccess": "Modul erfolgreich deaktiviert",
"error": "Fehler bei der Modulverwaltung",
"grantsLink": "Freigaben-Matrix",
+ "categoriesLink": "Kategorien",
"grants": {
"title": "Freigaben-Matrix",
"searchPlaceholder": "Module oder Gruppen durchsuchen...",
@@ -874,6 +875,40 @@
"saveError": "Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.",
"noSearchResults": "Kein Treffer für „{search}\" — weder bei den Modulen noch bei den Gruppen."
},
+ "categories": {
+ "title": "Kategorien",
+ "description": "Legen Sie fest, wie die Module in der Seitenleiste und im Marktplatz gruppiert und sortiert werden. Die Einstellung gilt für alle Benutzer.",
+ "backToModules": "Zurück zu Module",
+ "createLabel": "Neue Kategorie",
+ "createPlaceholder": "Name der Kategorie",
+ "createButton": "Kategorie anlegen",
+ "moveUp": "Kategorie nach oben verschieben",
+ "moveDown": "Kategorie nach unten verschieben",
+ "itemMoveUp": "Eintrag nach oben verschieben",
+ "itemMoveDown": "Eintrag nach unten verschieben",
+ "rename": "Umbenennen",
+ "renameLabel": "Neuer Name",
+ "renameSave": "Speichern",
+ "renameCancel": "Abbrechen",
+ "delete": "Löschen",
+ "deleteBlocked": "Diese Kategorie kann nicht gelöscht werden",
+ "empty": "Keine Module in dieser Kategorie",
+ "customBadge": "Eigenes Modul",
+ "itemCategory": "Kategorie",
+ "itemCategoryLabel": "Kategorie für {name}",
+ "personalNote": "Außerdem {count, plural, one {# persönlicher Eintrag} other {# persönliche Einträge}} von Benutzern",
+ "loadError": "Die Kategorien konnten nicht geladen werden. Bitte versuchen Sie es erneut.",
+ "saveError": "Die Änderung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.",
+ "deleteDialog": {
+ "title": "Kategorie löschen",
+ "bodyEmpty": "Die Kategorie „{name}“ ist leer und wird gelöscht.",
+ "bodyMove": "Die Kategorie „{name}“ enthält Einträge. Diese Module werden in die Kategorie verschoben, die Sie hier wählen – auch persönliche Einträge von Benutzern. Es geht nichts verloren.",
+ "target": "Verschieben nach",
+ "confirm": "Löschen",
+ "confirmMove": "Verschieben und löschen",
+ "cancel": "Abbrechen"
+ }
+ },
"activationDialog": {
"title": "Modul aktivieren: {module}",
"body": "Soll die Standardgruppe sofort Zugriff auf dieses Modul erhalten, oder möchten Sie die Freigaben separat konfigurieren?",
diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json
index c42d380..9de225f 100644
--- a/apps/web/src/messages/en.json
+++ b/apps/web/src/messages/en.json
@@ -864,6 +864,7 @@
"deactivateSuccess": "Module deactivated successfully",
"error": "Error managing module",
"grantsLink": "Grants Matrix",
+ "categoriesLink": "Categories",
"grants": {
"title": "Grants Matrix",
"searchPlaceholder": "Search modules or groups...",
@@ -874,6 +875,40 @@
"saveError": "Could not save grant. Please try again.",
"noSearchResults": "No match for \"{search}\" — neither in the modules nor in the groups."
},
+ "categories": {
+ "title": "Categories",
+ "description": "Decide how modules are grouped and sorted in the sidebar and in the marketplace. The setting applies to all users.",
+ "backToModules": "Back to Modules",
+ "createLabel": "New category",
+ "createPlaceholder": "Category name",
+ "createButton": "Add category",
+ "moveUp": "Move category up",
+ "moveDown": "Move category down",
+ "itemMoveUp": "Move entry up",
+ "itemMoveDown": "Move entry down",
+ "rename": "Rename",
+ "renameLabel": "New name",
+ "renameSave": "Save",
+ "renameCancel": "Cancel",
+ "delete": "Delete",
+ "deleteBlocked": "This category cannot be deleted",
+ "empty": "No modules in this category",
+ "customBadge": "Custom module",
+ "itemCategory": "Category",
+ "itemCategoryLabel": "Category for {name}",
+ "personalNote": "Also {count, plural, one {# personal entry} other {# personal entries}} created by users",
+ "loadError": "The categories could not be loaded. Please try again.",
+ "saveError": "The change could not be saved. Please try again.",
+ "deleteDialog": {
+ "title": "Delete category",
+ "bodyEmpty": "The category “{name}” is empty and will be deleted.",
+ "bodyMove": "The category “{name}” contains entries. These modules are moved to the category you choose here, including personal entries created by users. Nothing is lost.",
+ "target": "Move to",
+ "confirm": "Delete",
+ "confirmMove": "Move and delete",
+ "cancel": "Cancel"
+ }
+ },
"activationDialog": {
"title": "Activate Module: {module}",
"body": "Should the default group get immediate access to this module, or would you rather configure grants separately?",
diff --git a/apps/web/src/messages/umlaut-dictionary.ts b/apps/web/src/messages/umlaut-dictionary.ts
index c234504..570a7d3 100644
--- a/apps/web/src/messages/umlaut-dictionary.ts
+++ b/apps/web/src/messages/umlaut-dictionary.ts
@@ -103,6 +103,8 @@ export const UMLAUT_REPLACEMENTS: Record = {
* and must never be touched by the replacement or flagged by the guard.
*/
export const UMLAUT_ALLOWLIST: readonly string[] = [
+ // quick-261003-387: Kategorienverwaltung (korrektes Deutsch)
+ 'Neuer',
// quick-261002-k67: Nextcloud-Status (korrektes Deutsch)
'Neueste',
'ausstehend',
diff --git a/docs/anleitung-administration.md b/docs/anleitung-administration.md
index 289fc83..e900e65 100644
--- a/docs/anleitung-administration.md
+++ b/docs/anleitung-administration.md
@@ -257,6 +257,19 @@ Hat jemand über mehrere Wege Zugriff (zum Beispiel direkt „Benutzen“ und ü
Weiterhin **ausschließlich Administratoren** vorbehalten bleiben: Freigaben erteilen und deren Stufe wählen, Module aktivieren und deaktivieren, Benutzer, Gruppen, LDAP, SMTP, die Willkommensmail, gemeinsame eigene Module sowie die plattformweiten Einstellungen des Ausschreibungs-Radars (Abrufintervall, „Jetzt abrufen“, plattformweite RSS-Feeds). Wer „Verwalten“ für Proxmox erhält, darf Serveradressen eintragen; vergeben Sie die Stufe deshalb nur an Personen, denen Sie das zutrauen.
+### Kategorien
+
+Unter „Module → Kategorien“ bestimmen Sie, wie die Module in der Seitenleiste, im Marktplatz und in der Freigaben-Matrix gruppiert und sortiert werden. Die Einstellung gilt für alle Benutzer. Die Seite ist nur für Administratoren erreichbar.
+
+- **Kategorie anlegen:** Name eintragen und „Kategorie anlegen“ wählen. Die neue Kategorie steht hinten. Der Name lässt sich später jederzeit ändern; die Adresse der Kategorie (der Teil nach /modules/ in der Adresszeile) bleibt dabei bestehen, damit gespeicherte Lesezeichen weiter funktionieren.
+- **Umbenennen:** „Umbenennen“ an der Kategorie, neuen Namen eintragen, „Speichern“. Eine Kategorie, die Sie nicht umbenannt haben, zeigt weiterhin den mitgelieferten Namen in der Sprache des Benutzers.
+- **Reihenfolge der Kategorien:** Mit den Pfeilen schieben Sie eine Kategorie nach oben oder unten. Diese Reihenfolge gilt in der Seitenleiste, bei den Filtern im Marktplatz und in der Freigaben-Matrix.
+- **Module zuordnen:** Bei jedem Eintrag wählen Sie im Auswahlfeld die Kategorie. Das gilt für die Module aus dem Marktplatz und für gemeinsame eigene Module. Der Eintrag steht danach am Ende der gewählten Kategorie.
+- **Reihenfolge innerhalb einer Kategorie:** Mit den Pfeilen neben dem Eintrag legen Sie fest, in welcher Reihenfolge die Module in der Seitenleiste stehen. Noch nicht sortierte Einträge stehen nach den sortierten, eingebaute Module vor eigenen. Persönliche eigene Module der Benutzer stehen damit immer hinter Ihrer Reihenfolge.
+- **Löschen:** Eine leere Kategorie löschen Sie nach einer Rückfrage. Enthält die Kategorie Einträge, fragt Tessera nach einer **Zielkategorie**: Alle Module wandern dorthin – auch die persönlichen Einträge der Benutzer, die dann in der Zielkategorie stehen. Es geht nichts verloren. Ohne Zielkategorie löscht Tessera nichts. Die Kategorie „Eigene Module“ lässt sich umbenennen und verschieben, aber nicht löschen.
+
+Benutzer können für ihre persönlichen Einträge unter „Einstellungen → Eigene Module“ jede vorhandene Kategorie wählen, ebenso im Formular für gemeinsame eigene Module. Alte Modul-Adressen aus Lesezeichen öffnen das Modul weiterhin, auch wenn Sie es in eine andere Kategorie verschoben haben.
+
### Eigene Module (Seitenleisten-Einträge)
Unter „Verwaltung → Eigene Module“ legen Sie **gemeinsame** Seitenleisten-Einträge an, die alle Benutzer des Mandanten sehen: Name, Adresse (nur https, ohne Benutzername und Kennwort) und Kategorie. Ein Klick auf den Eintrag zeigt die Seite direkt in Tessera; manche Webseiten verbieten das Einbetten, dann hilft der Knopf „In neuem Tab öffnen“. Diese Einträge hängen an keiner Modul-Aktivierung und keiner Freigabe – sie erscheinen für jeden angemeldeten Benutzer. Nur Administratoren dürfen sie anlegen, ändern und löschen.
diff --git a/docs/anleitung-anwender.md b/docs/anleitung-anwender.md
index 3047952..2db9a31 100644
--- a/docs/anleitung-anwender.md
+++ b/docs/anleitung-anwender.md
@@ -239,7 +239,7 @@ Das Modul ordnet Handelswaren-Umsätze aus einer Excel-Liste den Erlöskonten zu
- **Akzentfarbe:** Passt die Hauptfarbe der Oberfläche an Ihren Geschmack an; über „Zurücksetzen" kehren Sie zur Standardfarbe zurück.
- **Passwort ändern:** Nur sichtbar und nutzbar, wenn Ihr Konto **lokal** in Tessera verwaltet wird. Wird Ihr Konto stattdessen über das Verzeichnis (LDAP/Active Directory) verwaltet, zeigt Tessera stattdessen den Hinweis „Ihr Passwort wird über das Verzeichnis (LDAP) verwaltet. Eine Änderung ist hier nicht möglich." — in diesem Fall ändern Sie Ihr Passwort über die üblichen Firmenwege (z. B. Windows-Anmeldung), nicht in Tessera.
-**Allgemein > Eigene Module:** Hier nehmen Sie Webseiten, die Sie oft brauchen, als eigene Einträge in Ihre Seitenleiste auf. Diese Einträge sehen nur Sie – kein anderer Benutzer und auch kein Administrator. Mit „Eigenes Modul anlegen“ vergeben Sie einen Namen, tragen die Adresse ein (sie muss mit https:// beginnen und darf keinen Benutzernamen und kein Kennwort enthalten) und wählen die Kategorie, unter der der Eintrag in der Seitenleiste erscheint, zum Beispiel „Infrastruktur“ – oder „Eigene Module“, dann stehen Ihre Einträge gesammelt in einer eigenen Gruppe ganz unten in der Seitenleiste. Diese Gruppe erscheint nur, solange ein Eintrag darin liegt. In der Liste ändern („Bearbeiten“) oder entfernen („Löschen“) Sie Ihre Einträge; die Seitenleiste zieht sofort nach. Ein Klick auf den Eintrag zeigt die Seite direkt in Tessera; manche Webseiten erlauben das Einbetten nicht – der Rahmen bleibt dann leer, der Knopf „In neuem Tab öffnen“ funktioniert trotzdem. Einträge, die ein Administrator für alle angelegt hat, erscheinen ebenfalls in Ihrer Seitenleiste, lassen sich hier aber nicht ändern.
+**Allgemein > Eigene Module:** Hier nehmen Sie Webseiten, die Sie oft brauchen, als eigene Einträge in Ihre Seitenleiste auf. Diese Einträge sehen nur Sie – kein anderer Benutzer und auch kein Administrator. Mit „Eigenes Modul anlegen“ vergeben Sie einen Namen, tragen die Adresse ein (sie muss mit https:// beginnen und darf keinen Benutzernamen und kein Kennwort enthalten) und wählen die Kategorie, unter der der Eintrag in der Seitenleiste erscheint, zum Beispiel „Infrastruktur“ – oder „Eigene Module“, dann stehen Ihre Einträge gesammelt in einer eigenen Gruppe, standardmäßig ganz unten in der Seitenleiste. Zur Wahl stehen alle Kategorien, die Ihr Administrator eingerichtet hat; er legt auch fest, in welcher Reihenfolge die Gruppen und die Einträge darin stehen. Eine Gruppe erscheint nur, solange ein Eintrag darin liegt. In der Liste ändern („Bearbeiten“) oder entfernen („Löschen“) Sie Ihre Einträge; die Seitenleiste zieht sofort nach. Ein Klick auf den Eintrag zeigt die Seite direkt in Tessera; manche Webseiten erlauben das Einbetten nicht – der Rahmen bleibt dann leer, der Knopf „In neuem Tab öffnen“ funktioniert trotzdem. Einträge, die ein Administrator für alle angelegt hat, erscheinen ebenfalls in Ihrer Seitenleiste, lassen sich hier aber nicht ändern.
**Dashboard > Widgets:** Hier finden Sie für jedes auf Ihrem Dashboard platzierte Widget die zugehörigen Einstellungen, zum Beispiel eigene Suchanbieter für die Suchleiste, beim Kalender die Monatsansicht (ein/aus), die Anzahl der angezeigten Termine (bis zu zehn, oder ausgeblendet) und den Zeitraum (7 bis 90 Tage), oder bei Notizen und Favoriten die Überschrift der Kachel. Beim Bilderrahmen verwalten Sie hier die Bilder: „Bild hochladen“ wählt eine Datei von Ihrem Rechner (PNG, JPEG, GIF oder WebP, höchstens 5 MB, bis zu 30 Bilder), „Webadresse hinzufügen“ bindet ein Bild über seine https-Adresse ein (http-Adressen werden abgewiesen). Jeder Eintrag zeigt eine Vorschau, ein Feld für die Bildunterschrift, die Pfeile „Nach oben“/„Nach unten“ für die Reihenfolge und „Bild entfernen“; ein hochgeladenes Bild wird beim Entfernen auch vom Server gelöscht. Darüber legen Sie den Bildausschnitt (ganz sichtbar oder formatfüllend), das Wechselintervall (kein Wechsel oder 5 Sekunden bis 60 Minuten) und die Reihenfolge (der Reihe nach oder zufällig) fest. Beim XFrame tragen Sie die https-Adresse der Webseite ein (http-Adressen werden abgewiesen), optional einen Titel für die Kopfleiste und wählen, ob die Seite automatisch neu geladen wird; ein dauerhafter Hinweis erinnert daran, dass manche Webseiten das Einbetten verweigern. Mit „Nur einen Ausschnitt der Seite anzeigen“ erscheint eine Vorschau der Seite, in der Sie den Rahmen verschieben und an den Ecken ziehen oder die Werte eintippen; der Ausschnitt ist eine Position auf der Seite und muss neu gesetzt werden, wenn die Seite ihren Aufbau ändert. Bei der Proxmox-Kachel legen Sie einen Titel fest und haken an, welche Server sie zeigt; ohne Haken zeigt sie alle Server. Dieselbe Auswahl erreichen Sie im Bearbeitungsmodus des Dashboards auch direkt an der Kachel über „Server auswählen“ — das ist der Weg für eine Proxmox-Kachel auf einem weiteren Reiter, denn diese Seite zeigt nur die Kacheln des ersten Reiters.