Commit Graph

636 Commits

Author SHA1 Message Date
schalli 19717954d6 fix(16): WR-02 discriminate P2002 target in group rename branch
syncBoundGroupsForTenant()'s rename write updates name and ldapDn in one
call, so a P2002 there can come from either @@unique([tenantId, name])
or @@unique([tenantId, ldapDn]). The catch previously reported every
P2002 as a name collision unconditionally; it now inspects
err.meta.target the same way importGroupsByDn() already does for its
own create() call, so a non-name unique violation is no longer
mislabelled and sent the admin down the wrong troubleshooting path.
2026-08-06 16:57:34 +02:00
schalli dd59bf592f fix(16): WR-01 name lock in GroupsService.update() also checks ldapDn
A legacy binding from plan 15-06 has ldapDn set but ldapObjectGuid stays
null until the first syncBoundGroupsForTenant() run backfills it. Until
then, GroupsService.update() let a direct PATCH rename through even
though GroupFormModal.tsx already treats the same group as AD-bound
(isImported = ldapDn != null) — the D-03 name lock was only a UI
convention for that window, not the backend invariant the 16-02 summary
claimed.
2026-08-06 16:56:30 +02:00
schalli fac152af74 feat(16-05): show internal-name fallback in grants matrix column headers (D-04)
- Local Group interface gains internalName?: string | null — GET
  /module-grants/matrix already returns the field (no select on the
  groups query), only the frontend type was missing it.
- Column header text and title tooltip now use internalName ?? name
  (nullish, not truthy) — same fallback pattern as admin/groups and
  UserAccessModal. No layout change: same truncate-with-tooltip cell.
- Search filter now matches both the display name and the stored AD
  name, so neither the internal nor the original AD name search goes
  empty after the header text changed.
2026-08-06 16:38:04 +02:00
schalli f66546df7e feat(16-05): wire full sync report + visible sync-request error (D-06, D-21)
- SyncResult interface grows additively: groupMembershipsAdded/Removed
  (D-21 backend gap, existed since Phase 15 but never wired into the
  frontend) plus groupsAdopted/Renamed/Deleted and defaultMarkerMoved
  (Plan 16-03).
- New syncRequestError state: a failed sync request (network error or
  !res.ok) now renders a visible text-sm text-destructive line instead
  of silently reporting a three-zero result as a successful no-op.
- Sync report container gains three new lines: group-membership counts,
  AD-group counts (always visible, even at zero), and a conditional
  amber "default marker reassigned" line shown only when
  defaultMarkerMoved > 0.
- Four new i18n keys under admin.ldap.sync in de.json/en.json.
2026-08-06 16:37:07 +02:00
schalli e900b43d57 test(16-04): remove orphaned ldapBind i18n keys, add D-07 regression tests
Delete the admin.groups.ldapBind.* subtree (hint/bound/unbind/
searchPlaceholder/discoverError/noResults) from de.json and en.json —
fully orphaned since GroupFormModal.tsx no longer has an AD-binding
codepath. admin.groups.ldapBinding (column header) and every other
admin.groups.* key are untouched; key sets stay in parity across both
languages.

Test file's translation stub loses the same dead ldapBind block and
gains the seven Task 1 keys. Two new cases lock down D-07: opening the
create dialog issues no /ldap/groups request, and editing an imported
group renders a disabled name input plus the internal-name field
instead of any AD-search UI.
2026-08-06 16:30:49 +02:00
schalli 6802b48cd8 feat(16-04): show internalName with AD-name fallback in groups list
Name column renders group.internalName ?? group.name (nullish, not
truthiness, since the backend already normalizes blank values to null)
inside a span carrying title={group.name} so the AD name stays
discoverable on hover once an internal name is set. Badge column is
untouched — it remains the sole imported-vs-local marker per D-07.

Adds two component-test cases covering the fallback and its title
attribute (D-04).
2026-08-06 16:29:15 +02:00
schalli 30affbbc7e feat(16-04): rebuild GroupFormModal to three states without AD binding
- Remove AD radio-selection block, discovery effect/state, and the
  two-step create-then-PATCH-bind flow from GroupFormModal.tsx (D-07):
  a local group can no longer be bound to an AD group from this dialog.
- Add locked name field with provenance hint + AD-DN read-only line and
  an editable internalName field for imported groups (D-03/D-04); create
  state gets a hint linking to the LDAP import area.
- Visible save-error line (never a silent catch{}) that distinguishes a
  409 name collision from a generic failure; dialog stays open, inputs
  are preserved.
- Add Group.internalName to the page.tsx interface now (Rule 3 — the
  modal cannot typecheck without it; Task 2 adds the display-cell usage).
- Add seven new admin.groups i18n keys to de.json/en.json (orphaned
  ldapBind.* keys removed in Task 3).
