Compare commits
7 Commits
a435a30c34
..
v1.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| cd4b5b56ee | |||
| b15a43f3d3 | |||
| 8f41bd26bd | |||
| ee97b4ed9f | |||
| bc4c0119de | |||
| c703d87a1c | |||
| 76a923450f |
+3
-2
@@ -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)
|
||||
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
|
||||
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%
|
||||
|
||||
@@ -480,6 +480,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
||||
| 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-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
|
||||
|
||||
@@ -525,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.
|
||||
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
|
||||
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
|
||||
|
||||
+66
@@ -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).
|
||||
+170
@@ -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.
|
||||
+5
-1
@@ -4,19 +4,23 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
|
||||
## Unveröffentlicht
|
||||
|
||||
## 1.6.0 – 2026-09-29
|
||||
|
||||
### 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: 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
|
||||
|
||||
- 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
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
@@ -53,6 +53,7 @@ model User {
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
groupMemberships GroupMembership[]
|
||||
moduleGrants ModuleGrant[]
|
||||
customModules CustomModule[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([username])
|
||||
@@ -727,8 +728,14 @@ model CustomModule {
|
||||
name String
|
||||
url String
|
||||
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())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, ownerUserId])
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import 'reflect-metadata';
|
||||
import { ForbiddenException, ValidationPipe } from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||
import { CustomModulesController } from './custom-modules.controller';
|
||||
import { CreateCustomModuleDto } from './dto/custom-module.dto';
|
||||
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
|
||||
|
||||
function makeService() {
|
||||
return {
|
||||
@@ -17,14 +16,20 @@ function makeService() {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
describe('CustomModulesController — Rollen (T-9WC-01)', () => {
|
||||
it.each(['create', 'update', 'remove'])('%s ist nur fuer ADMIN und SUPER_ADMIN offen', (name) => {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toEqual([Role.ADMIN, Role.SUPER_ADMIN]);
|
||||
});
|
||||
|
||||
it.each(['list', 'getOne'])('%s traegt keine Rollen (jeder Angemeldete)', (name) => {
|
||||
describe('CustomModulesController — Rollen (quick-260929-dzu)', () => {
|
||||
// Jeder Angemeldete darf persoenliche Eintraege anlegen/aendern/loeschen; die
|
||||
// Administrator-Pflicht fuer gemeinsame Eintraege prueft der Dienst (hangt
|
||||
// vom Eintrag ab, nicht von der Route) — siehe custom-modules.service.spec.ts.
|
||||
it.each([
|
||||
'list',
|
||||
'getOne',
|
||||
'create',
|
||||
'update',
|
||||
'remove',
|
||||
])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -37,26 +42,26 @@ describe('CustomModulesController — Mandant', () => {
|
||||
it('reicht req.tenantId an den Dienst weiter', async () => {
|
||||
const service = makeService();
|
||||
const controller = new CustomModulesController(service as any);
|
||||
await controller.list(req('t1'));
|
||||
await controller.getOne(req('t1'), 'x');
|
||||
await controller.create(req('t1'), { name: 'a', url: 'https://a.de', category: 'fleet' });
|
||||
await controller.update(req('t1'), 'x', { name: 'b' });
|
||||
await controller.remove(req('t1'), 'x');
|
||||
expect(service.list).toHaveBeenCalledWith('t1');
|
||||
expect(service.getOne).toHaveBeenCalledWith('t1', 'x');
|
||||
expect(service.create.mock.calls[0][0]).toBe('t1');
|
||||
expect(service.update.mock.calls[0].slice(0, 2)).toEqual(['t1', 'x']);
|
||||
expect(service.remove).toHaveBeenCalledWith('t1', 'x');
|
||||
await controller.list(req('t1'), user);
|
||||
await controller.getOne(req('t1'), user, 'x');
|
||||
await controller.create(req('t1'), user, { name: 'a', url: 'https://a.de', category: 'fleet' });
|
||||
await controller.update(req('t1'), user, 'x', { name: 'b' });
|
||||
await controller.remove(req('t1'), user, 'x');
|
||||
expect(service.list).toHaveBeenCalledWith('t1', user);
|
||||
expect(service.getOne).toHaveBeenCalledWith('t1', user, 'x');
|
||||
expect(service.create.mock.calls[0].slice(0, 2)).toEqual(['t1', user]);
|
||||
expect(service.update.mock.calls[0].slice(0, 3)).toEqual(['t1', user, 'x']);
|
||||
expect(service.remove).toHaveBeenCalledWith('t1', user, 'x');
|
||||
});
|
||||
|
||||
it('wirft ForbiddenException ohne req.tenantId', async () => {
|
||||
const controller = new CustomModulesController(makeService() as any);
|
||||
await expect(controller.list(req())).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(controller.getOne(req(), 'x')).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(controller.list(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(controller.getOne(req(), user, 'x')).rejects.toBeInstanceOf(ForbiddenException);
|
||||
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);
|
||||
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 () => {
|
||||
@@ -67,6 +72,27 @@ describe('CustomModulesController — Mandant', () => {
|
||||
);
|
||||
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)', () => {
|
||||
|
||||
@@ -9,18 +9,21 @@ import {
|
||||
Post,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthenticatedRequest, AuthUser } from '../auth/types/auth-user';
|
||||
import { CustomModulesService } from './custom-modules.service';
|
||||
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
|
||||
|
||||
/**
|
||||
* Eigene Module (quick-260929-9wc). Lesen (`GET`, `GET :id`) steht jedem
|
||||
* angemeldeten Benutzer offen — die Eintraege sind fuer alle sichtbar (D-01);
|
||||
* Schreiben nur `@Roles(ADMIN, SUPER_ADMIN)` (T-9WC-01). Kein `@UseModule`:
|
||||
* Eigene Module (quick-260929-9wc, erweitert in quick-260929-dzu). Jeder
|
||||
* angemeldete Benutzer darf lesen und eigene (persoenliche) Eintraege anlegen,
|
||||
* 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
|
||||
* 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.
|
||||
* Jede kuenftige statische GET-Route MUSS ueber `getOne` (`@Get(':id')`)
|
||||
@@ -40,34 +43,44 @@ export class CustomModulesController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
async list(@Req() req: AuthenticatedRequest) {
|
||||
return this.service.list(this.requireTenantId(req));
|
||||
async list(@Req() req: AuthenticatedRequest, @CurrentUser() user: AuthUser) {
|
||||
return this.service.list(this.requireTenantId(req), user);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getOne(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.service.getOne(this.requireTenantId(req), id);
|
||||
async getOne(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.service.getOne(this.requireTenantId(req), user, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateCustomModuleDto) {
|
||||
return this.service.create(this.requireTenantId(req), dto);
|
||||
async create(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() dto: CreateCustomModuleDto,
|
||||
) {
|
||||
return this.service.create(this.requireTenantId(req), user, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async update(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateCustomModuleDto,
|
||||
) {
|
||||
return this.service.update(this.requireTenantId(req), id, dto);
|
||||
return this.service.update(this.requireTenantId(req), user, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async remove(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.service.remove(this.requireTenantId(req), id);
|
||||
async remove(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@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';
|
||||
|
||||
// `forTenant` reicht den Klienten durch — Mandantenbindung selbst prueft
|
||||
// rls-access-inventory.spec.ts; hier zaehlt, dass je Methode (prisma, tenantId)
|
||||
// uebergeben wird.
|
||||
// rls-access-inventory.spec.ts; hier zaehlt, mit welchen Argumenten je Methode
|
||||
// gebunden wird (mit oder ohne Benutzer).
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((p: unknown) => p),
|
||||
}));
|
||||
@@ -24,6 +25,11 @@ function makeFakePrisma() {
|
||||
findMany: vi.fn(async ({ where, orderBy }: { where?: any; orderBy?: any } = {}) => {
|
||||
let list = [...rows.values()];
|
||||
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));
|
||||
return list;
|
||||
}),
|
||||
@@ -41,86 +47,233 @@ function makeFakePrisma() {
|
||||
}
|
||||
|
||||
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', () => {
|
||||
it('create speichert tenantId aus dem Argument, nie aus dem DTO', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new CustomModulesService(prisma as any);
|
||||
await service.create('t1', { ...dto, tenantId: 'evil' } as any);
|
||||
expect(prisma.customModule.create).toHaveBeenCalledTimes(1);
|
||||
function setup() {
|
||||
const prisma = makeFakePrisma();
|
||||
return { prisma, service: new CustomModulesService(prisma as any) };
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
it('list liefert nur Zeilen des Mandanten, nach Name sortiert', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new CustomModulesService(prisma as any);
|
||||
await service.create('t1', { ...dto, name: 'Zebra' });
|
||||
await service.create('t1', { ...dto, name: 'Anker' });
|
||||
await service.create('t2', { ...dto, name: 'Fremd' });
|
||||
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('ohne shared-Angabe ist der Eintrag persoenlich (ownerUserId = Aufrufer)', async () => {
|
||||
const { prisma, service } = setup();
|
||||
const res: any = await service.create('t1', userA, dto);
|
||||
expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBe('ua');
|
||||
expect(res.personal).toBe(true);
|
||||
expect(res).not.toHaveProperty('ownerUserId');
|
||||
});
|
||||
|
||||
it('getOne liefert die Zeile ohne tenantId', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new CustomModulesService(prisma as any);
|
||||
const created: any = await service.create('t1', dto);
|
||||
const row: any = await service.getOne('t1', created.id);
|
||||
it('auch ein Administrator legt ohne shared persoenlich an', async () => {
|
||||
const { prisma, service } = setup();
|
||||
const res: any = await service.create('t1', admin, dto);
|
||||
expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBe('admin1');
|
||||
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.personal).toBe(false);
|
||||
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 () => {
|
||||
const service = new CustomModulesService(makeFakePrisma() as any);
|
||||
await expect(service.getOne('t1', 'nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.update('t1', 'nope', { name: 'x' })).rejects.toBeInstanceOf(
|
||||
const { service } = setup();
|
||||
await expect(service.getOne('t1', userA, 'nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.update('t1', userA, 'nope', { name: 'x' })).rejects.toBeInstanceOf(
|
||||
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 () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new CustomModulesService(prisma as any);
|
||||
const created: any = await service.create('t2', dto);
|
||||
await expect(service.getOne('t1', created.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.update('t1', created.id, { name: 'x' })).rejects.toBeInstanceOf(
|
||||
const { prisma, service } = setup();
|
||||
const created: any = await service.create('t2', admin, { ...dto, shared: true });
|
||||
await expect(service.getOne('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.update('t1', admin, created.id, { name: 'x' })).rejects.toBeInstanceOf(
|
||||
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.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update aendert nur gesetzte Felder', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new CustomModulesService(prisma as any);
|
||||
const created: any = await service.create('t1', dto);
|
||||
await service.update('t1', created.id, { name: 'Neu' });
|
||||
expect(prisma.customModule.update.mock.calls[0][0].data).toEqual({ name: 'Neu' });
|
||||
it('auch ein Administrator kann den persoenlichen Eintrag eines Benutzers nicht aendern (404)', async () => {
|
||||
const { prisma, service } = setup();
|
||||
const created: any = await service.create('t1', userA, dto);
|
||||
await expect(service.update('t1', admin, created.id, { name: 'x' })).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
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 () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new CustomModulesService(prisma as any);
|
||||
const created: any = await service.create('t1', dto);
|
||||
await expect(service.remove('t1', created.id)).resolves.toEqual({ deleted: true });
|
||||
it('ein normaler Benutzer kann einen gemeinsamen Eintrag weder aendern noch loeschen (403)', async () => {
|
||||
const { prisma, service } = setup();
|
||||
const created: any = await service.create('t1', admin, { ...dto, shared: true });
|
||||
await expect(service.update('t1', userA, created.id, { name: 'x' })).rejects.toBeInstanceOf(
|
||||
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);
|
||||
});
|
||||
|
||||
it('ruft forTenant je Methode mit (prisma, tenantId) auf', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new CustomModulesService(prisma as any);
|
||||
vi.mocked(forTenant).mockClear();
|
||||
const created: any = await service.create('t1', dto);
|
||||
await service.list('t1');
|
||||
await service.getOne('t1', created.id);
|
||||
await service.update('t1', created.id, { name: 'a' });
|
||||
await service.remove('t1', created.id);
|
||||
expect(forTenant).toHaveBeenCalledTimes(5);
|
||||
for (const call of vi.mocked(forTenant).mock.calls) {
|
||||
expect(call).toEqual([prisma, 't1']);
|
||||
}
|
||||
it('update aendert nur gesetzte Felder und nie Besitz oder Gemeinsamkeit', async () => {
|
||||
const { prisma, service } = setup();
|
||||
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();
|
||||
const shared: any = await service.create('t1', admin, { ...dto, shared: true });
|
||||
const mine: any = await service.create('t1', userA, dto);
|
||||
await service.list('t1', userA);
|
||||
await service.getOne('t1', userA, mine.id);
|
||||
await service.update('t1', userA, mine.id, { name: 'a' });
|
||||
await service.update('t1', admin, shared.id, { name: 'b' });
|
||||
const calls = vi.mocked(forTenant).mock.calls;
|
||||
// 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 { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import type { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
|
||||
@@ -9,34 +10,132 @@ const CUSTOM_MODULE_SELECT = {
|
||||
name: true,
|
||||
url: true,
|
||||
category: true,
|
||||
ownerUserId: true,
|
||||
createdAt: 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
|
||||
* Seitenleisten-Eintraege mit externer https-Adresse. `tenantId` kommt
|
||||
* ausschliesslich als Argument (aus `req.tenantId`), nie aus dem DTO. Je
|
||||
* Methode ein eigener `forTenant`-Klient; zusaetzlich pruefen
|
||||
* getOne/update/remove `row.tenantId` — zweites Netz, solange der
|
||||
* RLS-Schalter aus ist (Muster DashboardImage). Eine fremde oder unbekannte
|
||||
* id ergibt immer `NotFoundException`, nie einen Hinweis auf die Existenz.
|
||||
* Eigene Module (quick-260929-9wc, erweitert in quick-260929-dzu): Seitenleisten-
|
||||
* Eintraege mit externer https-Adresse. Zwei Arten:
|
||||
*
|
||||
* - gemeinsam (`ownerUserId` null): vom Administrator angelegt, fuer alle
|
||||
* Benutzer des Mandanten sichtbar; Schreiben nur fuer Administratoren.
|
||||
* - persoenlich (`ownerUserId` = Benutzer): nur der Besitzer sieht, aendert und
|
||||
* 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()
|
||||
export class CustomModulesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
return tenantPrisma.customModule.findMany({
|
||||
where: { tenantId },
|
||||
/** Gemeinsame Eintraege plus die eigenen des Aufrufers. */
|
||||
async list(tenantId: string, caller: CustomModuleCaller) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, caller.id);
|
||||
const rows = await tenantPrisma.customModule.findMany({
|
||||
where: { tenantId, OR: [{ ownerUserId: null }, { ownerUserId: caller.id }] },
|
||||
orderBy: { name: 'asc' },
|
||||
select: CUSTOM_MODULE_SELECT,
|
||||
});
|
||||
return rows.map(toResponse);
|
||||
}
|
||||
|
||||
async getOne(tenantId: string, id: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
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 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({
|
||||
where: { id },
|
||||
select: { ...CUSTOM_MODULE_SELECT, tenantId: true },
|
||||
@@ -44,42 +143,28 @@ export class CustomModulesService {
|
||||
if (!row || row.tenantId !== tenantId) {
|
||||
throw new NotFoundException('Eigenes Modul nicht gefunden');
|
||||
}
|
||||
const { tenantId: _omit, ...result } = row;
|
||||
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) {
|
||||
if (row.ownerUserId !== null && row.ownerUserId !== caller.id) {
|
||||
throw new NotFoundException('Eigenes Modul nicht gefunden');
|
||||
}
|
||||
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;
|
||||
return tenantPrisma.customModule.update({
|
||||
where: { id },
|
||||
data,
|
||||
select: CUSTOM_MODULE_SELECT,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
async remove(tenantId: string, id: string) {
|
||||
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');
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
await tenantPrisma.customModule.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
if (!isAdmin(caller)) {
|
||||
throw new ForbiddenException('Gemeinsame Einträge dürfen nur Administratoren ändern');
|
||||
}
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
return tenantPrisma;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { OmitType, PartialType } from '@nestjs/mapped-types';
|
||||
import { MODULE_CATEGORIES } from '@tessera/shared';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Validate,
|
||||
@@ -60,7 +62,23 @@ export class CreateCustomModuleDto {
|
||||
|
||||
@IsIn([...MODULE_CATEGORIES])
|
||||
category!: (typeof 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),
|
||||
) {}
|
||||
|
||||
@@ -694,6 +694,16 @@ fn get_client_info() -> String {
|
||||
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)
|
||||
/// wird zuerst wiederhergestellt.
|
||||
fn show_main_window(app: &AppHandle) {
|
||||
@@ -750,6 +760,20 @@ pub fn run() {
|
||||
.center()
|
||||
.decorations(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| {
|
||||
if let tauri::webview::DownloadEvent::Requested { url, .. } = event {
|
||||
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]
|
||||
fn parse_server_url_gueltige_adresse() {
|
||||
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>> = {
|
||||
'admin.customModules': {
|
||||
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',
|
||||
edit: 'Eigenes Modul bearbeiten',
|
||||
name: 'Name',
|
||||
url: 'Adresse',
|
||||
urlHint: 'Hinweis https',
|
||||
category: 'Kategorie',
|
||||
actions: 'Aktionen',
|
||||
noModules: 'Noch keine eigenen Module',
|
||||
noModulesBody: 'Legen Sie Ihr erstes Modul an.',
|
||||
nameRequired: 'Bitte geben Sie einen Namen ein.',
|
||||
urlNotHttps: 'Bitte geben Sie eine Adresse ein, die mit https:// beginnt.',
|
||||
urlCredentials: 'Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.',
|
||||
saveError: 'Das Modul konnte nicht gespeichert werden.',
|
||||
deleteError: 'Das Modul konnte nicht gelöscht werden.',
|
||||
loadError: 'Laden fehlgeschlagen.',
|
||||
deleteConfirm: {
|
||||
title: 'Eigenes Modul löschen',
|
||||
body: 'Möchten Sie „{name}“ wirklich löschen?',
|
||||
bodyShared: 'Möchten Sie „{name}“ wirklich löschen? Für alle Benutzer.',
|
||||
bodyPersonal: 'Möchten Sie „{name}“ wirklich löschen? Nur bei Ihnen.',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
@@ -107,6 +113,7 @@ const wiki = {
|
||||
name: 'Wiki',
|
||||
url: 'https://wiki.example.com',
|
||||
category: 'infrastructure',
|
||||
personal: false,
|
||||
createdAt: '2026-09-29T00:00:00.000Z',
|
||||
updatedAt: '2026-09-29T00:00:00.000Z',
|
||||
};
|
||||
@@ -127,9 +134,9 @@ afterEach(() => {
|
||||
describe('AdminCustomModulesPage', () => {
|
||||
it('zeigt ohne Einträge den Leer-Zustand mit Anlegen-Knopf', async () => {
|
||||
render(<AdminCustomModulesPage />);
|
||||
expect(await screen.findByText('Noch keine eigenen Module')).toBeInTheDocument();
|
||||
expect(screen.getByText('Legen Sie Ihr erstes Modul an.')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length).toBeGreaterThan(0);
|
||||
expect(await screen.findByText('Noch keine gemeinsamen Einträge')).toBeInTheDocument();
|
||||
expect(screen.getByText('Legen Sie Ihren ersten gemeinsamen Eintrag an.')).toBeInTheDocument();
|
||||
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 () => {
|
||||
@@ -146,8 +153,8 @@ describe('AdminCustomModulesPage', () => {
|
||||
|
||||
it('Anlegen: http-Adresse zeigt die Meldung und ruft createCustomModule nicht auf', async () => {
|
||||
render(<AdminCustomModulesPage />);
|
||||
await screen.findByText('Noch keine eigenen Module');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
|
||||
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
||||
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 () => {
|
||||
render(<AdminCustomModulesPage />);
|
||||
await screen.findByText('Noch keine eigenen Module');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
|
||||
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
||||
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 () => {
|
||||
render(<AdminCustomModulesPage />);
|
||||
await screen.findByText('Noch keine eigenen Module');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
|
||||
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Name'), ' ');
|
||||
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 () => {
|
||||
render(<AdminCustomModulesPage />);
|
||||
await screen.findByText('Noch keine eigenen Module');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
|
||||
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Name'), ' Beispielseite ');
|
||||
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||
@@ -201,6 +208,7 @@ describe('AdminCustomModulesPage', () => {
|
||||
name: 'Beispielseite',
|
||||
url: 'https://example.com',
|
||||
category: 'security-tools',
|
||||
shared: true,
|
||||
});
|
||||
});
|
||||
await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1));
|
||||
@@ -211,8 +219,8 @@ describe('AdminCustomModulesPage', () => {
|
||||
|
||||
it('Anlegen: die Kategorie ist vorbelegt mit Infrastruktur', async () => {
|
||||
render(<AdminCustomModulesPage />);
|
||||
await screen.findByText('Noch keine eigenen Module');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
|
||||
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
|
||||
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 () => {
|
||||
mockCreate.mockRejectedValue(new Error('500'));
|
||||
render(<AdminCustomModulesPage />);
|
||||
await screen.findByText('Noch keine eigenen Module');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
|
||||
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
||||
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' }));
|
||||
|
||||
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 waitFor(() => expect(mockDelete).toHaveBeenCalledWith('cm-1'));
|
||||
@@ -294,6 +302,22 @@ describe('AdminCustomModulesPage', () => {
|
||||
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 () => {
|
||||
mockRole = 'USER';
|
||||
render(<AdminCustomModulesPage />);
|
||||
|
||||
@@ -1,74 +1,24 @@
|
||||
'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 { CustomModuleManager } from '@/components/custom-modules/custom-module-manager';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||
import { CustomModuleFormModal } from './components/CustomModuleFormModal';
|
||||
import { DeleteCustomModuleDialog } from './components/DeleteCustomModuleDialog';
|
||||
|
||||
/**
|
||||
* Verwaltung „Eigene Module“ (quick-260929-9wc, D-07, Vorbild
|
||||
* `admin/groups`). ADMIN und SUPER_ADMIN; die Rollenpruefung hier ist nur
|
||||
* Anzeige — durchgesetzt wird sie serverseitig durch `@Roles` an POST, PATCH
|
||||
* und DELETE von `/custom-modules`. Nach jedem erfolgreichen Anlegen,
|
||||
* Aendern oder Loeschen zieht die Seitenleiste ueber `bumpSidebarRefresh`
|
||||
* ohne Neuladen nach (D-05).
|
||||
* `admin/groups`; quick-260929-dzu: nur die GEMEINSAMEN Eintraege). ADMIN und
|
||||
* SUPER_ADMIN; die Rollenpruefung hier ist nur Anzeige — durchgesetzt wird sie
|
||||
* serverseitig: die API verlangt fuer gemeinsame Eintraege (`shared: true`,
|
||||
* Aendern, Loeschen) die Administrator-Rolle. Persoenliche Eintraege eines
|
||||
* Administrators stehen wie bei jedem Benutzer unter Einstellungen.
|
||||
*/
|
||||
export default function AdminCustomModulesPage() {
|
||||
const t = useTranslations('admin.customModules');
|
||||
const tCommon = useTranslations('common');
|
||||
const categoryLabel = useCategoryLabel();
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const bumpSidebarRefresh = useMarketplaceStore((s) => s.bumpSidebarRefresh);
|
||||
|
||||
const [modules, setModules] = useState<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 fetchModules = useCallback(async () => {
|
||||
try {
|
||||
setModules(await listCustomModules());
|
||||
setLoadFailed(false);
|
||||
} catch {
|
||||
setLoadFailed(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasAccess) {
|
||||
fetchModules();
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [hasAccess, fetchModules]);
|
||||
|
||||
const handleChanged = () => {
|
||||
fetchModules();
|
||||
bumpSidebarRefresh();
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setShowFormModal(true);
|
||||
};
|
||||
|
||||
const openEdit = (mod: CustomModule) => {
|
||||
setEditing(mod);
|
||||
setShowFormModal(true);
|
||||
};
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
@@ -78,97 +28,15 @@ export default function AdminCustomModulesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
||||
<button type="button" onClick={openCreate} className="btn btn-primary">
|
||||
{t('create')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<CustomModuleManager
|
||||
scope="shared"
|
||||
texts={{
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
create: t('create'),
|
||||
noModules: t('noModules'),
|
||||
noModulesBody: t('noModulesBody'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Namensraumfaehiger next-intl-Mock (Muster admin/custom-modules-page.test.tsx).
|
||||
const messages: Record<string, Record<string, unknown>> = {
|
||||
'settings.customModules': {
|
||||
title: 'Eigene Module',
|
||||
description: 'Diese Einträge sehen nur Sie.',
|
||||
create: 'Eigenes Modul anlegen',
|
||||
noModules: 'Noch keine eigenen Module',
|
||||
noModulesBody: 'Legen Sie Ihr erstes Modul an.',
|
||||
},
|
||||
'customModules.manage': { actions: 'Aktionen', loadError: 'Laden fehlgeschlagen.' },
|
||||
'customModules.form': {
|
||||
create: 'Eigenes Modul anlegen',
|
||||
edit: 'Eigenes Modul bearbeiten',
|
||||
name: 'Name',
|
||||
url: 'Adresse',
|
||||
urlHint: 'Hinweis https',
|
||||
category: 'Kategorie',
|
||||
nameRequired: 'Bitte geben Sie einen Namen ein.',
|
||||
urlNotHttps: 'Bitte geben Sie eine Adresse ein, die mit https:// beginnt.',
|
||||
urlCredentials: 'Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.',
|
||||
saveError: 'Das Modul konnte nicht gespeichert werden.',
|
||||
deleteError: 'Das Modul konnte nicht gelöscht werden.',
|
||||
deleteConfirm: {
|
||||
title: 'Eigenes Modul löschen',
|
||||
bodyShared: 'Möchten Sie „{name}“ wirklich löschen? Für alle Benutzer.',
|
||||
bodyPersonal: 'Möchten Sie „{name}“ wirklich löschen? Nur bei Ihnen.',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
loading: 'Laden...',
|
||||
cancel: 'Abbrechen',
|
||||
save: 'Speichern',
|
||||
delete: 'Löschen',
|
||||
edit: 'Bearbeiten',
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) => {
|
||||
let val: unknown = messages[ns];
|
||||
for (const part of key.split('.')) {
|
||||
val = (val as Record<string, unknown> | undefined)?.[part];
|
||||
}
|
||||
if (typeof val !== 'string') return key;
|
||||
return params
|
||||
? val.replace(/\{(\w+)\}/g, (_m, name: string) => String(params[name] ?? ''))
|
||||
: val;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({
|
||||
children,
|
||||
href,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
className?: string;
|
||||
}) => (
|
||||
<a href={href} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/use-category-label', () => ({
|
||||
useCategoryLabel: () => (slug: string) =>
|
||||
({ infrastructure: 'Infrastruktur', 'security-tools': 'Sicherheit' })[slug] ?? slug,
|
||||
}));
|
||||
|
||||
const { mockList, mockCreate, mockUpdate, mockDelete, mockBump } = vi.hoisted(() => ({
|
||||
mockList: vi.fn(),
|
||||
mockCreate: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
mockBump: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/custom-modules-api', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/custom-modules-api')>(
|
||||
'@/lib/custom-modules-api',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
listCustomModules: mockList,
|
||||
createCustomModule: mockCreate,
|
||||
updateCustomModule: mockUpdate,
|
||||
deleteCustomModule: mockDelete,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/lib/stores/marketplace-store', () => ({
|
||||
useMarketplaceStore: (selector: (s: { bumpSidebarRefresh: () => void }) => unknown) =>
|
||||
selector({ bumpSidebarRefresh: mockBump }),
|
||||
}));
|
||||
|
||||
import CustomModulesSettingsPage from './page';
|
||||
|
||||
const mine = {
|
||||
id: 'cm-1',
|
||||
name: 'Mein Wiki',
|
||||
url: 'https://wiki.example.com',
|
||||
category: 'infrastructure',
|
||||
personal: true,
|
||||
createdAt: '2026-09-29T00:00:00.000Z',
|
||||
updatedAt: '2026-09-29T00:00:00.000Z',
|
||||
};
|
||||
const shared = { ...mine, id: 'cm-2', name: 'Firmenseite', personal: false };
|
||||
|
||||
beforeEach(() => {
|
||||
mockList.mockReset().mockResolvedValue([]);
|
||||
mockCreate.mockReset().mockResolvedValue(mine);
|
||||
mockUpdate.mockReset().mockResolvedValue(mine);
|
||||
mockDelete.mockReset().mockResolvedValue(undefined);
|
||||
mockBump.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('CustomModulesSettingsPage', () => {
|
||||
it('zeigt Einleitung und ohne Einträge den Leer-Zustand mit Anlegen-Knopf', async () => {
|
||||
render(<CustomModulesSettingsPage />);
|
||||
expect(await screen.findByText('Noch keine eigenen Module')).toBeInTheDocument();
|
||||
expect(screen.getByText('Diese Einträge sehen nur Sie.')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('listet nur die persönlichen Einträge, gemeinsame der Verwaltung nicht', async () => {
|
||||
mockList.mockResolvedValue([shared, mine]);
|
||||
render(<CustomModulesSettingsPage />);
|
||||
expect(await screen.findByRole('link', { name: 'Mein Wiki' })).toHaveAttribute(
|
||||
'href',
|
||||
'/modules/custom/cm-1',
|
||||
);
|
||||
expect(screen.queryByText('Firmenseite')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button', { name: 'Bearbeiten' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('Anlegen sendet KEIN shared (persönlicher Eintrag), lädt neu und aktualisiert die Seitenleiste', async () => {
|
||||
render(<CustomModulesSettingsPage />);
|
||||
await screen.findByText('Noch keine eigenen Module');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Name'), ' Mein Wiki ');
|
||||
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
name: 'Mein Wiki',
|
||||
url: 'https://example.com',
|
||||
category: 'infrastructure',
|
||||
});
|
||||
});
|
||||
expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('shared');
|
||||
await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1));
|
||||
expect(mockList).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Anlegen: http-Adresse wird vor dem Senden abgelehnt', async () => {
|
||||
render(<CustomModulesSettingsPage />);
|
||||
await screen.findByText('Noch keine eigenen Module');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
|
||||
await userEvent.type(screen.getByLabelText('Name'), 'X');
|
||||
await userEvent.type(screen.getByLabelText('Adresse'), 'http://example.com');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('Bitte geben Sie eine Adresse ein, die mit https:// beginnt.'),
|
||||
).toBeInTheDocument();
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Bearbeiten: ruft update ohne shared und aktualisiert die Seitenleiste', async () => {
|
||||
mockList.mockResolvedValue([mine]);
|
||||
render(<CustomModulesSettingsPage />);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Bearbeiten' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const nameInput = within(dialog).getByLabelText('Name');
|
||||
expect(nameInput).toHaveValue('Mein Wiki');
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, 'Wiki neu');
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Speichern' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith('cm-1', {
|
||||
name: 'Wiki neu',
|
||||
url: 'https://wiki.example.com',
|
||||
category: 'infrastructure',
|
||||
});
|
||||
});
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('Löschen: Rückfrage nennt den persönlichen Text, Bestätigen ruft delete und aktualisiert die Seitenleiste', async () => {
|
||||
mockList.mockResolvedValue([mine]);
|
||||
render(<CustomModulesSettingsPage />);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(
|
||||
within(dialog).getByText('Möchten Sie „Mein Wiki“ wirklich löschen? Nur bei Ihnen.'),
|
||||
).toBeInTheDocument();
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Löschen' }));
|
||||
|
||||
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('cm-1'));
|
||||
await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1));
|
||||
expect(mockList).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Serverfehler beim Speichern bleibt im Dialog sichtbar', async () => {
|
||||
mockCreate.mockRejectedValue(new Error('500'));
|
||||
render(<CustomModulesSettingsPage />);
|
||||
await screen.findByText('Noch keine eigenen Module');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
|
||||
await userEvent.type(screen.getByLabelText('Name'), 'X');
|
||||
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
|
||||
expect(await screen.findByText('Das Modul konnte nicht gespeichert werden.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(mockBump).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { CustomModuleManager } from '@/components/custom-modules/custom-module-manager';
|
||||
|
||||
/**
|
||||
* Einstellungen > Eigene Module (quick-260929-dzu) — /settings/custom-modules.
|
||||
* Jeder angemeldete Benutzer pflegt hier seine PERSOENLICHEN Seitenleisten-
|
||||
* Eintraege; nur er selbst sieht sie. Die Trennung von den gemeinsamen
|
||||
* Eintraegen der Verwaltung erzwingt die API, nicht diese Seite.
|
||||
*/
|
||||
export default function CustomModulesSettingsPage() {
|
||||
const t = useTranslations('settings.customModules');
|
||||
|
||||
return (
|
||||
<CustomModuleManager
|
||||
scope="personal"
|
||||
texts={{
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
create: t('create'),
|
||||
noModules: t('noModules'),
|
||||
noModulesBody: t('noModulesBody'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+17
-4
@@ -14,6 +14,12 @@ import { useCategoryLabel } from '@/lib/use-category-label';
|
||||
interface CustomModuleFormModalProps {
|
||||
/** `null` = neues Modul anlegen, sonst bearbeiten. */
|
||||
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;
|
||||
onSaved: () => void;
|
||||
}
|
||||
@@ -22,13 +28,20 @@ const NAME_MAX = 100;
|
||||
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
|
||||
* Regel wie die API): nur https, keine Zugangsdaten. `noValidate`, damit die
|
||||
* eigenen, uebersetzten Meldungen erscheinen statt der des Browsers.
|
||||
*/
|
||||
export function CustomModuleFormModal({ mod, onClose, onSaved }: CustomModuleFormModalProps) {
|
||||
const t = useTranslations('admin.customModules');
|
||||
export function CustomModuleFormModal({
|
||||
mod,
|
||||
shared = false,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: CustomModuleFormModalProps) {
|
||||
const t = useTranslations('customModules.form');
|
||||
const tCommon = useTranslations('common');
|
||||
const categoryLabel = useCategoryLabel();
|
||||
|
||||
@@ -64,7 +77,7 @@ export function CustomModuleFormModal({ mod, onClose, onSaved }: CustomModuleFor
|
||||
if (mod) {
|
||||
await updateCustomModule(mod.id, input);
|
||||
} else {
|
||||
await createCustomModule(input);
|
||||
await createCustomModule(shared ? { ...input, shared: true } : input);
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
|
||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||
import { CustomModuleFormModal } from './custom-module-form-modal';
|
||||
import { DeleteCustomModuleDialog } from './delete-custom-module-dialog';
|
||||
|
||||
/** Seitenspezifische, bereits uebersetzte Texte der beiden Aufrufer. */
|
||||
export interface CustomModuleManagerTexts {
|
||||
title: string;
|
||||
description: string;
|
||||
create: string;
|
||||
noModules: string;
|
||||
noModulesBody: string;
|
||||
}
|
||||
|
||||
interface CustomModuleManagerProps {
|
||||
/**
|
||||
* `shared`: Verwaltung — zeigt und pflegt nur die gemeinsamen Eintraege (fuer
|
||||
* alle Benutzer sichtbar). `personal`: Einstellungen — zeigt und pflegt nur
|
||||
* die eigenen persoenlichen Eintraege des angemeldeten Benutzers.
|
||||
*/
|
||||
scope: 'shared' | 'personal';
|
||||
texts: CustomModuleManagerTexts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste mit Anlegen, Bearbeiten und Loeschen eigener Module (quick-260929-dzu).
|
||||
* Eine Oberflaeche fuer zwei Aufrufer: `admin/custom-modules` (`scope="shared"`)
|
||||
* und `settings/custom-modules` (`scope="personal"`). Die API liefert gemeinsame
|
||||
* PLUS eigene Eintraege; die Seite filtert ueber `personal` auf ihren Bereich.
|
||||
* Nach jedem erfolgreichen Anlegen, Aendern oder Loeschen zieht die Seitenleiste
|
||||
* ueber `bumpSidebarRefresh` ohne Neuladen nach.
|
||||
*/
|
||||
export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps) {
|
||||
const t = useTranslations('customModules.form');
|
||||
const tManage = useTranslations('customModules.manage');
|
||||
const tCommon = useTranslations('common');
|
||||
const categoryLabel = useCategoryLabel();
|
||||
const bumpSidebarRefresh = useMarketplaceStore((s) => s.bumpSidebarRefresh);
|
||||
const shared = scope === 'shared';
|
||||
|
||||
const [modules, setModules] = useState<CustomModule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
|
||||
const [showFormModal, setShowFormModal] = useState(false);
|
||||
const [editing, setEditing] = useState<CustomModule | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<CustomModule | null>(null);
|
||||
|
||||
const fetchModules = useCallback(async () => {
|
||||
try {
|
||||
const all = await listCustomModules();
|
||||
setModules(all.filter((m) => m.personal !== shared));
|
||||
setLoadFailed(false);
|
||||
} catch {
|
||||
setLoadFailed(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [shared]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchModules();
|
||||
}, [fetchModules]);
|
||||
|
||||
const handleChanged = () => {
|
||||
fetchModules();
|
||||
bumpSidebarRefresh();
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setShowFormModal(true);
|
||||
};
|
||||
|
||||
const openEdit = (mod: CustomModule) => {
|
||||
setEditing(mod);
|
||||
setShowFormModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1
|
||||
className={`${shared ? 'text-2xl' : 'text-lg'} font-semibold text-foreground`}
|
||||
>
|
||||
{texts.title}
|
||||
</h1>
|
||||
<button type="button" onClick={openCreate} className="btn btn-primary">
|
||||
{texts.create}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">{texts.description}</p>
|
||||
|
||||
{loadFailed && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{tManage('loadError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground">{tCommon('loading')}</p>
|
||||
) : modules.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<h2 className="mb-2 text-lg font-semibold text-foreground">{texts.noModules}</h2>
|
||||
<p className="mb-6 text-sm text-muted-foreground">{texts.noModulesBody}</p>
|
||||
<button type="button" onClick={openCreate} className="btn btn-primary">
|
||||
{texts.create}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('name')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('url')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('category')}</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
|
||||
{tManage('actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{modules.map((mod) => (
|
||||
<tr key={mod.id} className="transition-colors hover:bg-muted/30">
|
||||
<td className="px-4 py-3 font-medium text-foreground">
|
||||
<Link href={`/modules/custom/${mod.id}`} className="link">
|
||||
{mod.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="max-w-[24rem] px-4 py-3 text-muted-foreground">
|
||||
<span className="block truncate" title={mod.url}>
|
||||
{mod.url}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{categoryLabel(mod.category)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(mod)}
|
||||
className="rounded px-2 py-1 text-xs text-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
{tCommon('edit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteTarget(mod)}
|
||||
className="rounded px-2 py-1 text-xs text-destructive transition-colors hover:bg-destructive/10"
|
||||
>
|
||||
{tCommon('delete')}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFormModal && (
|
||||
<CustomModuleFormModal
|
||||
mod={editing}
|
||||
shared={shared}
|
||||
onClose={() => setShowFormModal(false)}
|
||||
onSaved={handleChanged}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteTarget && (
|
||||
<DeleteCustomModuleDialog
|
||||
mod={deleteTarget}
|
||||
shared={shared}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onDeleted={handleChanged}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+13
-5
@@ -6,6 +6,8 @@ import { type CustomModule, deleteCustomModule } from '@/lib/custom-modules-api'
|
||||
|
||||
interface DeleteCustomModuleDialogProps {
|
||||
mod: CustomModule;
|
||||
/** Gemeinsamer Eintrag (verschwindet fuer alle) oder persoenlicher (nur fuer den Besitzer). */
|
||||
shared?: boolean;
|
||||
onClose: () => void;
|
||||
onDeleted: () => void;
|
||||
}
|
||||
@@ -13,11 +15,17 @@ interface DeleteCustomModuleDialogProps {
|
||||
/**
|
||||
* Rueckfrage vor dem Loeschen eines eigenen Moduls (quick-260929-9wc,
|
||||
* Vorbild `DeleteGroupDialog`). Bleibt bei einem Fehler offen und zeigt ihn —
|
||||
* der Eintrag verschwindet fuer ALLE Benutzer aus der Seitenleiste, ein
|
||||
* stilles Scheitern wuerde ein Loeschen vortaeuschen.
|
||||
* ein stilles Scheitern wuerde ein Loeschen vortaeuschen. Der Text nennt, fuer
|
||||
* wen der Eintrag verschwindet (quick-260929-dzu: gemeinsam = alle, persoenlich
|
||||
* = nur der Besitzer).
|
||||
*/
|
||||
export function DeleteCustomModuleDialog({ mod, onClose, onDeleted }: DeleteCustomModuleDialogProps) {
|
||||
const t = useTranslations('admin.customModules');
|
||||
export function DeleteCustomModuleDialog({
|
||||
mod,
|
||||
shared = false,
|
||||
onClose,
|
||||
onDeleted,
|
||||
}: DeleteCustomModuleDialogProps) {
|
||||
const t = useTranslations('customModules.form');
|
||||
const tCommon = useTranslations('common');
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
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">
|
||||
{t('deleteConfirm.title')}
|
||||
</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 && (
|
||||
<div
|
||||
@@ -2,7 +2,7 @@ import { Children, isValidElement } from 'react';
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
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
|
||||
vi.mock('react-grid-layout/css/styles.css', () => ({}));
|
||||
@@ -539,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 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('centeringOffset (Design „Mosaik“, Runde 3)', () => {
|
||||
it('stellt belegte Spalten mittig und laesst eine volle Anordnung stehen', () => {
|
||||
// 48 Spalten auf 1212 px: Spalte 13 px + 12 px Abstand = 25 px je Spalte.
|
||||
expect(centeringOffset([{ x: 0, w: 24 }], 48, 1212)).toBe(300);
|
||||
expect(centeringOffset([{ x: 24, w: 24 }], 48, 1212)).toBe(0);
|
||||
expect(centeringOffset([], 48, 1212)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,33 +93,6 @@ const GRID_CONSTRAINTS: LayoutConstraint[] = [...defaultConstraints, RESIZE_AXIS
|
||||
|
||||
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 {
|
||||
i: string;
|
||||
x: number;
|
||||
@@ -140,8 +113,6 @@ interface DashboardGridProps {
|
||||
onOpenCatalog?: () => void;
|
||||
/** Leerer Zustand: fuegt eine vorgeschlagene Kachel direkt hinzu. */
|
||||
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. */
|
||||
@@ -210,7 +181,6 @@ export function DashboardGrid({
|
||||
onRemoveWidget,
|
||||
onOpenCatalog,
|
||||
onQuickAdd,
|
||||
onInsetChange,
|
||||
}: DashboardGridProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const [width, setWidth] = useState(1200);
|
||||
@@ -291,17 +261,6 @@ export function DashboardGrid({
|
||||
// stabil bleibt.
|
||||
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
|
||||
// Illustration — Titel, ein Satz, drei Vorschlaege zum direkten
|
||||
// Hinzufuegen und der Hauptknopf zum Katalog.
|
||||
@@ -347,14 +306,7 @@ export function DashboardGrid({
|
||||
}
|
||||
|
||||
return (
|
||||
// overflow-x-clip: die verschobene Rasterflaeche ragt rechts ueber den
|
||||
// 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}
|
||||
>
|
||||
<div ref={measureRef}>
|
||||
<Responsive
|
||||
width={width}
|
||||
breakpoints={BREAKPOINTS}
|
||||
@@ -403,7 +355,6 @@ export function DashboardGrid({
|
||||
);
|
||||
})}
|
||||
</Responsive>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,19 @@ export function SettingsSidebar() {
|
||||
>
|
||||
{t('categoryDesktopApp')}
|
||||
</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>
|
||||
|
||||
{/* Dashboard category — existing items unchanged */}
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface CustomModule {
|
||||
name: string;
|
||||
url: string;
|
||||
category: string;
|
||||
/** `true` = persoenlicher Eintrag des angemeldeten Benutzers, `false` = gemeinsam (vom Administrator). */
|
||||
personal: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -23,6 +25,14 @@ export interface CustomModuleInput {
|
||||
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). */
|
||||
export class CustomModuleRequestError extends Error {
|
||||
constructor(
|
||||
@@ -83,7 +93,7 @@ export async function getCustomModule(id: string): Promise<CustomModule | null>
|
||||
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`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
|
||||
@@ -165,6 +165,7 @@
|
||||
"categoryGeneral": "Allgemein",
|
||||
"categoryAccount": "Konto",
|
||||
"categoryDesktopApp": "Desktop-App",
|
||||
"categoryCustomModules": "Eigene Module",
|
||||
"categorySmtp": "SMTP",
|
||||
"account": {
|
||||
"title": "Konto",
|
||||
@@ -228,6 +229,13 @@
|
||||
"testFailed": "Verbindung fehlgeschlagen",
|
||||
"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."
|
||||
},
|
||||
"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": {
|
||||
@@ -672,26 +680,10 @@
|
||||
},
|
||||
"customModules": {
|
||||
"title": "Eigene Module",
|
||||
"description": "Nehmen Sie andere Webseiten als Einträge in die Seitenleiste auf. Alle Benutzer sehen diese Einträge unter der gewählten Kategorie, ein Klick zeigt die Seite direkt in Tessera.",
|
||||
"create": "Eigenes Modul anlegen",
|
||||
"edit": "Eigenes Modul bearbeiten",
|
||||
"name": "Name",
|
||||
"url": "Adresse",
|
||||
"urlHint": "Die Adresse muss mit https:// beginnen.",
|
||||
"category": "Kategorie",
|
||||
"actions": "Aktionen",
|
||||
"noModules": "Noch keine eigenen Module",
|
||||
"noModulesBody": "Legen Sie Ihr erstes Modul an, um eine Webseite in der Seitenleiste zu zeigen.",
|
||||
"nameRequired": "Bitte geben Sie einen Namen ein.",
|
||||
"urlNotHttps": "Bitte geben Sie eine Adresse ein, die mit https:// beginnt.",
|
||||
"urlCredentials": "Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.",
|
||||
"saveError": "Das Modul konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.",
|
||||
"deleteError": "Das Modul konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
|
||||
"loadError": "Die eigenen Module konnten nicht geladen werden.",
|
||||
"deleteConfirm": {
|
||||
"title": "Eigenes Modul löschen",
|
||||
"body": "Möchten Sie „{name}“ wirklich löschen? Der Eintrag verschwindet für alle Benutzer aus der Seitenleiste."
|
||||
}
|
||||
"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": "Gemeinsamen Eintrag anlegen",
|
||||
"noModules": "Noch keine gemeinsamen Einträge",
|
||||
"noModulesBody": "Legen Sie Ihren ersten gemeinsamen Eintrag an, um eine Webseite für alle Benutzer in der Seitenleiste zu zeigen."
|
||||
}
|
||||
},
|
||||
"adminModules": {
|
||||
@@ -1375,7 +1367,29 @@
|
||||
"embedHint": "Manche Seiten lassen sich nicht einbetten. Öffnen Sie die Seite dann in einem neuen Tab.",
|
||||
"notFound": "Dieses Modul gibt es nicht mehr.",
|
||||
"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": {
|
||||
"domain-tools": "Domains",
|
||||
|
||||
@@ -165,6 +165,7 @@
|
||||
"categoryGeneral": "General",
|
||||
"categoryAccount": "Account",
|
||||
"categoryDesktopApp": "Desktop App",
|
||||
"categoryCustomModules": "Custom modules",
|
||||
"categorySmtp": "SMTP",
|
||||
"account": {
|
||||
"title": "Account",
|
||||
@@ -228,6 +229,13 @@
|
||||
"testFailed": "Connection failed",
|
||||
"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."
|
||||
},
|
||||
"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": {
|
||||
@@ -672,26 +680,10 @@
|
||||
},
|
||||
"customModules": {
|
||||
"title": "Custom modules",
|
||||
"description": "Add other websites as entries in the sidebar. All users see these entries under the chosen category, and a click shows the page right inside Tessera.",
|
||||
"create": "Add custom module",
|
||||
"edit": "Edit custom module",
|
||||
"name": "Name",
|
||||
"url": "Address",
|
||||
"urlHint": "The address must start with https://.",
|
||||
"category": "Category",
|
||||
"actions": "Actions",
|
||||
"noModules": "No custom modules yet",
|
||||
"noModulesBody": "Add your first module to show a website in the sidebar.",
|
||||
"nameRequired": "Please enter a name.",
|
||||
"urlNotHttps": "Please enter an address that starts with https://.",
|
||||
"urlCredentials": "The address must not contain a user name or a password.",
|
||||
"saveError": "The module could not be saved. Please try again.",
|
||||
"deleteError": "The module could not be deleted. Please try again.",
|
||||
"loadError": "The custom modules could not be loaded.",
|
||||
"deleteConfirm": {
|
||||
"title": "Delete custom module",
|
||||
"body": "Do you really want to delete “{name}”? The entry disappears from the sidebar for all users."
|
||||
}
|
||||
"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 shared entry",
|
||||
"noModules": "No shared entries yet",
|
||||
"noModulesBody": "Add your first shared entry to show a website in the sidebar for all users."
|
||||
}
|
||||
},
|
||||
"adminModules": {
|
||||
@@ -1375,7 +1367,29 @@
|
||||
"embedHint": "Some pages cannot be embedded. If this one stays blank, open it in a new tab.",
|
||||
"notFound": "This module no longer exists.",
|
||||
"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": {
|
||||
"domain-tools": "Domains",
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
|
||||
@@ -58,7 +58,7 @@ Unten in der Seitenleiste begrüßt Tessera Sie je nach Tageszeit mit Ihrem Name
|
||||
|
||||
## 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:
|
||||
- Ü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
|
||||
|
||||
Ö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:**
|
||||
- **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.
|
||||
- **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“. 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 > 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 |
|
||||
| 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) |
|
||||
| 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. |
|
||||
| **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 |
|
||||
| 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** | **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)
|
||||
|
||||
@@ -362,6 +362,10 @@ Tabelle hinzugekommen, ohne sie fortzuschreiben. Jetzt +1 `muss-mandantengebunde
|
||||
`muss-mandantengebunden`, 21 `keine-mandantengebundene-tabelle`, 14 `beides`,
|
||||
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
|
||||
|
||||
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/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/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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user