3 Commits

Author SHA1 Message Date
schalli 1315f370a9 docs(quick-260922-m1h): Akte - Widget-Aufraeumen, Rundgang verhaltensneutral bestaetigt
Tessera CI/CD / Lint & Type Check (push) Successful in 46s
Tessera CI/CD / Tests (push) Successful in 1m15s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 4m7s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 16:12:17 +02:00
schalli 8be0725577 docs(quick-260922-m1h): Hinweis bei gesperrter Kachel, Changelog und Entwicklerdoku
- eine Kachel ohne Bauteil (entfernter Typ oder gesperrtes Modul) zeigt
  statt des rohen Typnamens den Satz `widgets.unavailable`, zentriert und
  grau; Schluessel in de.json und en.json
- Entwicklerdoku: neuer Abschnitt "Eine Kachel zum Modul" im
  Modul-Walkthrough — die drei verbliebenen Stellen und was eine Kachel
  mit moduleSlug automatisch tut
- Changelog unter "Unveroeffentlicht - Geaendert"

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 16:06:25 +02:00
schalli 56c07c3581 refactor(quick-260922-m1h): Widget-Typen an einer Stelle, Katalog aus der Registry, Kachel kennt ihr Modul
Ein neuer Widget-Typ war an sieben Stellen einzutragen; vergass man eine,
fehlte die Kachel im Katalog oder die API lehnte sie mit 400 ab.

- WIDGET_TYPES/WidgetType/WIDGET_MODULE_SLUGS stehen jetzt einmal in
  packages/shared; Registry, Katalog und die @IsIn-Whitelist der API
  leiten davon ab
- neun wireXWidget()-Funktionen durch ein generisches registerWidget()
  ersetzt (idempotent, unbekannter Typ wirft in der Entwicklung)
- der Katalog fuehrt keine zweite Typliste mehr, sondern leitet sie aus
  der Registry ab und filtert nach Modulzugriff (fail-closed, wenn die
  Modulliste unbekannt ist); der Abruf von /modules/active liegt auf der
  Dashboard-Seite, nicht im Dialog
- widget-module-map.ts liest die geteilte Tabelle statt einer Kopie, die
  oeffentliche Funktion bleibt unveraendert

Der Katalogfilter ist Komfort (T-M1H-01) — verbindlich bleibt der
serverseitige Filter in DashboardService.getWidgets.