2026-08-06 16:28:07 +02:00
schalli 68aca81f71 feat(16-03): wire group reconciliation before membership sync (5a)
- syncUsersForTenant() now calls syncBoundGroupsForTenant() (5a) BEFORE
  syncGroupMembershipsForTenant() (5b) — the central correctness ordering
  of Phase 16 (RESEARCH.md Pitfall 1): a rename detected in the same run
  must be written back before the memberOf filter is built, or the
  membership sync would misreport a rename as a membership wipeout
- Add observable ordering test (call-order spies), a no-op-guard test, and
  a regression test proving a memberOf search never uses the stale
  pre-rename DN
- Update the D-21 membership-sync test fixtures to resolve step 5a as a
  deterministic no-op (DN-derived identity GUID), since the wiring now
  runs 5a ahead of every syncUsersForTenant() call those tests exercise

A1 (objectGUID survives an AD rename) and A2 (binary filter escape syntax)
remain unverified against a real directory — no reachable AD in this
sandbox. Documented as an outstanding live verification in the plan
SUMMARY, not silently skipped.
2026-08-06 16:21:10 +02:00
schalli 522293417a feat(16-03): add syncBoundGroupsForTenant reconciliation method
- New private LdapService.syncBoundGroupsForTenant(): rename detection
  (SC-3), disappearance deletion with default-marker handoff before delete
  (SC-4/D-05/D-06), legacy ldapDn-only binding GUID backfill (D-07), and a
  32-hex-char guard before any objectGUID filter interpolation (T-16-01)
- LdapSyncResult grows additively: groupsAdopted, groupsRenamed,
  groupsDeleted, defaultMarkerMoved
- LdapService constructor takes GroupsService; LdapModule imports
  GroupsModule (no cycle)
