Documents the GroupFormModal three-state rebuild (D-03/D-04/D-07), the
groups-list internalName fallback, and the ldapBind i18n cleanup for
Phase 16 Plan 4.
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.
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).
- 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).
- 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.
- 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
- 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)
- 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)
- 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
- 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)
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.
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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)
- 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)
- 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)
- 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