16 Commits

Author SHA1 Message Date
schalli d0e649baa1 docs(changelog): Version 1.7.0 abgeschlossen
Tessera CI/CD / Build & Publish Images (push) Successful in 3m7s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m46s
Tessera CI/CD / Lint & Type Check (push) Successful in 52s
Tessera CI/CD / Tests (push) Successful in 1m24s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:40:19 +02:00
schalli cbc89d9810 style(web): Such-Widget ohne helle Unterkante an Auswahl und Suchfeld
Opt-out-Klasse field-plain fuer die Fluent-Unterkante; im Dunkelmodus
wirkte sie im Such-Widget wie eine weisse Linie (Nutzerwunsch 29.09.).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:34:38 +02:00
schalli 3cb43d6cc0 fix(web): eingeklappt stehen Eintraege aus "Eigene Module" zuletzt
Die eingeklappte Seitenleiste zeigte die Kacheln in Ladefolge; ein Eintrag
aus "Eigene Module" konnte so vor einem eigenen Eintrag anderer
Kategorien stehen. Jetzt gleiche Gruppenfolge wie ausgeklappt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:30:06 +02:00
schalli 4ff9a239fd feat: Kategorie "Eigene Module" fuer selbst angelegte Eintraege
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 1m27s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m33s
CUSTOM_MODULE_CATEGORIES = MODULE_CATEGORIES + custom-modules; API prueft
dagegen, das Formular bietet sie an. Die Seitenleiste zeigt die Gruppe wie
jede Kategorie nur mit Eintrag und stellt sie immer ans Ende. Nutzerwunsch
29.09.; ohne Migration (category ist Freitext mit IsIn-Pruefung).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:24:56 +02:00
schalli 00c2cfe2c9 style(web): eingeklappte Seitenleiste mit groesseren Symbolen, dichter
Tessera CI/CD / Lint & Type Check (push) Successful in 1m1s
Tessera CI/CD / Tests (push) Successful in 1m33s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 20s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m29s
Kacheln 24 statt 20 px, Navigationssymbole 22 statt 20 px, Eintraege
34 px hoch mit 1 px Abstand (vorher 36 + 2 px) — Nutzerwunsch 29.09.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:17:47 +02:00
schalli cd4b5b56ee docs(changelog): Version 1.6.0 abgeschlossen
Tessera CI/CD / Build & Publish Images (push) Successful in 3m22s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m36s
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m17s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:51:17 +02:00
schalli b15a43f3d3 docs(quick-260929-dzu): eigene Module fuer jeden Benutzer, Browser-Pruefung
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:51:17 +02:00
schalli 8f41bd26bd docs(260929-dzu): Eigene Module fuer jeden Benutzer im CHANGELOG und den Anleitungen
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:29:56 +02:00
schalli ee97b4ed9f 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>
2026-09-29 10:17:22 +02:00
schalli bc4c0119de fix(web): Widgets nicht mehr zur Mitte versetzen
Die Zentrierung der belegten Spalten (Design Mosaik, Runde 3) liess
Widgets nach dem Bearbeiten springen, z. B. ein einzelnes Widget oben
links in die Seitenmitte (Nutzer, live 29.09.). Auf Wunsch ersatzlos
entfernt: Ansicht = Bearbeitungsraster. centeringOffset, breakpointFor
und die ungenutzte Prop onInsetChange entfallen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:13:03 +02:00
schalli c703d87a1c feat(260929-dzu): persoenliche eigene Module je Benutzer (API, Migration, Zeilenschutz)
- ownerUserId (NULL = gemeinsam, sonst persoenlich) mit Zeilenschutz nach Muster SearchProvider
- GET nur gemeinsame + eigene, fremde persoenliche Eintraege 404
- POST fuer jeden Benutzer, shared nur fuer Administratoren (403)
- PATCH/DELETE: persoenlich nur Besitzer, gemeinsam nur Administrator
- Zugriffsklassifikation nachgemessen: 61/223/6

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:09:08 +02:00
schalli 76a923450f fix(desktop): neue Fenster im System-Browser oeffnen
Tessera CI/CD / Lint & Type Check (push) Successful in 59s
Tessera CI/CD / Tests (push) Successful in 1m39s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 8m42s
Tessera CI/CD / Build & Publish Images (push) Successful in 5m7s
Die Webansicht verwarf window.open und Links mit target=_blank still:
Suche-Widget und "In neuem Tab oeffnen" (XFrame, eigene Module,
Favoriten) taten im Client nichts. on_new_window reicht http/https-
Adressen an den System-Browser weiter und lehnt das neue Fenster ab;
andere Schemata werden verworfen (external_target, mit Test).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:08:41 +02:00
schalli a435a30c34 docs(quick-260929-dmx): feineres Raster, Kalender schmaler, Browser-Pruefung
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m18s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m7s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:56:27 +02:00
schalli 46ebb4e7ce docs(changelog): Widget-Raster feiner, Kalender schmaler
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:55:07 +02:00
schalli 9c9e1420fe feat(260929-dmx): Widget-Breiten im 48er-Raster, Kalender schmaler ziehbar
- minW/defaultW aller Widgets verdoppelt (gleiche Bildschirmbreite)
- Kalender minW 8 (rund 250 px), defaultW 16
- Test: bestehender Kalender bekommt das neue Minimum in jedem Breakpoint

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:53:08 +02:00
schalli 97744b59cd feat(260929-dmx): Widget-Raster horizontal doppelt so fein (48 Spalten, Version 3)
- COLS lg 48 / md 40 / sm 24 / xs 16 / xxs 4, Zeilenhoehe und Abstand unveraendert
- Migration stufenweise: v1->v2 wie bisher, v2->v3 nur x/w/minW/maxW x2
- Rueckfallwerte fuer Widgets ohne Eintrag auf 8 Einheiten

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:51:26 +02:00
41 changed files with 1962 additions and 567 deletions
+4 -2
View File
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden) Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
Plan: 6 of 6 Plan: 6 of 6
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
Last activity: 2026-09-29 - Quick 260929-9wc (Eigene Module) + 260929-d37 (Desktop-App nur einmal starten); main auf beta gepusht, live unveraendert auf 1.5.2 Last activity: 2026-09-29 - Quicks 260929-9wc/d37/dmx/dzu + Fixes; Freigabe 1.6.0
Progress: [██████████] 99% Progress: [██████████] 99%
@@ -479,6 +479,8 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 260928-ujj | **Design Mosaik uebernommen + Hintergrund pro Benutzer.** Merge design/mosaik (76d17fe, inkl. RESIZE_AXIS_FALLBACK), Spalte `User.dashboardBackground` JSONB (Migration 20260928120000), `PATCH /users/me/dashboard-background` mit `parseDashboardBackground` aus packages/shared (Preset-Liste, imageId nur UUID), Web liest aus Sitzung, alte localStorage-Wahl einmalig uebernommen. Browser: Duenen gewaehlt, DB-Zeile gesetzt, nach localStorage-Loeschen weiter sichtbar. api 1462, web 952 gruen; Freigabe als 1.5.0. | 2026-09-28 | 9fa0a3f,0aaa152,cb45d26 | [260928-ujj-design-mosaik-uebernehmen-und-als-1-5-0-](./quick/260928-ujj-design-mosaik-uebernehmen-und-als-1-5-0-/) | | 260928-ujj | **Design Mosaik uebernommen + Hintergrund pro Benutzer.** Merge design/mosaik (76d17fe, inkl. RESIZE_AXIS_FALLBACK), Spalte `User.dashboardBackground` JSONB (Migration 20260928120000), `PATCH /users/me/dashboard-background` mit `parseDashboardBackground` aus packages/shared (Preset-Liste, imageId nur UUID), Web liest aus Sitzung, alte localStorage-Wahl einmalig uebernommen. Browser: Duenen gewaehlt, DB-Zeile gesetzt, nach localStorage-Loeschen weiter sichtbar. api 1462, web 952 gruen; Freigabe als 1.5.0. | 2026-09-28 | 9fa0a3f,0aaa152,cb45d26 | [260928-ujj-design-mosaik-uebernehmen-und-als-1-5-0-](./quick/260928-ujj-design-mosaik-uebernehmen-und-als-1-5-0-/) |
| 260929-9wc | **Eigene Module (nur lokal, nicht gepusht).** Modell `CustomModule` + Migration 20260929120000 mit RLS (Muster ProxmoxServer), `/custom-modules` (GET alle Angemeldeten, POST/PATCH/DELETE Admin, nur https ohne Zugangsdaten), `MODULE_CATEGORIES` in packages/shared, Seitenleisten-Eintrag unter gewaehlter Kategorie, Rahmen-Seite `/modules/custom/[id]` mit XFRAME_SANDBOX + no-referrer + „In neuem Tab öffnen“, Verwaltung `/admin/custom-modules`, Zugriffsklassifikation 61/224/6. Gruppen-Beschraenkung zurueckgestellt (ModuleGrant haengt an Module). api 1495, web 992 gruen; Browser dunkel 9 Schritte bestanden. | 2026-09-29 | b9d87be,e7fc4de,e48c0de | [260929-9wc-eigene-module-admin-legt-seitenleisten-e](./quick/260929-9wc-eigene-module-admin-legt-seitenleisten-e/) | | 260929-9wc | **Eigene Module (nur lokal, nicht gepusht).** Modell `CustomModule` + Migration 20260929120000 mit RLS (Muster ProxmoxServer), `/custom-modules` (GET alle Angemeldeten, POST/PATCH/DELETE Admin, nur https ohne Zugangsdaten), `MODULE_CATEGORIES` in packages/shared, Seitenleisten-Eintrag unter gewaehlter Kategorie, Rahmen-Seite `/modules/custom/[id]` mit XFRAME_SANDBOX + no-referrer + „In neuem Tab öffnen“, Verwaltung `/admin/custom-modules`, Zugriffsklassifikation 61/224/6. Gruppen-Beschraenkung zurueckgestellt (ModuleGrant haengt an Module). api 1495, web 992 gruen; Browser dunkel 9 Schritte bestanden. | 2026-09-29 | b9d87be,e7fc4de,e48c0de | [260929-9wc-eigene-module-admin-legt-seitenleisten-e](./quick/260929-9wc-eigene-module-admin-legt-seitenleisten-e/) |
| 260929-d37 | **Desktop-App nur einmal starten.** User-Meldung Windows 11: beim Systemstart zwei Instanzen/zwei Tray-Symbole. `tauri-plugin-single-instance` 2.4.5 als erstes Plugin, zweiter Start ruft `show_main_window` (neuer Helper, ersetzt 3 Kopien) und beendet sich. cargo build/test (44)/clippy gruen. Windows-Pruefung offen (VM 8233 oder User-PC nach naechster Desktop-Version). | 2026-09-29 | c0b145a,0751198 | [260929-d37-desktop-client-nur-einmal-starten-single](./quick/260929-d37-desktop-client-nur-einmal-starten-single/) | | 260929-d37 | **Desktop-App nur einmal starten.** User-Meldung Windows 11: beim Systemstart zwei Instanzen/zwei Tray-Symbole. `tauri-plugin-single-instance` 2.4.5 als erstes Plugin, zweiter Start ruft `show_main_window` (neuer Helper, ersetzt 3 Kopien) und beendet sich. cargo build/test (44)/clippy gruen. Windows-Pruefung offen (VM 8233 oder User-PC nach naechster Desktop-Version). | 2026-09-29 | c0b145a,0751198 | [260929-d37-desktop-client-nur-einmal-starten-single](./quick/260929-d37-desktop-client-nur-einmal-starten-single/) |
| 260929-dmx | **Widget-Raster horizontal feiner + Kalender schmaler.** COLS lg 48/md 40/sm 24/xs 16/xxs 4, GRID_VERSION 3 (v2->v3 nur x/w/minW/maxW x2), alle minW/defaultW x2, Kalender minW 8 (~250 px). Browser: Anordnung pixelgleich, Kalender bis 252 px, Schritt 33 px. Auch: Hover-Anheben der Widgets entfernt (acd3c7a, Nutzerwunsch). | 2026-09-29 | 97744b5,9c9e142,46ebb4e | [260929-dmx-widget-raster-horizontal-feiner-48-spalt](./quick/260929-dmx-widget-raster-horizontal-feiner-48-spalt/) |
| 260929-dzu | **Eigene Module fuer jeden Benutzer (persoenlich).** `CustomModule.ownerUserId` (null = gemeinsam), RLS-Muster SearchProvider, Einstellungen > Eigene Module (nur eigene), Verwaltung nur gemeinsame; Browser: Sichtbarkeit/Rechte wie verlangt. Nebenbei ohne eigenen Quick: Zentrierung entfernt (bc4c011), Desktop neue Fenster -> System-Browser (76a9234, Windows-VM bestaetigt), Single-Instance auf VM bestaetigt. | 2026-09-29 | c703d87,ee97b4e,8f41bd2 | [260929-dzu-eigene-module-fuer-jeden-benutzer-persoe](./quick/260929-dzu-eigene-module-fuer-jeden-benutzer-persoe/) |
## Deferred Items ## Deferred Items
@@ -524,4 +526,4 @@ Last session: 2026-09-22T13:40:00Z
Resumed: 2026-09-21 (abends) ueber /gsd-resume-work; seitdem Bilderrahmen, XFrame (inkl. Ausschnitt), Desktop-Korrekturen, Freigabe 1.3.0, Bilder in den Dateibereich. Resumed: 2026-09-21 (abends) ueber /gsd-resume-work; seitdem Bilderrahmen, XFrame (inkl. Ausschnitt), Desktop-Korrekturen, Freigabe 1.3.0, Bilder in den Dateibereich.
Stopped at: hk4 fertig und nachgewiesen. Dem Nutzer vorgelegt: erst das Aufraeumen (Modul bringt seine Kachel selbst mit), dann Proxmox-Modul + Kachel — Antwort steht aus. Stopped at: hk4 fertig und nachgewiesen. Dem Nutzer vorgelegt: erst das Aufraeumen (Modul bringt seine Kachel selbst mit), dann Proxmox-Modul + Kachel — Antwort steht aus.
Resume file: None Resume file: None
Last activity: 2026-09-29 - Quick 260929-9wc (Eigene Module) + 260929-d37 (Desktop-App nur einmal starten); main auf beta gepusht, live unveraendert auf 1.5.2 Last activity: 2026-09-29 - Quicks 260929-9wc/d37/dmx/dzu + Fixes; Freigabe 1.6.0
@@ -0,0 +1,61 @@
---
quick_id: 260929-dmx
type: quick
wave: 1
autonomous: true
---
# Quick 260929-dmx: Widget-Raster horizontal feiner (48 Spalten), Kalender schmaler
## User requests (29.09.2026)
1. "das kalender widget soll in der breite schmäler gemacht werden können."
2. "und mache das widget raster horizontal etwas feiner."
## Measurements (orchestrator, browser, lg breakpoint, grid width 1593 px, margin 12)
- Today: 24 cols → one width unit ≈ 66 px. Calendar minW 6 ≈ 383 px, default 8 ≈ 515 px.
- Calendar rendered at 251 px: fully usable (month grid, header "September 2026", event list truncates titles cleanly).
- Calendar at 185 px: header clipped, event titles reduced to one letter → too narrow.
- Target: calendar minimum ≈ 250 px.
## Decision (locked)
Double the HORIZONTAL resolution only: `COLS = { lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 }` in
`apps/web/src/components/dashboard/dashboard-grid.tsx`. Row height (20) and margin (12) unchanged.
Every existing widget keeps its exact on-screen size and position.
## Task 1: Grid version 3 (horizontal x2) with migration
- files: apps/web/src/lib/grid-layout-migration.ts (+ test), apps/web/src/components/dashboard/dashboard-grid.tsx (+ test), apps/web/src/lib/stores/dashboard-store.ts (only if needed)
- action:
- Bump `GRID_VERSION` to 3. Migration becomes stepwise and cumulative:
v1 → v2: existing behavior (x,y,w,h,minW,minH,maxW,maxH × 2).
v2 → v3: NEW, horizontal only: x, w, minW, maxW × 2 (y, h, minH, maxH unchanged).
So a v1 layout gets both steps, a v2 layout only the second, a v3 layout nothing. Keep idempotence and marker semantics (marker only in persisted JSON). Update the file header comment (German, same style) to document v3.
- `COLS` as above. Check every other place that depends on column count or widget width units:
centering offset, `RESIZE_AXIS_FALLBACK`, `breakpointFor`, default positions when adding a widget
(`dashboard-grid.tsx` ~380: `defaultW ?? 4`, `minW ?? 4` fallbacks → 8), empty-dashboard suggestions,
any layout templates/seed data in apps/web or apps/api (grep `defaultW`, `w:` in dashboard code,
`layouts` defaults in apps/api/src/dashboard). Anything expressed in width units gets × 2.
- Tests: extend grid-layout-migration tests (v1→v3, v2→v3, v3 untouched, idempotence, marker 3 written), update dashboard-grid tests pinning cols.
- verify: `pnpm --filter web exec vitest run src/lib src/components/dashboard` green.
## Task 2: Widget width constraints in new units
- files: apps/web/src/components/dashboard/widget-registry.tsx (+ test)
- action: In `WIDGET_CONSTRAINTS` double every `minW` and `defaultW` (same physical size as before),
EXCEPT calendar: `minW: 8` (≈ 251 px at lg — the measured usable minimum), `defaultW: 16` (unchanged size).
minH/defaultH unchanged. Update the comment above calendar (German): narrower on user request 29.09., 8 of 48 ≈ 250 px measured usable.
Existing layouts: the existing override logic in dashboard-grid (quick-260916-dyv: stored minW/minH replaced by constants in every breakpoint) must pick up the new calendar minW so existing calendars can be shrunk — verify that path with a test.
- verify: web tests green; `pnpm turbo run type-check lint --filter web` green; biome warnings for web not above baseline 55.
## Task 3: CHANGELOG, rebuild, commit
- CHANGELOG.md under `## Unveröffentlicht` → `### Geändert` (section exists) add plain-German bullets (app text uses "Sie"):
- Dashboard: Das Raster ist in der Breite doppelt so fein – Widgets lassen sich in kleineren Schritten breiter oder schmaler ziehen und genauer platzieren. Bestehende Anordnungen bleiben unverändert.
- Dashboard: Das Kalender-Widget lässt sich deutlich schmaler ziehen als bisher.
- Rebuild local stack: `docker compose up -d --build web` (plain `up` does not rebuild).
- Commits per task, messages end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`.
- NEVER git push (orchestrator pushes after browser check).
- Browser check is done by the orchestrator (resize calendar to minimum, existing layout unchanged after migration, marker 3 persisted).
@@ -0,0 +1,84 @@
---
quick_id: 260929-dmx
status: complete
commits: 3
plan_head_before: acd3c7a05f9f01e2f5cf8ebe9aed66d8c789a055
plan_head_after: 46ebb4e7ceafa1dc782514e487ac820166279f48
completed: 2026-09-29
actuals:
tasks: 3
commits: 3
---
# Quick 260929-dmx: Widget-Raster horizontal 48 Spalten, Kalender schmaler
Grid version 3: horizontal resolution doubled (COLS lg 48 / md 40 / sm 24 / xs 16 / xxs 4), row height 20 and margin 12 unchanged. Stored layouts are migrated stepwise, so every existing widget keeps its on-screen size and position. Calendar minimum is now 8 of 48 columns (about 250 px at lg).
## Commits
- 97744b5 feat: grid v3 with stepwise migration, COLS, fallbacks
- 9c9e142 feat: widget constraints in 48-column units, calendar minW 8
- 46ebb4e docs(changelog): two bullets under "Unveröffentlicht / Geändert"
## Every place where width units changed
1. `apps/web/src/lib/grid-layout-migration.ts`: `GRID_VERSION` 2 to 3. Migration is now a step table: v1 to v2 scales x, y, w, h, minW, minH, maxW, maxH by 2; v2 to v3 scales only x, w, minW, maxW by 2. A v1 layout gets both steps, a v2 layout only the second, a v3 layout nothing. Marker semantics unchanged (marker only in the persisted JSON, stripped on load, re-added by `withGridVersion`). Header comment updated.
2. `apps/web/src/components/dashboard/dashboard-grid.tsx`:
- `COLS` is `{ lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 }`.
- Fallback for a widget without a layout entry: `defaultW ?? 8` and `minW ?? 8` (was 4/4). The `?? 4` fallbacks for height stay.
- Comment block above BREAKPOINTS/COLS extended.
3. `apps/web/src/components/dashboard/widget-registry.tsx` (`WIDGET_CONSTRAINTS`, minW/defaultW doubled, heights untouched):
- clock 4/8
- search 12/24
- calendar minW 8, defaultW 16. Calendar is the only one that is not a plain doubling for minW: 8 instead of 12.
- note 8/12
- calculator 6/12
- favorites 2/12
- stopwatch 8/12
- picture-frame 8/16
- xframe 8/24
- proxmox 6/16
- Comments updated, German, including the calendar note (narrower on user request 29.09., 8 of 48 is about 250 px measured usable).
4. `dashboard-store.ts` needed no code change. `addWidget` reads `defaultW` from `WIDGET_CONSTRAINTS`, so new widgets are placed in the new units automatically. Load and save already route through `migrateGridLayouts` and `withGridVersion`.
5. `centeringOffset` needed no code change. It takes `cols` as a parameter and gets the new `COLS[breakpoint]`.
6. `breakpointFor` needed no change. It depends only on BREAKPOINTS, not on the column count.
The existing override in `applyConstraintMinima` (quick-260916-dyv) already replaces the stored minW/minH from the constants in every breakpoint, so existing calendars pick up minW 8 with no code change there. This is verified by a new test.
## Tests
- `grid-layout-migration.test.ts`: v1 to v3 (x/w/minW/maxW times 4, y/h/minH/maxH times 2), v2 to v3, v3 untouched, v1 result equals v2 result, idempotence for both paths with marker 3 written, future marker 4 untouched, string marker, foreign values.
- `dashboard-store.test.ts`: marker 3, new expected values, new-widget default of 8 wide.
- `dashboard-grid.test.tsx`: COLS pin, `centeringOffset` with 48 columns, data-grid fallbacks, minima overrides, new Test 9c (existing calendar with stored minW 12 gets minW 8 in every breakpoint, w 6 raised to 8, w 16 kept).
- `widget-registry.test.tsx`: constraints table.
- Verification: `pnpm --filter web exec vitest run src/lib src/components/dashboard` green (513). The full web suite is green (995). Type-check for `@tessera/web` is green. Biome shows 55 warnings, equal to the baseline. Note: the turbo filter name is `@tessera/web`, not `web`.
## Rebuild
`docker compose up -d --build web` ran, and the web container is up. Browser check is left to the orchestrator (calendar at minimum, existing layout unchanged after migration, marker 3 persisted).
## Found but deliberately left
- `apps/api/src/dashboard/dashboard.service.spec.ts:834` stores `__gridVersion: 2` as a passthrough fixture. The API only passes the JSON through, so the value is arbitrary, and I did not touch it.
- `RESIZE_AXIS_FALLBACK` and its tests use abstract grid numbers and are unit-independent, so nothing was changed. Its resize logic has no column dependency.
- Historical comments that mention "24 Spalten" (the quick-260922-vdk explanation in `dashboard-grid.tsx`, the quick-260916-bwo test description, the bwo header text) describe past states and were left. The vdk comment's numbers (24 columns, 50 px) describe the old bug, not the current state.
- Widget internals (calendar, favorites, calculator) use pixel-based or container-based layout, not grid units, so no change was needed.
- md/sm/xs/xxs columns are doubled proportionally with lg. Only lg was measured, and I did not check the smaller breakpoints in a browser.
- API/`seed`: no default layouts or seed data with width units exist in apps/api (empty defaults `{ lg: [], ... }` only).
- A brand-new empty v2 layout is not re-saved with marker 3 on load (`migrated` stays false for empty layouts, existing behaviour). The marker gets written on the next save.
## Deviations from Plan
None. The plan was executed as written. The SUMMARY, STATE, PLAN and ROADMAP files were not committed, as instructed.
## Self-Check: PASSED
Commits 97744b5, 9c9e142, 46ebb4e exist. Changed files exist. Working tree contains only the untracked quick-task directory.
## Browser-Pruefung (Orchestrator, 29.09., dunkel, lg 1888 px)
- Bestehende Anordnung nach Migration pixelgenau gleich (6 Widgets, left/top/width/height vor und nach identisch); DB: `__gridVersion` 3, lg-w verdoppelt.
- Kalender im Bearbeitungsmodus nach links gezogen: stoppt bei 252 px (vorher Minimum 383 px), Monatsraster, Kopf und Terminliste sauber lesbar.
- Schrittweite beim Ziehen 33 px (284/317/350), vorher 66 px.
- Kalender danach wieder auf 515 px gezogen, Testzustand zurueckgesetzt.
- Nicht im Browser geprueft: kleinere Breakpoints (md/sm/xs/xxs).
@@ -0,0 +1,66 @@
---
quick_id: 260929-dzu
type: quick
wave: 1
autonomous: true
---
# Quick 260929-dzu: Eigene Module für jeden Benutzer (persönlich)
## User request (29.09.2026)
"Jeder User soll eigene Module anlegen können. nicht nur admins."
Decision (AskUserQuestion, locked): **"Nur er selbst"** — a normal user's entries are visible ONLY to that user.
Admins keep creating shared entries (visible to everyone) on /admin/custom-modules as today.
No user can put anything into another user's sidebar.
## Existing state (quick 260929-9wc, commits b9d87be, e7fc4de)
- Prisma `CustomModule { id, tenantId, name, url, category, createdAt, updatedAt }`, migration
`20260929120000_custom_module` with RLS (tenant only, pattern ProxmoxServer).
- API `apps/api/src/custom-modules/*`: GET list/one for any authenticated user; POST/PATCH/DELETE admin only;
https-only, no credentials in URL.
- Web: sidebar loads `listCustomModules()`, frame page `/modules/custom/[id]`, admin page `/admin/custom-modules`
with `CustomModuleFormModal` + `DeleteCustomModuleDialog`, `bumpSidebarRefresh` after changes.
## Task 1: Model + API (tests first)
- Add nullable `ownerUserId String?` (+ relation to User with onDelete: Cascade, index `[tenantId, ownerUserId]`)
via NEW migration (e.g. `20260929130000_custom_module_owner`). `null` = shared (admin-made), set = personal.
- RLS: extend the existing policy the way user-scoped tables already do it (find the pattern used by e.g.
DashboardImage / Favorite / other tables with a user dimension). Personal rows must only be readable/writable by
their owner; shared rows readable by the whole tenant. If the project's RLS pattern handles the user dimension
in the service layer instead, follow that pattern and document it. Update the RLS inventory test and
`docs/mandantentrennung-zugriffsklassifikation.md` (re-measure totals as last time).
- Service/controller:
- `GET /custom-modules` → shared rows + rows owned by the caller. Response carries `personal: boolean` (or `ownerUserId === me`).
- `GET /custom-modules/:id` → 404 unless shared or owned by caller.
- `POST /custom-modules` → any authenticated user; body flag `shared?: boolean`. `shared: true` only allowed for admins
(403 otherwise); default personal (ownerUserId = caller). The admin page sends `shared: true`.
- `PATCH` / `DELETE` → personal rows: only the owner (404 for others, do not leak existence); shared rows: admin only (403 for non-admin).
Ownership/shared-ness cannot be changed via PATCH.
- Keep URL validation. Keep static routes before `:id`.
- Admin page list: `GET /custom-modules?scope=shared` (admin) or filter client-side — pick the simplest; the admin page shows only shared entries; the settings page only the caller's personal ones.
- Tests: service + controller specs for all permission cases (user A cannot see/edit/delete user B's entry; non-admin cannot create/edit/delete shared; admin personal vs shared).
- verify: `pnpm --filter @tessera/api exec vitest run src/custom-modules` + RLS inventory test green; migrate local DB (db container IP 172.19.x, tessera/tessera_dev), rebuild api, curl check.
## Task 2: Web — settings section
- Settings: new section/page "Eigene Module" in the user settings (`apps/web/src/app/(portal)/settings/`, follow how
`general` / `dashboard` sub-pages and their nav are built). Reuse `CustomModuleFormModal` and
`DeleteCustomModuleDialog` (move to a shared location if needed, e.g. `components/custom-modules/`) — one form, two callers.
Intro text (Sie-Form): e.g. "Nehmen Sie Webseiten, die Sie oft brauchen, als eigene Einträge in Ihre Seitenleiste auf. Diese Einträge sehen nur Sie."
- Admin page: shows only shared entries; intro text states they are visible for all users.
- Sidebar: unchanged behavior, shows shared + own personal entries (API already filters). `bumpSidebarRefresh` after changes on the settings page too.
- de + en texts; umlaut dictionary if needed.
- Tests: component tests for the settings page (create/edit/delete, list only personal), admin page still passes `shared: true`.
- verify: `pnpm --filter @tessera/web exec vitest run` green; `pnpm turbo run type-check lint` green; biome web ≤ 55, api ≤ 82.
## Task 3: CHANGELOG + rebuild
- CHANGELOG `## Unveröffentlicht` → adjust the existing "Eigene Module" bullet under "Neu" (not released yet, so rewrite it):
every user can add own entries under "Einstellungen → Eigene Module", visible only to them; administrators can additionally add entries for everyone under "Verwaltung → Eigene Module". Plain German, Sie-Form.
- Update `docs/anleitung-anwender.md` (and admin guide if it mentions custom modules) accordingly.
- `docker compose up -d --build web api`.
- Commits per task, end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`. NEVER git push.
- Browser check is done by the orchestrator (normal user + admin, dark mode).
@@ -0,0 +1,170 @@
---
phase: quick-260929-dzu
plan: 01
quick_id: 260929-dzu
subsystem: api, web, prisma
tags: [custom-modules, personal, rls, settings]
status: complete
requires: [260929-9wc]
provides:
- Spalte CustomModule.ownerUserId (NULL = gemeinsam, gesetzt = persoenlich), Migration 20260929130000
- Zeilenschutz mit Benutzerdimension nach Muster SearchProvider
- API /custom-modules mit persoenlichen und gemeinsamen Eintraegen (Antwortfeld personal)
- Einstellungen > Eigene Module (/settings/custom-modules) fuer jeden Benutzer
- gemeinsame Oberflaeche CustomModuleManager (Formular, Loeschdialog, Liste) fuer Verwaltung und Einstellungen
key-files:
created:
- apps/api/prisma/migrations/20260929130000_custom_module_owner/migration.sql
- apps/web/src/components/custom-modules/custom-module-manager.tsx
- apps/web/src/app/(portal)/settings/custom-modules/page.tsx
- apps/web/src/app/(portal)/settings/custom-modules/custom-modules-settings.test.tsx
modified:
- apps/api/prisma/schema.prisma
- apps/api/src/custom-modules/ (Dienst, Controller, DTO, Specs)
- docs/mandantentrennung-zugriffsklassifikation.md
- apps/web/src/components/custom-modules/custom-module-form-modal.tsx (verschoben aus admin/custom-modules/components)
- apps/web/src/components/custom-modules/delete-custom-module-dialog.tsx (verschoben)
- apps/web/src/app/(portal)/admin/custom-modules/page.tsx (+ Test)
- apps/web/src/components/settings/settings-sidebar.tsx
- apps/web/src/lib/custom-modules-api.ts
- apps/web/src/messages/de.json, en.json
- CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md
decisions:
- "RLS: Muster SearchProvider (nullable Besitzerspalte, vier Regeln je Befehl), nicht die einfache Muster DashboardImage (Pflicht-userId)"
- "Gemeinsame Eintraege werden ohne Benutzerkontext geschrieben (forTenant ohne userId), persoenliche mit Benutzer"
- "Rollenpruefung fuer gemeinsame Eintraege im Dienst statt per @Roles, weil sie vom Eintrag abhaengt"
- "Filter fuer Verwaltung/Einstellungen im Web ueber personal, kein scope-Parameter in der API"
- "Texte von Formular und Loeschdialog in eigenen Namensraum customModules.form, Umzug aus admin.customModules"
completed: 2026-09-29
commits: 3
plan_head_before: 76a923450fd2f492ec046d5945a6965c8e94b707
plan_head_after: 8f41bd26bd3eddee1979485281cb375ade52949d
actuals:
tokens: 42000
tasks: 3
commits: 3
---
# Phase quick-260929-dzu Plan 01: Eigene Module fuer jeden Benutzer Summary
Jeder angemeldete Benutzer legt unter Einstellungen > Eigene Module persoenliche Seitenleisten-Eintraege an, die nur er sieht; Administratoren pflegen weiter gemeinsame Eintraege unter Verwaltung > Eigene Module (Senden von `shared: true`). Niemand kann etwas in die Seitenleiste eines anderen Benutzers legen.
## Was gebaut wurde
**Aufgabe 1, Commit c703d87 (Modell + API, Tests zuerst angepasst)**
- Schema: `ownerUserId String?` mit Relation zu `User` (`onDelete: Cascade`), Index `[tenantId, ownerUserId]`; Gegenfeld `customModules` am `User`.
- Migration `20260929130000_custom_module_owner` (von Hand, mit Kopfkommentar): Spalte, Index, Fremdschluessel, alte Regel ersetzt durch vier Regeln.
- Dienst/Controller: `GET /custom-modules` liefert gemeinsame plus eigene Zeilen mit `personal: boolean` (ownerUserId wird nicht ausgeliefert); `GET :id` 404 bei fremdem persoenlichem Eintrag (auch fuer Administratoren); `POST` fuer jeden Angemeldeten, `shared: true` nur fuer ADMIN/SUPER_ADMIN (sonst 403), Standard persoenlich; `PATCH`/`DELETE`: persoenlich nur Besitzer (fremd: 404), gemeinsam nur Administrator (sonst 403). `shared`/`ownerUserId` sind per PATCH nicht aenderbar (`OmitType` im DTO plus `whitelist`).
- Routen: `list` steht weiter vor `getOne`; kein `@Roles` mehr an den Schreibrouten, die Rollenpruefung sitzt im Dienst.
- Specs: Dienst (22 Faelle: A sieht/aendert/loescht B nicht, Nicht-Admin nicht shared, Admin persoenlich vs. gemeinsam, RLS-Bindung mit/ohne Benutzer), Controller, DTO-Pipe-Faelle.
- Zugriffsklassifikation nachgemessen (siehe unten).
**Aufgabe 2, Commit ee97b4e (Web)**
- Neue Seite `/settings/custom-modules` und Nav-Eintrag „Eigene Module“ unter „Allgemein“.
- Gemeinsame Komponenten unter `components/custom-modules/`: `CustomModuleFormModal` und `DeleteCustomModuleDialog` (verschoben, Parameter `shared`) plus neu `CustomModuleManager` (Liste, Anlegen/Bearbeiten/Loeschen, `bumpSidebarRefresh`), aufgerufen mit `scope="shared"` (Verwaltung) oder `scope="personal"` (Einstellungen). Filter ueber `personal` im Web.
- Verwaltung sendet beim Anlegen `shared: true`, zeigt nur gemeinsame Eintraege, Einleitung nennt „alle Benutzer“; Einstellungen senden kein `shared`, Einleitung: „Diese Einträge sehen nur Sie.“
- Texte de/en (Namensraeume `customModules.form`, `customModules.manage`, `settings.customModules`), Umlaut-Waechter gruen.
- Tests: neuer Settings-Test (7), Admin-Test angepasst (`shared: true`, Filter; 14).
**Aufgabe 3, Commit 8f41bd2 (Doku) + Neubau**
- CHANGELOG-Punkt „Eigene Module“ umgeschrieben (Einstellungen fuer jeden, Verwaltung zusaetzlich fuer alle), `docs/anleitung-anwender.md` (Abschnitt „Allgemein > Eigene Module“) und `docs/anleitung-administration.md` (Unterabschnitt bei 5.).
- `docker compose up -d --build web api`: web :3000/login 200, api /health ok, `GET /custom-modules` anonym 401, `/settings/custom-modules` ohne Anmeldung 307 (Umleitung auf Login).
## RLS-Muster und Begruendung
Gefolgt bin ich dem Muster **SearchProvider** aus `20260911120000_rls_user_dimension_personal_tables`: nullable Besitzerspalte, vier nach Befehl getrennte Regeln.
- SELECT: Mandant UND (kein Benutzer gesetzt ODER `ownerUserId IS NULL` ODER `ownerUserId = current_user_id()`).
- INSERT/UPDATE/DELETE: Mandant UND (kein Benutzer gesetzt ODER `ownerUserId = current_user_id()`).
Warum nicht das einfachere Muster DashboardImage/Favorite (Pflicht-`userId`, eine Regel): eigene Module haben gemeinsame Zeilen (`NULL`), die jeder lesen, aber nur ein Administrator schreiben darf. Eine einzelne Regel, die die gemeinsame Zeile zum Lesen freigibt, wuerde sie auch zum Aendern/Loeschen freigeben (Praezedenz 260910-jab (3)), deshalb getrennte Befehle. Folge: ein Benutzerkontext kann gemeinsame Zeilen nicht schreiben; der Dienst bindet Schreibzugriffe auf gemeinsame Eintraege deshalb bewusst OHNE Benutzer (`forTenant(prisma, tenantId)`), nachdem er die Administrator-Rolle geprueft hat. Persoenliche Zugriffe binden mit Benutzer. Wie bei allen RLS-Regeln wirkt der Schutz erst mit dem Datenbankrollen-Schalter (heute AUS); bis dahin tragen die Anwendungspruefungen (`row.tenantId`, `ownerUserId`) den Schutz.
## Curl-Pruefung (lokal, echte API :3001)
Benutzer: admin (SUPER_ADMIN), testuser (USER), curltmp (USER, nur fuer die Pruefung angelegt und danach geloescht).
| Fall | Ergebnis |
|------|----------|
| USER legt Eintrag ohne shared an | 200, `personal: true` |
| USER `shared: true` | 403 „Gemeinsame Einträge dürfen nur Administratoren anlegen“ |
| Admin `shared: true` | 200, `personal: false` |
| Admin ohne shared | 200, `personal: true` |
| Liste USER | gemeinsam + eigener |
| Liste zweiter USER | nur gemeinsam |
| Liste Admin | nur gemeinsam (persoenliche Eintraege anderer nicht) |
| zweiter USER: GET / PATCH / DELETE auf fremden persoenlichen Eintrag | 404 / 404 / 404 |
| Admin: GET / DELETE auf persoenlichen Eintrag eines Benutzers | 404 / 404 |
| USER GET gemeinsam | 200 |
| USER PATCH / DELETE gemeinsam | 403 / 403 |
| Admin PATCH gemeinsam (mit eingeschmuggeltem `shared:false`) | 200, bleibt gemeinsam |
| USER PATCH eigenen mit `shared:true`, `ownerUserId:null` | 200, bleibt persoenlich |
| http-Adresse | 400 |
| anonym | 401 |
| Benutzer loeschen -> seine persoenlichen Eintraege | Cascade, 0 Zeilen |
Alle Testeintraege sind geloescht, `CustomModule` ist leer.
## Tore (gemessen)
| Tor | Ergebnis |
|-----|----------|
| API-Tests vollstaendig | 88 Dateien, 1511 Tests gruen |
| Web-Tests vollstaendig | 103 Dateien, 1003 Tests gruen |
| `pnpm turbo run type-check lint --force` | 9/9 erfolgreich |
| Biome-Warnungen Web / API | 55 (Grundlinie 55) / 82 (Grundlinie 82) |
| rls-coverage / rls-access-inventory | gruen |
| `prisma migrate deploy` lokal (Container-IP 172.19.0.2) | Migration angewendet, `migrate diff` danach leer |
| Zugriffsklassifikation | Gate-Schleife 61/223/6 (vorher 61/224/6); `custom-modules` 0/6/0 |
## Testbenutzer fuer die Browser-Pruefung des Orchestrators
Es gab lokal schon die Nicht-Admin-Konten `nutzer1` und `nutzer2`, deren Passwoerter aber nicht bekannt sind. Deshalb habe ich per Admin-API angelegt: Login **testuser**, Passwort **Test1234!test** (Rolle USER, `mustChangePassword` auf false gesetzt, damit die Anmeldung nicht auf die Passwort-Seite umleitet). Der Administrator ist wie gehabt admin / admin123.
Vorschlag fuer die Browser-Pruefung (dunkel): als testuser unter Einstellungen > Allgemein > Eigene Module einen Eintrag anlegen (Seitenleiste zieht ohne Neuladen nach), als admin unter Verwaltung > Eigene Module einen gemeinsamen Eintrag anlegen (testuser sieht ihn in der Seitenleiste, kann ihn unter Einstellungen aber nicht bearbeiten), als admin pruefen, dass der persoenliche Eintrag von testuser weder in Seitenleiste noch Verwaltung erscheint. Danach die Testeintraege loeschen.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Festplatte voll (0 Byte frei) mitten in der Arbeit**
- **Found during:** Aufgabe 2 (Biome meldete „No space left on device“)
- **Issue:** die Docker-Build-Cache-Ablagen der Neubauten fuellten die Platte.
- **Fix:** `docker builder prune -f` (nur Build-Cache, 17,97 GB, keine Images, Container oder Volumes); danach type-check/lint/Tests frisch und vollstaendig wiederholt, alle gruen.
- **Commit:** kein Code betroffen.
**2. [Rule 1 - Bug] Detektor-Vorgaben fuer `forTenant`**
- **Found during:** Aufgabe 1 (rls-access-inventory schlug zweimal fehl)
- **Issue:** eine Ternary-Bindung (`shared ? forTenant(..) : forTenant(..)`) und ein `client.customModule.create` in einer Hilfsfunktion werden vom Detektor nicht als Zuweisungsform/Modellaufruf erkannt.
- **Fix:** je Zweig `const tenantPrisma = forTenant(...)` mit direktem Modellaufruf; Ausnahmeliste unveraendert leer.
- **Files modified:** `apps/api/src/custom-modules/custom-modules.service.ts`
- **Commit:** c703d87
**3. Plan-Feinheit:** Kein API-Parameter `scope`; die Verwaltung filtert im Web ueber `personal` (Plan liess beides zu, „das Einfachste“). Nebenwirkung: die Verwaltungsseite laedt auch die eigenen persoenlichen Eintraege des Administrators und blendet sie aus.
**4. Plan-Feinheit:** Formular-/Dialog-Texte aus `admin.customModules` in den neuen Namensraum `customModules.form` umgezogen (beide Aufrufer teilen sie); Admin-Test entsprechend angepasst. Die Anleitung des Anwenders hatte den Punkt „Eigene Module“ vorher nicht, er ist jetzt neu beschrieben (der Plan sprach von „aktualisieren“).
## Hinweise
- Zwischen c703d87 und ee97b4e liegt ein fremder Commit `bc4c011` (fix(web) Widgets nicht mehr zur Mitte versetzen), nicht von diesem Plan; er beruehrt CHANGELOG.md und `docs/anleitung-anwender.md` an anderen Stellen. Die 3 Commits dieses Plans sind c703d87, ee97b4e, 8f41bd2 (`git rev-list` ab dem Vorgaenger von c703d87 zaehlt 4 inklusive des fremden). Der Ledger nach Protokoll 0c wurde nicht vor dem ersten Commit angelegt, `plan_head_before` ist deshalb der Vorgaenger von c703d87.
- Die Verwaltungs-Nav zeigt weiterhin „Eigene Module“; sie fuehrt jetzt auf die gemeinsamen Eintraege, die Einleitung nennt das.
- Nichts gepusht.
## Known Stubs
Keine.
## Threat Flags
Keine neue Angriffsflaeche ausserhalb des bestehenden Modells: `ownerUserId` kommt nie aus dem Body (Whitelist, im Test belegt), `shared` ist per PATCH nicht setzbar, fremde persoenliche Eintraege sind ununterscheidbar 404.
## Self-Check: PASSED
- Dateien vorhanden: Migration `20260929130000_custom_module_owner`, `custom-module-manager.tsx`, `settings/custom-modules/page.tsx`, Settings-Test.
- Commits vorhanden: c703d87, ee97b4e, 8f41bd2 (`git log`); nichts gepusht (`git branch -r --contains HEAD` leer).
## Browser-Pruefung (Orchestrator, 29.09.)
- testuser: Einstellungen > Eigene Module vorhanden; „Meine Seite“ angelegt -> sofort in eigener Seitenleiste (Infrastruktur).
- admin: sieht „Meine Seite“ weder in Seitenleiste noch Verwaltung; Direktlink zeigt „Dieses Modul gibt es nicht mehr.“; Verwaltung heisst „Gemeinsamen Eintrag anlegen“.
- admin legt „Firmenseite“ (Sicherheit) an -> testuser sieht sie in der Seitenleiste, nicht in seinen Einstellungen; DELETE als testuser -> 403.
- Nebenbei: Widgets nicht mehr zentriert (bc4c011) — alle linken Kanten am Raster (272 px bei Rasterbeginn 260 + 12 Rand).
- Testeintraege geloescht, CustomModule leer.
+18 -1
View File
@@ -4,17 +4,34 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
## Unveröffentlicht ## Unveröffentlicht
## 1.7.0 – 2026-09-29
### Neu ### Neu
- Eigene Module: Als Administrator können Sie unter „Verwaltung“ im Punkt „Eigene Module“ andere Webseiten in die Seitenleiste aufnehmen – mit Name, Adresse (nur https) und Kategorie, etwa „Infrastruktur“. Alle Benutzer sehen die Einträge unter der gewählten Kategorie; ein Klick zeigt die Seite direkt in Tessera. Manche Seiten verbieten das Einbetten – dafür gibt es immer den Knopf „In neuem Tab öffnen“. - Eigene Module: Als Kategorie steht jetzt auch „Eigene Module“ zur Auswahl. Einträge dort erscheinen gesammelt in einer eigenen Gruppe ganz unten in der Seitenleiste; die Gruppe ist nur zu sehen, solange ein Eintrag darin liegt. Bestehende Einträge verschieben Sie über „Bearbeiten“ dorthin.
### Geändert
- Seitenleiste: Ist sie eingeklappt, sind die Symbole etwas größer und stehen etwas enger beieinander.
- Dashboard: Im Such-Widget haben die Auswahl der Suchmaschine und das Suchfeld keine helle Linie an der Unterkante mehr.
## 1.6.0 – 2026-09-29
### Neu
- Eigene Module: Jeder Benutzer kann unter „Einstellungen → Eigene Module“ Webseiten, die er oft braucht, als eigene Einträge in seine Seitenleiste aufnehmen – mit Name, Adresse (nur https) und Kategorie, etwa „Infrastruktur“. Diese Einträge sieht nur der Benutzer selbst. Ein Klick zeigt die Seite direkt in Tessera. Manche Seiten verbieten das Einbetten – dafür gibt es immer den Knopf „In neuem Tab öffnen“. Administratoren können zusätzlich unter „Verwaltung → Eigene Module“ Einträge für alle Benutzer anlegen; die sehen dann alle unter der gewählten Kategorie.
### Geändert ### Geändert
- Dashboard: Die Widgets bleiben beim Darüberfahren mit der Maus ruhig stehen, sie heben sich nicht mehr an. - Dashboard: Die Widgets bleiben beim Darüberfahren mit der Maus ruhig stehen, sie heben sich nicht mehr an.
- Dashboard: Die Widgets stehen in der Ansicht genau dort, wo Sie sie beim Bearbeiten platziert haben. Bisher rückte Tessera sie nach dem Bearbeiten zur Seitenmitte, sodass etwa ein einzelnes Widget oben links plötzlich in die Mitte sprang.
- Dashboard: Das Raster ist in der Breite doppelt so fein – Widgets lassen sich in kleineren Schritten breiter oder schmaler ziehen und genauer platzieren. Bestehende Anordnungen bleiben unverändert.
- Dashboard: Das Kalender-Widget lässt sich deutlich schmaler ziehen als bisher.
### Behoben ### Behoben
- Desktop-App: Tessera startet nicht mehr doppelt. Wird die App ein zweites Mal gestartet – etwa beim Anmelden an Windows –, holt sie nur das vorhandene Fenster nach vorne; im Infobereich erscheint nur noch ein Symbol. - Desktop-App: Tessera startet nicht mehr doppelt. Wird die App ein zweites Mal gestartet – etwa beim Anmelden an Windows –, holt sie nur das vorhandene Fenster nach vorne; im Infobereich erscheint nur noch ein Symbol.
- Desktop-App: Die Suche im Such-Widget und Knöpfe wie „In neuem Tab öffnen“ funktionieren jetzt auch in der Desktop-App – die Seite öffnet sich in Ihrem normalen Browser. Bisher passierte dort beim Klick nichts.
## 1.5.2 – 2026-09-28 ## 1.5.2 – 2026-09-28
@@ -0,0 +1,77 @@
-- 260929-dzu — Eigene Module fuer jeden Benutzer: persoenliche Eintraege.
--
-- Zweck: jeder Benutzer darf eigene Seitenleisten-Eintraege anlegen, die nur
-- er selbst sieht. Die Spalte "ownerUserId" unterscheidet: NULL = gemeinsamer
-- Eintrag (vom Administrator, fuer alle sichtbar, bisheriges Verhalten),
-- gesetzt = persoenlicher Eintrag dieses Benutzers. Faellt der Benutzer weg,
-- fallen seine Eintraege mit (ON DELETE CASCADE). Bestehende Zeilen bleiben
-- gemeinsam (NULL).
--
-- Zeilenschutz: Muster "SearchProvider" (20260911120000_rls_user_dimension_
-- personal_tables) — Spalte mit NULL = gemeinsame Zeile. Die eine Regel
-- "tenant_isolation_policy" (aus 20260929120000, ohne Benutzerdimension) wird
-- durch vier nach Befehl getrennte Regeln ersetzt (Praezedenz 260910-jab (3)):
-- ein einzelner USING-Ausdruck, der die gemeinsame Zeile zum Lesen einschliesst,
-- wuerde sie sonst auch zum Aendern/Entfernen freigeben.
-- SELECT: Mandant UND (kein Benutzer gesetzt ODER gemeinsame Zeile ODER
-- eigene Zeile).
-- INSERT/UPDATE/DELETE: Mandant UND (kein Benutzer gesetzt ODER eigene
-- Zeile). Ein Benutzerkontext kann gemeinsame Zeilen also NICHT
-- schreiben; der Administrator-Weg fuer gemeinsame Eintraege bindet
-- deshalb ohne Benutzer (`forTenant(prisma, tenantId)`), die
-- Rollenpruefung liegt im Controller/Dienst.
-- Die Regelnamen sind neu (vier statt eine), rls-coverage.spec.ts fordert nur
-- mindestens eine Regel je Tabelle mit eingeschaltetem RLS.
--
-- Rechte fuer tessera_app kommen ueber ALTER DEFAULT PRIVILEGES aus
-- 20260909130000_rls_app_role — hier nichts zu tun.
--
-- WICHTIG: wie alle RLS-Regeln dieses Schemas wirken diese erst, wenn die
-- Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter heute AUS, siehe
-- docs/mandantentrennung-datenbankrolle.md). Bis dahin tragen die
-- Anwendungspruefungen im Dienst den Schutz allein.
ALTER TABLE "CustomModule" ADD COLUMN "ownerUserId" TEXT;
CREATE INDEX "CustomModule_tenantId_ownerUserId_idx" ON "CustomModule"("tenantId", "ownerUserId");
ALTER TABLE "CustomModule" ADD CONSTRAINT "CustomModule_ownerUserId_fkey"
FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
DROP POLICY tenant_isolation_policy ON "CustomModule";
CREATE POLICY tenant_user_read_policy ON "CustomModule"
FOR SELECT
USING (
"tenantId" = current_tenant_id()
AND (
current_user_id() IS NULL
OR "ownerUserId" IS NULL
OR "ownerUserId" = current_user_id()
)
);
CREATE POLICY tenant_user_insert_policy ON "CustomModule"
FOR INSERT
WITH CHECK (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "ownerUserId" = current_user_id())
);
CREATE POLICY tenant_user_update_policy ON "CustomModule"
FOR UPDATE
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "ownerUserId" = current_user_id())
)
WITH CHECK (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "ownerUserId" = current_user_id())
);
CREATE POLICY tenant_user_delete_policy ON "CustomModule"
FOR DELETE
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "ownerUserId" = current_user_id())
);
+7
View File
@@ -53,6 +53,7 @@ model User {
passwordResetTokens PasswordResetToken[] passwordResetTokens PasswordResetToken[]
groupMemberships GroupMembership[] groupMemberships GroupMembership[]
moduleGrants ModuleGrant[] moduleGrants ModuleGrant[]
customModules CustomModule[]
@@index([tenantId]) @@index([tenantId])
@@index([username]) @@index([username])
@@ -727,8 +728,14 @@ model CustomModule {
name String name String
url String url String
category String // eine der MODULE_CATEGORIES aus @tessera/shared category String // eine der MODULE_CATEGORIES aus @tessera/shared
// quick-260929-dzu: null = gemeinsamer Eintrag (vom Administrator, fuer alle
// sichtbar); gesetzt = persoenlicher Eintrag, nur fuer diesen Benutzer
// sichtbar. Faellt der Benutzer weg, fallen seine Eintraege mit.
ownerUserId String?
owner User? @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([tenantId]) @@index([tenantId])
@@index([tenantId, ownerUserId])
} }
@@ -1,10 +1,9 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { ForbiddenException, ValidationPipe } from '@nestjs/common'; import { ForbiddenException, ValidationPipe } from '@nestjs/common';
import { Role } from '@prisma/client';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator'; import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { CustomModulesController } from './custom-modules.controller'; import { CustomModulesController } from './custom-modules.controller';
import { CreateCustomModuleDto } from './dto/custom-module.dto'; import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
function makeService() { function makeService() {
return { return {
@@ -17,14 +16,20 @@ function makeService() {
} }
const req = (tenantId?: string) => ({ tenantId }) as any; const req = (tenantId?: string) => ({ tenantId }) as any;
const user = { id: 'u1', username: 'u', role: 'USER', tenantId: 't1' } as any;
const proto = CustomModulesController.prototype as any; const proto = CustomModulesController.prototype as any;
describe('CustomModulesController — Rollen (T-9WC-01)', () => { describe('CustomModulesController — Rollen (quick-260929-dzu)', () => {
it.each(['create', 'update', 'remove'])('%s ist nur fuer ADMIN und SUPER_ADMIN offen', (name) => { // Jeder Angemeldete darf persoenliche Eintraege anlegen/aendern/loeschen; die
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toEqual([Role.ADMIN, Role.SUPER_ADMIN]); // Administrator-Pflicht fuer gemeinsame Eintraege prueft der Dienst (hangt
}); // vom Eintrag ab, nicht von der Route) — siehe custom-modules.service.spec.ts.
it.each([
it.each(['list', 'getOne'])('%s traegt keine Rollen (jeder Angemeldete)', (name) => { 'list',
'getOne',
'create',
'update',
'remove',
])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined(); expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
}); });
@@ -37,26 +42,26 @@ describe('CustomModulesController — Mandant', () => {
it('reicht req.tenantId an den Dienst weiter', async () => { it('reicht req.tenantId an den Dienst weiter', async () => {
const service = makeService(); const service = makeService();
const controller = new CustomModulesController(service as any); const controller = new CustomModulesController(service as any);
await controller.list(req('t1')); await controller.list(req('t1'), user);
await controller.getOne(req('t1'), 'x'); await controller.getOne(req('t1'), user, 'x');
await controller.create(req('t1'), { name: 'a', url: 'https://a.de', category: 'fleet' }); await controller.create(req('t1'), user, { name: 'a', url: 'https://a.de', category: 'fleet' });
await controller.update(req('t1'), 'x', { name: 'b' }); await controller.update(req('t1'), user, 'x', { name: 'b' });
await controller.remove(req('t1'), 'x'); await controller.remove(req('t1'), user, 'x');
expect(service.list).toHaveBeenCalledWith('t1'); expect(service.list).toHaveBeenCalledWith('t1', user);
expect(service.getOne).toHaveBeenCalledWith('t1', 'x'); expect(service.getOne).toHaveBeenCalledWith('t1', user, 'x');
expect(service.create.mock.calls[0][0]).toBe('t1'); expect(service.create.mock.calls[0].slice(0, 2)).toEqual(['t1', user]);
expect(service.update.mock.calls[0].slice(0, 2)).toEqual(['t1', 'x']); expect(service.update.mock.calls[0].slice(0, 3)).toEqual(['t1', user, 'x']);
expect(service.remove).toHaveBeenCalledWith('t1', 'x'); expect(service.remove).toHaveBeenCalledWith('t1', user, 'x');
}); });
it('wirft ForbiddenException ohne req.tenantId', async () => { it('wirft ForbiddenException ohne req.tenantId', async () => {
const controller = new CustomModulesController(makeService() as any); const controller = new CustomModulesController(makeService() as any);
await expect(controller.list(req())).rejects.toBeInstanceOf(ForbiddenException); await expect(controller.list(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.getOne(req(), 'x')).rejects.toBeInstanceOf(ForbiddenException); await expect(controller.getOne(req(), user, 'x')).rejects.toBeInstanceOf(ForbiddenException);
await expect( await expect(
controller.create(req(), { name: 'a', url: 'https://a.de', category: 'fleet' }), controller.create(req(), user, { name: 'a', url: 'https://a.de', category: 'fleet' }),
).rejects.toBeInstanceOf(ForbiddenException); ).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.remove(req(), 'x')).rejects.toBeInstanceOf(ForbiddenException); await expect(controller.remove(req(), user, 'x')).rejects.toBeInstanceOf(ForbiddenException);
}); });
it('die globale Pipe verwirft ein untergeschobenes tenantId (T-9WC-07)', async () => { it('die globale Pipe verwirft ein untergeschobenes tenantId (T-9WC-07)', async () => {
@@ -67,6 +72,27 @@ describe('CustomModulesController — Mandant', () => {
); );
expect(out).not.toHaveProperty('tenantId'); expect(out).not.toHaveProperty('tenantId');
}); });
it('die globale Pipe verwirft ownerUserId, laesst shared beim Anlegen durch', async () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
const out: any = await pipe.transform(
{ name: 'a', url: 'https://a.de', category: 'fleet', ownerUserId: 'evil', shared: true },
{ type: 'body', metatype: CreateCustomModuleDto },
);
expect(out).not.toHaveProperty('ownerUserId');
expect(out.shared).toBe(true);
});
it('die globale Pipe verwirft shared und ownerUserId beim Aendern', async () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
const out: any = await pipe.transform(
{ name: 'b', shared: true, ownerUserId: 'evil' },
{ type: 'body', metatype: UpdateCustomModuleDto },
);
expect(out).not.toHaveProperty('shared');
expect(out).not.toHaveProperty('ownerUserId');
expect(out.name).toBe('b');
});
}); });
describe('CustomModulesController — Routen-Reihenfolge (statisch vor :id)', () => { describe('CustomModulesController — Routen-Reihenfolge (statisch vor :id)', () => {
@@ -9,18 +9,21 @@ import {
Post, Post,
Req, Req,
} from '@nestjs/common'; } from '@nestjs/common';
import { Role } from '@prisma/client'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator'; import type { AuthenticatedRequest, AuthUser } from '../auth/types/auth-user';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { CustomModulesService } from './custom-modules.service'; import { CustomModulesService } from './custom-modules.service';
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto'; import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
/** /**
* Eigene Module (quick-260929-9wc). Lesen (`GET`, `GET :id`) steht jedem * Eigene Module (quick-260929-9wc, erweitert in quick-260929-dzu). Jeder
* angemeldeten Benutzer offen — die Eintraege sind fuer alle sichtbar (D-01); * angemeldete Benutzer darf lesen und eigene (persoenliche) Eintraege anlegen,
* Schreiben nur `@Roles(ADMIN, SUPER_ADMIN)` (T-9WC-01). Kein `@UseModule`: * aendern und loeschen; gemeinsame Eintraege (`shared: true`) darf nur ein
* Administrator anlegen, aendern und loeschen — diese Rollenentscheidung trifft
* der Dienst, weil sie vom Eintrag abhaengt (gemeinsam oder persoenlich), nicht
* von der Route. Deshalb tragen die Routen kein `@Roles`. Kein `@UseModule`:
* eigene Module haengen an keiner Modul-Aktivierung. `tenantId` kommt * eigene Module haengen an keiner Modul-Aktivierung. `tenantId` kommt
* ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`). * ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`), der Benutzer
* aus dem Token.
* *
* ROUTEN-REIHENFOLGE: NestJS bildet Routen in Deklarationsreihenfolge ab. * ROUTEN-REIHENFOLGE: NestJS bildet Routen in Deklarationsreihenfolge ab.
* Jede kuenftige statische GET-Route MUSS ueber `getOne` (`@Get(':id')`) * Jede kuenftige statische GET-Route MUSS ueber `getOne` (`@Get(':id')`)
@@ -40,34 +43,44 @@ export class CustomModulesController {
} }
@Get() @Get()
async list(@Req() req: AuthenticatedRequest) { async list(@Req() req: AuthenticatedRequest, @CurrentUser() user: AuthUser) {
return this.service.list(this.requireTenantId(req)); return this.service.list(this.requireTenantId(req), user);
} }
@Get(':id') @Get(':id')
async getOne(@Req() req: AuthenticatedRequest, @Param('id') id: string) { async getOne(
return this.service.getOne(this.requireTenantId(req), id); @Req() req: AuthenticatedRequest,
@CurrentUser() user: AuthUser,
@Param('id') id: string,
) {
return this.service.getOne(this.requireTenantId(req), user, id);
} }
@Post() @Post()
@Roles(Role.ADMIN, Role.SUPER_ADMIN) async create(
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateCustomModuleDto) { @Req() req: AuthenticatedRequest,
return this.service.create(this.requireTenantId(req), dto); @CurrentUser() user: AuthUser,
@Body() dto: CreateCustomModuleDto,
) {
return this.service.create(this.requireTenantId(req), user, dto);
} }
@Patch(':id') @Patch(':id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async update( async update(
@Req() req: AuthenticatedRequest, @Req() req: AuthenticatedRequest,
@CurrentUser() user: AuthUser,
@Param('id') id: string, @Param('id') id: string,
@Body() dto: UpdateCustomModuleDto, @Body() dto: UpdateCustomModuleDto,
) { ) {
return this.service.update(this.requireTenantId(req), id, dto); return this.service.update(this.requireTenantId(req), user, id, dto);
} }
@Delete(':id') @Delete(':id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) async remove(
async remove(@Req() req: AuthenticatedRequest, @Param('id') id: string) { @Req() req: AuthenticatedRequest,
return this.service.remove(this.requireTenantId(req), id); @CurrentUser() user: AuthUser,
@Param('id') id: string,
) {
return this.service.remove(this.requireTenantId(req), user, id);
} }
} }
@@ -1,9 +1,10 @@
import { NotFoundException } from '@nestjs/common'; import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { Role } from '@prisma/client';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
// `forTenant` reicht den Klienten durch — Mandantenbindung selbst prueft // `forTenant` reicht den Klienten durch — Mandantenbindung selbst prueft
// rls-access-inventory.spec.ts; hier zaehlt, dass je Methode (prisma, tenantId) // rls-access-inventory.spec.ts; hier zaehlt, mit welchen Argumenten je Methode
// uebergeben wird. // gebunden wird (mit oder ohne Benutzer).
vi.mock('../prisma/prisma-tenant.extension', () => ({ vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p), forTenant: vi.fn((p: unknown) => p),
})); }));
@@ -24,6 +25,11 @@ function makeFakePrisma() {
findMany: vi.fn(async ({ where, orderBy }: { where?: any; orderBy?: any } = {}) => { findMany: vi.fn(async ({ where, orderBy }: { where?: any; orderBy?: any } = {}) => {
let list = [...rows.values()]; let list = [...rows.values()];
if (where?.tenantId) list = list.filter((r) => r.tenantId === where.tenantId); if (where?.tenantId) list = list.filter((r) => r.tenantId === where.tenantId);
if (where?.OR) {
list = list.filter((r) =>
where.OR.some((c: { ownerUserId: string | null }) => r.ownerUserId === c.ownerUserId),
);
}
if (orderBy?.name === 'asc') list.sort((a, b) => a.name.localeCompare(b.name)); if (orderBy?.name === 'asc') list.sort((a, b) => a.name.localeCompare(b.name));
return list; return list;
}), }),
@@ -41,86 +47,233 @@ function makeFakePrisma() {
} }
const dto = { name: 'Wiki', url: 'https://example.com', category: 'infrastructure' as const }; const dto = { name: 'Wiki', url: 'https://example.com', category: 'infrastructure' as const };
const admin = { id: 'admin1', role: Role.ADMIN };
const userA = { id: 'ua', role: Role.USER };
const userB = { id: 'ub', role: Role.USER };
describe('CustomModulesService', () => { function setup() {
it('create speichert tenantId aus dem Argument, nie aus dem DTO', async () => {
const prisma = makeFakePrisma(); const prisma = makeFakePrisma();
const service = new CustomModulesService(prisma as any); return { prisma, service: new CustomModulesService(prisma as any) };
await service.create('t1', { ...dto, tenantId: 'evil' } as any); }
expect(prisma.customModule.create).toHaveBeenCalledTimes(1);
describe('CustomModulesService — anlegen', () => {
it('speichert tenantId aus dem Argument, nie aus dem DTO', async () => {
const { prisma, service } = setup();
await service.create('t1', userA, { ...dto, tenantId: 'evil' } as any);
expect(prisma.customModule.create.mock.calls[0][0].data.tenantId).toBe('t1'); expect(prisma.customModule.create.mock.calls[0][0].data.tenantId).toBe('t1');
}); });
it('list liefert nur Zeilen des Mandanten, nach Name sortiert', async () => { it('ohne shared-Angabe ist der Eintrag persoenlich (ownerUserId = Aufrufer)', async () => {
const prisma = makeFakePrisma(); const { prisma, service } = setup();
const service = new CustomModulesService(prisma as any); const res: any = await service.create('t1', userA, dto);
await service.create('t1', { ...dto, name: 'Zebra' }); expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBe('ua');
await service.create('t1', { ...dto, name: 'Anker' }); expect(res.personal).toBe(true);
await service.create('t2', { ...dto, name: 'Fremd' }); expect(res).not.toHaveProperty('ownerUserId');
const result = await service.list('t1');
expect(result.map((r: any) => r.name)).toEqual(['Anker', 'Zebra']);
expect(prisma.customModule.findMany.mock.calls[0]?.[0]?.where).toEqual({ tenantId: 't1' });
}); });
it('getOne liefert die Zeile ohne tenantId', async () => { it('auch ein Administrator legt ohne shared persoenlich an', async () => {
const prisma = makeFakePrisma(); const { prisma, service } = setup();
const service = new CustomModulesService(prisma as any); const res: any = await service.create('t1', admin, dto);
const created: any = await service.create('t1', dto); expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBe('admin1');
const row: any = await service.getOne('t1', created.id); expect(res.personal).toBe(true);
});
it('shared: true durch einen Administrator legt einen gemeinsamen Eintrag an', async () => {
const { prisma, service } = setup();
const res: any = await service.create('t1', admin, { ...dto, shared: true });
expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBeNull();
expect(res.personal).toBe(false);
});
it('shared: true durch einen normalen Benutzer -> ForbiddenException, nichts gespeichert', async () => {
const { prisma, service } = setup();
await expect(service.create('t1', userA, { ...dto, shared: true })).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(prisma.customModule.create).not.toHaveBeenCalled();
});
it('shared: false durch einen normalen Benutzer bleibt persoenlich', async () => {
const { prisma, service } = setup();
await service.create('t1', userA, { ...dto, shared: false });
expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBe('ua');
});
it('das Feld shared landet nie in den gespeicherten Daten', async () => {
const { prisma, service } = setup();
await service.create('t1', admin, { ...dto, shared: true });
expect(prisma.customModule.create.mock.calls[0][0].data).not.toHaveProperty('shared');
});
});
describe('CustomModulesService — lesen', () => {
it('list liefert gemeinsame plus eigene Eintraege, nie die eines anderen Benutzers', async () => {
const { service } = setup();
await service.create('t1', admin, { ...dto, name: 'Gemeinsam', shared: true });
await service.create('t1', userA, { ...dto, name: 'A-privat' });
await service.create('t1', userB, { ...dto, name: 'B-privat' });
const resA: any[] = await service.list('t1', userA);
expect(resA.map((r) => [r.name, r.personal])).toEqual([
['A-privat', true],
['Gemeinsam', false],
]);
const resB: any[] = await service.list('t1', userB);
expect(resB.map((r) => r.name)).toEqual(['B-privat', 'Gemeinsam']);
});
it('list sieht auch als Administrator keine persoenlichen Eintraege anderer', async () => {
const { service } = setup();
await service.create('t1', userA, { ...dto, name: 'A-privat' });
await service.create('t1', admin, { ...dto, name: 'Gemeinsam', shared: true });
const res: any[] = await service.list('t1', admin);
expect(res.map((r) => r.name)).toEqual(['Gemeinsam']);
});
it('list liefert nur Zeilen des Mandanten, nach Name sortiert', async () => {
const { prisma, service } = setup();
await service.create('t1', admin, { ...dto, name: 'Zebra', shared: true });
await service.create('t1', admin, { ...dto, name: 'Anker', shared: true });
await service.create('t2', admin, { ...dto, name: 'Fremd', shared: true });
const result: any[] = await service.list('t1', userA);
expect(result.map((r) => r.name)).toEqual(['Anker', 'Zebra']);
expect(prisma.customModule.findMany.mock.calls[0]?.[0]?.where).toEqual({
tenantId: 't1',
OR: [{ ownerUserId: null }, { ownerUserId: 'ua' }],
});
});
it('getOne liefert einen gemeinsamen Eintrag jedem, ohne tenantId und ownerUserId', async () => {
const { service } = setup();
const created: any = await service.create('t1', admin, { ...dto, shared: true });
const row: any = await service.getOne('t1', userA, created.id);
expect(row.name).toBe('Wiki'); expect(row.name).toBe('Wiki');
expect(row.personal).toBe(false);
expect(row).not.toHaveProperty('tenantId'); expect(row).not.toHaveProperty('tenantId');
expect(row).not.toHaveProperty('ownerUserId');
});
it('getOne liefert einen eigenen persoenlichen Eintrag', async () => {
const { service } = setup();
const created: any = await service.create('t1', userA, dto);
const row: any = await service.getOne('t1', userA, created.id);
expect(row.personal).toBe(true);
});
it('getOne auf den persoenlichen Eintrag eines anderen -> NotFoundException (auch fuer Administratoren)', async () => {
const { service } = setup();
const created: any = await service.create('t1', userA, dto);
await expect(service.getOne('t1', userB, created.id)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.getOne('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException);
}); });
it('getOne/update/remove mit unbekannter id -> NotFoundException', async () => { it('getOne/update/remove mit unbekannter id -> NotFoundException', async () => {
const service = new CustomModulesService(makeFakePrisma() as any); const { service } = setup();
await expect(service.getOne('t1', 'nope')).rejects.toBeInstanceOf(NotFoundException); await expect(service.getOne('t1', userA, 'nope')).rejects.toBeInstanceOf(NotFoundException);
await expect(service.update('t1', 'nope', { name: 'x' })).rejects.toBeInstanceOf( await expect(service.update('t1', userA, 'nope', { name: 'x' })).rejects.toBeInstanceOf(
NotFoundException, NotFoundException,
); );
await expect(service.remove('t1', 'nope')).rejects.toBeInstanceOf(NotFoundException); await expect(service.remove('t1', userA, 'nope')).rejects.toBeInstanceOf(NotFoundException);
}); });
it('getOne/update/remove mit Zeile eines anderen Mandanten -> NotFoundException', async () => { it('getOne/update/remove mit Zeile eines anderen Mandanten -> NotFoundException', async () => {
const prisma = makeFakePrisma(); const { prisma, service } = setup();
const service = new CustomModulesService(prisma as any); const created: any = await service.create('t2', admin, { ...dto, shared: true });
const created: any = await service.create('t2', dto); await expect(service.getOne('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.getOne('t1', created.id)).rejects.toBeInstanceOf(NotFoundException); await expect(service.update('t1', admin, created.id, { name: 'x' })).rejects.toBeInstanceOf(
await expect(service.update('t1', created.id, { name: 'x' })).rejects.toBeInstanceOf(
NotFoundException, NotFoundException,
); );
await expect(service.remove('t1', created.id)).rejects.toBeInstanceOf(NotFoundException); await expect(service.remove('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.customModule.update).not.toHaveBeenCalled();
expect(prisma.customModule.delete).not.toHaveBeenCalled();
});
});
describe('CustomModulesService — aendern und loeschen', () => {
it('der Besitzer aendert und loescht seinen persoenlichen Eintrag', async () => {
const { prisma, service } = setup();
const created: any = await service.create('t1', userA, dto);
const updated: any = await service.update('t1', userA, created.id, { name: 'Neu' });
expect(updated.name).toBe('Neu');
expect(updated.personal).toBe(true);
await expect(service.remove('t1', userA, created.id)).resolves.toEqual({ deleted: true });
expect(prisma.rows.size).toBe(0);
});
it('ein anderer Benutzer kann den persoenlichen Eintrag weder aendern noch loeschen (404)', async () => {
const { prisma, service } = setup();
const created: any = await service.create('t1', userA, dto);
await expect(service.update('t1', userB, created.id, { name: 'x' })).rejects.toBeInstanceOf(
NotFoundException,
);
await expect(service.remove('t1', userB, created.id)).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.customModule.update).not.toHaveBeenCalled(); expect(prisma.customModule.update).not.toHaveBeenCalled();
expect(prisma.customModule.delete).not.toHaveBeenCalled(); expect(prisma.customModule.delete).not.toHaveBeenCalled();
}); });
it('update aendert nur gesetzte Felder', async () => { it('auch ein Administrator kann den persoenlichen Eintrag eines Benutzers nicht aendern (404)', async () => {
const prisma = makeFakePrisma(); const { prisma, service } = setup();
const service = new CustomModulesService(prisma as any); const created: any = await service.create('t1', userA, dto);
const created: any = await service.create('t1', dto); await expect(service.update('t1', admin, created.id, { name: 'x' })).rejects.toBeInstanceOf(
await service.update('t1', created.id, { name: 'Neu' }); NotFoundException,
expect(prisma.customModule.update.mock.calls[0][0].data).toEqual({ name: 'Neu' }); );
await expect(service.remove('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.rows.size).toBe(1);
}); });
it('remove loescht und liefert { deleted: true }', async () => { it('ein normaler Benutzer kann einen gemeinsamen Eintrag weder aendern noch loeschen (403)', async () => {
const prisma = makeFakePrisma(); const { prisma, service } = setup();
const service = new CustomModulesService(prisma as any); const created: any = await service.create('t1', admin, { ...dto, shared: true });
const created: any = await service.create('t1', dto); await expect(service.update('t1', userA, created.id, { name: 'x' })).rejects.toBeInstanceOf(
await expect(service.remove('t1', created.id)).resolves.toEqual({ deleted: true }); ForbiddenException,
);
await expect(service.remove('t1', userA, created.id)).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(prisma.customModule.update).not.toHaveBeenCalled();
expect(prisma.customModule.delete).not.toHaveBeenCalled();
});
it('ein Administrator aendert und loescht einen gemeinsamen Eintrag', async () => {
const { prisma, service } = setup();
const created: any = await service.create('t1', admin, { ...dto, shared: true });
const updated: any = await service.update('t1', admin, created.id, { name: 'Neu' });
expect(updated.name).toBe('Neu');
expect(updated.personal).toBe(false);
await expect(service.remove('t1', admin, created.id)).resolves.toEqual({ deleted: true });
expect(prisma.rows.size).toBe(0); expect(prisma.rows.size).toBe(0);
}); });
it('ruft forTenant je Methode mit (prisma, tenantId) auf', async () => { it('update aendert nur gesetzte Felder und nie Besitz oder Gemeinsamkeit', async () => {
const prisma = makeFakePrisma(); const { prisma, service } = setup();
const service = new CustomModulesService(prisma as any); const created: any = await service.create('t1', userA, dto);
await service.update('t1', userA, created.id, {
name: 'Neu',
shared: true,
ownerUserId: 'ub',
} as any);
expect(prisma.customModule.update.mock.calls[0][0].data).toEqual({ name: 'Neu' });
});
});
describe('CustomModulesService — RLS-Bindung', () => {
it('bindet persoenliche Zugriffe mit Benutzer, gemeinsame Schreibzugriffe ohne', async () => {
const { prisma, service } = setup();
vi.mocked(forTenant).mockClear(); vi.mocked(forTenant).mockClear();
const created: any = await service.create('t1', dto); const shared: any = await service.create('t1', admin, { ...dto, shared: true });
await service.list('t1'); const mine: any = await service.create('t1', userA, dto);
await service.getOne('t1', created.id); await service.list('t1', userA);
await service.update('t1', created.id, { name: 'a' }); await service.getOne('t1', userA, mine.id);
await service.remove('t1', created.id); await service.update('t1', userA, mine.id, { name: 'a' });
expect(forTenant).toHaveBeenCalledTimes(5); await service.update('t1', admin, shared.id, { name: 'b' });
for (const call of vi.mocked(forTenant).mock.calls) { const calls = vi.mocked(forTenant).mock.calls;
expect(call).toEqual([prisma, 't1']); // create shared: ohne Benutzer
} expect(calls[0]).toEqual([prisma, 't1']);
// create personal + list + getOne + (update personal: Laden + Schreiben)
expect(calls[1]).toEqual([prisma, 't1', 'ua']);
expect(calls[2]).toEqual([prisma, 't1', 'ua']);
expect(calls[3]).toEqual([prisma, 't1', 'ua']);
// update shared als Admin: Laden mit Benutzer, Schreiben ohne
expect(calls[calls.length - 2]).toEqual([prisma, 't1', 'admin1']);
expect(calls[calls.length - 1]).toEqual([prisma, 't1']);
}); });
}); });
@@ -1,4 +1,5 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { Role } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import type { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto'; import type { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
@@ -9,34 +10,132 @@ const CUSTOM_MODULE_SELECT = {
name: true, name: true,
url: true, url: true,
category: true, category: true,
ownerUserId: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
}; };
/** Der Aufrufer, wie ihn der Controller aus dem Anmelde-Token liest. */
export interface CustomModuleCaller {
id: string;
role: Role;
}
function isAdmin(caller: CustomModuleCaller): boolean {
return caller.role === Role.ADMIN || caller.role === Role.SUPER_ADMIN;
}
/** Aus der Zeile die Antwort machen: `personal` statt der Besitzer-Kennung. */
function toResponse<T extends { ownerUserId: string | null }>(row: T) {
const { ownerUserId, ...rest } = row;
return { ...rest, personal: ownerUserId !== null };
}
/** /**
* Eigene Module (quick-260929-9wc): vom Administrator angelegte * Eigene Module (quick-260929-9wc, erweitert in quick-260929-dzu): Seitenleisten-
* Seitenleisten-Eintraege mit externer https-Adresse. `tenantId` kommt * Eintraege mit externer https-Adresse. Zwei Arten:
* ausschliesslich als Argument (aus `req.tenantId`), nie aus dem DTO. Je *
* Methode ein eigener `forTenant`-Klient; zusaetzlich pruefen * - gemeinsam (`ownerUserId` null): vom Administrator angelegt, fuer alle
* getOne/update/remove `row.tenantId` — zweites Netz, solange der * Benutzer des Mandanten sichtbar; Schreiben nur fuer Administratoren.
* RLS-Schalter aus ist (Muster DashboardImage). Eine fremde oder unbekannte * - persoenlich (`ownerUserId` = Benutzer): nur der Besitzer sieht, aendert und
* id ergibt immer `NotFoundException`, nie einen Hinweis auf die Existenz. * loescht ihn. Ein anderer Benutzer bekommt fuer die id immer 404 — nie einen
* Hinweis, dass es sie gibt.
*
* `tenantId` kommt ausschliesslich als Argument (aus `req.tenantId`), nie aus
* dem DTO. Je Methode ein eigener `forTenant`-Klient.
*
* RLS-BINDUNG (Muster SearchProvider, siehe Migration 20260929130000): Lesen
* und Schreiben PERSOENLICHER Eintraege laeuft mit dem Benutzer als drittem
* Argument (`forTenant(prisma, tenantId, user.id)`); die Regel laesst dann nur
* gemeinsame und eigene Zeilen zu. Schreiben GEMEINSAMER Eintraege laeuft
* bewusst OHNE Benutzer (`forTenant(prisma, tenantId)`), weil die Regel einem
* Benutzerkontext das Schreiben gemeinsamer Zeilen verwehrt — die
* Rollenpruefung (Administrator) sitzt vorher im Dienst. Zusaetzlich pruefen
* alle Methoden `row.tenantId` und `row.ownerUserId` in der Anwendung, solange
* der RLS-Schalter aus ist.
*/ */
@Injectable() @Injectable()
export class CustomModulesService { export class CustomModulesService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async list(tenantId: string) { /** Gemeinsame Eintraege plus die eigenen des Aufrufers. */
const tenantPrisma = forTenant(this.prisma, tenantId); async list(tenantId: string, caller: CustomModuleCaller) {
return tenantPrisma.customModule.findMany({ const tenantPrisma = forTenant(this.prisma, tenantId, caller.id);
where: { tenantId }, const rows = await tenantPrisma.customModule.findMany({
where: { tenantId, OR: [{ ownerUserId: null }, { ownerUserId: caller.id }] },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
select: CUSTOM_MODULE_SELECT, select: CUSTOM_MODULE_SELECT,
}); });
return rows.map(toResponse);
} }
async getOne(tenantId: string, id: string) { async getOne(tenantId: string, caller: CustomModuleCaller, id: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, caller.id);
const row = await this.loadVisible(tenantPrisma, tenantId, caller, id);
const { tenantId: _omit, ...result } = row;
return toResponse(result);
}
async create(tenantId: string, caller: CustomModuleCaller, dto: CreateCustomModuleDto) {
const shared = dto.shared === true;
if (shared && !isAdmin(caller)) {
throw new ForbiddenException('Gemeinsame Einträge dürfen nur Administratoren anlegen');
}
const data = {
tenantId,
name: dto.name,
url: dto.url,
category: dto.category,
ownerUserId: shared ? null : caller.id,
};
if (shared) {
// Gemeinsam: ohne Benutzerkontext (die Regel verwehrt ihn dort).
const tenantPrisma = forTenant(this.prisma, tenantId); const tenantPrisma = forTenant(this.prisma, tenantId);
const row = await tenantPrisma.customModule.create({ data, select: CUSTOM_MODULE_SELECT });
return toResponse(row);
}
const tenantPrisma = forTenant(this.prisma, tenantId, caller.id);
const row = await tenantPrisma.customModule.create({ data, select: CUSTOM_MODULE_SELECT });
return toResponse(row);
}
async update(
tenantId: string,
caller: CustomModuleCaller,
id: string,
dto: UpdateCustomModuleDto,
) {
const tenantPrisma = await this.writableClient(tenantId, caller, id);
const data: { name?: string; url?: string; category?: string } = {};
if (dto.name !== undefined) data.name = dto.name;
if (dto.url !== undefined) data.url = dto.url;
if (dto.category !== undefined) data.category = dto.category;
// Besitz und Gemeinsamkeit stehen nie in `data` — sie aendern sich nicht.
const row = await tenantPrisma.customModule.update({
where: { id },
data,
select: CUSTOM_MODULE_SELECT,
});
return toResponse(row);
}
async remove(tenantId: string, caller: CustomModuleCaller, id: string) {
const tenantPrisma = await this.writableClient(tenantId, caller, id);
await tenantPrisma.customModule.delete({ where: { id } });
return { deleted: true };
}
/**
* Zeile laden, die der Aufrufer sehen darf: gleicher Mandant UND (gemeinsam
* ODER eigene). Alles andere — unbekannt, fremder Mandant, fremder
* persoenlicher Eintrag — ist ununterscheidbar 404.
*/
private async loadVisible(
tenantPrisma: ReturnType<typeof forTenant>,
tenantId: string,
caller: CustomModuleCaller,
id: string,
) {
const row = await tenantPrisma.customModule.findUnique({ const row = await tenantPrisma.customModule.findUnique({
where: { id }, where: { id },
select: { ...CUSTOM_MODULE_SELECT, tenantId: true }, select: { ...CUSTOM_MODULE_SELECT, tenantId: true },
@@ -44,42 +143,28 @@ export class CustomModulesService {
if (!row || row.tenantId !== tenantId) { if (!row || row.tenantId !== tenantId) {
throw new NotFoundException('Eigenes Modul nicht gefunden'); throw new NotFoundException('Eigenes Modul nicht gefunden');
} }
const { tenantId: _omit, ...result } = row; if (row.ownerUserId !== null && row.ownerUserId !== caller.id) {
return result;
}
async create(tenantId: string, dto: CreateCustomModuleDto) {
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.customModule.create({
data: { tenantId, name: dto.name, url: dto.url, category: dto.category },
select: CUSTOM_MODULE_SELECT,
});
}
async update(tenantId: string, id: string, dto: UpdateCustomModuleDto) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.customModule.findUnique({ where: { id } });
if (!existing || existing.tenantId !== tenantId) {
throw new NotFoundException('Eigenes Modul nicht gefunden'); throw new NotFoundException('Eigenes Modul nicht gefunden');
} }
const data: { name?: string; url?: string; category?: string } = {}; return row;
if (dto.name !== undefined) data.name = dto.name;
if (dto.url !== undefined) data.url = dto.url;
if (dto.category !== undefined) data.category = dto.category;
return tenantPrisma.customModule.update({
where: { id },
data,
select: CUSTOM_MODULE_SELECT,
});
} }
async remove(tenantId: string, id: string) { /**
* Klient fuer Aendern/Loeschen: persoenlicher Eintrag -> mit Benutzer (nur der
* Besitzer kommt bis hierher); gemeinsamer Eintrag -> nur Administrator (403
* sonst, der Eintrag ist fuer alle sichtbar, sein Bestehen ist kein
* Geheimnis), dann ohne Benutzerkontext.
*/
private async writableClient(tenantId: string, caller: CustomModuleCaller, id: string) {
const userClient = forTenant(this.prisma, tenantId, caller.id);
const row = await this.loadVisible(userClient, tenantId, caller, id);
if (row.ownerUserId === caller.id) {
return userClient;
}
if (!isAdmin(caller)) {
throw new ForbiddenException('Gemeinsame Einträge dürfen nur Administratoren ändern');
}
const tenantPrisma = forTenant(this.prisma, tenantId); const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.customModule.findUnique({ where: { id } }); return tenantPrisma;
if (!existing || existing.tenantId !== tenantId) {
throw new NotFoundException('Eigenes Modul nicht gefunden');
}
await tenantPrisma.customModule.delete({ where: { id } });
return { deleted: true };
} }
} }
@@ -1,9 +1,11 @@
import { PartialType } from '@nestjs/mapped-types'; import { OmitType, PartialType } from '@nestjs/mapped-types';
import { MODULE_CATEGORIES } from '@tessera/shared'; import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { import {
IsBoolean,
IsIn, IsIn,
IsNotEmpty, IsNotEmpty,
IsOptional,
IsString, IsString,
MaxLength, MaxLength,
Validate, Validate,
@@ -58,9 +60,25 @@ export class CreateCustomModuleDto {
@Validate(NurHttpsOhneZugangsdatenConstraint) @Validate(NurHttpsOhneZugangsdatenConstraint)
url!: string; url!: string;
@IsIn([...MODULE_CATEGORIES]) @IsIn([...CUSTOM_MODULE_CATEGORIES])
category!: (typeof MODULE_CATEGORIES)[number]; category!: (typeof CUSTOM_MODULE_CATEGORIES)[number];
/**
* quick-260929-dzu: `true` legt einen gemeinsamen Eintrag fuer alle Benutzer
* an (nur Administratoren, sonst 403 im Dienst). Fehlt das Feld oder ist es
* `false`, ist der Eintrag persoenlich und gehoert dem Aufrufer.
*/
@IsOptional()
@IsBoolean()
shared?: boolean;
} }
/** Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen. */ /**
export class UpdateCustomModuleDto extends PartialType(CreateCustomModuleDto) {} * Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen.
* `shared` ist ausgenommen — ob ein Eintrag gemeinsam oder persoenlich ist,
* aendert sich nach dem Anlegen nicht (die globale Pipe verwirft das Feld
* dank `whitelist: true`).
*/
export class UpdateCustomModuleDto extends PartialType(
OmitType(CreateCustomModuleDto, ['shared'] as const),
) {}
+46
View File
@@ -694,6 +694,16 @@ fn get_client_info() -> String {
client_info_label(env!("CARGO_PKG_VERSION"), env!("APP_COMMIT")) client_info_label(env!("CARGO_PKG_VERSION"), env!("APP_COMMIT"))
} }
/// Ziel fuer den System-Browser, wenn die Seite ein neues Fenster verlangt
/// (`window.open`, Links mit `target="_blank"`). Nur http/https -- andere
/// Schemata (file:, javascript:, eigene Protokolle) werden verworfen.
fn external_target(url: &tauri::Url) -> Option<String> {
match url.scheme() {
"http" | "https" => Some(url.to_string()),
_ => None,
}
}
/// Hauptfenster nach vorne holen. Ein minimiertes Fenster (z. B. per Win+D) /// Hauptfenster nach vorne holen. Ein minimiertes Fenster (z. B. per Win+D)
/// wird zuerst wiederhergestellt. /// wird zuerst wiederhergestellt.
fn show_main_window(app: &AppHandle) { fn show_main_window(app: &AppHandle) {
@@ -750,6 +760,20 @@ pub fn run() {
.center() .center()
.decorations(true) .decorations(true)
.resizable(true) .resizable(true)
// Neue Fenster (Suche-Widget per `window.open`, „In neuem Tab
// öffnen“ bei XFrame/eigenen Modulen/Favoriten) verwarf die
// Webansicht still -- Nutzer, 29.09.2026: Suche im Client ohne
// Funktion. Jetzt gehen sie wie im Browser-Tab nach aussen, in den
// System-Browser; die App selbst oeffnet kein zweites Fenster.
.on_new_window({
let handle = app.handle().clone();
move |url, _features| {
if let Some(target) = external_target(&url) {
let _ = handle.opener().open_url(target, None::<&str>);
}
tauri::webview::NewWindowResponse::Deny
}
})
.on_download(|webview, event| { .on_download(|webview, event| {
if let tauri::webview::DownloadEvent::Requested { url, .. } = event { if let tauri::webview::DownloadEvent::Requested { url, .. } = event {
let _ = webview let _ = webview
@@ -1226,6 +1250,28 @@ mod tests {
); );
} }
#[test]
fn external_target_nur_http_und_https() {
let https = tauri::Url::parse("https://www.google.com/search?q=tessera").unwrap();
assert_eq!(
external_target(&https).as_deref(),
Some("https://www.google.com/search?q=tessera")
);
let http = tauri::Url::parse("http://intranet.local/").unwrap();
assert_eq!(
external_target(&http).as_deref(),
Some("http://intranet.local/")
);
for bad in [
"file:///C:/Windows/system32/calc.exe",
"javascript:alert(1)",
"ms-settings:privacy",
] {
let url = tauri::Url::parse(bad).unwrap();
assert_eq!(external_target(&url), None, "{bad}");
}
}
#[test] #[test]
fn parse_server_url_gueltige_adresse() { fn parse_server_url_gueltige_adresse() {
let parsed = parse_server_url("https://tessera.ctl.de").unwrap(); let parsed = parse_server_url("https://tessera.ctl.de").unwrap();
@@ -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'),
}}
/>
);
}
+5 -3
View File
@@ -255,11 +255,13 @@ body {
Rahmen plus 2 px Akzentlinie unten. Gilt fuer Textfelder und Auswahllisten Rahmen plus 2 px Akzentlinie unten. Gilt fuer Textfelder und Auswahllisten
mit dem ueblichen `border-input`/`border-border`-Rahmen; Fehlerzustaende mit dem ueblichen `border-input`/`border-border`-Rahmen; Fehlerzustaende
(aria-invalid, border-destructive) behalten ihre Farbe. Bewusst ausserhalb (aria-invalid, border-destructive) behalten ihre Farbe. Bewusst ausserhalb
eines @layer, damit die Regel die Tailwind-Utilities schlaegt. */ eines @layer, damit die Regel die Tailwind-Utilities schlaegt.
:is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"]), select, textarea):is(.border-input, .border-border):not([aria-invalid="true"], .border-destructive) { `.field-plain` nimmt ein Feld aus (Such-Widget, Nutzerwunsch 29.09.: die
helle Unterkante wirkte im Dunkelmodus wie eine weisse Linie). */
:is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"]), select, textarea):is(.border-input, .border-border):not([aria-invalid="true"], .border-destructive, .field-plain) {
border-bottom-color: var(--input-strong); border-bottom-color: var(--input-strong);
} }
:is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"]), select, textarea):is(.border-input, .border-border):not([aria-invalid="true"], .border-destructive):focus { :is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"]), select, textarea):is(.border-input, .border-border):not([aria-invalid="true"], .border-destructive, .field-plain):focus {
outline: none; outline: none;
border-color: var(--input-strong); border-color: var(--input-strong);
border-bottom-color: var(--primary); border-bottom-color: var(--primary);
@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { MODULE_CATEGORIES } from '@tessera/shared'; import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared';
import { import {
type CustomModule, type CustomModule,
checkCustomModuleUrl, checkCustomModuleUrl,
@@ -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();
@@ -129,7 +142,7 @@ export function CustomModuleFormModal({ mod, onClose, onSaved }: CustomModuleFor
onChange={(e) => setCategory(e.target.value)} 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" className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
> >
{MODULE_CATEGORIES.map((c) => ( {CUSTOM_MODULE_CATEGORIES.map((c) => (
<option key={c} value={c}> <option key={c} value={c}>
{categoryLabel(c)} {categoryLabel(c)}
</option> </option>
@@ -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>
);
}
@@ -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
@@ -2,7 +2,7 @@ import { Children, isValidElement } from 'react';
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { stubResizeObserver } from '@/test/fake-resize-observer'; import { stubResizeObserver } from '@/test/fake-resize-observer';
import { centeringOffset, RESIZE_AXIS_FALLBACK } from './dashboard-grid'; import { RESIZE_AXIS_FALLBACK } from './dashboard-grid';
// Mock CSS imports that vitest cannot resolve // Mock CSS imports that vitest cannot resolve
vi.mock('react-grid-layout/css/styles.css', () => ({})); vi.mock('react-grid-layout/css/styles.css', () => ({}));
@@ -194,7 +194,7 @@ describe('DashboardGrid', () => {
expect(document.querySelector('.widget-drag-handle')).toBeInTheDocument(); expect(document.querySelector('.widget-drag-handle')).toBeInTheDocument();
}); });
it('quick-260916-bwo Test 4: Grid-Props — 24/20/12/8/2 Spalten, rowHeight 20, margin 12, Breakpoints unveraendert, containerPadding folgt dem margin', async () => { it('quick-260916-bwo Test 4: Grid-Props — 48/40/24/16/4 Spalten (quick-260929-dmx), rowHeight 20, margin 12, Breakpoints unveraendert, containerPadding folgt dem margin', async () => {
captured.props = null; captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid'); const { DashboardGrid } = await import('./dashboard-grid');
render( render(
@@ -208,7 +208,7 @@ describe('DashboardGrid', () => {
); );
expect(captured.props).not.toBeNull(); expect(captured.props).not.toBeNull();
expect(captured.props?.cols).toEqual({ lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 }); expect(captured.props?.cols).toEqual({ lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 });
expect(captured.props?.rowHeight).toBe(20); expect(captured.props?.rowHeight).toBe(20);
expect(captured.props?.margin).toEqual([12, 12]); expect(captured.props?.margin).toEqual([12, 12]);
expect(captured.props?.breakpoints).toEqual({ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }); expect(captured.props?.breakpoints).toEqual({ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 });
@@ -233,8 +233,9 @@ describe('DashboardGrid', () => {
const first = children[0]; const first = children[0];
expect(isValidElement(first)).toBe(true); expect(isValidElement(first)).toBe(true);
const props = (first as React.ReactElement<Record<string, unknown>>).props; const props = (first as React.ReactElement<Record<string, unknown>>).props;
// quick-260916-dyv: Uhr-Minimum 2x2 (vorher verdoppelt 4x4), Vorgabe 4x4 unveraendert. // quick-260916-dyv: Uhr-Minimum 2 Zeilen hoch; quick-260929-dmx: Breiten im
expect(props['data-grid']).toEqual({ x: 0, y: 0, w: 4, h: 4, minW: 2, minH: 2 }); // 48er-Raster verdoppelt (Minimum 4, Vorgabe 8), Hoehen unveraendert.
expect(props['data-grid']).toEqual({ x: 0, y: 0, w: 8, h: 4, minW: 4, minH: 2 });
}); });
it('quick-260916-dyv Test 9: gespeicherte minW/minH werden in JEDEM Breakpoint aus WIDGET_CONSTRAINTS ueberschrieben, x/y/w/h bleiben, unbekannte Typen und Schluessel unveraendert', async () => { it('quick-260916-dyv Test 9: gespeicherte minW/minH werden in JEDEM Breakpoint aus WIDGET_CONSTRAINTS ueberschrieben, x/y/w/h bleiben, unbekannte Typen und Schluessel unveraendert', async () => {
@@ -265,9 +266,9 @@ describe('DashboardGrid', () => {
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>; const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
expect(Object.keys(passed)).toEqual(Object.keys(layouts)); expect(Object.keys(passed)).toEqual(Object.keys(layouts));
expect(passed.lg[0]).toEqual({ i: 'inst-1', x: 2, y: 4, w: 6, h: 6, minW: 2, minH: 2 }); expect(passed.lg[0]).toEqual({ i: 'inst-1', x: 2, y: 4, w: 6, h: 6, minW: 4, minH: 2 });
expect(passed.md[0]).toEqual({ i: 'inst-1', x: 0, y: 0, w: 4, h: 4, minW: 2, minH: 2 }); expect(passed.md[0]).toEqual({ i: 'inst-1', x: 0, y: 0, w: 4, h: 4, minW: 4, minH: 2 });
expect(passed.md[0].minW).toBe(2); expect(passed.md[0].minW).toBe(4);
// Unbekannter Typ: Eintrag bleibt woertlich erhalten (kein Absturz, T-DYV-01). // Unbekannter Typ: Eintrag bleibt woertlich erhalten (kein Absturz, T-DYV-01).
expect(passed.lg[1]).toEqual({ i: 'inst-x', x: 10, y: 0, w: 5, h: 5, minW: 7, minH: 7 }); expect(passed.lg[1]).toEqual({ i: 'inst-x', x: 10, y: 0, w: 5, h: 5, minW: 7, minH: 7 });
expect(passed.sm).toEqual([]); expect(passed.sm).toEqual([]);
@@ -278,9 +279,9 @@ describe('DashboardGrid', () => {
const { DashboardGrid } = await import('./dashboard-grid'); const { DashboardGrid } = await import('./dashboard-grid');
const layouts = { const layouts = {
lg: [ lg: [
// Rechner: gespeichert mit h 8 (bwo-Minimum), neues Minimum 3x9 -> h wird 9, w 6 bleibt. // Rechner: gespeichert mit h 8 (bwo-Minimum), neues Minimum 6x10 -> h wird 10, w 6 bleibt.
{ i: 'calc-1', x: 0, y: 0, w: 6, h: 8, minW: 4, minH: 8 }, { i: 'calc-1', x: 0, y: 0, w: 6, h: 8, minW: 4, minH: 8 },
// Suche: gespeichert mit w 4 (unter minW 6) -> w wird 6, h 4 bleibt. // Suche: gespeichert mit w 4 (unter minW 12) -> w wird 12, h 4 bleibt.
{ i: 'srch-1', x: 6, y: 0, w: 4, h: 4, minW: 6, minH: 4 }, { i: 'srch-1', x: 6, y: 0, w: 4, h: 4, minW: 6, minH: 4 },
], ],
md: [{ i: 'calc-1', x: 0, y: 0, w: 3, h: 7, minW: 4, minH: 8 }], md: [{ i: 'calc-1', x: 0, y: 0, w: 3, h: 7, minW: 4, minH: 8 }],
@@ -302,15 +303,53 @@ describe('DashboardGrid', () => {
); );
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>; const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
expect(passed.lg[0]).toEqual({ i: 'calc-1', x: 0, y: 0, w: 6, h: 10, minW: 3, minH: 10 }); expect(passed.lg[0]).toEqual({ i: 'calc-1', x: 0, y: 0, w: 6, h: 10, minW: 6, minH: 10 });
expect(passed.lg[1]).toEqual({ i: 'srch-1', x: 6, y: 0, w: 6, h: 4, minW: 6, minH: 2 }); expect(passed.lg[1]).toEqual({ i: 'srch-1', x: 6, y: 0, w: 12, h: 4, minW: 12, minH: 2 });
// Jeder Breakpoint: md-Rechner 3x7 -> 3x10 (w schon = minW, h angehoben). // Jeder Breakpoint: md-Rechner 3x7 -> 6x10 (w und h auf das Minimum angehoben).
expect(passed.md[0]).toEqual({ i: 'calc-1', x: 0, y: 0, w: 3, h: 10, minW: 3, minH: 10 }); expect(passed.md[0]).toEqual({ i: 'calc-1', x: 0, y: 0, w: 6, h: 10, minW: 6, minH: 10 });
// Eingabe unveraendert (keine Mutation des Store-Objekts). // Eingabe unveraendert (keine Mutation des Store-Objekts).
expect(layouts.lg[0].h).toBe(8); expect(layouts.lg[0].h).toBe(8);
expect(layouts.lg[0].minH).toBe(8); expect(layouts.lg[0].minH).toBe(8);
}); });
it('quick-260929-dmx Test 9c: bestehender Kalender (migrierte Werte minW 12, w 16) bekommt minW 8 in JEDEM Breakpoint und kann schmaler gezogen werden; zu schmale Breite wird auf 8 angehoben', async () => {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
// So sieht ein aus v2 (minW 6 x2) migrierter Kalender im Zustand aus.
const layouts = {
lg: [
{ i: 'cal-1', x: 4, y: 0, w: 16, h: 16, minW: 12, minH: 8 },
{ i: 'cal-2', x: 24, y: 0, w: 6, h: 16, minW: 12, minH: 8 },
],
md: [{ i: 'cal-1', x: 0, y: 0, w: 16, h: 16, minW: 12, minH: 8 }],
sm: [],
xs: [],
xxs: [],
};
render(
<DashboardGrid
layouts={layouts}
widgets={[
{ id: 'cal-1', widgetType: 'calendar', config: {} },
{ id: 'cal-2', widgetType: 'calendar', config: {} },
]}
isEditMode
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
// Breite bleibt (Bildschirmgroesse unveraendert), nur das Minimum sinkt.
expect(passed.lg[0]).toEqual({ i: 'cal-1', x: 4, y: 0, w: 16, h: 16, minW: 8, minH: 8 });
expect(passed.md[0]).toEqual({ i: 'cal-1', x: 0, y: 0, w: 16, h: 16, minW: 8, minH: 8 });
expect(passed.lg[1]).toEqual({ i: 'cal-2', x: 24, y: 0, w: 8, h: 16, minW: 8, minH: 8 });
// Auch der data-grid-Wert der Kinder traegt das neue Minimum.
const children = Children.toArray(captured.props?.children as React.ReactNode);
const grid = (children[0] as React.ReactElement<Record<string, unknown>>).props['data-grid'] as Record<string, unknown>;
expect(grid.minW).toBe(8);
});
const CANCEL = 'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag'; const CANCEL = 'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag';
it('quick-260916-dyv Test 6: dragConfig-Pin — handle Karte, cancel fuer Interaktives, threshold 3; resizeConfig folgt dem Bearbeitungsmodus', async () => { it('quick-260916-dyv Test 6: dragConfig-Pin — handle Karte, cancel fuer Interaktives, threshold 3; resizeConfig folgt dem Bearbeitungsmodus', async () => {
@@ -500,12 +539,3 @@ describe('RESIZE_AXIS_FALLBACK (Design „Mosaik“, Runde 3)', () => {
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 3, 9, 'se', context)).toEqual({ w: 3, h: 9 }); expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 3, 9, 'se', context)).toEqual({ w: 3, h: 9 });
}); });
}); });
describe('centeringOffset (Design „Mosaik“, Runde 3)', () => {
it('stellt belegte Spalten mittig und laesst eine volle Anordnung stehen', () => {
// 24 Spalten auf 1212 px: Spalte 38 px + 12 px Abstand = 50 px je Spalte.
expect(centeringOffset([{ x: 0, w: 12 }], 24, 1212)).toBe(300);
expect(centeringOffset([{ x: 12, w: 12 }], 24, 1212)).toBe(0);
expect(centeringOffset([], 24, 1212)).toBe(0);
});
});
@@ -14,9 +14,16 @@ import { WidgetWrapper } from './widgets/widget-wrapper';
// quick-260916-bwo: Raster doppelt so fein wie zuvor (24 statt 12 Spalten, // quick-260916-bwo: Raster doppelt so fein wie zuvor (24 statt 12 Spalten,
// 20 statt 40 px Zeilenhoehe, 8 statt 16 px Abstand). Gespeicherte Anordnungen // 20 statt 40 px Zeilenhoehe, 8 statt 16 px Abstand). Gespeicherte Anordnungen
// in alten Einheiten werden in `@/lib/grid-layout-migration` einmalig // in alten Einheiten werden in `@/lib/grid-layout-migration` einmalig
// umgerechnet und mit `__gridVersion: 2` markiert. BREAKPOINTS unveraendert. // umgerechnet und mit `__gridVersion` markiert. BREAKPOINTS unveraendert.
//
// quick-260929-dmx (Version 3): Raster in der BREITE noch einmal doppelt so
// fein (48 statt 24 Spalten am lg-Breakpoint, uebrige Breakpoints ebenso
// verdoppelt). Zeilenhoehe (20 px) und Abstand (12 px) bleiben. Alle
// Breitenwerte (x, w, minW, maxW, WIDGET_CONSTRAINTS, Rueckfallwerte) sind
// damit in 48stel-Einheiten; gespeicherte Anordnungen rechnet die Migration
// einmalig um, jedes Widget behaelt seine Bildschirmgroesse und -position.
const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }; const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
const COLS = { lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 }; const COLS = { lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 };
// quick-260916-dyv: Ziehen zuverlaessig. // quick-260916-dyv: Ziehen zuverlaessig.
// - Griff ist die GANZE Karte (`widget-drag-handle` in widget-wrapper.tsx). // - Griff ist die GANZE Karte (`widget-drag-handle` in widget-wrapper.tsx).
@@ -86,33 +93,6 @@ const GRID_CONSTRAINTS: LayoutConstraint[] = [...defaultConstraints, RESIZE_AXIS
const GRID_MARGIN = 12; const GRID_MARGIN = 12;
/**
* Seitlicher Versatz, um die belegten Spalten in der Ansicht mittig zu
* stellen (Design „Mosaik“, Runde 3): auf breiten Bildschirmen blieb rechts
* ein leerer Streifen, weil die Kacheln links beginnen. Belegt die Anordnung
* schon (fast) die ganze Breite, bleibt es bei 0.
*/
export function centeringOffset(
items: Array<{ x: number; w: number }> | undefined,
cols: number,
width: number,
): number {
if (!items || items.length === 0 || cols <= 0 || width <= 0) return 0;
const usedCols = Math.min(cols, Math.max(...items.map((l) => l.x + l.w)));
if (usedCols >= cols) return 0;
const colWidth = (width - GRID_MARGIN * (cols + 1)) / cols;
const freeWidth = (cols - usedCols) * (colWidth + GRID_MARGIN);
return Math.max(0, Math.floor(freeWidth / 2));
}
/** Breakpoint zur Breite — gleiche Regel wie react-grid-layout (strikt groesser). */
function breakpointFor(width: number): keyof typeof COLS {
const order = (Object.keys(BREAKPOINTS) as Array<keyof typeof BREAKPOINTS>).sort(
(a, b) => BREAKPOINTS[b] - BREAKPOINTS[a],
);
return order.find((bp) => width > BREAKPOINTS[bp]) ?? 'xxs';
}
interface LayoutItemShape { interface LayoutItemShape {
i: string; i: string;
x: number; x: number;
@@ -133,8 +113,6 @@ interface DashboardGridProps {
onOpenCatalog?: () => void; onOpenCatalog?: () => void;
/** Leerer Zustand: fuegt eine vorgeschlagene Kachel direkt hinzu. */ /** Leerer Zustand: fuegt eine vorgeschlagene Kachel direkt hinzu. */
onQuickAdd?: (type: WidgetType) => void; onQuickAdd?: (type: WidgetType) => void;
/** Meldet den seitlichen Versatz der zentrierten Ansicht (0 im Bearbeitungsmodus). */
onInsetChange?: (px: number) => void;
} }
/** Vorschlaege im leeren Zustand — Plattform-Kacheln, immer verfuegbar. */ /** Vorschlaege im leeren Zustand — Plattform-Kacheln, immer verfuegbar. */
@@ -203,7 +181,6 @@ export function DashboardGrid({
onRemoveWidget, onRemoveWidget,
onOpenCatalog, onOpenCatalog,
onQuickAdd, onQuickAdd,
onInsetChange,
}: DashboardGridProps) { }: DashboardGridProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const [width, setWidth] = useState(1200); const [width, setWidth] = useState(1200);
@@ -284,17 +261,6 @@ export function DashboardGrid({
// stabil bleibt. // stabil bleibt.
const effectiveLayouts = useMemo(() => applyConstraintMinima(layouts, widgets), [layouts, widgets]); const effectiveLayouts = useMemo(() => applyConstraintMinima(layouts, widgets), [layouts, widgets]);
// Zentrieren nur in der Ansicht — im Bearbeitungsmodus braucht es das
// volle Raster, damit Kacheln auch in die freien Spalten rechts passen.
const breakpoint = breakpointFor(width);
const inset = isEditMode
? 0
: centeringOffset(effectiveLayouts[breakpoint] ?? effectiveLayouts.lg, COLS[breakpoint], width);
useEffect(() => {
onInsetChange?.(widgets.length === 0 ? 0 : inset);
}, [inset, onInsetChange, widgets.length]);
// Leerer Zustand (D-02, Design „Mosaik“ Runde 2): freundlich ohne // Leerer Zustand (D-02, Design „Mosaik“ Runde 2): freundlich ohne
// Illustration — Titel, ein Satz, drei Vorschlaege zum direkten // Illustration — Titel, ein Satz, drei Vorschlaege zum direkten
// Hinzufuegen und der Hauptknopf zum Katalog. // Hinzufuegen und der Hauptknopf zum Katalog.
@@ -340,14 +306,7 @@ export function DashboardGrid({
} }
return ( return (
// overflow-x-clip: die verschobene Rasterflaeche ragt rechts ueber den <div ref={measureRef}>
// Rand; `clip` (nicht `hidden`) laesst die Schatten oben/unten stehen.
<div ref={measureRef} className="overflow-x-clip">
<div
data-testid="dashboard-grid-inset"
className="transition-transform duration-200 ease-out motion-reduce:transition-none"
style={inset > 0 ? { transform: `translateX(${inset}px)` } : undefined}
>
<Responsive <Responsive
width={width} width={width}
breakpoints={BREAKPOINTS} breakpoints={BREAKPOINTS}
@@ -379,10 +338,10 @@ export function DashboardGrid({
...(effectiveLayouts.lg?.find((l) => l.i === widget.id) ?? { ...(effectiveLayouts.lg?.find((l) => l.i === widget.id) ?? {
x: 0, x: 0,
y: 0, y: 0,
w: constraints?.defaultW ?? 4, w: constraints?.defaultW ?? 8,
h: constraints?.defaultH ?? 4, h: constraints?.defaultH ?? 4,
}), }),
minW: constraints?.minW ?? 4, minW: constraints?.minW ?? 8,
minH: constraints?.minH ?? 4, minH: constraints?.minH ?? 4,
}} }}
> >
@@ -397,6 +356,5 @@ export function DashboardGrid({
})} })}
</Responsive> </Responsive>
</div> </div>
</div>
); );
} }
@@ -67,21 +67,21 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
}); });
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu, zehn Typen)', () => { it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu, zehn Typen)', () => {
// Raster 24 Spalten / 20 px (quick-260916-bwo). Die Minima sind seit // Raster 48 Spalten / 20 px (quick-260929-dmx, vorher 24 Spalten). Alle
// quick-260916-dyv inhaltsgetrieben (Rechnung im Plan), die Vorgaben // Breitenwerte sind gegenueber dem 24er-Raster verdoppelt (gleiche
// (defaultW/defaultH) bleiben das Doppelte der alten 12-Spalten-Werte. // Bildschirmbreite), Hoehen unveraendert; einzige Ausnahme Kalender-minW 8
// 3 und 9 sind ungerade — die bwo-Pruefung "jeder Wert ist gerade" entfaellt. // (rund 250 px, gemessene kleinste benutzbare Breite; vorher 6 von 24).
expect(WIDGET_CONSTRAINTS).toEqual({ expect(WIDGET_CONSTRAINTS).toEqual({
clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 }, clock: { minW: 4, minH: 2, defaultW: 8, defaultH: 4 },
search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 }, search: { minW: 12, minH: 2, defaultW: 24, defaultH: 4 },
calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 16 }, calendar: { minW: 8, minH: 8, defaultW: 16, defaultH: 16 },
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 }, note: { minW: 8, minH: 4, defaultW: 12, defaultH: 8 },
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 }, calculator: { minW: 6, minH: 10, defaultW: 12, defaultH: 10 },
favorites: { minW: 1, minH: 3, defaultW: 6, defaultH: 10 }, favorites: { minW: 2, minH: 3, defaultW: 12, defaultH: 10 },
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 }, stopwatch: { minW: 8, minH: 3, defaultW: 12, defaultH: 6 },
'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 12 }, 'picture-frame': { minW: 8, minH: 4, defaultW: 16, defaultH: 12 },
xframe: { minW: 4, minH: 4, defaultW: 12, defaultH: 12 }, xframe: { minW: 8, minH: 4, defaultW: 24, defaultH: 12 },
proxmox: { minW: 3, minH: 4, defaultW: 8, defaultH: 8 }, proxmox: { minW: 6, minH: 4, defaultW: 16, defaultH: 8 },
}); });
let counted = 0; let counted = 0;
@@ -40,45 +40,55 @@ export const WIDGET_CONSTRAINTS: Record<
{ minW: number; minH: number; defaultW: number; defaultH: number } { minW: number; minH: number; defaultW: number; defaultH: number }
> = { > = {
// quick-260916-dyv: minW/minH = kleinste noch bedienbare Kachel je Typ im // quick-260916-dyv: minW/minH = kleinste noch bedienbare Kachel je Typ im
// 24-Spalten/20-px-Raster, aus dem Innenaufbau gerechnet (Suche: Auswahl 120 + // Raster, aus dem Innenaufbau gerechnet (Suche: Auswahl 120 +
// Eingabe + Knopf; Rechner: Anzeige 40 + Speicherzeile 28 + 5 Tastenreihen 28 = // Eingabe + Knopf; Rechner: Anzeige 40 + Speicherzeile 28 + 5 Tastenreihen 28 =
// 268 px -> 10 Zeilen; Stoppuhr: kompakte Bedienleiste). defaultW/defaultH = // 268 px -> 10 Zeilen; Stoppuhr: kompakte Bedienleiste). defaultW/defaultH =
// altes 12-Spalten-Mass x2, unveraendert. Gespeicherte minW/minH werden in // altes 12-Spalten-Mass x2, unveraendert. Gespeicherte minW/minH werden in
// dashboard-grid.tsx aus dieser Tabelle ueberschrieben. // dashboard-grid.tsx aus dieser Tabelle ueberschrieben.
// quick-260929-dmx: Raster in der Breite doppelt so fein (48 Spalten am
// lg-Breakpoint). Alle Breitenwerte (minW, defaultW) sind gegenueber dem
// 24-Spalten-Raster verdoppelt, die Kacheln behalten ihre Bildschirmbreite;
// Hoehen (minH, defaultH) unveraendert. Einzige Ausnahme: Kalender-minW.
// Existing widgets // Existing widgets
clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 }, clock: { minW: 4, minH: 2, defaultW: 8, defaultH: 4 },
search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 }, search: { minW: 12, minH: 2, defaultW: 24, defaultH: 4 },
// quick-260916-htc: Monatsraster braucht Breite fuer 7 Spalten und Hoehe // quick-260916-htc: Monatsraster braucht Breite fuer 7 Spalten und Hoehe
// fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block. // fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block.
// Runde 3 (Design „Mosaik“): defaultH 16 statt 12 — mit 12 passte unter // Runde 3 (Design „Mosaik“): defaultH 16 statt 12 — mit 12 passte unter
// den Monat nur ein Termin. // den Monat nur ein Termin.
calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 16 }, // quick-260929-dmx: auf Nutzerwunsch (29.09.) schmaler ziehbar — minW 8 von
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 }, // 48 Spalten = rund 250 px am lg-Breakpoint (gemessen: Monatsraster,
// Ueberschrift und Terminliste sind dort noch benutzbar; bei rund 185 px
// wird die Ueberschrift abgeschnitten). defaultW 16 = unveraenderte Breite.
calendar: { minW: 8, minH: 8, defaultW: 16, defaultH: 16 },
note: { minW: 8, minH: 4, defaultW: 12, defaultH: 8 },
// Phase 8 new widgets (D-01) // Phase 8 new widgets (D-01)
// minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat // minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat
// SECHS Tastenreihen (%/CE/C/Loeschen, 1/x/x2/Wurzel/Division, 7-9, 4-6, 1-3, 0/Komma/=), // SECHS Tastenreihen (%/CE/C/Loeschen, 1/x/x2/Wurzel/Division, 7-9, 4-6, 1-3, 0/Komma/=),
// Inhalt 268 px; bei 9 Zeilen (244 px) fehlte die unterste Reihe um 25 px. // Inhalt 268 px; bei 9 Zeilen (244 px) fehlte die unterste Reihe um 25 px.
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 }, calculator: { minW: 6, minH: 10, defaultW: 12, defaultH: 10 },
// Favoriten: minW 1 statt 3 (Nutzerwunsch 23.09.) — bei kurzen Linknamen // Favoriten: minW 1 (im 24er-Raster, jetzt 2 von 48) statt 3 (Nutzerwunsch
// blieb rechts viel Leerraum. Der Titel kuerzt mit Auslassungszeichen, // 23.09.) — bei kurzen Linknamen blieb rechts viel Leerraum. Der Titel
// in der schmalsten Stufe bleibt das Symbol als Leiste stehen. // kuerzt mit Auslassungszeichen, in der schmalsten Stufe bleibt das Symbol
favorites: { minW: 1, minH: 3, defaultW: 6, defaultH: 10 }, // als Leiste stehen.
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 }, favorites: { minW: 2, minH: 3, defaultW: 12, defaultH: 10 },
// quick-260921-pi9: ein Bild braucht Flaeche — 8x12 entspricht der stopwatch: { minW: 8, minH: 3, defaultW: 12, defaultH: 6 },
// Kalender-Vorgabe (rund 530x330 px, Querformat wie ein Foto); 8x8 war im // quick-260921-pi9: ein Bild braucht Flaeche — 16x12 (im 48er-Raster)
// Browser-Rundgang mit 216 px Hoehe zu flach. 4x4 ist die kleinste Kachel, // entspricht der Kalender-Vorgabe (rund 530x330 px, Querformat wie ein
// in der Bild und Unterschrift-Streifen noch erkennbar sind. // Foto); Hoehe 8 war im Browser-Rundgang mit 216 px zu flach. 8x4 ist die
'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 12 }, // kleinste Kachel, in der Bild und Unterschrift-Streifen noch erkennbar sind.
// quick-260921-qd3: eine Webseite braucht Breite UND Hoehe — 12x12 = halbe 'picture-frame': { minW: 8, minH: 4, defaultW: 16, defaultH: 12 },
// Rasterbreite bei rund 330 px Hoehe (8 Zeilen waeren nur 216 px, zu flach // quick-260921-qd3: eine Webseite braucht Breite UND Hoehe — 24x12 im
// fuer eine Seite; vgl. Bilderrahmen 8x12). 4x4 kleinste Kachel, in der ein // 48er-Raster = halbe Rasterbreite bei rund 330 px Hoehe (8 Zeilen waeren
// Rahmen noch Sinn hat. // nur 216 px, zu flach fuer eine Seite; vgl. Bilderrahmen). 8x4 kleinste
xframe: { minW: 4, minH: 4, defaultW: 12, defaultH: 12 }, // Kachel, in der ein Rahmen noch Sinn hat.
xframe: { minW: 8, minH: 4, defaultW: 24, defaultH: 12 },
// quick-260924-i8v: 4 Zeilen = 104 px reichen genau fuer Balken und // quick-260924-i8v: 4 Zeilen = 104 px reichen genau fuer Balken und
// Zusammenfassung (die Serverliste blendet sich darunter per Container- // Zusammenfassung (die Serverliste blendet sich darunter per Container-
// Query aus); 8x8 = rund 456x216 px bei 1400 px Breite zeigt etwa sechs // Query aus); 16x8 (im 48er-Raster) = rund 456x216 px bei 1400 px Breite
// Serverzeilen; 3 Spalten = rund 166 px zeigen nur Punkte und Namen. // zeigt etwa sechs Serverzeilen; 6 Spalten = rund 166 px zeigen nur Punkte
proxmox: { minW: 3, minH: 4, defaultW: 8, defaultH: 8 }, // und Namen.
proxmox: { minW: 6, minH: 4, defaultW: 16, defaultH: 8 },
}; };
/** /**
@@ -89,7 +89,7 @@ export function SearchWidget({ instanceId, config }: WidgetProps) {
<div className="flex h-full items-center gap-2 px-1.5"> <div className="flex h-full items-center gap-2 px-1.5">
{/* Provider dropdown (D-14: ~120px left) */} {/* Provider dropdown (D-14: ~120px left) */}
<select <select
className="h-8 w-[120px] shrink-0 rounded border border-border bg-card px-2 text-sm text-foreground" className="field-plain h-8 w-[120px] shrink-0 rounded border border-border bg-card px-2 text-sm text-foreground"
value={selectedProviderId} value={selectedProviderId}
onChange={(e) => handleProviderChange(e.target.value)} onChange={(e) => handleProviderChange(e.target.value)}
> >
@@ -103,7 +103,7 @@ export function SearchWidget({ instanceId, config }: WidgetProps) {
{/* Search input (flex-1 center) */} {/* Search input (flex-1 center) */}
<input <input
type="text" type="text"
className="h-8 flex-1 rounded border border-border bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" className="field-plain h-8 flex-1 rounded border border-border bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder={t('search.placeholder')} placeholder={t('search.placeholder')}
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
@@ -40,9 +40,13 @@ vi.mock('next-intl', () => ({
}, },
})); }));
let mockCollapsed = false;
function mockSidebarCollapsed(value: boolean) {
mockCollapsed = value;
}
vi.mock('@/lib/stores/sidebar-store', () => ({ vi.mock('@/lib/stores/sidebar-store', () => ({
useSidebarStore: () => ({ useSidebarStore: () => ({
isCollapsed: false, isCollapsed: mockCollapsed,
isMobileOpen: false, isMobileOpen: false,
toggle: vi.fn(), toggle: vi.fn(),
setMobileOpen: vi.fn(), setMobileOpen: vi.fn(),
@@ -108,6 +112,7 @@ afterEach(() => {
mockPathname = '/'; mockPathname = '/';
mockRefreshKey = 0; mockRefreshKey = 0;
mockCustomModules = []; mockCustomModules = [];
mockCollapsed = false;
}); });
function customModule(overrides: Partial<(typeof mockCustomModules)[number]> = {}) { function customModule(overrides: Partial<(typeof mockCustomModules)[number]> = {}) {
@@ -299,6 +304,50 @@ describe('Sidebar', () => {
expect(screen.getByText('Domain-Tools')).toBeInTheDocument(); expect(screen.getByText('Domain-Tools')).toBeInTheDocument();
}); });
it('Kategorie „Eigene Module“ steht zuletzt und nur, wenn ein Eintrag darin liegt', async () => {
mockCustomModules = [
customModule({ id: 'cm-1', category: 'custom-modules', name: 'Mein Wiki' }),
customModule({ id: 'cm-2', category: 'fleet', name: 'Tankkarte' }),
];
const Sidebar = await importSidebar();
const { unmount } = render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Mein Wiki')).toBeInTheDocument();
});
const groups = Array.from(
screen.getByRole('group', { name: 'Kategorien' }).querySelectorAll(':scope > ul > li > button span.truncate'),
).map((n) => n.textContent);
expect(groups.at(-1)).toBe('custom-modules');
expect(groups).toContain('fleet');
unmount();
mockCustomModules = [customModule({ category: 'fleet' })];
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('fleet')).toBeInTheDocument();
});
expect(screen.queryByText('custom-modules')).toBeNull();
});
it('eingeklappt stehen Eintraege aus „Eigene Module“ zuletzt', async () => {
mockCustomModules = [
customModule({ id: 'cm-1', category: 'custom-modules', name: 'Mein Wiki' }),
customModule({ id: 'cm-2', category: 'fleet', name: 'Tankkarte' }),
];
mockSidebarCollapsed(true);
const Sidebar = await importSidebar();
render(<Sidebar />);
const hrefs = () =>
Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) => a.getAttribute('href'));
await waitFor(() => {
expect(hrefs()).toContain('/modules/custom/cm-1');
});
expect(hrefs().at(-1)).toBe('/modules/custom/cm-1');
expect(hrefs().indexOf('/modules/custom/cm-2')).toBeLessThan(hrefs().indexOf('/modules/custom/cm-1'));
});
it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => { it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => {
mockCustomModules = [customModule({ category: 'Domain-Tools', name: 'Aaa Eigenes' })]; mockCustomModules = [customModule({ category: 'Domain-Tools', name: 'Aaa Eigenes' })];
const Sidebar = await importSidebar(); const Sidebar = await importSidebar();
+31 -10
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { TesseraLogo } from '@/components/brand/tessera-logo'; import { TesseraLogo } from '@/components/brand/tessera-logo';
import { useSidebarStore } from '@/lib/stores/sidebar-store'; import { useSidebarStore } from '@/lib/stores/sidebar-store';
@@ -116,6 +117,11 @@ export function Sidebar() {
const moduleItemClass = (active: boolean) => const moduleItemClass = (active: boolean) =>
itemClass(active).replace('h-9', 'h-8').replace('gap-3', 'gap-2.5').replace('text-sm', 'text-[13px]'); itemClass(active).replace('h-9', 'h-8').replace('gap-3', 'gap-2.5').replace('text-sm', 'text-[13px]');
// Eingeklappt (Nutzerwunsch 29.09.): Symbole etwas groesser (Kachel 24 statt
// 20 px, Navigationssymbole 22 statt 20 px), Eintraege dichter — 34 px hoch
// mit 1 px Abstand statt 36 + 2 px.
const collapsedItemClass = (active: boolean) => itemClass(active).replace('h-9', 'h-[34px]');
const fetchActiveModules = useCallback(async () => { const fetchActiveModules = useCallback(async () => {
const loadBuiltIn = async () => { const loadBuiltIn = async () => {
try { try {
@@ -180,6 +186,17 @@ export function Sidebar() {
}); });
}; };
// Eingeklappt in derselben Gruppenfolge wie ausgeklappt — „Eigene Module“
// also auch hier immer zuletzt.
const collapsedEntries = useMemo(
() =>
[...entries].sort(
(a, b) =>
Number(a.category === CUSTOM_MODULE_CATEGORY) - Number(b.category === CUSTOM_MODULE_CATEGORY),
),
[entries],
);
const filteredCategories = useMemo(() => { const filteredCategories = useMemo(() => {
const categories = new Map<string, SidebarEntry[]>(); const categories = new Map<string, SidebarEntry[]>();
for (const entry of entries) { for (const entry of entries) {
@@ -187,11 +204,15 @@ export function Sidebar() {
categories.get(entry.category)!.push(entry); categories.get(entry.category)!.push(entry);
} }
if (!searchQuery) return Array.from(categories.entries()); // „Eigene Module“ steht immer zuletzt, die uebrigen in Fundreihenfolge.
const ordered = Array.from(categories.entries()).sort(
([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY),
);
if (!searchQuery) return ordered;
const q = searchQuery.toLowerCase(); const q = searchQuery.toLowerCase();
const result: [string, SidebarEntry[]][] = []; const result: [string, SidebarEntry[]][] = [];
for (const [cat, mods] of categories.entries()) { for (const [cat, mods] of ordered) {
if (cat.toLowerCase().includes(q) || categoryLabel(cat).toLowerCase().includes(q)) { if (cat.toLowerCase().includes(q) || categoryLabel(cat).toLowerCase().includes(q)) {
result.push([cat, mods]); result.push([cat, mods]);
} else { } else {
@@ -203,7 +224,7 @@ export function Sidebar() {
}, [entries, searchQuery, categoryLabel]); }, [entries, searchQuery, categoryLabel]);
const navIcon = (children: React.ReactNode) => ( const navIcon = (children: React.ReactNode) => (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="shrink-0"> <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width={isCollapsed ? 22 : 20} height={isCollapsed ? 22 : 20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
{children} {children}
</svg> </svg>
); );
@@ -211,9 +232,9 @@ export function Sidebar() {
const sidebarContent = ( const sidebarContent = (
<div className="flex h-full flex-col bg-sidebar"> <div className="flex h-full flex-col bg-sidebar">
<nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}> <nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}>
<ul className="flex flex-col gap-0.5"> <ul className={`flex flex-col ${isCollapsed ? 'gap-px' : 'gap-0.5'}`}>
<li> <li>
<Link href="/" className={linkClass('/')} aria-label={t('dashboard')} title={isCollapsed ? t('dashboard') : undefined}> <Link href="/" className={isCollapsed ? collapsedItemClass(isActive('/')) : linkClass('/')} aria-label={t('dashboard')} title={isCollapsed ? t('dashboard') : undefined}>
{isActive('/') && <SelectionPill />} {isActive('/') && <SelectionPill />}
{navIcon( {navIcon(
<> <>
@@ -227,7 +248,7 @@ export function Sidebar() {
</Link> </Link>
</li> </li>
<li> <li>
<Link href="/marketplace" className={linkClass('/marketplace')} aria-label={t('marketplace')} title={isCollapsed ? t('marketplace') : undefined}> <Link href="/marketplace" className={isCollapsed ? collapsedItemClass(isActive('/marketplace')) : linkClass('/marketplace')} aria-label={t('marketplace')} title={isCollapsed ? t('marketplace') : undefined}>
{isActive('/marketplace') && <SelectionPill />} {isActive('/marketplace') && <SelectionPill />}
{navIcon( {navIcon(
<> <>
@@ -242,14 +263,14 @@ export function Sidebar() {
{/* Eingeklappt: nur die Modul-Kacheln untereinander (Name als Tooltip). */} {/* Eingeklappt: nur die Modul-Kacheln untereinander (Name als Tooltip). */}
{isCollapsed && entries.length > 0 && ( {isCollapsed && entries.length > 0 && (
<ul className="mt-3 flex flex-col gap-0.5 border-t border-sidebar-border pt-3"> <ul className="mt-3 flex flex-col gap-px border-t border-sidebar-border pt-3">
{entries.map((entry) => { {collapsedEntries.map((entry) => {
const active = isEntryActive(entry); const active = isEntryActive(entry);
return ( return (
<li key={entry.key}> <li key={entry.key}>
<Link href={entry.href} className={itemClass(active)} aria-label={entry.name} title={entry.name}> <Link href={entry.href} className={collapsedItemClass(active)} aria-label={entry.name} title={entry.name}>
{active && <SelectionPill />} {active && <SelectionPill />}
<ModuleTile slug={entry.tileSlug} size={20} tone={active ? 'accent' : 'neutral'} /> <ModuleTile slug={entry.tileSlug} size={24} tone={active ? 'accent' : 'neutral'} />
</Link> </Link>
</li> </li>
); );
@@ -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 */}
+11 -1
View File
@@ -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',
+55 -17
View File
@@ -8,13 +8,14 @@ import {
} from './grid-layout-migration'; } from './grid-layout-migration';
/** /**
* grid-layout-migration.test — NEU (quick-260916-bwo, feineres Dashboard-Raster). * grid-layout-migration.test (quick-260916-bwo, erweitert quick-260929-dmx).
* *
* Sieben Tests fuer die einmalige Umrechnung gespeicherter Anordnungen von * Tests fuer die stufenweise Umrechnung gespeicherter Anordnungen:
* den alten Einheiten (12 Spalten / 40 px) in die neuen (24 Spalten / 20 px). * v1 (12 Spalten / 40 px) -> v2 (24 / 20 px, alles x2) -> v3 (48 Spalten,
* Kern ist T-BWO-02: die Verdopplung darf GENAU EINMAL geschehen. Der Marker * nur Breite x2). Kern ist T-BWO-02: jede Stufe darf GENAU EINMAL geschehen.
* `__gridVersion` steht dafuer im gespeicherten JSON, nie im Zustand — Test 4 * Der Marker `__gridVersion` steht dafuer im gespeicherten JSON, nie im
* (Idempotenz) wird rot, sobald jemand die Marker-Pruefung entfernt. * Zustand — der Idempotenz-Test wird rot, sobald jemand die Marker-Pruefung
* entfernt.
*/ */
function altLayouts() { function altLayouts() {
@@ -31,14 +32,14 @@ function altLayouts() {
} }
describe('grid-layout-migration (quick-260916-bwo)', () => { describe('grid-layout-migration (quick-260916-bwo)', () => {
it('Test 1: alte Anordnung ohne Marker -> alle Positionen und Groessen x2, migrated true, kein Marker im Ergebnis', () => { it('Test 1: v1-Anordnung ohne Marker -> beide Stufen: x/w/minW/maxW x4, y/h/minH/maxH x2, migrated true, kein Marker im Ergebnis', () => {
const { layouts, migrated } = migrateGridLayouts(altLayouts()); const { layouts, migrated } = migrateGridLayouts(altLayouts());
expect(layouts.lg[0]).toEqual({ expect(layouts.lg[0]).toEqual({
i: 'a', x: 2, y: 4, w: 4, h: 6, minW: 4, minH: 4, moved: false, static: false, i: 'a', x: 4, y: 4, w: 8, h: 6, minW: 8, minH: 4, moved: false, static: false,
}); });
expect(layouts.lg[1]).toEqual({ i: 'b', x: 4, y: 0, w: 12, h: 4, maxW: 24, maxH: 16 }); expect(layouts.lg[1]).toEqual({ i: 'b', x: 8, y: 0, w: 24, h: 4, maxW: 48, maxH: 16 });
expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 4, h: 4 }); expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 8, h: 4 });
expect(layouts.sm).toEqual([]); expect(layouts.sm).toEqual([]);
expect(layouts.xs).toEqual([]); expect(layouts.xs).toEqual([]);
expect(layouts.xxs).toEqual([]); expect(layouts.xxs).toEqual([]);
@@ -47,15 +48,44 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
expect(GRID_SCALE_FACTOR).toBe(2); expect(GRID_SCALE_FACTOR).toBe(2);
}); });
it('Test 2: markierte Anordnung (__gridVersion 2) bleibt unveraendert, migrated false', () => { it('Test 2: v2-Anordnung (__gridVersion 2) -> nur die zweite Stufe: x/w/minW/maxW x2, y/h/minH/maxH unveraendert, migrated true', () => {
const alt = altLayouts(); const alt = altLayouts();
const { layouts, migrated } = migrateGridLayouts({ ...alt, __gridVersion: 2 }); const { layouts, migrated } = migrateGridLayouts({ ...alt, __gridVersion: 2 });
expect(layouts.lg[0]).toEqual({
i: 'a', x: 2, y: 2, w: 4, h: 3, minW: 4, minH: 2, moved: false, static: false,
});
expect(layouts.lg[1]).toEqual({ i: 'b', x: 4, y: 0, w: 12, h: 2, maxW: 24, maxH: 8 });
expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 4, h: 2 });
expect(migrated).toBe(true);
expect(Object.keys(layouts)).not.toContain(GRID_VERSION_KEY);
});
it('Test 2b: v3-Anordnung (__gridVersion 3) bleibt unveraendert, migrated false', () => {
const alt = altLayouts();
const { layouts, migrated } = migrateGridLayouts({ ...alt, __gridVersion: 3 });
expect(layouts).toEqual(alt); expect(layouts).toEqual(alt);
expect(migrated).toBe(false); expect(migrated).toBe(false);
expect(Object.keys(layouts)).not.toContain(GRID_VERSION_KEY); expect(Object.keys(layouts)).not.toContain(GRID_VERSION_KEY);
}); });
it('Test 2c: v1 und v2 -> v3 ergeben dieselbe Anordnung, wenn die v2-Eingabe die v1-Eingabe x2 ist', () => {
const v1 = altLayouts();
const v2 = {
lg: [
{ i: 'a', x: 2, y: 4, w: 4, h: 6, minW: 4, minH: 4, moved: false, static: false },
{ i: 'b', x: 4, y: 0, w: 12, h: 4, maxW: 24, maxH: 16 },
],
md: [{ i: 'a', x: 0, y: 0, w: 4, h: 4 }],
sm: [],
xs: [],
xxs: [],
__gridVersion: 2,
};
expect(migrateGridLayouts(v2).layouts).toEqual(migrateGridLayouts(v1).layouts);
});
it('Test 3: leere Anordnung bleibt leer, migrated false (kein Speichern noetig)', () => { it('Test 3: leere Anordnung bleibt leer, migrated false (kein Speichern noetig)', () => {
const empty = { lg: [], md: [], sm: [], xs: [], xxs: [] }; const empty = { lg: [], md: [], sm: [], xs: [], xxs: [] };
const r1 = migrateGridLayouts(empty); const r1 = migrateGridLayouts(empty);
@@ -73,6 +103,14 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
expect(twice.layouts).toEqual(once.layouts); expect(twice.layouts).toEqual(once.layouts);
expect(twice.migrated).toBe(false); expect(twice.migrated).toBe(false);
// Auch die einstufige v2 -> v3 ist idempotent, und der Marker 3 wird geschrieben.
const onceV2 = migrateGridLayouts({ ...altLayouts(), __gridVersion: 2 });
const marked = withGridVersion(onceV2.layouts);
expect(marked[GRID_VERSION_KEY]).toBe(3);
const twiceV2 = migrateGridLayouts(marked);
expect(twiceV2.layouts).toEqual(onceV2.layouts);
expect(twiceV2.migrated).toBe(false);
}); });
it('Test 5: withGridVersion haengt den Marker an, ohne die Eingabe zu veraendern', () => { it('Test 5: withGridVersion haengt den Marker an, ohne die Eingabe zu veraendern', () => {
@@ -80,30 +118,30 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
const marked = withGridVersion(layouts); const marked = withGridVersion(layouts);
expect(marked[GRID_VERSION_KEY]).toBe(GRID_VERSION); expect(marked[GRID_VERSION_KEY]).toBe(GRID_VERSION);
expect(GRID_VERSION).toBe(2); expect(GRID_VERSION).toBe(3);
expect(marked.lg).toBe(layouts.lg); expect(marked.lg).toBe(layouts.lg);
expect(marked.md).toBe(layouts.md); expect(marked.md).toBe(layouts.md);
expect(Object.keys(layouts)).not.toContain(GRID_VERSION_KEY); expect(Object.keys(layouts)).not.toContain(GRID_VERSION_KEY);
expect((layouts as Record<string, unknown>)[GRID_VERSION_KEY]).toBeUndefined(); expect((layouts as Record<string, unknown>)[GRID_VERSION_KEY]).toBeUndefined();
}); });
it('Test 6: Zukunft und Robustheit — Marker 3 bleibt, Zeichenketten-Marker zaehlt nicht, nicht-numerische Felder bleiben', () => { it('Test 6: Zukunft und Robustheit — Marker 4 bleibt, Zeichenketten-Marker zaehlt nicht, nicht-numerische Felder bleiben', () => {
const alt = altLayouts(); const alt = altLayouts();
const future = migrateGridLayouts({ ...alt, __gridVersion: 3 }); const future = migrateGridLayouts({ ...alt, __gridVersion: 4 });
expect(future.layouts).toEqual(alt); expect(future.layouts).toEqual(alt);
expect(future.migrated).toBe(false); expect(future.migrated).toBe(false);
const stringMarker = migrateGridLayouts({ ...alt, __gridVersion: '2' }); const stringMarker = migrateGridLayouts({ ...alt, __gridVersion: '2' });
expect(stringMarker.migrated).toBe(true); expect(stringMarker.migrated).toBe(true);
expect(stringMarker.layouts.lg[0].x).toBe(2); expect(stringMarker.layouts.lg[0].x).toBe(4);
expect(Object.keys(stringMarker.layouts)).not.toContain(GRID_VERSION_KEY); expect(Object.keys(stringMarker.layouts)).not.toContain(GRID_VERSION_KEY);
const odd = migrateGridLayouts({ const odd = migrateGridLayouts({
lg: [{ i: 'c', x: 'kaputt', y: 1, w: 2, h: 2, resizeHandles: ['se'], moved: true, static: false }], lg: [{ i: 'c', x: 'kaputt', y: 1, w: 2, h: 2, resizeHandles: ['se'], moved: true, static: false }],
}); });
expect(odd.layouts.lg[0]).toEqual({ expect(odd.layouts.lg[0]).toEqual({
i: 'c', x: 'kaputt', y: 2, w: 4, h: 4, resizeHandles: ['se'], moved: true, static: false, i: 'c', x: 'kaputt', y: 2, w: 8, h: 4, resizeHandles: ['se'], moved: true, static: false,
}); });
expect(Number.isNaN(odd.layouts.lg[0].x)).toBe(false); expect(Number.isNaN(odd.layouts.lg[0].x)).toBe(false);
expect(odd.migrated).toBe(true); expect(odd.migrated).toBe(true);
@@ -112,7 +150,7 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
it('Test 7: Fremdwerte — Nicht-Arrays werden weggelassen, Nicht-Objekte liefern eine leere Anordnung', () => { it('Test 7: Fremdwerte — Nicht-Arrays werden weggelassen, Nicht-Objekte liefern eine leere Anordnung', () => {
const r = migrateGridLayouts({ lg: 'kaputt', md: null, sm: [{ i: 'a', x: 1, y: 1, w: 1, h: 1 }] }); const r = migrateGridLayouts({ lg: 'kaputt', md: null, sm: [{ i: 'a', x: 1, y: 1, w: 1, h: 1 }] });
expect(Object.keys(r.layouts)).toEqual(['sm']); expect(Object.keys(r.layouts)).toEqual(['sm']);
expect(r.layouts.sm[0]).toEqual({ i: 'a', x: 2, y: 2, w: 2, h: 2 }); expect(r.layouts.sm[0]).toEqual({ i: 'a', x: 4, y: 2, w: 4, h: 2 });
expect(r.migrated).toBe(true); expect(r.migrated).toBe(true);
expect(migrateGridLayouts(null)).toEqual({ layouts: {}, migrated: false }); expect(migrateGridLayouts(null)).toEqual({ layouts: {}, migrated: false });
+42 -21
View File
@@ -1,23 +1,35 @@
/** /**
* grid-layout-migration — einmalige Umrechnung gespeicherter Dashboard- * grid-layout-migration — einmalige, stufenweise Umrechnung gespeicherter
* Anordnungen in die feineren Raster-Einheiten (quick-260916-bwo). * Dashboard-Anordnungen in die feineren Raster-Einheiten (quick-260916-bwo,
* quick-260929-dmx).
* *
* Warum: Das Raster wurde von 12 Spalten / 40 px Zeilenhoehe auf 24 Spalten / * Warum: Das Raster wurde zweimal feiner. Eine in ALTEN Einheiten gespeicherte
* 20 px verdoppelt (`dashboard-grid.tsx`). Eine in ALTEN Einheiten gespeicherte * Anordnung wuerde im neuen Raster kleiner und an anderer Position erscheinen.
* Anordnung wuerde im neuen Raster halb so gross und an der halben Position * Deshalb wird jedes Element in jedem Breakpoint stufenweise umgerechnet,
* erscheinen. Deshalb werden `x, y, w, h` (und, falls vorhanden, `minW, minH, * jede Stufe GENAU EINMAL:
* maxW, maxH`) jedes Elements in jedem Breakpoint GENAU EINMAL mit 2
* multipliziert.
* *
* Marker: Damit die Verdopplung nur einmal geschieht, traegt das gespeicherte * - Version 1 -> 2 (quick-260916-bwo): 12 Spalten / 40 px Zeilenhoehe wurden
* JSON den Schluessel `__gridVersion: 2`. Der Marker lebt NUR im persistierten * 24 Spalten / 20 px. `x, y, w, h` (und, falls vorhanden, `minW, minH, maxW,
* JSON (Spalte `DashboardLayout.layouts`, Json, kein Schema noetig) — nie im * maxH`) werden mit 2 multipliziert.
* Zustand des Stores, der mit `Object.keys` ueber die Breakpoints iteriert und * - Version 2 -> 3 (quick-260929-dmx): NUR die Breite wurde noch einmal
* `.filter` auf jedem Wert aufruft (ein Zahlwert wuerde dort abstuerzen). * verdoppelt (24 -> 48 Spalten am lg-Breakpoint, Zeilenhoehe und Abstand
* `migrateGridLayouts` entfernt den Marker beim Laden, `withGridVersion` * unveraendert). Nur `x, w, minW, maxW` werden mit 2 multipliziert; `y, h,
* haengt ihn beim Speichern wieder an. Fehlt der Marker beim Speichern, wird * minH, maxH` bleiben.
* beim naechsten Laden ERNEUT verdoppelt — deshalb muss JEDER Speichervorgang *
* `withGridVersion` benutzen (T-BWO-02, Store-Tests pinnen das). * Die Stufen sind kumulativ: eine Version-1-Anordnung durchlaeuft beide
* (x/w/minW/maxW also x4, y/h/minH/maxH x2), eine Version-2-Anordnung nur die
* zweite, eine Version-3-Anordnung keine.
*
* Marker: Damit jede Stufe nur einmal geschieht, traegt das gespeicherte JSON
* den Schluessel `__gridVersion` (aktuell 3). Der Marker lebt NUR im
* persistierten JSON (Spalte `DashboardLayout.layouts`, Json, kein Schema
* noetig) — nie im Zustand des Stores, der mit `Object.keys` ueber die
* Breakpoints iteriert und `.filter` auf jedem Wert aufruft (ein Zahlwert
* wuerde dort abstuerzen). `migrateGridLayouts` entfernt den Marker beim
* Laden, `withGridVersion` haengt ihn beim Speichern wieder an. Fehlt der
* Marker beim Speichern, wird beim naechsten Laden ERNEUT umgerechnet —
* deshalb muss JEDER Speichervorgang `withGridVersion` benutzen (T-BWO-02,
* Store-Tests pinnen das).
* *
* Idempotenz: `migrateGridLayouts(withGridVersion(migrateGridLayouts(alt).layouts))` * Idempotenz: `migrateGridLayouts(withGridVersion(migrateGridLayouts(alt).layouts))`
* liefert dasselbe Ergebnis wie `migrateGridLayouts(alt)` mit `migrated: false`. * liefert dasselbe Ergebnis wie `migrateGridLayouts(alt)` mit `migrated: false`.
@@ -28,7 +40,7 @@
* React- oder Store-Abhaengigkeit. * React- oder Store-Abhaengigkeit.
*/ */
export const GRID_VERSION = 2; export const GRID_VERSION = 3;
export const GRID_VERSION_KEY = '__gridVersion'; export const GRID_VERSION_KEY = '__gridVersion';
export const GRID_SCALE_FACTOR = 2; export const GRID_SCALE_FACTOR = 2;
@@ -43,7 +55,13 @@ export interface GridLayoutItem {
export type GridLayouts = Record<string, GridLayoutItem[]>; export type GridLayouts = Record<string, GridLayoutItem[]>;
const SCALED_FIELDS = ['x', 'y', 'w', 'h', 'minW', 'minH', 'maxW', 'maxH'] as const; /** Felder je Umrechnungsstufe; `from` ist die Version, von der die Stufe ausgeht. */
const MIGRATION_STEPS: ReadonlyArray<{ from: number; fields: readonly string[] }> = [
// 1 -> 2 (quick-260916-bwo): Spalten UND Zeilen verdoppelt.
{ from: 1, fields: ['x', 'y', 'w', 'h', 'minW', 'minH', 'maxW', 'maxH'] },
// 2 -> 3 (quick-260929-dmx): nur die Breite verdoppelt.
{ from: 2, fields: ['x', 'w', 'minW', 'maxW'] },
];
function isPlainObject(value: unknown): value is Record<string, unknown> { function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value); return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -62,7 +80,8 @@ export function migrateGridLayouts(raw: unknown): { layouts: GridLayouts; migrat
const markerValue = raw[GRID_VERSION_KEY]; const markerValue = raw[GRID_VERSION_KEY];
// Nur eine Zahl ist ein Marker; alles andere (fehlend, Zeichenkette) zaehlt als alt. // Nur eine Zahl ist ein Marker; alles andere (fehlend, Zeichenkette) zaehlt als alt.
const version = typeof markerValue === 'number' ? markerValue : 1; const version = typeof markerValue === 'number' ? markerValue : 1;
const needsScaling = version < GRID_VERSION; const steps = MIGRATION_STEPS.filter((step) => version <= step.from);
const needsScaling = steps.length > 0;
const layouts: GridLayouts = {}; const layouts: GridLayouts = {};
let migrated = false; let migrated = false;
@@ -75,12 +94,14 @@ export function migrateGridLayouts(raw: unknown): { layouts: GridLayouts; migrat
layouts[key] = value.map((item) => { layouts[key] = value.map((item) => {
const copy = { ...(item as GridLayoutItem) }; const copy = { ...(item as GridLayoutItem) };
if (needsScaling) { if (needsScaling) {
for (const field of SCALED_FIELDS) { for (const step of steps) {
for (const field of step.fields) {
const n = copy[field]; const n = copy[field];
if (typeof n === 'number') { if (typeof n === 'number') {
copy[field] = n * GRID_SCALE_FACTOR; copy[field] = n * GRID_SCALE_FACTOR;
} }
} }
}
migrated = true; migrated = true;
} }
return copy; return copy;
@@ -74,12 +74,12 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
await useDashboardStore.getState().loadDashboard(); await useDashboardStore.getState().loadDashboard();
const state = useDashboardStore.getState(); const state = useDashboardStore.getState();
expect(state.layouts.lg[0]).toEqual({ i: 'a', x: 2, y: 2, w: 4, h: 4 }); expect(state.layouts.lg[0]).toEqual({ i: 'a', x: 4, y: 2, w: 8, h: 4 });
expect(Object.keys(state.layouts)).not.toContain('__gridVersion'); expect(Object.keys(state.layouts)).not.toContain('__gridVersion');
expect(api.saveLayout).toHaveBeenCalledTimes(1); expect(api.saveLayout).toHaveBeenCalledTimes(1);
expect(api.saveLayout).toHaveBeenCalledWith( expect(api.saveLayout).toHaveBeenCalledWith(
'dash-1', 'dash-1',
expect.objectContaining({ __gridVersion: 2, lg: [{ i: 'a', x: 2, y: 2, w: 4, h: 4 }] }), expect.objectContaining({ __gridVersion: 3, lg: [{ i: 'a', x: 4, y: 2, w: 8, h: 4 }] }),
); );
expect(state.isDirty).toBe(false); expect(state.isDirty).toBe(false);
expect(state.isLoading).toBe(false); expect(state.isLoading).toBe(false);
@@ -88,7 +88,7 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
it('Test 2: markierte Anordnung bleibt unveraendert, kein Speichern, kein Marker im Zustand', async () => { it('Test 2: markierte Anordnung bleibt unveraendert, kein Speichern, kein Marker im Zustand', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 2, lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 3,
}); });
await useDashboardStore.getState().loadDashboard(); await useDashboardStore.getState().loadDashboard();
@@ -121,7 +121,7 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
expect(api.saveLayout).toHaveBeenCalledTimes(1); expect(api.saveLayout).toHaveBeenCalledTimes(1);
expect(api.saveLayout).toHaveBeenCalledWith( expect(api.saveLayout).toHaveBeenCalledWith(
'dash-1', 'dash-1',
expect.objectContaining({ __gridVersion: 2, ...layouts }), expect.objectContaining({ __gridVersion: 3, ...layouts }),
); );
expect(useDashboardStore.getState().isDirty).toBe(false); expect(useDashboardStore.getState().isDirty).toBe(false);
expect(Object.keys(useDashboardStore.getState().layouts)).not.toContain('__gridVersion'); expect(Object.keys(useDashboardStore.getState().layouts)).not.toContain('__gridVersion');
@@ -137,13 +137,13 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
await expect(useDashboardStore.getState().loadDashboard()).resolves.toBeUndefined(); await expect(useDashboardStore.getState().loadDashboard()).resolves.toBeUndefined();
const state = useDashboardStore.getState(); const state = useDashboardStore.getState();
expect(state.layouts.lg[0].x).toBe(2); expect(state.layouts.lg[0].x).toBe(4);
expect(state.error).toBeNull(); expect(state.error).toBeNull();
expect(state.isLoading).toBe(false); expect(state.isLoading).toBe(false);
expect(errorSpy).toHaveBeenCalledTimes(1); expect(errorSpy).toHaveBeenCalledTimes(1);
}); });
it('Test 6: neues Widget wird in den neuen (verdoppelten) Vorgabegroessen fuer den aktiven Reiter angelegt', async () => { it('Test 6: neues Widget wird in den Vorgabegroessen des 48er-Rasters (quick-260929-dmx) fuer den aktiven Reiter angelegt', async () => {
await useDashboardStore.getState().loadDashboard(); await useDashboardStore.getState().loadDashboard();
vi.mocked(api.addWidget).mockResolvedValue({ id: 'n1', widgetType: 'clock', config: {} }); vi.mocked(api.addWidget).mockResolvedValue({ id: 'n1', widgetType: 'clock', config: {} });
@@ -151,7 +151,7 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
const state = useDashboardStore.getState(); const state = useDashboardStore.getState();
expect(api.addWidget).toHaveBeenCalledWith('dash-1', 'clock'); expect(api.addWidget).toHaveBeenCalledWith('dash-1', 'clock');
expect(state.layouts.lg).toContainEqual({ i: 'n1', x: 0, y: 0, w: 4, h: 4 }); expect(state.layouts.lg).toContainEqual({ i: 'n1', x: 0, y: 0, w: 8, h: 4 });
expect(state.isDirty).toBe(true); expect(state.isDirty).toBe(true);
}); });
}); });
@@ -213,7 +213,7 @@ describe('dashboard-store — Reiterwechsel (quick-260923-ad9, Task 3)', () => {
await useDashboardStore.getState().loadDashboard(); await useDashboardStore.getState().loadDashboard();
vi.mocked(api.fetchLayout).mockResolvedValue({ vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'only-on-dash-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 2, lg: [{ i: 'only-on-dash-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 3,
}); });
vi.mocked(api.fetchWidgets).mockResolvedValue([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]); vi.mocked(api.fetchWidgets).mockResolvedValue([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]);
+37 -22
View File
@@ -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,13 +1367,36 @@
"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",
"security-tools": "Sicherheit", "security-tools": "Sicherheit",
"fleet": "Fuhrpark", "fleet": "Fuhrpark",
"infrastructure": "Infrastruktur", "infrastructure": "Infrastruktur",
"procurement": "Beschaffung" "procurement": "Beschaffung",
"custom-modules": "Eigene Module"
} }
} }
+37 -22
View File
@@ -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,13 +1367,36 @@
"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",
"security-tools": "Security", "security-tools": "Security",
"fleet": "Fleet", "fleet": "Fleet",
"infrastructure": "Infrastructure", "infrastructure": "Infrastructure",
"procurement": "Procurement" "procurement": "Procurement",
"custom-modules": "Custom modules"
} }
} }
@@ -1,4 +1,4 @@
import { MODULE_CATEGORIES } from '@tessera/shared'; import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import de from './de.json'; import de from './de.json';
import en from './en.json'; import en from './en.json';
@@ -9,13 +9,13 @@ import en from './en.json';
* API) braucht einen Anzeigenamen in `moduleCategories` beider Sprachen — * API) braucht einen Anzeigenamen in `moduleCategories` beider Sprachen —
* sonst zeigte die Seitenleiste die rohe Kennung. * sonst zeigte die Seitenleiste die rohe Kennung.
*/ */
describe('MODULE_CATEGORIES', () => { describe('CUSTOM_MODULE_CATEGORIES (inkl. MODULE_CATEGORIES)', () => {
it.each([ it.each([
['de', de], ['de', de],
['en', en], ['en', en],
] as const)('hat je Kennung einen Schluessel in moduleCategories (%s)', (_lang, messages) => { ] as const)('hat je Kennung einen Schluessel in moduleCategories (%s)', (_lang, messages) => {
const labels = messages.moduleCategories as Record<string, string>; const labels = messages.moduleCategories as Record<string, string>;
for (const category of MODULE_CATEGORIES) { for (const category of CUSTOM_MODULE_CATEGORIES) {
expect(labels[category], `moduleCategories.${category}`).toBeTruthy(); expect(labels[category], `moduleCategories.${category}`).toBeTruthy();
} }
}); });
+6
View File
@@ -199,6 +199,12 @@ Unter „Module → Freigaben-Matrix“ erscheint eine Tabelle: Zeilen sind die
Ein Hinweistext unterhalb der Matrix erinnert daran, dass ADMIN und SUPER_ADMIN immer Zugriff auf alle aktiven Module haben und diese Matrix nur die Rolle USER betrifft. Ein Hinweistext unterhalb der Matrix erinnert daran, dass ADMIN und SUPER_ADMIN immer Zugriff auf alle aktiven Module haben und diese Matrix nur die Rolle USER betrifft.
### 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.
Daneben kann **jeder Benutzer** – auch Sie selbst – unter „Einstellungen → Eigene Module“ persönliche Einträge anlegen. Diese sieht ausschließlich der Besitzer; Administratoren sehen und ändern sie nicht. Wird ein Benutzer gelöscht, verschwinden seine persönlichen Einträge mit. Die Verwaltungsseite zeigt deshalb nur die gemeinsamen Einträge.
## 6. SMTP ## 6. SMTP
Unter **Administrator → SMTP** wird der Mailversand konfiguriert: Host, Port, Verschlüsselung (Keine, STARTTLS oder SSL-TLS), Benutzername, Passwort, die Absenderadresse und optional das Feld „Fehlermeldungen an“ (siehe unten). Das Passwortfeld wird aus Sicherheitsgründen nie mit dem gespeicherten Wert vorbefüllt – es bleibt beim Laden immer leer und wird nur mitgesendet, wenn tatsächlich ein neuer Wert eingegeben wurde. Unter **Administrator → SMTP** wird der Mailversand konfiguriert: Host, Port, Verschlüsselung (Keine, STARTTLS oder SSL-TLS), Benutzername, Passwort, die Absenderadresse und optional das Feld „Fehlermeldungen an“ (siehe unten). Das Passwortfeld wird aus Sicherheitsgründen nie mit dem gespeicherten Wert vorbefüllt – es bleibt beim Laden immer leer und wird nur mitgesendet, wenn tatsächlich ein neuer Wert eingegeben wurde.
+4 -2
View File
@@ -58,7 +58,7 @@ Unten in der Seitenleiste begrüßt Tessera Sie je nach Tageszeit mit Ihrem Name
## Dashboard ## Dashboard
Das Dashboard ist Ihre persönliche Startseite und öffnet sich automatisch nach der Anmeldung. Es zeigt ein Raster aus Kacheln — den **Widgets** —, das mittig auf der Seite steht. Jedes Widget trägt oben ein gelbes Symbol-Feld und blendet beim Laden sanft ein. Ist noch kein Widget platziert, sehen Sie den Hinweis „Ihr Dashboard ist noch leer" mit einigen Vorschlägen für erste Kacheln. Das Dashboard ist Ihre persönliche Startseite und öffnet sich automatisch nach der Anmeldung. Es zeigt ein Raster aus Kacheln — den **Widgets** —, die genau dort stehen, wo Sie sie im Bearbeitungsmodus platziert haben. Jedes Widget trägt oben ein gelbes Symbol-Feld und blendet beim Laden sanft ein. Ist noch kein Widget platziert, sehen Sie den Hinweis „Ihr Dashboard ist noch leer" mit einigen Vorschlägen für erste Kacheln.
**Mehrere Dashboards (Reiter):** Die Reiter stehen in der Mitte der App-Leiste — Sie können mehrere Dashboards anlegen, die dort nebeneinander stehen. Jeder Reiter trägt seine eigenen Kacheln und seine eigene Anordnung; was auf dem einen Reiter liegt, erscheint nicht auf dem anderen. Ein Klick auf einen Reiter wechselt dorthin. Beim Öffnen wird immer der ERSTE Reiter geladen — Sie legen ihn fest, indem Sie einen Reiter mit der Maus ganz nach vorn ziehen (das geht jederzeit, auch ohne den Bearbeitungsmodus). Im Bearbeitungsmodus können Sie außerdem: **Mehrere Dashboards (Reiter):** Die Reiter stehen in der Mitte der App-Leiste — Sie können mehrere Dashboards anlegen, die dort nebeneinander stehen. Jeder Reiter trägt seine eigenen Kacheln und seine eigene Anordnung; was auf dem einen Reiter liegt, erscheint nicht auf dem anderen. Ein Klick auf einen Reiter wechselt dorthin. Beim Öffnen wird immer der ERSTE Reiter geladen — Sie legen ihn fest, indem Sie einen Reiter mit der Maus ganz nach vorn ziehen (das geht jederzeit, auch ohne den Bearbeitungsmodus). Im Bearbeitungsmodus können Sie außerdem:
- Über den Knopf am Ende der Leiste einen neuen, leeren Reiter anlegen — er heißt automatisch „Dashboard 2", „Dashboard 3" und so weiter. - Über den Knopf am Ende der Leiste einen neuen, leeren Reiter anlegen — er heißt automatisch „Dashboard 2", „Dashboard 3" und so weiter.
@@ -169,13 +169,15 @@ Das Modul zeigt den Zustand Ihrer Proxmox-Server auf einen Blick — für die dr
## Persönliche Einstellungen ## Persönliche Einstellungen
Öffnen Sie **Einstellungen** über das Benutzermenü oben rechts. Der Bereich gliedert sich in zwei Kategorien in der linken Unterleiste: Öffnen Sie **Einstellungen** über das Benutzermenü oben rechts. Der Bereich gliedert sich in Kategorien in der linken Unterleiste („Allgemein“ mit Konto, Desktop-App und Eigene Module, dazu „Dashboard“):
**Allgemein > Konto:** **Allgemein > Konto:**
- **Profilbild:** Laden Sie ein Bild hoch (PNG, JPEG oder WebP, maximal 2 MB) oder löschen Sie das vorhandene wieder. - **Profilbild:** Laden Sie ein Bild hoch (PNG, JPEG oder WebP, maximal 2 MB) oder löschen Sie das vorhandene wieder.
- **Akzentfarbe:** Passt die Hauptfarbe der Oberfläche an Ihren Geschmack an; über „Zurücksetzen" kehren Sie zur Standardfarbe zurück. - **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. - **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.
**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. **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.
**Dashboard > Kalender:** Hier verwalten Sie die Kalenderquellen, aus denen das Kalender-Widget seine Termine bezieht — Quellen hinzufügen, die Verbindung testen und nicht mehr benötigte Quellen wieder entfernen. **Dashboard > Kalender:** Hier verwalten Sie die Kalenderquellen, aus denen das Kalender-Widget seine Termine bezieht — Quellen hinzufügen, die Verbindung testen und nicht mehr benötigte Quellen wieder entfernen.
@@ -176,8 +176,8 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet
| bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff | | bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff |
| settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen | | settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen |
| proxmox | 0 | 11 | 1 | **quick-260923-dhh (Aufgabe 5, Endstand):** 7→11 gebunden — `updateServer` (`proxmoxServer.findUnique` UND `.update`) und `deleteServer` (`proxmoxServer.findUnique` UND `.delete`) bringen vier weitere gebundene Rohtreffer, je ein Klient je Methode. Nachgemessen mit der Gate-Schleife (`grep -c` ueber `tenantPrisma\.\(proxmoxServer\|proxmoxServerStatus\)\.` in `proxmox.service.ts`: 10 fuer `proxmoxServer`, 1 fuer `proxmoxServerStatus`). Vorher: **quick-260923-dhh (Aufgabe 4):** 4→7 gebunden, 0→1 System — `proxmox.service.ts` bringt drei weitere gebundene Rohtreffer (`pollServer` mit `include: { status: true }` bleibt EIN Klient, `testConnection`, `listActiveServerIdsForTenant`, `loadActiveServersForTenantScheduling` — vier neue Methoden, aber `pollServer`s zweiter Zugriff war schon gezaehlt, macht drei zusaetzliche) und einen System-Rohtreffer (`loadActiveServersForScheduler()`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Vorher: **quick-260923-dhh (Aufgabe 1):** neu, vier gebundene Rohtreffer: `createServer` (`proxmoxServer.create`), `listWithStatus` (`proxmoxServer.findMany`), `pollServer` (`proxmoxServer.findUnique` UND `proxmoxServerStatus.upsert`, DERSELBE Klient in derselben Methode) | | proxmox | 0 | 11 | 1 | **quick-260923-dhh (Aufgabe 5, Endstand):** 7→11 gebunden — `updateServer` (`proxmoxServer.findUnique` UND `.update`) und `deleteServer` (`proxmoxServer.findUnique` UND `.delete`) bringen vier weitere gebundene Rohtreffer, je ein Klient je Methode. Nachgemessen mit der Gate-Schleife (`grep -c` ueber `tenantPrisma\.\(proxmoxServer\|proxmoxServerStatus\)\.` in `proxmox.service.ts`: 10 fuer `proxmoxServer`, 1 fuer `proxmoxServerStatus`). Vorher: **quick-260923-dhh (Aufgabe 4):** 4→7 gebunden, 0→1 System — `proxmox.service.ts` bringt drei weitere gebundene Rohtreffer (`pollServer` mit `include: { status: true }` bleibt EIN Klient, `testConnection`, `listActiveServerIdsForTenant`, `loadActiveServersForTenantScheduling` — vier neue Methoden, aber `pollServer`s zweiter Zugriff war schon gezaehlt, macht drei zusaetzliche) und einen System-Rohtreffer (`loadActiveServersForScheduler()`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Vorher: **quick-260923-dhh (Aufgabe 1):** neu, vier gebundene Rohtreffer: `createServer` (`proxmoxServer.create`), `listWithStatus` (`proxmoxServer.findMany`), `pollServer` (`proxmoxServer.findUnique` UND `proxmoxServerStatus.upsert`, DERSELBE Klient in derselben Methode) |
| custom-modules | 0 | 7 | 0 | **quick-260929-9wc:** neu, sieben gebundene Rohtreffer in `custom-modules.service.ts` (`list` 1, `getOne` 1, `create` 1, `update` 2, `remove` 2), nachgemessen mit der Gate-Schleife: 0/7/0. Kein ungebundener Zugriff, kein Systemkontext. | | custom-modules | 0 | 6 | 0 | **Nachgemessen quick-260929-dzu:** 0/6/0 — persönliche Einträge je Benutzer: `create` trägt jetzt zwei Klienten in getrennten Zweigen (gemeinsam ohne Benutzer, persönlich mit Benutzer, je ein `tenantPrisma.customModule.create`), die gemeinsame Ladefunktion `loadVisible` trägt das einzige `findUnique` für `getOne`/`update`/`remove` (vorher je Methode eines): `list` 1, `create` 2, `loadVisible` 1, `update` 1, `remove` 1. Das Ergebnis ist ein Treffer weniger als bei quick-260929-9wc, obwohl der Zugriff strenger geworden ist. Vorher: **quick-260929-9wc:** neu, sieben gebundene Rohtreffer in `custom-modules.service.ts` (`list` 1, `getOne` 1, `create` 1, `update` 2, `remove` 2), nachgemessen mit der Gate-Schleife: 0/7/0. Kein ungebundener Zugriff, kein Systemkontext. |
| **Summe** | **61** | **224** | **6** | **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | | **Summe** | **61** | **223** | **6** | **Nachgemessen quick-260929-dzu:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/223/6. Gegenüber der bisherigen Zeile (61/224/6): Gebunden −1 = `custom-modules` −1 (7→6, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 79 Paare) ## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 79 Paare)
@@ -362,6 +362,10 @@ Tabelle hinzugekommen, ohne sie fortzuschreiben. Jetzt +1 `muss-mandantengebunde
`muss-mandantengebunden`, 21 `keine-mandantengebundene-tabelle`, 14 `beides`, `muss-mandantengebunden`, 21 `keine-mandantengebundene-tabelle`, 14 `beides`,
2 `bewusst-uebergreifend`. 2 `bewusst-uebergreifend`.
quick-260929-dzu: keine neue (Datei, Modell)-Zeile, Verteilung unverändert (79 Paare); geändert
haben sich nur die Zeile `custom-modules.service.ts`/`customModule` (Benutzerdimension) und die
Bereichs-/Summenzeile (Gebunden 224→223, nachgemessen mit der Gate-Schleife).
## Der Hintergrunddienst als Falle — sechs Fälle ## Der Hintergrunddienst als Falle — sechs Fälle
Ein Planer, der über alle Mandanten iteriert, liest zu Recht übergreifend — Ein Planer, der über alle Mandanten iteriert, liest zu Recht übergreifend —
@@ -774,7 +778,7 @@ werden.
| apps/api/src/user/user.service.ts | user | beides | gemischt | Klassenkorrektur (260910-das, Aufgabe 3): wechselt von `muss-mandantengebunden` auf `beides` wegen der einen bewusst ungebundenen Suche — wortgleich derselbe Praezedenzfall wie `ldap.service.ts`/`user` in 260909-ipc (`resolveEmailForWrite`). `findById`/`create`/`update`/`deactivate`/`delete` sowie die beiden neuen Plattform-Administratorsicht-Methoden laufen ueber `forTenant()`; `create`/`update` uebersetzen eine plattformweite Eindeutigkeitsverletzung (P2002) in eine deutsche Konfliktmeldung ohne Halter/Mandant zu nennen. `findByUsername` bleibt bewusst UNGEBUNDEN: der Anmeldeweg laeuft seit Etappe 1 ueber die drei SECURITY-DEFINER-Funktionen und hat diese Methode nicht mehr als Aufrufer (260910-das, Aufgabe 1, Teil 3: genau ein Treffer, die eigene Definition); eine gebundene Suche saehe einen fremden Halter des plattformweit eindeutigen `username` nicht und meldete faelschlich "frei". | | apps/api/src/user/user.service.ts | user | beides | gemischt | Klassenkorrektur (260910-das, Aufgabe 3): wechselt von `muss-mandantengebunden` auf `beides` wegen der einen bewusst ungebundenen Suche — wortgleich derselbe Praezedenzfall wie `ldap.service.ts`/`user` in 260909-ipc (`resolveEmailForWrite`). `findById`/`create`/`update`/`deactivate`/`delete` sowie die beiden neuen Plattform-Administratorsicht-Methoden laufen ueber `forTenant()`; `create`/`update` uebersetzen eine plattformweite Eindeutigkeitsverletzung (P2002) in eine deutsche Konfliktmeldung ohne Halter/Mandant zu nennen. `findByUsername` bleibt bewusst UNGEBUNDEN: der Anmeldeweg laeuft seit Etappe 1 ueber die drei SECURITY-DEFINER-Funktionen und hat diese Methode nicht mehr als Aufrufer (260910-das, Aufgabe 1, Teil 3: genau ein Treffer, die eigene Definition); eine gebundene Suche saehe einen fremden Halter des plattformweit eindeutigen `username` nicht und meldete faelschlich "frei". |
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServer | muss-mandantengebunden | system-gebunden | **quick-260923-dhh, Aufgabe 4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `loadActiveServersForScheduler()` liest beim Start des Planers `const systemPrisma = forSystem(this.prisma);` (ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht ueber `system_read_policy … FOR SELECT` auf "ProxmoxServer", Migration 20260923140000) — der Planer muss die aktiven Server ALLER Mandanten sehen, um je Mandant einen Cron-Auftrag zu registrieren (Muster `DkvSchedulerService`). GESCHRIEBEN wird auch dort nur je Zeile gebunden. Sechs mandantengebundene Zugriffe blieben nach Aufgabe 4 bestehen: `createServer` (`proxmoxServer.create`), `listWithStatus` (`findMany`), `pollServer` (`findUnique`, mit `include: { status: true }` fuer die Zehn-Sekunden-Sperre), `testConnection` (`findUnique`), `listActiveServerIdsForTenant` (`findMany`), `loadActiveServersForTenantScheduling` (`findMany` auf `proxmoxServer`, `select: { pollIntervalMin: true }`). **Aufgabe 5** ergaenzt vier weitere: `updateServer` (`findUnique` UND `update`) und `deleteServer` (`findUnique` UND `delete`), je ein Klient je Methode — macht zehn mandantengebundene `proxmoxServer`-Rohtreffer insgesamt, plus der eine System-Rohtreffer aus Aufgabe 4. Vorher (Aufgabe 1): vom Administrator eingetragene Proxmox-Server (PVE/PBS/PMG), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, Form aus `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. `listWithStatus` waehlt die beiden Geheimnisfelder (`encryptedTokenSecret`/`encryptedPassword`) per `select` gar nicht erst aus (T-DHH-01). | | apps/api/src/proxmox/proxmox.service.ts | proxmoxServer | muss-mandantengebunden | system-gebunden | **quick-260923-dhh, Aufgabe 4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `loadActiveServersForScheduler()` liest beim Start des Planers `const systemPrisma = forSystem(this.prisma);` (ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht ueber `system_read_policy … FOR SELECT` auf "ProxmoxServer", Migration 20260923140000) — der Planer muss die aktiven Server ALLER Mandanten sehen, um je Mandant einen Cron-Auftrag zu registrieren (Muster `DkvSchedulerService`). GESCHRIEBEN wird auch dort nur je Zeile gebunden. Sechs mandantengebundene Zugriffe blieben nach Aufgabe 4 bestehen: `createServer` (`proxmoxServer.create`), `listWithStatus` (`findMany`), `pollServer` (`findUnique`, mit `include: { status: true }` fuer die Zehn-Sekunden-Sperre), `testConnection` (`findUnique`), `listActiveServerIdsForTenant` (`findMany`), `loadActiveServersForTenantScheduling` (`findMany` auf `proxmoxServer`, `select: { pollIntervalMin: true }`). **Aufgabe 5** ergaenzt vier weitere: `updateServer` (`findUnique` UND `update`) und `deleteServer` (`findUnique` UND `delete`), je ein Klient je Methode — macht zehn mandantengebundene `proxmoxServer`-Rohtreffer insgesamt, plus der eine System-Rohtreffer aus Aufgabe 4. Vorher (Aufgabe 1): vom Administrator eingetragene Proxmox-Server (PVE/PBS/PMG), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, Form aus `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. `listWithStatus` waehlt die beiden Geheimnisfelder (`encryptedTokenSecret`/`encryptedPassword`) per `select` gar nicht erst aus (T-DHH-01). |
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServerStatus | muss-mandantengebunden | gebunden | quick-260923-dhh, Aufgabe 1/4 — Zwischenlager je Server (D-05), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, dieselbe Form wie `proxmoxServer`). `pollServer` schreibt ueber `tenantPrisma.proxmoxServerStatus.upsert()`, DENSELBEN Klienten wie das Lesen des Servers in derselben Methode; dieselbe Methode liest zusaetzlich `include: { status: true }` fuer die Zehn-Sekunden-Sperre (Aufgabe 4, T-DHH-06) — ebenfalls ueber den gebundenen Klienten. Bewusst KEINE `system_read_policy` auf dieser Tabelle (anders als `proxmoxServer`) — der Planer-Startpfad liest nur die Serverzeilen, das Zwischenlager wird ausschliesslich je Mandant gebunden geschrieben, ein Systemlesezugriff hat keinen Aufrufer. | | apps/api/src/proxmox/proxmox.service.ts | proxmoxServerStatus | muss-mandantengebunden | gebunden | quick-260923-dhh, Aufgabe 1/4 — Zwischenlager je Server (D-05), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, dieselbe Form wie `proxmoxServer`). `pollServer` schreibt ueber `tenantPrisma.proxmoxServerStatus.upsert()`, DENSELBEN Klienten wie das Lesen des Servers in derselben Methode; dieselbe Methode liest zusaetzlich `include: { status: true }` fuer die Zehn-Sekunden-Sperre (Aufgabe 4, T-DHH-06) — ebenfalls ueber den gebundenen Klienten. Bewusst KEINE `system_read_policy` auf dieser Tabelle (anders als `proxmoxServer`) — der Planer-Startpfad liest nur die Serverzeilen, das Zwischenlager wird ausschliesslich je Mandant gebunden geschrieben, ein Systemlesezugriff hat keinen Aufrufer. |
| apps/api/src/custom-modules/custom-modules.service.ts | customModule | muss-mandantengebunden | gebunden | **quick-260929-9wc:** neu — vom Administrator angelegte Seitenleisten-Eintraege („Eigene Module“, Name, https-Adresse, Kategorie), fuer alle Benutzer des Mandanten sichtbar. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260929120000, Form aus `ProxmoxServer`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: es gibt keinen Hintergrunddienst, der eigene Module ueber alle Mandanten liest. Sieben mandantengebundene Rohtreffer, je Methode ein eigener Klient (`const tenantPrisma = forTenant(this.prisma, tenantId)`): `list` (`findMany` mit `where: { tenantId }`), `getOne` (`findUnique`), `create`, `update` (`findUnique` UND `update`), `remove` (`findUnique` UND `delete`). `getOne`/`update`/`remove` pruefen zusaetzlich `row.tenantId !== tenantId` und antworten mit 404 — zweites Netz, solange der RLS-Schalter aus ist (Muster `dashboardImage`). | | apps/api/src/custom-modules/custom-modules.service.ts | customModule | muss-mandantengebunden | gebunden | **quick-260929-9wc:** neu — vom Administrator angelegte Seitenleisten-Eintraege („Eigene Module“, Name, https-Adresse, Kategorie), fuer alle Benutzer des Mandanten sichtbar. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260929120000, Form aus `ProxmoxServer`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: es gibt keinen Hintergrunddienst, der eigene Module ueber alle Mandanten liest. Sieben mandantengebundene Rohtreffer, je Methode ein eigener Klient (`const tenantPrisma = forTenant(this.prisma, tenantId)`): `list` (`findMany` mit `where: { tenantId }`), `getOne` (`findUnique`), `create`, `update` (`findUnique` UND `update`), `remove` (`findUnique` UND `delete`). `getOne`/`update`/`remove` pruefen zusaetzlich `row.tenantId !== tenantId` und antworten mit 404 — zweites Netz, solange der RLS-Schalter aus ist (Muster `dashboardImage`). **quick-260929-dzu — persönliche Einträge:** neue Spalte `ownerUserId` (NULL = gemeinsam, gesetzt = persönlich, nur für den Besitzer sichtbar). Klasse und Stand unverändert (`muss-mandantengebunden`, `gebunden`); der Zeilenschutz bekommt die Benutzerdimension nach dem Muster `SearchProvider` (Migration 20260929130000): vier nach Befehl getrennte Regeln — Lesen: Mandant UND (kein Benutzer gesetzt ODER `ownerUserId` NULL ODER eigene Zeile), Schreiben (INSERT/UPDATE/DELETE): Mandant UND (kein Benutzer gesetzt ODER eigene Zeile). Persönliche Zugriffe binden mit Benutzer (`forTenant(prisma, tenantId, user.id)`); das Schreiben GEMEINSAMER Einträge bindet bewusst OHNE Benutzer, weil die Regel einem Benutzerkontext das Schreiben gemeinsamer Zeilen verwehrt — davor prüft der Dienst die Rolle (nur Administrator, sonst 403). Fremde persönliche Einträge sind für jeden anderen Benutzer, auch Administratoren, ununterscheidbar 404. Sechs mandantengebundene Rohtreffer (siehe Bereichszeile). |
## Was diese Etappe NICHT entscheidet ## Was diese Etappe NICHT entscheidet
+13
View File
@@ -274,3 +274,16 @@ export const MODULE_CATEGORIES = [
] as const; ] as const;
export type ModuleCategory = (typeof MODULE_CATEGORIES)[number]; export type ModuleCategory = (typeof MODULE_CATEGORIES)[number];
/**
* Zusaetzliche Kategorie nur fuer eigene Module (Nutzerwunsch 29.09.2026):
* „Eigene Module“ sammelt selbst angelegte Eintraege in einer eigenen Gruppe
* der Seitenleiste. Wie jede Kategorie erscheint sie nur, wenn ein Eintrag
* darin liegt, und steht immer zuletzt.
*/
export const CUSTOM_MODULE_CATEGORY = "custom-modules" as const;
/** Auswahl im Formular „Eigene Module“ und Pruefung in der API. */
export const CUSTOM_MODULE_CATEGORIES = [...MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY] as const;
export type CustomModuleCategory = (typeof CUSTOM_MODULE_CATEGORIES)[number];