Abweichung vom Plan: apps/web hing entgegen der Planannahme noch nicht
von @tessera/shared ab; die Abhaengigkeit wurde ergaenzt (Lockfile). Die
Dockerfiles kopieren packages/shared bereits, der Produktionsbau von
Next.js und der nest build laufen unveraendert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 16:03:58 +02:00
23 changed files with 976 additions and 155 deletions
+3 -2
View File
@@ -5,9 +5,9 @@ current_phase: 18
current_phase_name: desktop-client-fertigstellen current_phase_name: desktop-client-fertigstellen
status: verified status: verified
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren." stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
last_updated: "2026-09-22T13:40:00.000Z" last_updated: "2026-09-22T14:15:00.000Z"
last_activity: 2026-09-21 last_activity: 2026-09-21
last_activity_desc: Quick 260922-hk4 — Bilderrahmen-Bilder in den Dateibereich umgezogen (automatisch beim Start, Selbstheilung aus der alten Spalte), im Browser nachgewiesen; davor Freigabe 1.3.0 last_activity_desc: Quick 260922-m1h — Widget-Typen an einer Stelle, Katalog filtert nach Modulzugriff, Kachel kennt ihr Modul (Vorarbeit Proxmox); im Browser als verhaltensneutral bestaetigt
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2 state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
progress: progress:
total_phases: 18 total_phases: 18
@@ -462,6 +462,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| fast-260922-b | **Desktop-App: Download-Knoepfe in der App ohne Funktion (fast, 747a4d4).** Befund des Nutzers: „Herunterladen“ unter Einstellungen → Desktop-App tut in der App nichts (Windows und Linux). Ursache: die Webansicht hatte keinen Download-Handler — webkit2gtk verwirft Downloads dann still, WebView2 zeigte ebenfalls nichts. Fix: Hauptfenster entsteht im Code (`app.windows` in tauri.conf.json leer), weil nur `WebviewWindowBuilder` `on_download` annimmt; der Handler bricht den Download in der App ab und oeffnet die Adresse per Opener im System-Browser (Fortschritt, Speicherort, Passwortfenster fuer den Proxy). Capability `main` unveraendert. cargo fmt/clippy/test gruen. Nicht am laufenden Client geprueft (kein Display auf dem Dev-Host) — CI baut, Nachweis beim Nutzer oder auf der Windows-VM. | 2026-09-22 | 747a4d4 | — | | fast-260922-b | **Desktop-App: Download-Knoepfe in der App ohne Funktion (fast, 747a4d4).** Befund des Nutzers: „Herunterladen“ unter Einstellungen → Desktop-App tut in der App nichts (Windows und Linux). Ursache: die Webansicht hatte keinen Download-Handler — webkit2gtk verwirft Downloads dann still, WebView2 zeigte ebenfalls nichts. Fix: Hauptfenster entsteht im Code (`app.windows` in tauri.conf.json leer), weil nur `WebviewWindowBuilder` `on_download` annimmt; der Handler bricht den Download in der App ab und oeffnet die Adresse per Opener im System-Browser (Fortschritt, Speicherort, Passwortfenster fuer den Proxy). Capability `main` unveraendert. cargo fmt/clippy/test gruen. Nicht am laufenden Client geprueft (kein Display auf dem Dev-Host) — CI baut, Nachweis beim Nutzer oder auf der Windows-VM. | 2026-09-22 | 747a4d4 | — |
| 260922-ge2 | **XFrame: Ausschnitt der Seite waehlen und einpassen, Zoom, „Nur anzeigen“.** Wunsch des Nutzers: nur einen bestimmten Ausschnitt der eingebetteten Seite zeigen, und die Groesse soll skalieren. Config: `crop {x,y,w,h}` in Seitenpixeln bei fester Layoutbreite 1280 (`XFRAME_PAGE_WIDTH`, keine UI), Klemmung ueber EINE Funktion `clampXframeCrop` (x+w ≤ 1280 verschiebt x; w ≥ 100, h ≥ 60, y+h ≤ 4000); `zoom` (50…150 %, nur Ganzseiten-Modus); `readOnly` (transparente Flaeche ueber dem Rahmen im Ansichtsmodus). Kachel: `computeCropLayout` (contain + Zentrierung, Massstab darf > 1 sein), der `<iframe>` wird selbst verschoben und skaliert (cross-origin — die Seite laesst sich von aussen nicht scrollen), Kachelmass per ResizeObserver. Einstellungen: Vorschau der Seite bei 1280 px (Stage 3000 Seitenpixel hoch, eigener Bildlauf), Rahmen als `<fieldset>` (Biome `useSemanticElements`) mit vier Eckgriffen, Ziehen per Pointer-Events mit lokalem Entwurf und genau einem PATCH beim Loslassen, Zahlenfelder als Tastaturweg; Zoom-Auswahl nur ohne Ausschnitt; Aktivieren setzt `readOnly` mit. **Befund im Browser-Rundgang, behoben (cf70a19):** Kachel und Vorschau hatten verschiedene Rahmenhoehen (max(y+h,720) vs. 3000) — bei vh-relativen Seiten (example.com `margin: 15vh`) lag derselbe Inhalt an verschiedenen Stellen, der gewaehlte Ausschnitt haette in der Kachel daneben gelegen; jetzt dieselbe Layouthoehe. Neun Pruefpunkte bestanden (Verschieben, Ecken mit fester Gegenecke und Mindestbreite, Klemmung der Zahlenfelder, Einpassen und Mitskalieren bei Kachelgroesse, Nur-anzeigen, Zoom 60 %, verweigernde Seite). Playwright kann in einem per `transform` skalierten iframe nicht selbst klicken — per `elementFromPoint` + `mouse.click` umgangen, ist eine Werkzeuggrenze. Test-Helfer `src/test/fake-resize-observer.ts`. **Zahlen:** web 604 → 640, api 1175, type-check 4/4, lint 5/5 (web 53 Warnungen unveraendert), `as unknown as` 27/6, Umlaut-Allowlist + „Ausschnitt“. | 2026-09-22 | 445b1d3,30fdd99,cf70a19 | [260922-ge2-xframe-widget-ausschnitt-der-eingebettet](./quick/260922-ge2-xframe-widget-ausschnitt-der-eingebettet/) | | 260922-ge2 | **XFrame: Ausschnitt der Seite waehlen und einpassen, Zoom, „Nur anzeigen“.** Wunsch des Nutzers: nur einen bestimmten Ausschnitt der eingebetteten Seite zeigen, und die Groesse soll skalieren. Config: `crop {x,y,w,h}` in Seitenpixeln bei fester Layoutbreite 1280 (`XFRAME_PAGE_WIDTH`, keine UI), Klemmung ueber EINE Funktion `clampXframeCrop` (x+w ≤ 1280 verschiebt x; w ≥ 100, h ≥ 60, y+h ≤ 4000); `zoom` (50…150 %, nur Ganzseiten-Modus); `readOnly` (transparente Flaeche ueber dem Rahmen im Ansichtsmodus). Kachel: `computeCropLayout` (contain + Zentrierung, Massstab darf > 1 sein), der `<iframe>` wird selbst verschoben und skaliert (cross-origin — die Seite laesst sich von aussen nicht scrollen), Kachelmass per ResizeObserver. Einstellungen: Vorschau der Seite bei 1280 px (Stage 3000 Seitenpixel hoch, eigener Bildlauf), Rahmen als `<fieldset>` (Biome `useSemanticElements`) mit vier Eckgriffen, Ziehen per Pointer-Events mit lokalem Entwurf und genau einem PATCH beim Loslassen, Zahlenfelder als Tastaturweg; Zoom-Auswahl nur ohne Ausschnitt; Aktivieren setzt `readOnly` mit. **Befund im Browser-Rundgang, behoben (cf70a19):** Kachel und Vorschau hatten verschiedene Rahmenhoehen (max(y+h,720) vs. 3000) — bei vh-relativen Seiten (example.com `margin: 15vh`) lag derselbe Inhalt an verschiedenen Stellen, der gewaehlte Ausschnitt haette in der Kachel daneben gelegen; jetzt dieselbe Layouthoehe. Neun Pruefpunkte bestanden (Verschieben, Ecken mit fester Gegenecke und Mindestbreite, Klemmung der Zahlenfelder, Einpassen und Mitskalieren bei Kachelgroesse, Nur-anzeigen, Zoom 60 %, verweigernde Seite). Playwright kann in einem per `transform` skalierten iframe nicht selbst klicken — per `elementFromPoint` + `mouse.click` umgangen, ist eine Werkzeuggrenze. Test-Helfer `src/test/fake-resize-observer.ts`. **Zahlen:** web 604 → 640, api 1175, type-check 4/4, lint 5/5 (web 53 Warnungen unveraendert), `as unknown as` 27/6, Umlaut-Allowlist + „Ausschnitt“. | 2026-09-22 | 445b1d3,30fdd99,cf70a19 | [260922-ge2-xframe-widget-ausschnitt-der-eingebettet](./quick/260922-ge2-xframe-widget-ausschnitt-der-eingebettet/) |
| 260922-hk4 | **Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Datenbank.** Frage des Nutzers nach der Freigabe 1.3.0, ob `bytea` auf Dauer sinnvoll ist. Befund: Geschwindigkeit ist NICHT das Argument (ein Bild wird je Browser einmal taeglich geladen), die SICHERUNG ist es — gesichert wird von Hand per `pg_dump`, und 30 Bilder à 5 MiB je Benutzer waeren im Extremfall 150 MB pro Benutzer in jedem Abzug (alpha-DB heute 18 MB). Dazu Einheitlichkeit: Profilbilder (`user-files/avatars`, `User.avatarPath`) und DKV-Exporte liegen laengst im Volume. Umsetzung: Spalte `storagePath`, Ablage `user-files/dashboard-images/<userId>/<uuid>.<ext>` — Dateiname IMMER vom Server (UUID + Endung aus dem erkannten Mime-Typ), `originalName` nie im Pfad; ein eigener Ordner je Benutzer ist ausdruecklich KEIN Schutz, es entscheidet weiterhin die Besitzpruefung im Dienst. Umzug laeuft automatisch beim Start (`onApplicationBootstrap` ueber `forSystem()`), idempotent; die Spalte `data` bleibt bewusst vorerst stehen (Todo fuer den DROP, erst wenn alpha und live einmal gelaufen sind). **Befund im Rundgang, eigener Commit:** eine Zeile zeigte auf eine fehlende Datei (lokal Host vs. Container-Volume; im Betrieb: alter `pg_dump` + leeres Volume) — `getBytes` stellt die Datei jetzt aus der noch vorhandenen Spalte `data` wieder her, statt 404 zu melden. **Zahlen:** api 1175 → 1188, web 640, type-check 4/4, lint 5/5, RLS-Waechter 78/78. | 2026-09-22 | 9039cea,8cbfb8b,82472ee | [260922-hk4-bilderrahmen-bilder-auf-die-festplatte](./quick/260922-hk4-bilderrahmen-bilder-auf-die-festplatte/) | | 260922-hk4 | **Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Datenbank.** Frage des Nutzers nach der Freigabe 1.3.0, ob `bytea` auf Dauer sinnvoll ist. Befund: Geschwindigkeit ist NICHT das Argument (ein Bild wird je Browser einmal taeglich geladen), die SICHERUNG ist es — gesichert wird von Hand per `pg_dump`, und 30 Bilder à 5 MiB je Benutzer waeren im Extremfall 150 MB pro Benutzer in jedem Abzug (alpha-DB heute 18 MB). Dazu Einheitlichkeit: Profilbilder (`user-files/avatars`, `User.avatarPath`) und DKV-Exporte liegen laengst im Volume. Umsetzung: Spalte `storagePath`, Ablage `user-files/dashboard-images/<userId>/<uuid>.<ext>` — Dateiname IMMER vom Server (UUID + Endung aus dem erkannten Mime-Typ), `originalName` nie im Pfad; ein eigener Ordner je Benutzer ist ausdruecklich KEIN Schutz, es entscheidet weiterhin die Besitzpruefung im Dienst. Umzug laeuft automatisch beim Start (`onApplicationBootstrap` ueber `forSystem()`), idempotent; die Spalte `data` bleibt bewusst vorerst stehen (Todo fuer den DROP, erst wenn alpha und live einmal gelaufen sind). **Befund im Rundgang, eigener Commit:** eine Zeile zeigte auf eine fehlende Datei (lokal Host vs. Container-Volume; im Betrieb: alter `pg_dump` + leeres Volume) — `getBytes` stellt die Datei jetzt aus der noch vorhandenen Spalte `data` wieder her, statt 404 zu melden. **Zahlen:** api 1175 → 1188, web 640, type-check 4/4, lint 5/5, RLS-Waechter 78/78. | 2026-09-22 | 9039cea,8cbfb8b,82472ee | [260922-hk4-bilderrahmen-bilder-auf-die-festplatte](./quick/260922-hk4-bilderrahmen-bilder-auf-die-festplatte/) |
| 260922-m1h | **Ein Modul bringt seine Dashboard-Kachel jetzt selbst mit (Vorarbeit fuer Proxmox).** Bestandsaufnahme (lesend) hatte ergeben: ein neuer Widget-Typ war an SIEBEN Stellen hartkodiert (Union-Typ, Constraints, Registry, eigene `wireXWidget()` je Typ, Aufruf in page.tsx, zweite Liste im Katalogfenster, `@IsIn` im API-DTO); die Verbindung Kachel↔Modul existierte als `WIDGET_MODULE_MAP` in `dashboard.service.ts` (filtert fail-closed), war aber nie befuellt; der Katalog zeigte jedem alle Kacheln, auch die gesperrter Module. Umbau: `WIDGET_TYPES`/`WidgetType`/`WIDGET_MODULE_SLUGS` in `packages/shared` als EINE Quelle (API validiert per `@IsIn` gegen genau sie), ein generisches `registerWidget()` statt neun Funktionen, Katalog leitet seine Liste aus der Registry ab und filtert ueber `/modules/active` (fail-closed bei Fehler, reine Funktion `visibleWidgetTypes`), nicht verfuegbare Kachel zeigt `widgets.unavailable` statt leer zu bleiben. Deckungsgleichheits-Test faengt kuenftig jede vergessene Stelle. **Befund des Executors, geprueft statt vermutet:** `apps/web` hatte KEINE Abhaengigkeit auf `@tessera/shared` (frueher bewusst) — vor der Umsetzung nachgemessen, dass Bau und Produktions-Abbild das tragen (node:24-alpine strippt die Typen nativ); Folgeregel „nur loeschbare Syntax in shared“ steht als Warnung in der Datei. Verhalten der neun Kacheln unveraendert, im Browser bestaetigt (Reihenfolge, Anlegen, Entfernen, keine rohen Schluessel). Bewusst offen: der Einstellungs-Zweig je Typ in `widget-settings-panel.tsx` und die Live-Aktualisierung des Katalogs. **Zahlen:** api 1188 → 1202, web 640 → 659, type-check 4/4, lint 5/5 (74/53 wie Basis). | 2026-09-22 | 56c07c3,8be0725 | [260922-m1h-dashboard-widgets-ein-modul-bringt-seine](./quick/260922-m1h-dashboard-widgets-ein-modul-bringt-seine/) |
## Deferred Items ## Deferred Items
@@ -0,0 +1,145 @@
---
phase: quick-260922-m1h
plan: 01
type: refactor
autonomous: true
subsystem: apps/web/src/components/dashboard
requirements: []
---
# Quick-Aufgabe 260922-m1h: Ein Modul bringt seine Dashboard-Kachel selbst mit
## Warum (Auftrag des Nutzers, 22.09.2026)
Als Naechstes kommt ein Proxmox-Modul (PVE/PBS/PMG), das zusaetzlich als
kompakte Kachel auf dem Dashboard erscheinen soll — und kuenftig sollen weitere
Module dasselbe tun (PBS: Sicherungsstatus, PMG: Mail-Zahlen). Eine
Bestandsaufnahme (lesend, 22.09.) hat ergeben:
- **Ein neuer Widget-Typ ist heute an SIEBEN Stellen hartkodiert**: `WidgetType`
(Union), `WIDGET_CONSTRAINTS`, `WIDGET_REGISTRY`, eine eigene `wireXWidget()`
je Typ, der Aufruf in `(portal)/page.tsx`, die ZWEITE Liste `WIDGET_TYPES` in
`widget-catalog-modal.tsx` und die `@IsIn`-Whitelist in
`apps/api/src/dashboard/dto/create-widget.dto.ts`. Vergisst man eine, fehlt die
Kachel im Katalog oder die API lehnt sie mit 400 ab.
- **Die Verbindung Kachel↔Modul existiert schon, ist aber leer:**
`apps/api/src/dashboard/widget-module-map.ts` (`WIDGET_MODULE_MAP = {}`),
gelesen von `dashboard.service.ts` — `getWidgets()` filtert Kacheln aus, deren
Modul der Benutzer nicht hat (fail-closed, Zeile ~164-205). Das funktioniert,
wurde nur nie benutzt.
- **Zwei Luecken:** (a) der Katalog („Widget hinzufuegen") zeigt JEDEM alle
Kacheln, auch die gesperrter Module — anlegen geht, danach verschwindet die
Kachel kommentarlos; (b) eine Kachel mit unbekanntem Typ rendert leer, ohne
Erklaerung.
Diese Aufgabe raeumt das auf, BEVOR Proxmox kommt. Kein neues Modul, keine neue
Kachel — reiner Umbau mit unveraendertem Verhalten fuer die neun vorhandenen
Kacheln.
## Gebundene Entscheidungen (Orchestrator)
1. **Eine Quelle fuer die Typliste, geteilt zwischen Web und API.** In
`packages/shared/src/index.ts` (wird von beiden Apps bereits importiert, z. B.
`desktop.service.ts`, `apps/web/src/lib/app-version.ts`) kommt:
```ts
export const WIDGET_TYPES = ['clock','search','calendar','note','calculator','favorites','stopwatch','picture-frame','xframe'] as const;
export type WidgetType = (typeof WIDGET_TYPES)[number];
/** Kachel → Modul-Slug; eine Kachel ohne Eintrag ist immer sichtbar. */
export const WIDGET_MODULE_SLUGS: Partial<Record<WidgetType, string>> = {};
```
`create-widget.dto.ts` validiert mit `@IsIn([...WIDGET_TYPES])`, das Frontend
leitet `WidgetType` von dort ab. `widget-module-map.ts` behaelt seine
oeffentliche Funktion `getModuleSlugForWidgetType()`, liest aber
`WIDGET_MODULE_SLUGS` aus `@tessera/shared` statt einer eigenen Kopie
(Kommentar: eine Tabelle fuer beide Seiten, damit Katalogfilter und
Server-Filter nicht auseinanderlaufen).
2. **Eine Anmeldestelle je Kachel.** Statt neun `wireXWidget()`-Funktionen mit je
eigenem Bool-Flag ein generisches `registerWidget(type, component)` in
`widget-registry.tsx`; `(portal)/page.tsx` ruft es je Kachel einmal auf (die
Datei bleibt die Stelle, an der die Komponenten importiert werden — der
Zirkelimport-Grund aus dem Bestandskommentar gilt weiter, also NICHT die
Komponenten direkt in der Registry importieren). Mehrfachanmeldung desselben
Typs ist ein No-Op (wie die bisherigen Flags); Anmeldung eines unbekannten
Typs wirft in der Entwicklung und wird in der Produktion ignoriert.
3. **`WIDGET_REGISTRY` bekommt `moduleSlug?: string`** je Eintrag, befuellt aus
`WIDGET_MODULE_SLUGS`. Heute bleibt es fuer alle neun Kacheln leer.
4. **Der Katalog leitet seine Liste aus der Registry ab** (`Object.keys` in der
Reihenfolge der Registry-Definition, die heutige Reihenfolge bleibt erhalten —
Test darauf) und **filtert nach Modulzugriff**: `widget-catalog-modal.tsx`
bekommt eine Liste der zugaenglichen Modul-Slugs als Prop von der Seite, die
sie ueber den vorhandenen Weg `/modules/active` holt (Muster
`apps/web/src/components/layout/sidebar.tsx` — dort wird genau dieser Endpunkt
schon gefetcht; dieselbe Hilfsfunktion nutzen, nicht neu bauen). Eine Kachel
ohne `moduleSlug` ist immer sichtbar; eine mit `moduleSlug` nur, wenn der Slug
in der Liste steht. Schlaegt der Abruf fehl, werden Kacheln MIT `moduleSlug`
ausgeblendet (fail-closed, wie serverseitig).
5. **Gesperrte/unbekannte Kachel erklaert sich.** `widget-wrapper.tsx` rendert
heute nichts, wenn `definition?.component` fehlt. Neu: ein zentrierter grauer
Hinweistext `widgets.unavailable` („Diese Kachel steht nicht zur Verfügung —
das zugehörige Modul ist nicht freigegeben.") in de und en. Der Fall tritt
erst mit Proxmox real auf, ist aber ab jetzt abgedeckt.
6. **Verhalten der neun vorhandenen Kacheln aendert sich NICHT.** Gleiche Namen,
gleiche Reihenfolge im Katalog, gleiche Groessenvorgaben, gleiche Einstellungen.
Der Einstellungs-Zweig je Typ in `widget-settings-panel.tsx` bleibt wie er ist —
den generisch zu machen waere ein eigener Umbau und gehoert NICHT in diese
Aufgabe (im SUMMARY als bewusst offen gelassen nennen).
7. Keine neuen Abhaengigkeiten. Keine Aenderung an der Datenbank.
## Aufgaben
<tasks>
<task type="auto" tdd="true">
<name>Aufgabe 1: Typliste nach @tessera/shared, generische Anmeldung, Katalog aus der Registry</name>
<files>packages/shared/src/index.ts, apps/api/src/dashboard/dto/create-widget.dto.ts, apps/api/src/dashboard/widget-module-map.ts, apps/api/src/dashboard/widget-module-map.spec.ts, apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widget-registry.test.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/app/(portal)/page.test.tsx, apps/web/src/components/dashboard/widget-catalog-modal.tsx, apps/web/src/components/dashboard/widget-catalog-modal.test.tsx</files>
<action>
Entscheidungen 1-4 umsetzen. Reihenfolge: shared zuerst (beide Apps bauen dagegen), dann API-DTO und `widget-module-map.ts`, dann Registry + `registerWidget`, dann `page.tsx`, zuletzt der Katalog.
Tests zuerst anpassen/ergaenzen, wo sie die alten Namen festhalten (`widget-registry.test.tsx` prueft heute die Typliste und die Constraints-Tabelle; `widget-catalog-modal.test.tsx` die Eintraege). Neu mindestens: Katalogreihenfolge entspricht der Registry-Reihenfolge; eine Kachel mit `moduleSlug` fehlt im Katalog, wenn der Slug nicht in den zugaenglichen Modulen steht, und erscheint, wenn doch; fehlgeschlagener Modulabruf blendet Kacheln mit `moduleSlug` aus; `registerWidget` ist idempotent; `WIDGET_TYPES` aus shared und die Registry-Schluessel sind deckungsgleich (ein Test, der kuenftig jede vergessene Stelle faengt).
Fuer den Katalog-Test eine Kachel mit `moduleSlug` brauchen, ohne eine echte zu erfinden: die Registry im Test per Hilfsfunktion um einen Testeintrag erweitern ODER den Filter als reine Funktion `visibleWidgetTypes(registry, accessibleSlugs | null)` auslagern und diese direkt testen — die reine Funktion ist vorzuziehen (Muster `picture-frame-config.ts`).
Commit: `refactor(quick-260922-m1h): Widget-Typen an einer Stelle, Katalog aus der Registry, Kachel kennt ihr Modul`
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard "src/app/(portal)/page.test.tsx" && pnpm --filter @tessera/api exec vitest run src/dashboard && pnpm type-check && pnpm lint</automated>
</verify>
<done>`WIDGET_TYPES`/`WidgetType`/`WIDGET_MODULE_SLUGS` stehen in `packages/shared`; API-DTO und Web leiten davon ab; genau EINE `registerWidget`-Funktion (kein `wireXWidget` mehr); Katalogliste kommt aus der Registry (keine zweite Liste); Deckungsgleichheits-Test vorhanden und gruen. Alle bestehenden Tests gruen, Reihenfolge und Namen der neun Kacheln unveraendert.</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 2: Gesperrte Kachel erklaert sich, Uebersetzungen, Changelog, Entwicklerdoku</name>
<files>apps/web/src/components/dashboard/widgets/widget-wrapper.tsx, apps/web/src/components/dashboard/widgets/widget-wrapper.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, CHANGELOG.md, docs/anleitung-entwicklung.md</files>
<action>
Entscheidung 5 umsetzen (Hinweistext statt leerer Kachel, Test dafuer), Schluessel `widgets.unavailable` in beiden Sprachdateien.
`docs/anleitung-entwicklung.md`: den vorhandenen Modul-Walkthrough (Abschnitt um Zeile 372-400) um einen kurzen Abschnitt „Eine Kachel zum Modul" ergaenzen — welche drei Stellen es NACH diesem Umbau noch sind (Komponente schreiben, `registerWidget` in `page.tsx`, Eintrag in `WIDGET_TYPES` + optional `WIDGET_MODULE_SLUGS` in `packages/shared`, plus Uebersetzungen und Groessenvorgaben) und dass eine Kachel mit `moduleSlug` automatisch aus Katalog und Dashboard verschwindet, wenn das Modul fehlt.
CHANGELOG unter „Unveröffentlicht → Geändert": „Dashboard: Kacheln, die zu einem Modul gehören, erscheinen nur noch für Benutzer, die dieses Modul nutzen dürfen; eine nicht mehr freigegebene Kachel erklärt das jetzt, statt leer zu bleiben" (Stichpunkt, kein Fliesstext).
Volle Tore am Ende.
Commit: `docs(quick-260922-m1h): Hinweis bei gesperrter Kachel, Changelog und Entwicklerdoku`
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard src/messages && pnpm type-check && pnpm lint && pnpm --filter @tessera/api test && pnpm --filter @tessera/web test</automated>
</verify>
<done>Unbekannter/gesperrter Typ zeigt den Hinweistext (Test); beide Sprachdateien tragen den Schluessel; Changelog-Zeile steht; Entwicklerdoku nennt die verbliebenen Schritte; alle Tore gruen; genau zwei Commits mit Scope `quick-260922-m1h`.</done>
</task>
</tasks>
## Hinweise fuer den Executor
- HEAD ist `ee2b025`, Arbeitsbaum sauber, Zweig `main`. Version 1.3.0 wurde heute freigegeben; dieser Umbau geht in die naechste Freigabe. Zweig `live` und Tags NICHT anfassen.
- `packages/shared` wird von beiden Apps importiert (`@tessera/shared`); pruefen, ob ein Build-Schritt noetig ist (`pnpm --filter @tessera/shared build`?) — turbo erledigt das ueblicherweise, im Zweifel `pnpm build` fuer shared vor dem Typecheck.
- Qualitaetsregeln: keine neue `any`, `as unknown as` api 27 / web 6 unveraendert, keine `!`, kein `biome-ignore`, web-Warnungen bleiben 53, api 74.
- Commits: Conventional Commits, Scope `quick-260922-m1h`, deutscher Betreff im Stil von `git log --oneline -15`, Commit-Body endet mit
`Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`
- `.planning/**` NICHT committen.
- Testserver nicht anfassen. Lokaler Docker-Stack laeuft, nicht noetig fuer diese Aufgabe.
- SUMMARY nach `/home/vicolab/projects/tessera-ctl/.planning/quick/260922-m1h-dashboard-widgets-ein-modul-bringt-seine/260922-m1h-SUMMARY.md` (`status: complete`), mit: was jetzt noch zu tun ist, um eine Modul-Kachel hinzuzufuegen (die kurze Liste), Abweichungen, Zahlen, und einer kurzen Browser-Pruefliste fuer mich.
<threat_model>
ASVS 1, block on high.
| ID | Bedrohung | Schwere | Disposition |
|---|---|---|---|
| T-M1H-01 | Katalogfilter clientseitig = Umgehung moeglich (Kachel per API trotzdem anlegen) | medium | Der Katalogfilter ist Komfort, die Durchsetzung bleibt serverseitig in `dashboard.service.ts` (`getWidgets()` filtert fail-closed) und im Modul-Guard der jeweiligen Daten-Endpunkte. Im Code so kommentieren. Akzeptiert. |
| T-M1H-02 | Kachel eines gesperrten Moduls zeigt weiter Daten | high | Daten holt jede Kachel ueber ihre eigenen Modul-Endpunkte, die `@UseModule(slug)` tragen muessen — fuer Proxmox in der naechsten Aufgabe verbindlich. Diese Aufgabe aendert daran nichts und schwaecht nichts ab. |
| T-M1H-03 | Typliste in `packages/shared` als neue Vertrauensgrenze | low | Reine Konstantenliste, keine Laufzeitdaten; die API validiert weiterhin mit `@IsIn` gegen genau diese Liste. Mitigiert. |
| T-M1H-04 | Fehlender Modulabruf oeffnet den Katalog | medium | Fail-closed: bei Fehler werden Kacheln MIT `moduleSlug` ausgeblendet (Entscheidung 4), Test dafuer. Mitigiert. |
</threat_model>
@@ -0,0 +1,215 @@
---
phase: quick-260922-m1h
plan: 01
subsystem: apps/web/src/components/dashboard
tags: [refactor, dashboard, widgets, module-access]
status: complete
requires: []
provides:
- "WIDGET_TYPES/WidgetType/WIDGET_MODULE_SLUGS als geteilte Quelle in packages/shared"
- "registerWidget() als einzige Anmeldestelle je Kachel"
- "visibleWidgetTypes() — Katalogfilter nach Modulzugriff, fail-closed"
affects:
- apps/api/src/dashboard
- apps/web/src/app/(portal)/page.tsx
tech-stack:
added:
- "apps/web haengt jetzt auf @tessera/shared (workspace:*)"
patterns:
- "erster Laufzeit-Import aus @tessera/shared (bisher nur import type)"
key-files:
created:
- apps/api/src/dashboard/widget-module-map.spec.ts
modified:
- packages/shared/src/index.ts
- apps/web/src/components/dashboard/widget-registry.tsx
- apps/web/src/components/dashboard/widget-catalog-modal.tsx
- apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
- apps/web/src/app/(portal)/page.tsx
- apps/api/src/dashboard/dto/create-widget.dto.ts
- apps/api/src/dashboard/widget-module-map.ts
decisions:
- "Typliste als Laufzeit-Konstante in packages/shared statt gespiegelter Kopien — traegt, weil Node 24 rohes TypeScript per Type-Stripping laedt"
- "apps/web bekommt die Abhaengigkeit auf @tessera/shared; die frueher dokumentierte Gegenbegruendung war ueberholt"
- "Katalogfilter als reine Funktion visibleWidgetTypes(registry, slugs|null) statt Logik im Dialog"
metrics:
duration: "~70 min"
completed: 2026-09-22
actuals:
tokens: 21000
tasks: 2
commits: 2
plan_head_before: ee2b025
---
# Quick-Aufgabe 260922-m1h: Ein Modul bringt seine Dashboard-Kachel selbst mit — Zusammenfassung
Die Kachel-Typliste stand an sieben Stellen; sie steht jetzt an einer. Der
Katalog fuehrt keine zweite Liste mehr und blendet Kacheln gesperrter Module
aus, eine Kachel ohne Bauteil erklaert sich mit einem Satz statt leer zu
bleiben. Die neun vorhandenen Kacheln verhalten sich unveraendert.
## So fuegt man kuenftig eine Modul-Kachel hinzu
Vorher sieben Stellen, jetzt drei (plus das Uebliche an Text und Maßen):
1. **Kachel-Komponente schreiben** — `apps/web/src/components/dashboard/widgets/<name>-widget.tsx`,
nimmt `WidgetProps` (`instanceId`, `config`, `isEditMode`).
2. **Typ eintragen** — in `WIDGET_TYPES` in `packages/shared/src/index.ts`. Gehoert die Kachel zu
einem Modul, zusaetzlich `WIDGET_MODULE_SLUGS['<typ>'] = '<modul-slug>'` in derselben Datei.
Das ist die einzige Liste — die API validiert per `@IsIn` gegen genau sie.
3. **Anmelden** — `registerWidget('<typ>', <Name>Widget)` in `apps/web/src/app/(portal)/page.tsx`.
Dazu wie bei jeder Oberflaeche: Uebersetzungsschluessel `<typ>.name` und `<typ>.description` unter
`widgets` in **de.json und en.json**, ein Inline-SVG-Symbol und die Groessenvorgaben in
`WIDGET_CONSTRAINTS` — Symbol und Maße in `widget-registry.tsx`.
Eine Kachel mit `moduleSlug` verschwindet danach **von selbst** aus Katalog und Dashboard, wenn der
Benutzer das Modul nicht nutzen darf. Vergisst man eine der drei Stellen, schlaegt der
Deckungsgleichheits-Test in `widget-registry.test.tsx` fehl, statt dass die Kachel im Katalog fehlt
oder die API mit 400 antwortet.
Dieselbe Liste steht als Abschnitt „Eine Kachel zum Modul" in
`docs/anleitung-entwicklung.md`.
## Was gebaut wurde
**Aufgabe 1 — `56c07c3`** (`refactor`)
- `packages/shared/src/index.ts`: `WIDGET_TYPES`, `WidgetType`, `WIDGET_MODULE_SLUGS`.
- `create-widget.dto.ts`: `@IsIn([...WIDGET_TYPES])` statt handgepflegter Liste.
- `widget-module-map.ts`: liest `WIDGET_MODULE_SLUGS` statt einer eigenen Kopie; die oeffentliche
Funktion `getModuleSlugForWidgetType()` ist unveraendert, damit `dashboard.service.spec.ts`
sie weiter mocken kann.
- `widget-registry.tsx`: neun `wireXWidget()` → ein `registerWidget()` (idempotent; unbekannter Typ
wirft in der Entwicklung, wird in der Produktion ignoriert). `WidgetDefinition` traegt
`moduleSlug?`. Neue reine Funktion `visibleWidgetTypes(registry, slugs|null)`.
- `widget-catalog-modal.tsx`: Liste kommt aus der Registry (Reihenfolge erhalten, Test darauf),
gefiltert nach Modulzugriff; neue Prop `accessibleModuleSlugs`.
- `(portal)/page.tsx`: neun `registerWidget`-Aufrufe; holt `/modules/active` im Muster der
Seitenleiste (`credentials: 'include'`, Fehler still) und reicht die Slugs an den Katalog durch.
**Aufgabe 2 — `8be0725`** (`docs`)
- `widget-wrapper.tsx`: Kachel ohne Bauteil zeigt `widgets.unavailable` zentriert und grau statt des
rohen Typnamens; Schluessel in de.json und en.json.
- Changelog-Stichpunkt unter „Unveroeffentlicht → Geaendert"; Entwicklerdoku-Abschnitt.
## Abweichungen vom Plan
**1. [Rule 3 — blockierend] Die Planannahme „apps/web importiert @tessera/shared bereits" war falsch**
- **Gefunden bei:** Aufgabe 1, vor der ersten Zeile Code.
- **Befund:** `apps/web` hatte **keine** Abhaengigkeit auf `@tessera/shared`. Zwei Kommentare
(`lib/app-version.ts`, `lib/desktop.ts`) dokumentierten das sogar ausdruecklich als Absicht und
begruendeten damit gespiegelte Typen. Ohne Abhaengigkeit ist Entscheidung 1 des Plans nicht
umsetzbar. Zudem waren **alle** bisherigen `@tessera/shared`-Importe in `apps/api` reine
`import type` — die Typliste ist aber ein Laufzeitwert.
- **Geprueft statt vermutet:**
- `nest build` mit einem Laufzeit-Import: laeuft; das Ergebnis laedt `@tessera/shared` im
fertigen `dist` tatsaechlich (nachgestellt, 9 Typen).
- `packages/shared` liefert rohes TypeScript ohne Bauschritt — in `node:24-alpine` direkt
geprueft: Node 24 laedt es per nativem Type-Stripping (`OK [ 'clock', 'xframe' ] {}`).
- Die alte Gegenbegruendung ist ueberholt: der Web-Dockerfile kopiert `packages/shared` in
deps- **und** builder-Stufe bereits. Es aendert sich nur das Lockfile (3 Zeilen).
- `pnpm --filter @tessera/web build` laeuft durch — ohne `transpilePackages`.
- **Umsetzung:** `@tessera/shared: workspace:*` in `apps/web/package.json`. Die beiden Kommentare,
deren Begruendung dadurch unwahr wurde, sagen jetzt den aktuellen Stand; die Typ-Spiegel selbst
blieben bewusst unangetastet (nicht Teil dieser Aufgabe).
- **Nebenwirkung fuer die Zukunft:** `packages/shared/src/index.ts` darf nur noch loeschbare Syntax
enthalten — kein `enum`, kein `namespace`, keine Parameter-Eigenschaften. Steht als Warnung in
der Datei.
**2. [Abweichung vom Auftrag des Orchestrators] Keine gemeinsame Hilfsfunktion fuer `/modules/active`**
Der Auftrag nannte „dieselbe Hilfsfunktion wie die Seitenleiste". Eine solche gibt es nicht: die
Seitenleiste hat einen eingebauten `fetch`, und `lib/api.ts#getActiveModules` ist serverseitig
(Cookie-Header, kein `credentials`). Die Dashboard-Seite benutzt daher dasselbe **Muster** wie die
Seitenleiste. Eine Hilfsfunktion herauszuloesen haette `sidebar.tsx` angefasst — ausserhalb dieser
Aufgabe.
## Bewusst offen gelassen
- **`widget-settings-panel.tsx`** — der Einstellungs-Zweig je Typ bleibt wie er war. Den generisch
zu machen ist ein eigener Umbau (so im Plan festgelegt). Die Datei wurde nicht angefasst.
- **Die Typ-Spiegel** in `lib/app-version.ts` und `lib/desktop.ts` koennten jetzt echte Importe
werden. Nicht gemacht, nur die Kommentare richtiggestellt.
- **Katalog aktualisiert sich nicht live**, wenn im Marketplace gerade ein Modul freigeschaltet
wird — die Seitenleiste tut das ueber `sidebarRefreshKey`, die Dashboard-Seite holt die Liste nur
beim Aufbau. Heute ohne Wirkung (keine Kachel hat einen `moduleSlug`); mit Proxmox reicht ein
Neuladen der Seite. Bewusst so, weil der Auffrisch-Ausloeser einen `biome-ignore` erzwungen
haette, den die Qualitaetsregeln dieser Aufgabe ausschliessen.
## Keine Stubs
Es wurden keine Platzhalter, leeren Rueckgaben oder „coming soon"-Texte eingebaut.
`WIDGET_MODULE_SLUGS` ist leer — das ist kein Stub, sondern der korrekte Zustand: alle neun Kacheln
sind Plattform-Kacheln. Die erste Modul-Kachel (Proxmox) traegt sich dort ein.
## Bedrohungsmodell
| ID | Stand |
|---|---|
| T-M1H-01 | Akzeptiert wie geplant. Der Katalogfilter ist Komfort; im Code an drei Stellen so kommentiert. Durchsetzung bleibt `DashboardService.getWidgets` (unveraendert, 85 Tests gruen). |
| T-M1H-02 | Unveraendert — diese Aufgabe schwaecht nichts ab. Fuer Proxmox bleibt `@UseModule(slug)` verbindlich. |
| T-M1H-03 | Mitigiert. Reine Konstantenliste, keine Laufzeitdaten; die API validiert weiterhin `@IsIn` gegen genau diese Liste — jetzt nachweislich (Test validiert alle neun Typen und lehnt einen unbekannten ab). |
| T-M1H-04 | Mitigiert. `accessibleModuleSlugs === null` blendet Kacheln MIT `moduleSlug` aus; Test im Katalog und in `visibleWidgetTypes`. |
## Zahlen
| | |
|---|---|
| Commits | 2 (`56c07c3`, `8be0725`), Basis `ee2b025` |
| Dateien geaendert | 20 (1 neu) |
| Zeilen | +610 / −150 (gemessen: `git diff --shortstat ee2b025 HEAD`) |
| Neue Tests | 29 (Registry 9, Katalog 3, Seite 2, `widget-module-map.spec.ts` 14, Wrapper 1) |
| API-Tests | 1202 gruen (76 Dateien) |
| Web-Tests | 659 gruen (81 Dateien) |
| type-check | sauber (4 Pakete) |
| lint | api 74 / web 53 Warnungen — **unveraendert** zur Basis |
| `as unknown as` | api 27 / web 6 — **unveraendert** |
| neue `any` / `!` / `biome-ignore` | 0 / 0 / 0 |
| Next.js-Produktionsbau | laeuft |
| `nest build` | laeuft |
## Browser-Pruefliste
Der Umbau ist verhaltensneutral — die Pruefung soll vor allem bestaetigen, dass **nichts** anders
aussieht. Lokalen Stack neu bauen (`--build`), dann im Portal:
1. **Dashboard oeffnen.** Alle bisherigen Kacheln stehen an ihrem Platz und funktionieren wie
vorher (Uhr laeuft, Kalender zeigt Termine, Bilderrahmen wechselt, XFrame laedt).
2. **Stift → „Widget hinzufuegen".** Der Katalog zeigt **neun** Kacheln in genau dieser Reihenfolge:
Uhr, Suchleiste, Kalender, Notiz, Taschenrechner, Favoriten, Stoppuhr, Bilderrahmen, XFrame.
Namen und Beschreibungen unveraendert.
3. **Eine Kachel anlegen** (z. B. Stoppuhr) — sie erscheint, laesst sich ziehen, vergroessern und
wieder entfernen. Kein 400-Fehler.
4. **Groessen pruefen:** eine frisch angelegte Kachel hat dieselbe Startgroesse wie frueher, und
sie laesst sich nicht kleiner ziehen als bisher.
5. **Einstellungen → Dashboard:** die Einstellungen je Kachel sind unveraendert da (dieser Bereich
wurde bewusst nicht angefasst).
6. **Sprache auf Englisch umstellen** — der Katalog bleibt vollstaendig, keine rohen Schluessel wie
`clock.name` sichtbar.
7. *(optional, zeigt das Neue)* Der Hinweis bei einer nicht verfuegbaren Kachel laesst sich heute
nur kuenstlich ausloesen — er greift erst mit der ersten Modul-Kachel. Wer ihn sehen will: in der
Datenbank den `widgetType` einer vorhandenen Kachel auf `proxmox` setzen und die Seite neu laden;
die Kachel zeigt dann „Diese Kachel steht nicht zur Verfuegung — das zugehoerige Modul ist nicht
freigegeben." statt leer zu bleiben. Danach zuruecksetzen.
## Self-Check: PASSED
- `apps/api/src/dashboard/widget-module-map.spec.ts` vorhanden.
- Commits `56c07c3` und `8be0725` in `git log` gefunden.
- `git diff --diff-filter=D ee2b025..HEAD` — keine geloeschten Dateien.
- `git rev-list --count ee2b025..HEAD` = 2, gemessen.
- `.planning/**` nicht committet.
## Rundgang durch den Orchestrator (22.09.2026, lokaler Stack aus 8be0725)
Bestanden, keine Abweichung zum Stand vorher:
- Dashboard zeigt die bestehenden Kacheln (Kalender, Notizen, Favoriten, Bilderrahmen, XFrame) unveraendert, keine Konsolenfehler.
- Katalog zeigt **neun** Kacheln in der alten Reihenfolge: Uhr, Suchleiste, Kalender, Notizen, Taschenrechner, Favoriten, Stoppuhr, Bilderrahmen, XFrame; Namen und Beschreibungen unveraendert, keine rohen Schluessel.
- Stoppuhr angelegt → erscheint (396x160 px), wird gespeichert (`stopwatch` in `GET /dashboard/widgets`), kein 400; danach wieder entfernt, Liste sauber.
- `/modules/active` wird beim Seitenaufbau abgerufen (4x 200) — der Katalogfilter hat seine Datenquelle.
- Der Hinweis bei nicht verfuegbarer Kachel liess sich nicht echt ausloesen (es gibt noch keine Modul-Kachel); er ist durch den Test in `widget-wrapper.test.tsx` gedeckt und greift mit Proxmox.
+1
View File
@@ -7,6 +7,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
### Geändert ### Geändert
- Bilderrahmen: hochgeladene Bilder liegen jetzt im Dateibereich des Servers statt in der Datenbank — die Datenbanksicherung bleibt dadurch klein; vorhandene Bilder ziehen beim ersten Start automatisch um - Bilderrahmen: hochgeladene Bilder liegen jetzt im Dateibereich des Servers statt in der Datenbank — die Datenbanksicherung bleibt dadurch klein; vorhandene Bilder ziehen beim ersten Start automatisch um
- Dashboard: Kacheln, die zu einem Modul gehören, erscheinen nur noch für Benutzer, die dieses Modul nutzen dürfen; eine nicht mehr freigegebene Kachel erklärt das jetzt, statt leer zu bleiben
## 1.3.0 – 2026-09-22 ## 1.3.0 – 2026-09-22
@@ -1,24 +1,20 @@
import { IsIn, IsObject, IsOptional, IsString } from 'class-validator'; import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
import { WIDGET_TYPES } from '@tessera/shared';
/** /**
* DTO for creating a new widget instance on a user's dashboard. * DTO for creating a new widget instance on a user's dashboard.
* widgetType must be one of the nine supported types *
* ('picture-frame' seit quick-260921-pi9, 'xframe' seit quick-260921-qd3). * quick-260922-m1h: `widgetType` wird gegen `WIDGET_TYPES` aus
* `@tessera/shared` geprüft — dieselbe Liste, aus der das Frontend seine
* Registry und seinen Katalog ableitet. Vorher stand die Liste hier ein
* zweites Mal; vergaß man einen Eintrag, lehnte die API eine im Katalog
* angebotene Kachel mit 400 ab.
*
* config is optional and defaults to {} on the model. * config is optional and defaults to {} on the model.
*/ */
export class CreateWidgetDto { export class CreateWidgetDto {
@IsString() @IsString()
@IsIn([ @IsIn([...WIDGET_TYPES])
'clock',
'search',
'calendar',
'note',
'calculator',
'favorites',
'stopwatch',
'picture-frame',
'xframe',
])
widgetType!: string; widgetType!: string;
@IsOptional() @IsOptional()
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { WIDGET_MODULE_SLUGS, WIDGET_TYPES } from '@tessera/shared';
import { CreateWidgetDto } from './dto/create-widget.dto';
import { WIDGET_MODULE_MAP, getModuleSlugForWidgetType } from './widget-module-map';
/**
* quick-260922-m1h: Web und API lesen dieselbe Tabelle. Liefen sie
* auseinander, wuerde der Katalog eine Kachel anbieten, die der Server
* danach wieder herausfiltert (oder umgekehrt).
*/
describe('widget-module-map (quick-260922-m1h)', () => {
it('WIDGET_MODULE_MAP ist die Tabelle aus @tessera/shared, keine zweite Kopie', () => {
expect(WIDGET_MODULE_MAP).toBe(WIDGET_MODULE_SLUGS);
});
it('jeder Schluessel der Tabelle ist ein bekannter Widget-Typ', () => {
for (const type of Object.keys(WIDGET_MODULE_MAP)) {
expect(WIDGET_TYPES).toContain(type);
}
});
it('die neun heutigen Kacheln sind Plattform-Kacheln ohne Modulbezug', () => {
for (const type of WIDGET_TYPES) {
expect(getModuleSlugForWidgetType(type)).toBeUndefined();
}
});
it('ein unbekannter Typ liefert undefined statt zu werfen', () => {
expect(getModuleSlugForWidgetType('gibt-es-nicht')).toBeUndefined();
});
});
/**
* Der Kern des Umbaus: die `@IsIn`-Whitelist ist keine handgepflegte zweite
* Liste mehr. Vergisst kuenftig jemand einen Eintrag in `packages/shared`,
* schlaegt dieser Test fehl, statt die API eine gueltige Kachel mit 400
* ablehnen zu lassen.
*/
describe('CreateWidgetDto-Whitelist (quick-260922-m1h)', () => {
async function validateType(widgetType: string) {
const dto = plainToInstance(CreateWidgetDto, { widgetType });
return validate(dto);
}
it.each([...WIDGET_TYPES])('akzeptiert den Typ "%s"', async (widgetType) => {
await expect(validateType(widgetType)).resolves.toEqual([]);
});
it('lehnt einen Typ ab, der nicht in WIDGET_TYPES steht', async () => {
const errors = await validateType('gibt-es-nicht');
expect(errors).toHaveLength(1);
expect(errors[0].property).toBe('widgetType');
expect(errors[0].constraints).toHaveProperty('isIn');
});
});
+16 -9
View File
@@ -1,3 +1,5 @@
import { WIDGET_MODULE_SLUGS } from '@tessera/shared';
/** /**
* Zuordnung Widget-Typ → Modul (D-22, PERM-07). * Zuordnung Widget-Typ → Modul (D-22, PERM-07).
* *
@@ -8,24 +10,29 @@
* Schlüssel sind Werte von `WidgetInstance.widgetType`, Werte sind * Schlüssel sind Werte von `WidgetInstance.widgetType`, Werte sind
* Modul-Slugs aus `Module.slug`. * Modul-Slugs aus `Module.slug`.
* *
* quick-260922-m1h: Die Tabelle selbst steht seit diesem Umbau in
* `packages/shared/src/index.ts` als `WIDGET_MODULE_SLUGS` — EINE Tabelle
* für beide Seiten, damit der Katalogfilter im Web
* (`visibleWidgetTypes`, Komfort) und dieser Server-Filter (verbindlich)
* nicht auseinanderlaufen. Hier steht nur noch der Lesezugriff; die
* öffentliche Schnittstelle dieser Datei bleibt unverändert, weil
* dashboard.service.spec.ts sie gezielt mockt.
*
* Bewusst eine TypeScript-Konstante statt einer Spalte auf * Bewusst eine TypeScript-Konstante statt einer Spalte auf
* `WidgetInstance`: eine Migration auf einer bereits befüllten Tabelle * `WidgetInstance`: eine Migration auf einer bereits befüllten Tabelle
* für ein Feld, das derzeit für jede Zeile leer wäre, wiegt schwerer als * für ein Feld, das derzeit für jede Zeile leer wäre, wiegt schwerer als
* diese Konstante mit identischer Aussagekraft (15-RESEARCH.md Pitfall 5). * diese Konstante mit identischer Aussagekraft (15-RESEARCH.md Pitfall 5).
* *
* Die Tabelle ist am Ende dieser Phase bewusst leer: alle sieben heute * Die Tabelle ist bewusst leer: alle neun registrierten Widget-Typen
* registrierten Widget-Typen (clock/search/calendar/note/calculator/ * (clock/search/calendar/note/calculator/favorites/stopwatch/
* favorites/stopwatch, siehe apps/web/src/components/dashboard/ * picture-frame/xframe) sind Plattform-Widgets ohne Modulbezug. Die erste
* widget-registry.tsx) sind Plattform-Widgets ohne Modulbezug. Das * modulgebundene Kachel trägt ihren Slug in `WIDGET_MODULE_SLUGS` ein.
* einzige bislang geplante modulgebundene Widget steht in
* .planning/REQUIREMENTS.md unter "Future Requirements (deferred)" und
* wird in dieser Phase bewusst nicht registriert.
*/ */
export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = {}; export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = WIDGET_MODULE_SLUGS;
/** /**
* Liefert den Modul-Slug für einen Widget-Typ, oder `undefined`, wenn * Liefert den Modul-Slug für einen Widget-Typ, oder `undefined`, wenn
* der Typ kein Modul-Widget ist (der heutige Zustand für alle sieben * der Typ kein Modul-Widget ist (der heutige Zustand für alle neun
* bestehenden Typen). Einziger Lesezugriff auf die Zuordnungstabelle, * bestehenden Typen). Einziger Lesezugriff auf die Zuordnungstabelle,
* damit Tests sie gezielt mocken können. * damit Tests sie gezielt mocken können.
*/ */
+1
View File
@@ -11,6 +11,7 @@
"lint": "biome lint ." "lint": "biome lint ."
}, },
"dependencies": { "dependencies": {
"@tessera/shared": "workspace:*",
"@uiw/react-md-editor": "4.1.1", "@uiw/react-md-editor": "4.1.1",
"fflate": "^0.8.3", "fflate": "^0.8.3",
"html-to-image": "1.11.13", "html-to-image": "1.11.13",
+54 -2
View File
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// quick-260916-dyv: Dashboard-Seite — feste Aktionsleiste unten rechts // quick-260916-dyv: Dashboard-Seite — feste Aktionsleiste unten rechts
@@ -47,8 +47,16 @@ vi.mock('@/components/dashboard/dashboard-grid', () => ({
), ),
})); }));
// quick-260922-m1h: Der Katalog bekommt die zugaenglichen Modul-Slugs als
// Prop von dieser Seite — die Attrappe merkt sie sich, damit der Test sie
// pruefen kann, ohne den echten Dialog zu rendern.
const catalogProps: { accessibleModuleSlugs?: readonly string[] | null } = {};
vi.mock('@/components/dashboard/widget-catalog-modal', () => ({ vi.mock('@/components/dashboard/widget-catalog-modal', () => ({
WidgetCatalogModal: () => null, WidgetCatalogModal: (p: { accessibleModuleSlugs: readonly string[] | null }) => {
catalogProps.accessibleModuleSlugs = p.accessibleModuleSlugs;
return null;
},
})); }));
vi.mock('@/components/dashboard/widgets/clock-widget', () => ({ ClockWidget: () => null })); vi.mock('@/components/dashboard/widgets/clock-widget', () => ({ ClockWidget: () => null }));
@@ -62,6 +70,17 @@ vi.mock('@/components/dashboard/widgets/picture-frame-widget', () => ({ PictureF
vi.mock('@/components/dashboard/widgets/xframe-widget', () => ({ XframeWidget: () => null })); vi.mock('@/components/dashboard/widgets/xframe-widget', () => ({ XframeWidget: () => null }));
beforeEach(() => { beforeEach(() => {
catalogProps.accessibleModuleSlugs = undefined;
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: true,
json: async () => [
{ id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'tools' },
{ id: 'm2', slug: 'tender-radar', name: 'Tender', category: 'tools' },
],
})),
);
mockStore.isEditMode = false; mockStore.isEditMode = false;
mockStore.isLoading = false; mockStore.isLoading = false;
mockStore.error = null; mockStore.error = null;
@@ -71,6 +90,7 @@ beforeEach(() => {
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.unstubAllGlobals();
}); });
describe('DashboardPage (quick-260916-dyv)', () => { describe('DashboardPage (quick-260916-dyv)', () => {
@@ -117,3 +137,35 @@ describe('DashboardPage (quick-260916-dyv)', () => {
expect(mockStore.setEditMode).toHaveBeenCalledWith(true); expect(mockStore.setEditMode).toHaveBeenCalledWith(true);
}); });
}); });
describe('DashboardPage: zugaengliche Module fuer den Katalog (quick-260922-m1h)', () => {
it('Test 4: holt GET /modules/active mit Sitzungs-Keks und reicht die Slugs an den Katalog durch', async () => {
const { default: DashboardPage } = await import('./page');
render(<DashboardPage />);
await waitFor(() => {
expect(catalogProps.accessibleModuleSlugs).toEqual(['domaincheck', 'tender-radar']);
});
const call = vi.mocked(fetch).mock.calls[0];
expect(String(call[0])).toContain('/modules/active');
expect(call[1]).toMatchObject({ credentials: 'include' });
});
it('Test 5: fail-closed — schlaegt der Abruf fehl, bleibt die Liste unbekannt (null)', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('Netzwerk weg');
}),
);
const { default: DashboardPage } = await import('./page');
render(<DashboardPage />);
await waitFor(() => {
expect(vi.mocked(fetch)).toHaveBeenCalled();
});
expect(catalogProps.accessibleModuleSlugs).toBeNull();
});
});
+49 -12
View File
@@ -1,11 +1,11 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { DashboardGrid } from '@/components/dashboard/dashboard-grid'; import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle'; import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal'; import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget, wireStopwatchWidget, wireFavoritesWidget, wirePictureFrameWidget, wireXframeWidget } from '@/components/dashboard/widget-registry'; import { registerWidget } from '@/components/dashboard/widget-registry';
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget'; import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
import { SearchWidget } from '@/components/dashboard/widgets/search-widget'; import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget'; import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget';
@@ -18,20 +18,34 @@ import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget';
import { useDashboardStore } from '@/lib/stores/dashboard-store'; import { useDashboardStore } from '@/lib/stores/dashboard-store';
import type { WidgetType } from '@/components/dashboard/widget-registry'; import type { WidgetType } from '@/components/dashboard/widget-registry';
// Wire widget components into the registry (deferred to avoid circular deps) const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
wireClockWidget(ClockWidget);
wireSearchWidget(SearchWidget); // Anmeldung der Kachel-Komponenten an der Registry. Steht hier und nicht in
wireCalendarWidget(CalendarWidget); // der Registry selbst, weil die Komponenten ueber den Wrapper wieder die
wireNoteWidget(NoteWidget); // Registry importieren — ein Import aus der Registry heraus waere ein
wireCalculatorWidget(CalculatorWidget); // Zirkelimport. Seit quick-260922-m1h EINE Funktion statt neun `wireXWidget`.
wireStopwatchWidget(StopwatchWidget); registerWidget('clock', ClockWidget);
wireFavoritesWidget(FavoritesWidget); registerWidget('search', SearchWidget);
wirePictureFrameWidget(PictureFrameWidget); registerWidget('calendar', CalendarWidget);
wireXframeWidget(XframeWidget); registerWidget('note', NoteWidget);
registerWidget('calculator', CalculatorWidget);
registerWidget('stopwatch', StopwatchWidget);
registerWidget('favorites', FavoritesWidget);
registerWidget('picture-frame', PictureFrameWidget);
registerWidget('xframe', XframeWidget);
/** Modul-Eintrag aus `GET /modules/active` — hier zaehlt nur der Slug. */
interface ActiveModule {
slug: string;
}
export default function DashboardPage() { export default function DashboardPage() {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const [catalogOpen, setCatalogOpen] = useState(false); const [catalogOpen, setCatalogOpen] = useState(false);
// quick-260922-m1h: Slugs der Module, die dieser Benutzer nutzen darf —
// der Katalog blendet Kacheln gesperrter Module damit aus. `null` heisst
// "noch unbekannt oder Abruf fehlgeschlagen" und ist fail-closed.
const [accessibleModuleSlugs, setAccessibleModuleSlugs] = useState<string[] | null>(null);
const { const {
layouts, layouts,
@@ -51,6 +65,28 @@ export default function DashboardPage() {
loadDashboard(); loadDashboard();
}, [loadDashboard]); }, [loadDashboard]);
// Zugaengliche Module holen — gleiches Muster wie die Seitenleiste
// (`components/layout/sidebar.tsx`): derselbe Endpunkt, derselbe
// Sitzungs-Keks, Fehler still. Der Abruf steht hier und nicht im Dialog,
// damit der Dialog ein reines Anzeige-Bauteil bleibt.
const fetchAccessibleModules = useCallback(async () => {
try {
const res = await fetch(`${API_URL}/modules/active`, {
credentials: 'include',
});
if (!res.ok) return;
const modules: ActiveModule[] = await res.json();
setAccessibleModuleSlugs(modules.map((m) => m.slug));
} catch {
// still: die Liste bleibt null, der Katalog zeigt dann nur
// Plattform-Kacheln (fail-closed).
}
}, []);
useEffect(() => {
fetchAccessibleModules();
}, [fetchAccessibleModules]);
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex min-h-[60vh] items-center justify-center"> <div className="flex min-h-[60vh] items-center justify-center">
@@ -117,6 +153,7 @@ export default function DashboardPage() {
<WidgetCatalogModal <WidgetCatalogModal
isOpen={catalogOpen} isOpen={catalogOpen}
onClose={() => setCatalogOpen(false)} onClose={() => setCatalogOpen(false)}
accessibleModuleSlugs={accessibleModuleSlugs}
onAddWidget={(type: WidgetType) => { onAddWidget={(type: WidgetType) => {
addWidget(type); addWidget(type);
}} }}
@@ -36,12 +36,17 @@ vi.mock('next-intl', () => ({
}, },
})); }));
import { WIDGET_TYPES } from '@tessera/shared';
import { WIDGET_REGISTRY } from './widget-registry';
import { WidgetCatalogModal } from './widget-catalog-modal'; import { WidgetCatalogModal } from './widget-catalog-modal';
// Alle neun Kacheln sind heute Plattform-Kacheln ohne moduleSlug, also zeigt
// der Katalog sie auch bei leerer Modulliste vollstaendig an.
const baseProps = { const baseProps = {
isOpen: true, isOpen: true,
onClose: vi.fn(), onClose: vi.fn(),
onAddWidget: vi.fn(), onAddWidget: vi.fn(),
accessibleModuleSlugs: [] as string[],
}; };
afterEach(() => { afterEach(() => {
@@ -130,3 +135,50 @@ describe('WidgetCatalogModal', () => {
expect(screen.getByRole('button', { name: 'Schließen' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Schließen' })).toBeInTheDocument();
}); });
}); });
/**
* quick-260922-m1h: Der Katalog fuehrt keine zweite Typliste mehr — er leitet
* sie aus der Registry ab und filtert nach Modulzugriff.
*/
describe('WidgetCatalogModal: Liste kommt aus der Registry (quick-260922-m1h)', () => {
it('zeigt alle neun Kacheln in der Reihenfolge der Registry', () => {
render(<WidgetCatalogModal {...baseProps} />);
const dialog = screen.getByRole('dialog', { name: 'Widget hinzufügen' });
const cards = Array.from(
dialog.querySelectorAll<HTMLButtonElement>('button[data-widget-type]'),
);
expect(cards.map((c) => c.getAttribute('data-widget-type'))).toEqual([
...WIDGET_TYPES,
]);
expect(Object.keys(WIDGET_REGISTRY)).toEqual([...WIDGET_TYPES]);
});
it('eine Kachel MIT moduleSlug fehlt, wenn das Modul nicht zugaenglich ist, und erscheint, wenn doch', () => {
// Die Registry traegt heute keine Modul-Kachel — fuer den Nachweis am
// echten Bauteil wird clock voruebergehend zu einer gemacht.
WIDGET_REGISTRY.clock.moduleSlug = 'proxmox';
try {
const { rerender } = render(<WidgetCatalogModal {...baseProps} />);
expect(screen.queryByRole('button', { name: /Uhr/ })).toBeNull();
rerender(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['proxmox']} />);
expect(screen.getByRole('button', { name: /Uhr/ })).toBeInTheDocument();
} finally {
WIDGET_REGISTRY.clock.moduleSlug = undefined;
}
});
it('fail-closed: schlaegt der Modulabruf fehl (null), verschwinden Kacheln MIT moduleSlug, Plattform-Kacheln bleiben', () => {
WIDGET_REGISTRY.clock.moduleSlug = 'proxmox';
try {
render(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={null} />);
expect(screen.queryByRole('button', { name: /Uhr/ })).toBeNull();
expect(screen.getByRole('button', { name: /Notiz/ })).toBeInTheDocument();
} finally {
WIDGET_REGISTRY.clock.moduleSlug = undefined;
}
});
});
@@ -2,28 +2,30 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { WIDGET_REGISTRY, type WidgetType } from './widget-registry'; import { WIDGET_REGISTRY, type WidgetType, visibleWidgetTypes } from './widget-registry';
interface WidgetCatalogModalProps { interface WidgetCatalogModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onAddWidget: (type: WidgetType) => void; onAddWidget: (type: WidgetType) => void;
/**
* Slugs der Module, die der angemeldete Benutzer nutzen darf — geholt von
* der Dashboard-Seite ueber `GET /modules/active` (quick-260922-m1h).
* `null` heisst "noch unbekannt oder Abruf fehlgeschlagen": dann bleiben
* Kacheln MIT `moduleSlug` ausgeblendet (fail-closed).
*
* Der Abruf steht bewusst NICHT in diesem Dialog, damit er ein reines
* Anzeige-Bauteil bleibt und ohne Netzwerk-Attrappe testbar ist.
*/
accessibleModuleSlugs: readonly string[] | null;
} }
const WIDGET_TYPES: WidgetType[] = [
'clock',
'search',
'calendar',
'note',
'calculator',
'favorites',
'stopwatch',
'picture-frame',
'xframe',
];
/** /**
* Modal dialog showing available widget types as selectable cards. * Modal dialog showing available widget types as selectable cards.
*
* quick-260922-m1h: Die Liste kommt aus WIDGET_REGISTRY (Reihenfolge der
* Registry-Definition) statt aus einer zweiten, hier gepflegten Liste — eine
* neue Kachel musste sonst an zwei Stellen eingetragen werden.
* Click on a card adds the widget to the dashboard and closes the modal. * Click on a card adds the widget to the dashboard and closes the modal.
* Escape to close, click outside to close, focus trap (D-01 flow). * Escape to close, click outside to close, focus trap (D-01 flow).
*/ */
@@ -31,6 +33,7 @@ export function WidgetCatalogModal({
isOpen, isOpen,
onClose, onClose,
onAddWidget, onAddWidget,
accessibleModuleSlugs,
}: WidgetCatalogModalProps) { }: WidgetCatalogModalProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
@@ -107,13 +110,14 @@ export function WidgetCatalogModal({
{/* 2x2 grid of widget type cards */} {/* 2x2 grid of widget type cards */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{WIDGET_TYPES.map((type) => { {visibleWidgetTypes(WIDGET_REGISTRY, accessibleModuleSlugs).map((type) => {
const def = WIDGET_REGISTRY[type]; const def = WIDGET_REGISTRY[type];
const Icon = def.icon; const Icon = def.icon;
return ( return (
<button <button
key={type} key={type}
type="button" type="button"
data-widget-type={type}
onClick={() => { onClick={() => {
onAddWidget(type); onAddWidget(type);
onClose(); onClose();
@@ -1,5 +1,14 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { WIDGET_CONSTRAINTS, type WidgetType } from './widget-registry'; import { WIDGET_TYPES } from '@tessera/shared';
import {
WIDGET_CONSTRAINTS,
WIDGET_REGISTRY,
type WidgetDefinition,
type WidgetProps,
type WidgetType,
registerWidget,
visibleWidgetTypes,
} from './widget-registry';
/** /**
* DASH-11: Every WidgetType entry in WIDGET_CONSTRAINTS must have * DASH-11: Every WidgetType entry in WIDGET_CONSTRAINTS must have
@@ -82,3 +91,118 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
expect(counted).toBe(36); expect(counted).toBe(36);
}); });
}); });
/**
* quick-260922-m1h: Deckungsgleichheit. Die Typliste steht seit diesem Umbau
* EINMAL in `packages/shared`; Registry, Constraints-Tabelle und die
* Erwartungsliste dieses Tests muessen dieselben Schluessel in derselben
* Reihenfolge tragen. Dieser Test faengt kuenftig jede vergessene Stelle.
*/
describe('Typliste ist an einer Stelle definiert (quick-260922-m1h)', () => {
it('WIDGET_TYPES aus @tessera/shared, Registry-Schluessel und Constraints-Schluessel sind deckungsgleich (gleiche Reihenfolge)', () => {
expect(Object.keys(WIDGET_REGISTRY)).toEqual([...WIDGET_TYPES]);
expect(Object.keys(WIDGET_CONSTRAINTS)).toEqual([...WIDGET_TYPES]);
});
it('die neun erwarteten Kacheln stehen unveraendert und in unveraenderter Reihenfolge in WIDGET_TYPES', () => {
expect([...WIDGET_TYPES]).toEqual(ALL_WIDGET_TYPES);
});
it('jeder Registry-Eintrag traegt seinen eigenen Typ als `type`', () => {
for (const type of WIDGET_TYPES) {
expect(WIDGET_REGISTRY[type].type).toBe(type);
}
});
it('heute traegt keine der neun Kacheln einen moduleSlug (alle sind Plattform-Kacheln)', () => {
for (const type of WIDGET_TYPES) {
expect(WIDGET_REGISTRY[type].moduleSlug).toBeUndefined();
}
});
});
describe('registerWidget (quick-260922-m1h)', () => {
function makeComponent(): (props: WidgetProps) => null {
return () => null;
}
it('meldet eine Komponente fuer ihren Typ an', () => {
const before = WIDGET_REGISTRY.clock.component;
const component = makeComponent();
registerWidget('clock', component);
expect(WIDGET_REGISTRY.clock.component).toBe(component);
WIDGET_REGISTRY.clock.component = before;
});
it('ist idempotent: eine zweite Anmeldung desselben Typs ist ein No-Op (wie die alten wireX-Flags)', () => {
const before = WIDGET_REGISTRY.note.component;
const first = makeComponent();
const second = makeComponent();
registerWidget('note', first);
registerWidget('note', second);
expect(WIDGET_REGISTRY.note.component).toBe(first);
WIDGET_REGISTRY.note.component = before;
});
it('ein unbekannter Typ wirft in der Entwicklung', () => {
expect(() =>
// Absichtlich ein Typ ausserhalb der Union — genau der Fall, den der
// Wurf melden soll (eine Kachel, die in WIDGET_TYPES vergessen wurde).
registerWidget('proxmox' as WidgetType, makeComponent()),
).toThrow(/proxmox/);
});
it('ein unbekannter Typ wird in der Produktion still ignoriert', () => {
const previous = process.env.NODE_ENV;
vi.stubEnv('NODE_ENV', 'production');
expect(() => registerWidget('proxmox' as WidgetType, makeComponent())).not.toThrow();
vi.stubEnv('NODE_ENV', previous ?? 'test');
vi.unstubAllEnvs();
});
});
/**
* quick-260922-m1h: Der Katalogfilter als reine Funktion — so testbar ohne
* eine echte Modul-Kachel zu erfinden (Muster picture-frame-config.ts).
* WICHTIG (T-M1H-01): Dieser Filter ist Komfort. Die verbindliche
* Durchsetzung bleibt serverseitig in DashboardService.getWidgets.
*/
describe('visibleWidgetTypes (quick-260922-m1h)', () => {
const testRegistry: Record<string, Pick<WidgetDefinition, 'moduleSlug'>> = {
clock: {},
proxmox: { moduleSlug: 'proxmox' },
note: {},
};
it('behaelt die Reihenfolge der Registry bei', () => {
expect(visibleWidgetTypes(WIDGET_REGISTRY, [])).toEqual([...WIDGET_TYPES]);
});
it('Kacheln ohne moduleSlug sind immer sichtbar', () => {
expect(visibleWidgetTypes(testRegistry, [])).toEqual(['clock', 'note']);
});
it('eine Kachel mit moduleSlug fehlt, wenn der Slug nicht in den zugaenglichen Modulen steht', () => {
expect(visibleWidgetTypes(testRegistry, ['domaincheck'])).toEqual(['clock', 'note']);
});
it('eine Kachel mit moduleSlug erscheint, wenn der Slug in den zugaenglichen Modulen steht', () => {
expect(visibleWidgetTypes(testRegistry, ['domaincheck', 'proxmox'])).toEqual([
'clock',
'proxmox',
'note',
]);
});
it('fail-closed: ist die Modulliste unbekannt (null, z. B. fehlgeschlagener Abruf), verschwinden alle Kacheln MIT moduleSlug', () => {
expect(visibleWidgetTypes(testRegistry, null)).toEqual(['clock', 'note']);
});
});
@@ -1,7 +1,17 @@
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared';
/** /**
* Supported widget types for the dashboard. * Die Typliste der Kacheln steht seit quick-260922-m1h EINMAL, in
* `packages/shared/src/index.ts` — dieselbe Liste, gegen die die API in
* `create-widget.dto.ts` mit `@IsIn` validiert. Vorher stand sie an sieben
* Stellen; vergass man eine, fehlte die Kachel im Katalog oder die API
* lehnte sie mit 400 ab.
*
* Hier weiter-exportiert, weil ein knappes Dutzend Web-Dateien den Typ seit
* jeher von der Registry bezieht (dashboard-grid, widget-wrapper,
* dashboard-store, widget-settings-panel ...).
*
* clock/search/calendar/note: implemented in previous plans. * clock/search/calendar/note: implemented in previous plans.
* calculator/favorites/stopwatch: Phase 8 additions (der fruehere * calculator/favorites/stopwatch: Phase 8 additions (der fruehere
* Einzel-Schnellzugriffs-Typ wurde in quick-260916-iex entfernt — Favoriten * Einzel-Schnellzugriffs-Typ wurde in quick-260916-iex entfernt — Favoriten
@@ -9,16 +19,7 @@ import type { ComponentType } from 'react';
* picture-frame: Bilderrahmen (quick-260921-pi9). * picture-frame: Bilderrahmen (quick-260921-pi9).
* xframe: Webseite als Rahmen (quick-260921-qd3). * xframe: Webseite als Rahmen (quick-260921-qd3).
*/ */
export type WidgetType = export type { WidgetType };
| 'clock'
| 'search'
| 'calendar'
| 'note'
| 'calculator'
| 'favorites'
| 'stopwatch'
| 'picture-frame'
| 'xframe';
/** /**
* Props contract that every widget component must accept. * Props contract that every widget component must accept.
@@ -76,6 +77,12 @@ export interface WidgetDefinition {
type: WidgetType; type: WidgetType;
nameKey: string; nameKey: string;
descriptionKey: string; descriptionKey: string;
/**
* Modul, zu dem diese Kachel gehoert (quick-260922-m1h), aus
* `WIDGET_MODULE_SLUGS`. Fehlt der Eintrag, ist es eine Plattform-Kachel
* und immer sichtbar — der heutige Zustand fuer alle neun Kacheln.
*/
moduleSlug?: string;
/** Inline SVG icon as React component */ /** Inline SVG icon as React component */
icon: ComponentType<{ className?: string }>; icon: ComponentType<{ className?: string }>;
minW: number; minW: number;
@@ -318,7 +325,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
descriptionKey: 'clock.description', descriptionKey: 'clock.description',
icon: ClockIcon, icon: ClockIcon,
...WIDGET_CONSTRAINTS.clock, ...WIDGET_CONSTRAINTS.clock,
component: PlaceholderWidget, // Replaced via wireClockWidget() moduleSlug: WIDGET_MODULE_SLUGS.clock,
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
}, },
search: { search: {
type: 'search', type: 'search',
@@ -326,7 +334,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
descriptionKey: 'search.description', descriptionKey: 'search.description',
icon: SearchIcon, icon: SearchIcon,
...WIDGET_CONSTRAINTS.search, ...WIDGET_CONSTRAINTS.search,
component: PlaceholderWidget, // Replaced via wireSearchWidget() moduleSlug: WIDGET_MODULE_SLUGS.search,
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
}, },
calendar: { calendar: {
type: 'calendar', type: 'calendar',
@@ -334,7 +343,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
descriptionKey: 'calendar.description', descriptionKey: 'calendar.description',
icon: CalendarIcon, icon: CalendarIcon,
...WIDGET_CONSTRAINTS.calendar, ...WIDGET_CONSTRAINTS.calendar,
component: PlaceholderWidget, // Replaced via wireCalendarWidget() moduleSlug: WIDGET_MODULE_SLUGS.calendar,
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
}, },
note: { note: {
type: 'note', type: 'note',
@@ -342,7 +352,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
descriptionKey: 'note.description', descriptionKey: 'note.description',
icon: NoteIcon, icon: NoteIcon,
...WIDGET_CONSTRAINTS.note, ...WIDGET_CONSTRAINTS.note,
component: PlaceholderWidget, // Replaced via wireNoteWidget() moduleSlug: WIDGET_MODULE_SLUGS.note,
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
}, },
calculator: { calculator: {
type: 'calculator', type: 'calculator',
@@ -350,7 +361,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
descriptionKey: 'calculator.description', descriptionKey: 'calculator.description',
icon: CalculatorIcon, icon: CalculatorIcon,
...WIDGET_CONSTRAINTS.calculator, ...WIDGET_CONSTRAINTS.calculator,
component: PlaceholderWidget, // Replaced via wireCalculatorWidget() moduleSlug: WIDGET_MODULE_SLUGS.calculator,
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
}, },
favorites: { favorites: {
type: 'favorites', type: 'favorites',
@@ -358,7 +370,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
descriptionKey: 'favorites.description', descriptionKey: 'favorites.description',
icon: FavoritesIcon, icon: FavoritesIcon,
...WIDGET_CONSTRAINTS.favorites, ...WIDGET_CONSTRAINTS.favorites,
component: PlaceholderWidget, // Replaced via wireFavoritesWidget() moduleSlug: WIDGET_MODULE_SLUGS.favorites,
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
}, },
stopwatch: { stopwatch: {
type: 'stopwatch', type: 'stopwatch',
@@ -366,7 +379,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
descriptionKey: 'stopwatch.description', descriptionKey: 'stopwatch.description',
icon: StopwatchIcon, icon: StopwatchIcon,
...WIDGET_CONSTRAINTS.stopwatch, ...WIDGET_CONSTRAINTS.stopwatch,
component: PlaceholderWidget, // Replaced via wireStopwatchWidget() moduleSlug: WIDGET_MODULE_SLUGS.stopwatch,
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
}, },
'picture-frame': { 'picture-frame': {
type: 'picture-frame', type: 'picture-frame',
@@ -374,7 +388,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
descriptionKey: 'pictureFrame.description', descriptionKey: 'pictureFrame.description',
icon: PictureFrameIcon, icon: PictureFrameIcon,
...WIDGET_CONSTRAINTS['picture-frame'], ...WIDGET_CONSTRAINTS['picture-frame'],
component: PlaceholderWidget, // Replaced via wirePictureFrameWidget() moduleSlug: WIDGET_MODULE_SLUGS['picture-frame'],
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
}, },
xframe: { xframe: {
type: 'xframe', type: 'xframe',
@@ -382,82 +397,76 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
descriptionKey: 'xframe.description', descriptionKey: 'xframe.description',
icon: XframeIcon, icon: XframeIcon,
...WIDGET_CONSTRAINTS.xframe, ...WIDGET_CONSTRAINTS.xframe,
component: PlaceholderWidget, // Replaced via wireXframeWidget() moduleSlug: WIDGET_MODULE_SLUGS.xframe,
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
}, },
}; };
// Wire actual widget components lazily to avoid circular deps /**
// (imports are deferred so widget-registry can be imported by tests without * Meldet die Komponente einer Kachel an ihrem Registry-Eintrag an
// pulling in the entire React tree) * (quick-260922-m1h — ersetzt die vormals neun `wireXWidget()`-Funktionen
* mit je eigenem Bool-Flag).
*
* Aufgerufen wird sie in `apps/web/src/app/(portal)/page.tsx` und NICHT
* hier: die Komponenten duerfen nicht aus der Registry heraus importiert
* werden, sonst entsteht ein Zirkelimport (jede Kachel importiert ueber den
* Wrapper wieder die Registry). Die Seite ist die Stelle, an der beides
* zusammenkommt.
*
* Mehrfachanmeldung desselben Typs ist ein No-Op — die erste gewinnt, genau
* wie die alten Flags. Ein Typ, der nicht in `WIDGET_TYPES` steht, wirft in
* der Entwicklung (dann fehlt der Eintrag in `packages/shared`) und wird in
* der Produktion ignoriert, damit eine vergessene Kachel nicht das ganze
* Dashboard mitreisst.
*/
const registeredTypes = new Set<WidgetType>();
let clockWired = false; export function registerWidget(type: WidgetType, component: ComponentType<WidgetProps>) {
export function wireClockWidget(component: ComponentType<WidgetProps>) { const definition: WidgetDefinition | undefined = WIDGET_REGISTRY[type];
if (!clockWired) {
WIDGET_REGISTRY.clock.component = component; if (!definition) {
clockWired = true; if (process.env.NODE_ENV !== 'production') {
throw new Error(
`registerWidget: unbekannter Widget-Typ "${type}". Fehlt der Typ in WIDGET_TYPES (packages/shared/src/index.ts)?`,
);
} }
return;
} }
let searchWired = false; if (registeredTypes.has(type)) return;
export function wireSearchWidget(component: ComponentType<WidgetProps>) {
if (!searchWired) { definition.component = component;
WIDGET_REGISTRY.search.component = component; registeredTypes.add(type);
searchWired = true;
}
} }
let calendarWired = false; /**
export function wireCalendarWidget(component: ComponentType<WidgetProps>) { * Welche Kacheln der Katalog zeigen darf (quick-260922-m1h).
if (!calendarWired) { *
WIDGET_REGISTRY.calendar.component = component; * Eine Kachel ohne `moduleSlug` ist immer sichtbar; eine Kachel MIT
calendarWired = true; * `moduleSlug` nur, wenn der Slug unter den zugaenglichen Modulen steht.
} * `accessibleModuleSlugs === null` heisst "Modulliste unbekannt" (Abruf
} * laeuft noch oder ist fehlgeschlagen) — dann sind Modul-Kacheln
* ausgeblendet, fail-closed wie serverseitig.
*
* ACHTUNG (T-M1H-01): Das hier ist reiner Komfort — es verhindert nur, dass
* jemand eine Kachel anlegt, die ihm danach kommentarlos wieder verschwindet.
* Die verbindliche Durchsetzung bleibt serverseitig in
* `DashboardService.getWidgets` (fail-closed) und im Modul-Guard der
* jeweiligen Daten-Endpunkte. Diesen Filter zu umgehen bringt nichts.
*
* Reine Funktion mit der Registry als Parameter, damit sie ohne eine echte
* Modul-Kachel testbar ist (Muster `picture-frame-config.ts`).
*/
export function visibleWidgetTypes<T extends string>(
registry: Record<T, Pick<WidgetDefinition, 'moduleSlug'>>,
accessibleModuleSlugs: readonly string[] | null,
): T[] {
const types = Object.keys(registry) as T[];
let noteWired = false; return types.filter((type) => {
export function wireNoteWidget(component: ComponentType<WidgetProps>) { const slug = registry[type].moduleSlug;
if (!noteWired) { if (slug === undefined) return true;
WIDGET_REGISTRY.note.component = component; if (accessibleModuleSlugs === null) return false;
noteWired = true; return accessibleModuleSlugs.includes(slug);
} });
}
let calculatorWired = false;
export function wireCalculatorWidget(component: ComponentType<WidgetProps>) {
if (!calculatorWired) {
WIDGET_REGISTRY.calculator.component = component;
calculatorWired = true;
}
}
let favoritesWired = false;
export function wireFavoritesWidget(component: ComponentType<WidgetProps>) {
if (!favoritesWired) {
WIDGET_REGISTRY.favorites.component = component;
favoritesWired = true;
}
}
let stopwatchWired = false;
export function wireStopwatchWidget(component: ComponentType<WidgetProps>) {
if (!stopwatchWired) {
WIDGET_REGISTRY.stopwatch.component = component;
stopwatchWired = true;
}
}
let pictureFrameWired = false;
export function wirePictureFrameWidget(component: ComponentType<WidgetProps>) {
if (!pictureFrameWired) {
WIDGET_REGISTRY['picture-frame'].component = component;
pictureFrameWired = true;
}
}
let xframeWired = false;
export function wireXframeWidget(component: ComponentType<WidgetProps>) {
if (!xframeWired) {
WIDGET_REGISTRY.xframe.component = component;
xframeWired = true;
}
} }
@@ -3,15 +3,21 @@ import { describe, expect, it, vi } from 'vitest';
// quick-260916-iex: Link-Widget entfernt — unbekannte Widget-Typen (z. B. // quick-260916-iex: Link-Widget entfernt — unbekannte Widget-Typen (z. B.
// eine alte Link-Kachel vor dem Einspielen der Migration) muessen weiterhin // eine alte Link-Kachel vor dem Einspielen der Migration) muessen weiterhin
// ohne Absturz als grauer Text gerendert werden. // ohne Absturz gerendert werden.
// quick-260922-m1h: Statt des rohen Typnamens steht dort jetzt ein Satz, der
// den Fall erklaert — derselbe Fall tritt kuenftig auf, wenn eine Kachel zu
// einem Modul gehoert, das dem Benutzer nicht freigegeben ist.
vi.mock('next-intl', () => ({ vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key, useTranslations: () => (key: string) =>
key === 'unavailable'
? 'Diese Kachel steht nicht zur Verfügung — das zugehörige Modul ist nicht freigegeben.'
: key,
})); }));
import { WidgetWrapper } from './widget-wrapper'; import { WidgetWrapper } from './widget-wrapper';
describe('WidgetWrapper', () => { describe('WidgetWrapper', () => {
it('unbekannter Widget-Typ (z. B. eine alte Link-Kachel vor der Migration) rendert als grauer Text ohne Absturz', () => { it('unbekannter/gesperrter Widget-Typ erklaert sich mit einem Hinweistext statt leer oder als roher Typname zu rendern', () => {
render( render(
<WidgetWrapper <WidgetWrapper
widget={{ id: 'w-alt', widgetType: 'link', config: {} }} widget={{ id: 'w-alt', widgetType: 'link', config: {} }}
@@ -23,7 +29,29 @@ describe('WidgetWrapper', () => {
const article = screen.getByRole('article'); const article = screen.getByRole('article');
expect(article).toHaveAttribute('aria-label', 'link'); expect(article).toHaveAttribute('aria-label', 'link');
const fallback = screen.getByText('link'); const hint = screen.getByText(
expect(fallback.className).toContain('text-muted-foreground'); 'Diese Kachel steht nicht zur Verfügung — das zugehörige Modul ist nicht freigegeben.',
);
expect(hint.className).toContain('text-muted-foreground');
expect(hint.className).toContain('text-center');
// Der rohe Typname steht nicht mehr im Rumpf der Kachel.
expect(screen.queryByText('link')).toBeNull();
});
it('eine bekannte Kachel rendert weiterhin ihre Komponente, nicht den Hinweis', () => {
render(
<WidgetWrapper
widget={{ id: 'w-uhr', widgetType: 'clock', config: {} }}
isEditMode={false}
onRemove={vi.fn()}
/>,
);
expect(
screen.queryByText(
'Diese Kachel steht nicht zur Verfügung — das zugehörige Modul ist nicht freigegeben.',
),
).toBeNull();
}); });
}); });
@@ -111,8 +111,14 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
isEditMode={isEditMode} isEditMode={isEditMode}
/> />
) : ( ) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground"> /* quick-260922-m1h: Kein Bauteil zu diesem Typ — entweder eine alte
{widget.widgetType} Kachel eines entfernten Typs oder (ab der ersten Modul-Kachel) eine
Kachel, deren Modul dem Benutzer nicht freigegeben ist. Vorher
stand hier der rohe Typname, der dem Anwender nichts sagte. */
<div className="flex h-full items-center justify-center p-3">
<p className="text-center text-sm text-muted-foreground">
{t('unavailable')}
</p>
</div> </div>
)} )}
</div> </div>
+5 -3
View File
@@ -22,9 +22,11 @@ export interface AppVersionInfo {
} }
/** /**
* Spiegel von `VersionResponse` aus `packages/shared`: `apps/web` haengt * Spiegel von `VersionResponse` aus `packages/shared`. Die Begruendung
* nicht von `@tessera/shared` ab, und ein neuer Import wuerde Lockfile und * "apps/web haengt nicht von @tessera/shared ab" gilt seit quick-260922-m1h
* die deps-Stufe des Dockerfiles aendern. Die API-Wahrheit bleibt dort. * nicht mehr — die Kachel-Typliste wird von dort importiert. Dieser Spiegel
* bleibt trotzdem stehen: ihn aufzuloesen war nicht Teil jenes Umbaus und
* braucht einen eigenen Durchgang. Die API-Wahrheit bleibt in packages/shared.
*/ */
export interface ApiVersionInfo { export interface ApiVersionInfo {
name: string; name: string;
+3 -2
View File
@@ -17,8 +17,9 @@ export type DesktopPlatform = 'windows' | 'linux';
/** /**
* Spiegel von `DesktopLatestFile`/`DesktopLatestResponse` aus * Spiegel von `DesktopLatestFile`/`DesktopLatestResponse` aus
* `packages/shared`: `apps/web` haengt nicht von `@tessera/shared` ab * `packages/shared` (gleicher Stand wie in `app-version.ts`: seit
* (gleiche Begruendung wie in `app-version.ts`). Die API-Wahrheit bleibt in * quick-260922-m1h waere ein Import moeglich, der Spiegel wurde aber bewusst
* nicht mit aufgeloest). Die API-Wahrheit bleibt in
* `apps/api/src/desktop/desktop.service.ts`. * `apps/api/src/desktop/desktop.service.ts`.
*/ */
export interface DesktopFileInfo { export interface DesktopFileInfo {
+1
View File
@@ -213,6 +213,7 @@
"saveChanges": "Änderungen speichern", "saveChanges": "Änderungen speichern",
"layoutLoadError": "Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.", "layoutLoadError": "Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.",
"widgetSaveError": "Änderungen konnten nicht gespeichert werden. Bitte versuchen Sie es erneut.", "widgetSaveError": "Änderungen konnten nicht gespeichert werden. Bitte versuchen Sie es erneut.",
"unavailable": "Diese Kachel steht nicht zur Verfügung — das zugehörige Modul ist nicht freigegeben.",
"clock": { "clock": {
"name": "Uhr", "name": "Uhr",
"description": "Zeigt die aktuelle Uhrzeit an", "description": "Zeigt die aktuelle Uhrzeit an",
+1
View File
@@ -213,6 +213,7 @@
"saveChanges": "Save changes", "saveChanges": "Save changes",
"layoutLoadError": "Could not load dashboard. Please reload the page.", "layoutLoadError": "Could not load dashboard. Please reload the page.",
"widgetSaveError": "Could not save changes. Please try again.", "widgetSaveError": "Could not save changes. Please try again.",
"unavailable": "This tile is not available — the module it belongs to is not enabled for you.",
"clock": { "clock": {
"name": "Clock", "name": "Clock",
"description": "Shows the current time", "description": "Shows the current time",
+34
View File
@@ -400,6 +400,40 @@ Für ein Modul mit Unterrouten (Einstellungsseite, Verwaltungsansicht) orientier
`dkv-fleet` oder `tender-radar` — beide haben zusätzliche `settings/page.tsx` bzw. weitere `dkv-fleet` oder `tender-radar` — beide haben zusätzliche `settings/page.tsx` bzw. weitere
Unterverzeichnisse, die vom selben `layout.tsx` mitgedeckt werden. Unterverzeichnisse, die vom selben `layout.tsx` mitgedeckt werden.
### Eine Kachel zum Modul
Ein Modul kann zusätzlich als Kachel auf dem Dashboard erscheinen. Seit
`quick-260922-m1h` sind dafür **drei** Stellen nötig (vorher waren es sieben):
1. **Die Kachel-Komponente schreiben** — `apps/web/src/components/dashboard/widgets/<name>-widget.tsx`,
nimmt die `WidgetProps` aus `widget-registry.tsx` (`instanceId`, `config`, `isEditMode`) entgegen.
2. **Den Typ eintragen** — in `WIDGET_TYPES` in `packages/shared/src/index.ts`. Gehört die Kachel zu
einem Modul, zusätzlich `WIDGET_MODULE_SLUGS['<typ>'] = '<modul-slug>'` in derselben Datei. Das ist
die einzige Liste: die API validiert `POST /dashboard/widgets` per `@IsIn` gegen genau sie, und
das Frontend leitet Registry und Katalog davon ab.
3. **Anmelden** — `registerWidget('<typ>', <Name>Widget)` in `apps/web/src/app/(portal)/page.tsx`,
neben den übrigen Aufrufen. Der Aufruf steht dort und nicht in der Registry, weil die Kachel über
den Wrapper wieder die Registry importiert — ein Import aus der Registry heraus wäre ein
Zirkelimport.
Dazu kommen wie bei jeder Oberfläche die **Übersetzungsschlüssel** (`<typ>.name` und
`<typ>.description` unter `widgets` in `de.json` **und** `en.json`), ein **Symbol** als Inline-SVG
und die **Größenvorgaben** in `WIDGET_CONSTRAINTS` (`minW`/`minH` = kleinste noch bedienbare Kachel,
`defaultW`/`defaultH` = Startgröße) — beides in `widget-registry.tsx`. Ein Test in
`widget-registry.test.tsx` prüft, dass `WIDGET_TYPES`, Registry und Constraints deckungsgleich sind;
vergisst man eine Stelle, schlägt er fehl.
**Was eine Kachel mit `moduleSlug` automatisch tut:** Sie verschwindet für Benutzer, die das Modul
nicht nutzen dürfen — aus dem Katalog („Widget hinzufügen", `visibleWidgetTypes`) und aus dem
Dashboard selbst. Ist die Modulliste unbekannt, weil ihr Abruf fehlschlug, bleibt die Kachel
ebenfalls verborgen (fail-closed). Eine bereits angelegte Kachel eines gesperrten Moduls rendert
nicht mehr leer, sondern zeigt den Hinweis `widgets.unavailable`.
**Wie beim Modul-Gate gilt auch hier:** Der Katalogfilter ist Komfort, nicht Zugriffskontrolle. Die
verbindliche Prüfung sitzt serverseitig in `DashboardService.getWidgets()` (filtert Kacheln
gesperrter Module fail-closed aus `GET /dashboard/widgets`) — und die Daten, die eine Modul-Kachel
anzeigt, holt sie über die Endpunkte ihres Moduls, die `@UseModule('<slug>')` tragen müssen.
## Mandantentrennung ## Mandantentrennung
Der tatsächliche Mechanismus ist `TenantGuard` (`apps/api/src/tenant/tenant.guard.ts`), global als Der tatsächliche Mechanismus ist `TenantGuard` (`apps/api/src/tenant/tenant.guard.ts`), global als
+43
View File
@@ -76,3 +76,46 @@ export interface DesktopLatestResponse {
buildTime: string; buildTime: string;
files: Partial<Record<DesktopPlatform, DesktopLatestFile>>; files: Partial<Record<DesktopPlatform, DesktopLatestFile>>;
} }
/**
* Dashboard-Kacheln: EINE Typliste fuer Web und API (quick-260922-m1h).
*
* Vorher stand dieselbe Liste an sieben Stellen (Union-Typ, Constraints,
* Registry, Katalog-Liste, `@IsIn`-Whitelist ...). Vergass man eine, fehlte
* die Kachel im Katalog oder die API lehnte sie mit 400 ab. Seit m1h leiten
* beide Seiten von hier ab: `apps/web/src/components/dashboard/
* widget-registry.tsx` (Registry + Katalog) und
* `apps/api/src/dashboard/dto/create-widget.dto.ts` (`@IsIn`).
*
* ACHTUNG: Dies ist der erste LAUFZEIT-Import aus `@tessera/shared` (alle
* uebrigen sind `import type`). `packages/shared` liefert rohes TypeScript
* (`main: src/index.ts`, kein Bauschritt); die API laedt es im Betrieb ueber
* das native Type-Stripping von Node 24. Deshalb darf diese Datei nur
* loeschbare Syntax enthalten — keine `enum`, kein `namespace`, keine
* Parameter-Eigenschaften.
*/
export const WIDGET_TYPES = [
'clock',
'search',
'calendar',
'note',
'calculator',
'favorites',
'stopwatch',
'picture-frame',
'xframe',
] as const;
export type WidgetType = (typeof WIDGET_TYPES)[number];
/**
* Kachel → Modul-Slug. Eine Kachel ohne Eintrag ist immer sichtbar; eine
* Kachel MIT Eintrag erscheint nur fuer Benutzer, die das Modul nutzen
* duerfen — im Katalog (Komfort, `visibleWidgetTypes`) und verbindlich
* serverseitig in `DashboardService.getWidgets` (fail-closed).
*
* Heute bewusst leer: alle neun Kacheln sind Plattform-Kacheln ohne
* Modulbezug. Die erste modulgebundene Kachel (Proxmox) traegt hier ihren
* Slug ein.
*/
export const WIDGET_MODULE_SLUGS: Partial<Record<WidgetType, string>> = {};
+3
View File
@@ -181,6 +181,9 @@ importers:
apps/web: apps/web:
dependencies: dependencies:
'@tessera/shared':
specifier: workspace:*
version: link:../../packages/shared
'@uiw/react-md-editor': '@uiw/react-md-editor':
specifier: 4.1.1 specifier: 4.1.1
version: 4.1.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 4.1.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)