- 14 new test cases covering the full behavior matrix plus idempotency
2026-08-06 16:15:09 +02:00
schalli f71e614f7f feat(16-02): display name with fallback in user-detail projections (D-04, UI-SPEC Surface Contract 6)
- ModuleGrantsService.getUserAccess() now selects internalName on the
  membership query's group projection (already present via `include:
  { group: true }` on the grant query)
- Both display points (viaGroups names, membership chips' name field)
  use internalName ?? name; groups[] sorting now runs over the
  displayed name as a result, distinct from GroupsService.listForTenant()
  which still sorts by the raw name column
- 3 new test cases: fallback set/unset, sort-by-displayed-name
- No apps/web/ changes (verified via git diff --name-only)
2026-08-06 16:00:18 +02:00
schalli 253da91ba9 feat(16-02): server-side name lock for imported groups + internalName (D-03/D-04/D-07)
- GroupsService.update() rejects `name` with BadRequestException when the
  loaded group carries a set ldapObjectGuid (imported groups) — a real
  backend invariant, not a UI-only disable
- internalName is settable/clearable on any group; empty/whitespace-only
  values normalize to null instead of an empty display name
- listForTenant() now projects internalName alongside name
- UpdateGroupDto drops ldapDn (D-07: no more codepath binds a local group
  to AD via this route) and gains internalName?: string | null
- 9 new test cases in groups.service.spec.ts (name lock, internalName
  set/clear/idempotent/local-group/unicode, listForTenant projection);
  stale ldapDn update() test removed (behavior intentionally deleted)
2026-08-06 15:58:28 +02:00
schalli 2ef9b8638c feat(16-02): standard group handoff building block (D-06)
- DEFAULT_GROUP_NAME extracted as shared constant between
  ensureDefaultGroup() and the new reassignDefaultBeforeDelete()
- reassignDefaultBeforeDelete(tenantId, groupId) moves the default
  marker deterministically (DEFAULT_GROUP_NAME first, else oldest
  other group by createdAt asc), never deletes, never throws
- 6 test cases covering handoff, fallback ordering, no-other-group,
  non-default no-op, cross-tenant no-op, and P2002 race
2026-08-06 15:55:53 +02:00
schalli 626e29659d feat(16-01): apply Group.internalName/ldapObjectGuid migration to local DB
- Migration 20260806133916_add_group_internal_name_and_object_guid applied
  against the local Postgres container (baselined 24 prior migrations first
  — _prisma_migrations was missing, unrelated to this task's DDL)
- New describe block in migration-sql.spec.ts pins internalName,
  ldapObjectGuid, and the (tenantId, ldapObjectGuid) unique index
- Full API test suite green (40 files, 526 tests)
2026-08-06 15:43:19 +02:00
schalli 3523e43a13 feat(16-01): tracer — select and import AD groups end-to-end
Task 1 checkpoint resolved: approve-both, granted 2026-08-06 by the
project owner (D-04 one-way schema extension: Group.internalName +
Group.ldapObjectGuid, both nullable, one versioned migration).

Adds the Phase 16 tracer slice through every layer:
- Prisma schema: Group.internalName, Group.ldapObjectGuid,
  @@unique([tenantId, ldapObjectGuid]) (Prisma client regenerated;
  the versioned migration itself is Task 3, separately blocking).
- LdapService: listGroups() now reads objectGUID via
  explicitBufferAttributes and flags alreadyImported per tenant;
  new importGroupsByDn() creates a Group per checked DN with
  name/ldapDn/ldapObjectGuid, reject-with-report on name collision
  (P2002 on name -> nameCollisions, P2002 on ldapObjectGuid ->
  skipped), never aborts the batch on one DN's error; new static
  escapeLdapFilterBuffer() for Plan 16-03's later existence sweep.
- DTO/controller: ImportGroupsDto, POST /ldap/groups/import
  (ADMIN/SUPER_ADMIN), listGroups route now tenant-scoped.
- Frontend: new "AD-Gruppen importieren" section in /admin/ldap,
  own discovery/import handlers with a visible error state
  (Owner decision 2026-08-06 — no silent catch{} for these two
  handlers), i18n keys in de.json/en.json.
- Tests: 8 new cases covering the full <behavior> list plus
  listGroups sort order and alreadyImported.

Flagged assumption (RESEARCH.md A1/A2): objectGUID rename-stability
and the binary filter syntax are unverified against a real AD —
this plan only WRITES the GUID, Plan 16-03 reads it back live.
2026-08-06 15:09:35 +02:00
schalli 0d7d8a597e feat(260805-fok): beide Mandanten-Entstehungspfade verdrahten + Startup-Reparatur
- TenantService.create ruft nach prisma.tenant.create ensureDefaultGroup
  auf; Fehler werden protokolliert, nicht propagiert (Muster aus
  UserService.create)
- tenant.module.ts importiert GroupsModule (keine Zirkularitaet, wie
  UserModule bereits vormacht)
- AdminSeedService.onApplicationBootstrap besteht jetzt aus zwei
  sequenziellen await-Schritten: seedAdmin() (bisheriger Rumpf, plus
  ensureDefaultGroup nach dem Tenant-Upsert und VOR user.create), dann
  ensureDefaultGroupsForAllTenants() als abschliessende Reparatur ueber
  ALLE Mandanten — laeuft unabhaengig von seedAdmin()s fruehen
  Rueckkehrpfaden (fehlende ENV / Admin existiert bereits) und ist je
  Mandant sowie insgesamt try/catch-gekapselt, blockiert den API-Start nie
- Reparatur sitzt bewusst NICHT als eigener onApplicationBootstrap-Hook
  in GroupsModule (Ordering-Falle aus tender-scheduler.service.ts)
- tenant.service.spec.ts, admin-seed.service.spec.ts (neu): Reihenfolge,
  beide fruehen Rueckkehrpfade, Fehlerisolation je Mandant, Idempotenz
  ueber zwei Bootstrap-Laeufe
2026-08-05 11:30:49 +02:00
schalli 9d1254cd78 feat(260805-fok): GroupsService.ensureDefaultGroup(tenantId)
- Neue Methode ensureDefaultGroup: legt fuer einen Mandanten ohne jede
  Gruppe die Standardgruppe 'Alle Benutzer' (isDefault:true) an, nimmt
  alle Bestandsbenutzer als MANUAL-Mitglieder auf und erzeugt Grants
  fuer alle aktiven Module — derselbe Endzustand wie die drei
  Backfill-INSERTs der Migration 20260804130130
- Waechter prueft ausschliesslich group.count === 0, niemals die
  fehlende isDefault-Markierung (D-13)
- P2002 aus dem partiellen Index Group_one_default_per_tenant wird
  abgefangen und liefert null statt zu werfen (Race-Sicherheit)
- groups.service.spec.ts: Fake erweitert um group.count,
  tenantModuleActivation, moduleGrant.findMany/createMany,
  $transaction mit Callback-Form, plus voller ensureDefaultGroup-Testblock
2026-08-05 11:28:16 +02:00
schalli 8ce374818c feat(260805-d0r): membership-origin badge on chips (D-19/D-20)
- Reuses admin.groups.members.sourceManual/.sourceLdap -- no new i18n key
- Badge markup copied verbatim from GroupMembersModal.tsx (blue for LDAP,
  neutral gray otherwise), same visual language on both surfaces
2026-08-05 09:37:26 +02:00
schalli 73122b5676 test(260805-d0r): add failing test for MANUAL/LDAP origin badge on chips 2026-08-05 09:36:45 +02:00
schalli f8ff74bd3f feat(260805-d0r): UserAccessModal chips render from data.groups (D-16)
- Chips come from the response's groups (actual GroupMembership rows),
  not the union of row.viaGroups -- closes the reproduced defect where
  revoking a group's last grant hid an otherwise-unchanged membership
- React key is the group id, not the name
- Test file: moved the LDAP/MANUAL badge assertion out of this commit,
  it belongs to Task 2 which reuses admin.groups.members
2026-08-05 09:35:54 +02:00
schalli 771f680034 test(260805-d0r): rewrite modal test fixtures to { groups, modules } shape
- Regression: group without any module grant stays visible as a chip
- New: mixed empty-modules + populated-groups case, LDAP/MANUAL badge case
2026-08-05 09:34:42 +02:00
schalli ecadf69e14 feat(260805-d0r): getUserAccess returns groups from GroupMembership (D-16)
- New groupMembership.findMany query, tenant-scoped via group.tenantId
  (GroupMembership has no own tenantId column)
- Response shape changes from an array to { groups, modules }; modules
  entries stay field-identical to before
- Group without any module grant now stays visible, closing the
  reproduced defect
2026-08-05 09:33:51 +02:00
schalli b6d4e4acb6 test(260805-d0r): add failing tests for groups in getUserAccess
- Regression: user in a group without any module grant stays visible
- Cross-tenant: membership in a foreign tenant's group is excluded
- Origin (MANUAL/LDAP), empty-modules case, stable alpha sort
2026-08-05 09:33:22 +02:00
schalli 2e7daa481e feat(15-08): Marketplace-Karte mit drittem Zustand "Kein Zugriff"
- marketplace/page.tsx und marketplace/[slug]/page.tsx auf GET
  /modules/catalog umgestellt (ein Aufruf statt zwei), beide Statusflags
  (isActiveForTenant, hasAccess) kommen in einer Antwort -> kein
  Zwischenzustand, in dem eine Karte kurzzeitig ohne Sperr-Badge
  anklickbar erscheint
- MarketplaceCard bekommt hasAccess-Prop: drittes Badge (Bernstein,
  "Kein Zugriff") bei isActive && !hasAccess, Karte opacity-60/
  cursor-not-allowed, Klick loest Toast statt Navigation aus; bei
  Zugriff navigiert der Klick zu /marketplace/[slug]; Badge-Reihe
  bekommt flex-wrap gegen Overflow bei langen Namen
- [Rule 2] Marketplace-Ansicht war zuvor komplett isAdmin-gated
  (Zugriff verweigert fuer USER) - das widersprach D-08 ("Katalog
  bleibt Schaufenster fuer jeden authentifizierten Benutzer") und
  haette das neue Sperr-Badge fuer USER nie sichtbar gemacht. isAdmin
  gated jetzt nur noch die Aktivieren/Deaktivieren-Aktion (canManage),
  nicht mehr die gesamte Seite
- bestehende Marketplace-Tests auf einaufrufiges Catalog-Mock
  umgestellt, "access-denied fuer non-admin"-Test durch "Karten
  sichtbar, aber ohne Manage-Button" ersetzt
2026-08-04 19:49:46 +02:00
schalli 43c7fa200b feat(15-08): serverseitige Modulsperre mit 403-Seite
- checkModuleAccess (module-access-actions.ts) fragt GET /modules/active
  serverseitig ab, Cookie-Weiterleitung nach fetchCurrentUser-Muster,
  schliesst im Zweifel (fehlendes Cookie, nicht-ok, Fehler -> false)
- page.tsx zur async Server Component umgebaut, rendert bei fehlender
  Freigabe das 403-Markup direkt (kein notFound(), kein Redirect, D-07)
- bisheriger Client-Inhalt (Whitelist-Pruefung, Nicht-gefunden-Zustand,
  Ruecknavigation) unveraendert nach module-shell.tsx ausgelagert
- 5 Tests in module-access.test.tsx: 403-Zustand, Shell-Rendering,
  fehlendes Cookie, nicht-ok-Antwort, unregistrierter Slug trotz Zugriff
2026-08-04 19:41:25 +02:00
schalli 050d070340 feat(15-07): Benutzer-Detail mit geerbten Rechten und Direkt-Freigaben
- Neue UserAccessModal.tsx: laedt einmal GET /module-grants/users/:userId
  und rendert daraus zwei Abschnitte -- Gruppenmitgliedschaften (read-only
  Chip-Liste, dedupliziert aus allen viaGroups-Namen; Bearbeitung bleibt
  ausschliesslich unter /admin/groups, D-16) und Modul-Zugriff (Modul |
  erbende Gruppen als Chips oder "–" | Direkt-Checkbox)
- Direkt-Checkbox verhaelt sich identisch zur Matrix-Zelle: optimistisches
  Toggle via POST/DELETE /module-grants mit moduleId+userId, Rollback samt
  sichtbarer Fehlermeldung bei Fehlschlag (T-15-25), aria-label pro Zeile
  aus admin.users.grants.directCheckboxLabel
- admin/users/page.tsx: vierter Aktionsbutton "Details" je Zeile oeffnet
  das Modal
- user-access-modal.test.tsx: 5 Tests (Chip-Liste + Leerzustand, Modultabelle
  mit geerbtem/nicht-geerbtem Modul, Rollback bei Fehler, Hinweistext ohne
  aktive Module, aria-label je Checkbox)
2026-08-04 19:25:15 +02:00
schalli 3cd6d997cf feat(15-07): Aktivierungsdialog mit drei Aktionen in /admin/modules
- Neue ActivateModuleDialog.tsx: Abbrechen / "Spaeter konfigurieren" (nur
  POST /modules/:id/activate) / "Sofort freigeben" (POST .../activate
  gefolgt von POST /module-grants fuer die als Standard markierte Gruppe) --
  zwei getrennte Aufrufe, kein neuer kombinierter Endpoint (D-10)
- Laedt GET /groups beim Oeffnen; ohne markierte Standardgruppe ist "Sofort
  freigeben" disabled mit Hinweistext (D-13)
- admin/modules/page.tsx: toggleModule-Klick verzweigt -- Deaktivierung
  bleibt direkt, Aktivierung oeffnet den Dialog statt sofort zu aktivieren;
  Erfolg aktualisiert Aktivierungs-Map + Sidebar-Refresh wie bisher
- grants-matrix.test.tsx erweitert um 3 Tests fuer den Dialog (alle drei
  Buttons vorhanden, Sofort-freigeben deaktiviert ohne Standardgruppe,
  Aufrufreihenfolge activate->grant)
2026-08-04 19:21:25 +02:00
schalli 6b2a6f1ce4 feat(15-07): Freigabe-Matrix Module x Gruppen unter /admin/modules/grants
- Neue Client-Komponente admin/modules/grants/page.tsx: laedt GET /module-grants/matrix
  einmal, rendert Module x Gruppen mit sticky erster Spalte/Kopfzeile, Kategorie-
  Gruppierung, Suchfeld und Admin-Bypass-Fussnote (D-03/D-15, PERM-03)
- Jede Zelle togglet sofort optimistisch (POST/DELETE /module-grants); Fehlschlag
  springt die Checkbox zurueck und zeigt die Fehlermeldung im bestehenden error-Div
  (T-15-25) -- identisches Muster zu AdminModulesPage.toggleModule
- aria-label pro Checkbox aus admin.groups.grants.matrixCheckboxLabel beschreibt die
  bevorstehende Aktion (freigeben/entziehen), nicht den aktuellen Zustand
- admin/modules/page.tsx: neuer Header-Button "Freigaben-Matrix" verlinkt auf die
  Unterseite, kein siebter Sidebar-Eintrag
- grants-matrix.test.tsx: 5 Tests (befuellte Matrix, leerer Zustand, Rollback bei
  Fehler, Suchfilter, aria-label je Checkbox)
2026-08-04 19:18:30 +02:00
schalli 4985e43412 feat(15-06): group member management + delete dialog with concrete impact numbers
- GroupMembersModal.tsx: chip list of current members with source badge
  (MANUAL/LDAP); LDAP-sourced chips carry a disabled remove button with a
  "managed via AD sync" tooltip (D-19) instead of an active one; second
  section adds manual members via GET /users + POST /groups/:id/members,
  already-member candidates shown disabled (upsert on the API is
  folgenlos, no special-case needed)
- DeleteGroupDialog.tsx: loads GET /groups/:id/impact and interpolates
  memberCount/grantCount into the confirmation text (D-17); deliberately
  breaks from the project's silent-delete-failure precedent -- stays open
  and shows a visible error on a failed DELETE, since a silent failure
  here would leave an admin believing a group (and its grants) is gone
  while it still grants access (T-15-24)
- page.tsx: wires both dialogs in, refetches the group list after any
  member/delete mutation so member counts and badges stay current
- groups-page.test.tsx: disabled-vs-active remove button by membership
  source, delete text shows both numbers, visible error + dialog stays
  open on failed delete
2026-08-04 19:02:11 +02:00
schalli c3ba1f74ea feat(15-06): /admin/groups table + create/rename modal with AD radio-select binding
- page.tsx: sixth admin route, table (Name/AD-Bindung/Standardgruppe/
  Mitglieder/Aktionen), empty state matching AdminUsersPage's noUsers
  pattern, optimistic default-group star toggle (PATCH /groups/:id
  isDefault) with rollback + visible error div on failure, full refetch
  on success since setting one group default unsets all others server-side
  (D-13 transaction)
- GroupFormModal.tsx: create/rename dialog; AD binding section reuses
  GET /ldap/groups (D-18) with a radio list (D-05: exactly one AD group
  per Tessera group) instead of the LDAP page's checkbox multi-select;
  visible discoverError/noResults states instead of a silent-empty list
  (UI-SPEC backstop); create-with-binding does POST then a second PATCH
  since CreateGroupDto only accepts `name`
- groups-page.test.tsx: empty state, populated table, star-toggle
  optimistic PATCH + rollback-on-failure
2026-08-04 18:57:43 +02:00
schalli 90dc3981d5 feat(15-06): phase-wide i18n keys + sixth admin nav entry for groups
- de.json/en.json: admin.groups.* (incl. ldapBind, members, deleteConfirm,
  grants sub-namespaces), admin.users.grants.*, adminModules.grantsLink +
  grants.* + activationDialog.*, modules.accessDenied.*, marketplace
  statusNoAccess/toastNoAccess, header.admin.groups -- covers this plan's
  /admin/groups surface plus the Wave 4 surfaces (permission matrix,
  user-detail grants, activation dialog, 403 page, marketplace badge) so
  15-07/15-08 can run in parallel without touching the translation files
- admin-sidebar.tsx: sixth nav entry "Gruppen" -> /admin/groups with a
  roster/list icon (Lucide list glyph, distinct from the users icon)
2026-08-04 18:50:28 +02:00
schalli 1c32543f58 feat(15-03): GET /modules/catalog — beide Statusflags in einer Antwort
- ModuleAccessService.getCatalogFlags(tenantId, userId, role) liefert je
  aktivem Modul isActiveForTenant + hasAccess in einer Auflösung
- ModuleRegistryController.findCatalog (GET /modules/catalog), erreichbar
  für jeden authentifizierten Benutzer wie GET /modules (D-08)
- ADMIN/SUPER_ADMIN: hasAccess immer wahr für aktive Module (D-03)
- 4 neue Tests für getCatalogFlags
2026-08-04 18:41:23 +02:00
schalli 072fb7f62f feat(15-03): ModuleGrantsController und Einbindung in GroupsModule
- GET /module-grants/matrix, GET /module-grants/users/:userId,
  POST /module-grants, DELETE /module-grants — alle vier rollengeschützt
  (RolesGuard + Roles ADMIN/SUPER_ADMIN)
- matrix vor users/:userId deklariert (Beschattungsfehler-Vermeidung)
- GroupsModule bindet ModuleGrantsController/-Service ein; kein Import
  von ModuleRegistryModule nötig, da der Service nur PrismaService braucht
2026-08-04 18:41:17 +02:00
schalli 5e256db01d feat(15-03): ModuleGrantsService — Freigaben setzen/entziehen mit Mandanten-Gegenprüfung
- assertTargetBelongsToTenant prüft groupId/userId aus dem Request-Body
  gegen tenantId aus dem JWT (T-15-01), vor jedem Grant-Insert
- grant: Entweder-oder-Regel (D-04), aktive TenantModuleActivation (D-02),
  P2002 als Erfolg (Doppelklick-Schutz)
- getMatrix (D-15) und getUserAccess (D-16) für Matrix-Seite und
  Benutzer-Detail, jeweils sortiert und mandantengescoped
- 20 Tests inkl. adjacency/empty/ordering/idempotency/concurrency
2026-08-04 18:41:12 +02:00
schalli 614de2815a feat(15-04): AD-Gruppenmitgliedschafts-Abgleich im bestehenden LDAP-Sync
- LdapService.syncGroupMembershipsForTenant (neu, privat): pro AD-gebundener
  Group (ldapDn gesetzt) ein memberOf-Reverse-Query je Base-DN, nie ein
  Attribut-Lesen (Range-Retrieval-Pitfall). GroupMembership(source: LDAP)
  wird per createMany/skipDuplicates angelegt (lässt bestehende MANUAL-Zeilen
  unangetastet, D-19/D-20) und per deleteMany(source: 'LDAP', notIn: [...])
  bereinigt. Jede Gruppe läuft in eigenem try/catch, ein Fehler landet als
  "Gruppe <name>: <message>" in result.errors, die Schleife läuft weiter.
- Aufruf in syncUsersForTenant nach der Deaktivierungsschleife (Schritt 5)
  und vor lastSyncAt (Schritt 6) — hinter dem bestehenden Base-DN-No-Op-Wächter,
  kein separater Job, kein zweiter Button (D-21).
- LdapSyncResult um groupMembershipsAdded/groupMembershipsRemoved erweitert.
- ldap.service.spec.ts: neuer describe-Block mit 13 Tests (adjacency, empty,
  encoding, ordering, idempotency, concurrency/backstop) plus Anpassung der
  drei bestehenden Prisma-Fixtures und einer Ergebnis-Assertion an die
  erweiterte LdapSyncResult-Form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:50:42 +02:00
schalli 0ff46acd75 feat(15-05): getWidgets filters via ModuleAccessService (D-22, PERM-07)
- DashboardModule imports ModuleRegistryModule to inject ModuleAccessService
- getWidgets(userId, tenantId, role) runs the existing findMany unchanged
  first, then calls getAccessibleModuleIds exactly once — only if a loaded
  widget's type is in WIDGET_MODULE_MAP (currently always empty, so no
  lookup runs today); unresolved module slugs fail closed
- DashboardController.getWidgets forwards tenantId + role from the JWT
- dashboard.service.spec.ts (8 tests, TDD-GREEN): covers every <behavior>
  case incl. D-03 ADMIN bypass, adjacency/empty/ordering/idempotency, and
  fail-closed on an unresolved Module slug
- pnpm --filter @tessera/api test: 457/457 green; type-check clean
- manual e2e against local API + DB container: empty WIDGET_MODULE_MAP
  leaves an existing user's widget count unchanged (2/2 clock+search
  survived the filter), throwaway verification user/rows removed after
2026-08-04 15:37:28 +02:00
schalli d0ff6f0bc0 test(15-05): add failing test for module-filtered getWidgets
- covers every <behavior> case from 15-05-PLAN.md task 2, including the
  adjacency/empty/ordering/idempotency edge-probe categories and the
  fail-closed unresolved-slug case
- RED confirmed: 4/8 fail against the current 1-arg getWidgets(userId)
2026-08-04 15:31:35 +02:00
schalli 954cd6e171 feat(15-05): static widget-to-module registration table
- WIDGET_MODULE_MAP + getModuleSlugForWidgetType (apps/api/src/dashboard/widget-module-map.ts)
- table intentionally empty at end of phase: all 8 existing widget types are module-free platform widgets (D-22)
- code constant chosen over a WidgetInstance schema column — no migration for a field empty on every row
2026-08-04 15:30:51 +02:00
schalli 33838dde39 feat(15-02): automatische Standardgruppen-Mitgliedschaft an genau einem Ort
- UserService.create ruft nach der Anlage GroupsService.addUserToDefaultGroup
  auf (D-11/D-12) — einziger Erzeugungspunkt für Benutzer, erbt LdapService
  ohne eigene Kopie der Regel
- try/catch mit Logger: gescheiterte Gruppenzuordnung bricht weder die
  Benutzeranlage noch einen LDAP-Sync-Lauf ab (T-15-14)
- UserModule importiert GroupsModule, keine Zirkularität
- 4 Tests in user.service.spec.ts; ldap.service.ts unverändert
2026-08-04 15:23:01 +02:00
schalli 69494d7549 feat(15-02): GroupsModule — CRUD für Gruppen, Mitgliedschaften und Löschauswirkung
- GroupsService: listForTenant/create/update/remove/getImpact/listMembers/addMembers/removeMember/addUserToDefaultGroup, jede Query tenantId-gescoped (T-15-02/T-15-12)
- isDefault:true läuft in einer Transaktion (updateMany+update), D-13
- getImpact liefert { memberCount, grantCount } für den Löschdialog (D-17)
- removeMember beschränkt sich auf source:MANUAL (D-19)
- GroupsController: 8 rollengeschützte Routen unter /groups
- 19 Tests in groups.service.spec.ts, hand-rolled In-Memory-Fake
2026-08-04 15:21:41 +02:00
schalli 92e8eaffa5 feat(15-01): RLS policies for Group/GroupMembership/ModuleGrant (T-15-11)
- Second, deliberately separate migration (pure hand-SQL, no Prisma-
  generated DDL): ENABLE/FORCE ROW LEVEL SECURITY plus a
  tenant_isolation_policy for each of the three new tables, following
  the pattern of 20260618112133_rls_policies (Auth-Kerntabellen)
  rather than the RLS-exempt Tender* app-layer tables
- Group/ModuleGrant compare tenantId directly against
  current_tenant_id(); GroupMembership has no own tenantId and follows
  the PasswordResetToken join pattern (groupId IN (SELECT id FROM
  Group WHERE tenantId = ...))
- migration-sql.spec.ts extended with a second describe block covering
  both migration files (6x ROW LEVEL SECURITY, 3x CREATE POLICY, the
  join vs. direct-comparison shape)
- Re-ran the Task-2 end-to-end proof after applying this migration:
  identical result (USER without grant 403 + empty list, USER with
  direct grant 200 + slug present, ADMIN 200) — the app's DB role
  (tessera) is a Postgres superuser with rolbypassrls=true, so it
  bypasses RLS as documented as an acceptable outcome by the plan;
  RLS remains the defense-in-depth net for any future non-superuser
  connection
2026-08-04 15:11:33 +02:00
schalli 9a4ba8a33c feat(15-01): ModuleAccessService as single source of truth for module access (D-01)
- ModuleAccessService.getAccessibleModuleIds(tenantId, userId, role):
  ADMIN/SUPER_ADMIN bypass (D-03) via one query, otherwise a single
  Promise.all of direct + group ModuleGrant lookups intersected against
  active TenantModuleActivation (D-02) — no N+1 over the user's groups
- findAccessibleModules() adds the name-asc sort for stable sidebar order
- ModuleGuard now resolves userId/role from request.user (JWT-sourced,
  never body/params) and calls getAccessibleModuleIds instead of the
  tenant-only isModuleActive check; caches the result on
  request.moduleAccessIds for same-request reuse (D-09, no cross-request
  caching)
- ModuleRegistryController.findActive delegates to
  ModuleAccessService.findAccessibleModules instead of
  findActiveForTenant, which stays untouched for Plan 15-03's
  tenant-wide marketplace catalog
- ModuleRegistryModule exports ModuleAccessService for Plan 15-03/15-05
- module-access.service.spec.ts / module.guard.spec.ts cover every case
  in the plan's <behavior> list with a hand-rolled Prisma mock
- End-to-end verified against the running local API: a USER without a
  grant gets 403 on a @UseModule-protected endpoint and an empty
  /modules/active list; the same USER with a direct grant gets 200 plus
  the slug in the list; an ADMIN without any grant also gets 200 (D-03)
2026-08-04 15:09:10 +02:00
schalli c5c704bae9 feat(15-01): Group/GroupMembership/ModuleGrant schema + D-06 backfill migration
- Group/GroupMembership/ModuleGrant models plus MembershipSource enum
  (D-05), placed under TenantModuleActivation with German block comment
- Hand-SQL appended to the generated migration: partial unique index for
  one default group per tenant (D-13), CHECK num_nonnulls xor-constraint
  plus two partial unique indexes for ModuleGrant (D-04), and the D-06
  backfill (Group -> GroupMembership -> ModuleGrant, each INSERT guarded
  by WHERE NOT EXISTS for idempotent re-runs on `prisma migrate deploy`)
- apps/api/src/groups/migration-sql.spec.ts verifies the hand-SQL by
  reading migration.sql directly, no DB required
- Verified against the local DB: default-group count matches tenant
  count, membership/grant counts match existing users/active
  activations, and the XOR constraint rejects a group+user-less insert
2026-08-04 15:03:48 +02:00
schalli 96be7e168b feat(260729-d3k): multi-line Base-DN textarea + reworked scope i18n
- Base-DN admin field is now a multi-line textarea (one DN per line),
  value stays a single newline-separated string, no schema change
- baseDnHint key added (de/en) explaining the Base-DN(s) sync scope
- groupFilter.description/emptyMeansAll reworded: group filter is an
  optional extra restriction; empty selection means all users under
  the base DN(s) are synced (drops the old "nothing is synced"
  framing)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 09:37:51 +02:00
schalli 5cbd530a87 feat(260729-d3k): multi-base LDAP sync scope + re-keyed no-op guard
- parseBaseDns() splits the newline-separated baseDn field into a list
- syncUsersForTenant no-op guard re-keyed on empty parsed base-DN list
  (was empty groupFilterDns) — the sole condition that skips search +
  the deactivation loop, preventing mass-deactivation on an
  unconfigured config
- collectSearchEntries/listGroups/searchUsers loop every base DN and
  merge/dedupe results by entry dn
- empty groupFilterDns is no longer a no-op: it now performs a normal
  multi-base search with no memberOf restriction
- groupFilterDns ou= entries stay additional search bases; group DNs
  become an optional memberOf constraint applied to every base search
- spec: replaced empty-groupFilterDns no-op test with empty-base-DN
  no-op test, added multi-base merge/dedup test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 09:36:55 +02:00
schalli 63a07abb47 feat(260728-lih): default LDAP create-form syncIntervalMin to 0 + reword copy
- New-config create form now defaults syncIntervalMin to 0 (matches
  backend default, auto-sync off by default)
- de+en groupFilter.description + emptyMeansAll reworded: empty
  selection now says "nothing is synced" instead of "imports everyone
  under the base DN" (matches the backend semantic change)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 15:42:10 +02:00
schalli 57bc7f96b3 feat(260728-lih): make LDAP sync strictly selective (empty selection = no-op)
- collectSearchEntries() returns [] on empty/undefined groupFilterDns
  instead of scanning the whole baseDn subtree
- syncUsersForTenant() early-returns an empty successful result before
  any LDAP search or the deactivation loop when groupFilterDns is empty,
  so an empty selection can never mass-deactivate existing LDAP users
- Updated exclude-list tests to use a non-empty groupFilterDns; added a
  dedicated no-op test proving empty selection performs zero search/
  create/update/deactivate operations

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 15:41:33 +02:00
schalli c54e424c05 feat(260728-lih): default LDAP syncIntervalMin to 0 (auto-sync off)
- LdapConfig.syncIntervalMin default changed 60 -> 0
- New migration sets column DEFAULT only, no data rewrite
- isActive @default(true) left unchanged (gates LDAP login only)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 15:40:28 +02:00
schalli ae85b91468 fix(admin): refresh sidebar after module toggle on admin page
Tessera CI/CD / Lint & Type Check (push) Successful in 52s
Tessera CI/CD / Tests (push) Successful in 54s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m25s
Activating/deactivating a module from the admin modules page updated only
the page's local state — the sidebar (which refetches its active-module
list on the shared marketplace-store sidebarRefreshKey signal) was never
bumped, so the module's nav link only appeared/disappeared after a manual
full page reload. The marketplace pages already call bumpSidebarRefresh()
after a toggle; mirror that here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 12:10:39 +02:00
schalli bdaf154f66 feat(260723-lvg): add "Jetzt abrufen" manual poll button to tender radar
PollNowButton sits next to the settings gear in the tender-radar header,
mirrors the DKV spinner/disabled UX, and bumps a refreshKey on success to
refetch ResultsList without touching any filter. Failure surfaces an
i18n error (de/en parity). pollNow() client hits POST
/modules/tender-radar/poll-now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:54:43 +02:00
schalli 7502f97e85 feat(260723-lvg): add admin-gated POST /poll-now endpoint for tender radar
Manual "Jetzt abrufen" trigger delegates to
TenderIngestionService.pollDueSources() — the same fan-out tick the
scheduler cron runs. Gated to ADMIN/SUPER_ADMIN (T-lvg-01, DoS) and
declared before @Get(':id') per the established route-order convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:52:44 +02:00