Files

36 KiB

Phase 16: AD-Gruppen-Synchronisation - Pattern Map

Mapped: 2026-08-06 Files analyzed: 13 Analogs found: 13 / 13

File Classification

New/Modified File Role Data Flow Closest Analog Match Quality
apps/api/prisma/schema.prisma (Group.internalName, Group.ldapObjectGuid) model CRUD apps/api/prisma/schema.prisma:137-152 (Group-Modell selbst) exact
apps/api/prisma/migrations/<neu>_add_group_internal_name_and_object_guid/migration.sql migration batch 20260804130130_add_groups_and_module_grants/migration.sql (Spalten/Index) + 20260804130918_groups_rls_policies/migration.sql (RLS-Referenz, unverändert zu übernehmen) exact
apps/api/src/ldap/ldap.service.ts — importGroupsByDn() service batch (LDAP → DB) importUsersByDn() (Zeilen 447-547) exact
apps/api/src/ldap/ldap.service.ts — syncBoundGroupsForTenant() service batch (LDAP → DB, reconciliation) syncGroupMembershipsForTenant() (Zeilen 839-935) exact
apps/api/src/ldap/ldap.controller.ts — POST /ldap/groups/import controller request-response POST /ldap/users/import (importUsers(), Zeilen 215-245) exact
apps/api/src/ldap/dto/ldap-config.dto.ts — ImportGroupsDto model (DTO) request-response ImportUsersDto (Zeilen 109-114) exact
apps/api/src/groups/groups.service.ts — internalName-Handling, Name-Lock, Default-Handoff service CRUD update() (Zeilen 99-154), findOwned() (78-86), ensureDefaultGroup() (277-328) exact
apps/api/src/groups/module-grants.service.ts (Zeilen ~249/264) service transform (Projektion) dieselbe Datei, Zeilen 244-267 (groupNamesByModule/groups-Projektion) exact
apps/api/src/ldap/ldap.service.spec.ts (neue describe-Blöcke) test batch bestehende describe-Blöcke für syncGroupMembershipsForTenant/D-21 im selben File exact
apps/api/src/groups/groups.service.spec.ts (Mock-Anpassung findFirst) test CRUD bestehende Mock-Struktur für GroupsService.update/findOwned im selben File exact
apps/web/src/app/(portal)/admin/ldap/page.tsx — Sektion "AD-Gruppen importieren" component request-response (discover + import) Sektion 2.55 "Einzelbenutzer suchen & importieren" (Zeilen 808-909) + Sektion 2.5 (684-806) für das reine Auswahllisten-Markup exact
apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx component request-response (form) dieselbe Datei — bestehendes Namensfeld (136-145) und saveError-Banner (229-233) als Vorbild für den neuen, jetzt konsequent genutzten Fehlerzustand exact
apps/web/src/app/(portal)/admin/groups/page.tsx (Namensspalte) component CRUD (Anzeige) dieselbe Datei, Namens-/Badge-Zellen (Zeilen 179-190) exact
apps/web/src/app/(portal)/admin/modules/grants/page.tsx (Zeilen ~206-213) component transform (Anzeige) dieselbe Datei, th-Block (Zeilen 206-213) exact
apps/web/src/messages/de.json / en.json config — bestehende admin.ldap.*/admin.groups.*-Blöcke (de.json Zeilen 334-398) exact

Pattern Assignments

apps/api/prisma/schema.prisma — Group.internalName, Group.ldapObjectGuid (model, CRUD)

Analog: das Group-Modell selbst, apps/api/prisma/schema.prisma:137-152

model Group {
  id          String            @id @default(uuid())
  tenantId    String
  tenant      Tenant            @relation(fields: [tenantId], references: [id])
  name        String
  ldapDn      String?           // optionale AD-Bindung (D-05)
  isDefault   Boolean           @default(false) // D-13 — genau eine pro Mandant, DB-erzwungen (Hand-SQL)
  createdAt   DateTime          @default(now())
  updatedAt   DateTime          @updatedAt
  memberships GroupMembership[]
  grants      ModuleGrant[]

  @@unique([tenantId, name]) // Gruppennamen sind pro Mandant eindeutig
  @@unique([tenantId, ldapDn]) // NULL ist in Postgres je Zeile distinct — mehrere ungebundene Gruppen sind erlaubt
  @@index([tenantId])
}

Neue Spalten folgen exakt demselben Kommentar-/Nullable-Stil wie ldapDn:

  internalName    String?           // D-04: vom Sync nie berührt, überschreibt die Anzeige wenn gesetzt
  ldapObjectGuid  String?           // D-03/SC-3: hex-kodierter objectGUID, rename-stabiler Sync-Match-Key (ldapDn bleibt Anzeige-/Debug-Feld)

  @@unique([tenantId, ldapObjectGuid]) // gleiches NULL-ist-distinct-Muster wie @@unique([tenantId, ldapDn])

internalName trägt bewusst keinen eigenen Unique-Index — zwei Gruppen dürfen denselben internen Namen tragen (keine Entscheidung dagegen in D-04).


apps/api/prisma/migrations/<neu>_... (migration, batch)

Analog 1 (Spalten/Index-Stil): apps/api/prisma/migrations/20260804130130_add_groups_and_module_grants/migration.sql, Zeilen 1-46:

CREATE TABLE "Group" (
    "id" TEXT NOT NULL,
    "tenantId" TEXT NOT NULL,
    "name" TEXT NOT NULL,
    "ldapDn" TEXT,
    ...
);
CREATE UNIQUE INDEX "Group_tenantId_ldapDn_key" ON "Group"("tenantId", "ldapDn");

→ Analoges Muster für die neue Migration: ALTER TABLE "Group" ADD COLUMN "internalName" TEXT, ADD COLUMN "ldapObjectGuid" TEXT; + CREATE UNIQUE INDEX "Group_tenantId_ldapObjectGuid_key" ON "Group"("tenantId", "ldapObjectGuid");

Analog 2 (RLS-Referenz, NICHT erneut ausführen, nur als Kontext): apps/api/prisma/migrations/20260804130918_groups_rls_policies/migration.sql, Zeilen 15-20 — bestätigt, dass Group bereits FORCE ROW LEVEL SECURITY trägt; die neue Migration braucht keine eigene RLS-Anweisung, die Policy greift automatisch auf die neuen Spalten mit.

Lokaler Migrationsbefehl (aus RESEARCH.md, verifiziertes Muster):

docker start tessera-ctl-db-1
DB_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1)
cd apps/api && DATABASE_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/tessera" \
  pnpm exec prisma migrate dev --name add_group_internal_name_and_object_guid

apps/api/src/ldap/ldap.service.ts — importGroupsByDn() (service, batch)

Analog: importUsersByDn(), Zeilen 447-547 (vollständig gelesen)

Imports (Datei-Kopf, Zeilen 1-5):

import { Injectable, Logger } from '@nestjs/common';
import { Client, Entry } from 'ldapts';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { UserService } from '../user/user.service';

Signatur- und Result-Muster:

async importUsersByDn(
  config: LdapConfigData,
  tenantId: string,
  dns: string[],
): Promise<LdapUserImportResult> {
  const result: LdapUserImportResult = { created: 0, updated: 0, skipped: 0, errors: [] };
  const client = new Client(this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized));
  try {
    await this.bind(client, config.bindDn, config.bindPassword);
    for (const dn of dns) {
      try {
        // Base-scoped lookup of exactly this DN.
        const { searchEntries } = await client.search(dn, { filter: '(objectClass=person)', attributes, scope: 'base' });
        if (searchEntries.length === 0) { result.errors.push(`${dn}: not found`); continue; }
        // ... map, dedup by existing ldapDn/username, create or skip ...
      } catch (entryError: unknown) {
        const msg = entryError instanceof Error ? entryError.message : 'Unknown error importing entry';
        result.errors.push(`${dn}: ${msg}`);
      }
    }
  } finally {
    try { await client.unbind(); } catch { /* Ignore unbind errors */ }
  }
  return result;
}

importGroupsByDn() folgt demselben Gerüst, aber mit scope: 'base'-Suche nach (objectClass=group), Extraktion von cn+objectGUID (per explicitBufferAttributes: ['objectGUID'], siehe RESEARCH.md Pattern 2), und einem prisma.group.create()/GroupsService-Aufruf statt userService.create(). Für die Idempotenz-/Dedup-Prüfung ist searchUsers()s Existenz-Query das bessere Vorbild (siehe unten), nicht importUsersByDn selbst.

Idempotenz-/alreadyImported-Muster (Analog: searchUsers(), Zeilen 404-430):

const dns = entries.map((e) => e.dn);
const existing = await this.prisma.user.findMany({
  where: { tenantId, OR: [{ ldapDn: { in: dns } }, { username: { in: usernames } }] },
  select: { ldapDn: true, username: true },
});
const dnSet = new Set(existing.map((u) => u.ldapDn).filter((d): d is string => !!d));
return entries.map((e) => ({ ...e, alreadyImported: dnSet.has(e.dn) || ... }));

→ Gruppen-Äquivalent laut RESEARCH.md: Group.findMany({ where: { tenantId, ldapObjectGuid: { in: guids } } }).

Binäres Attribut lesen (RESEARCH.md Pattern 2, Quelle: installierter ldapts-Bibliothekscode):

const { searchEntries } = await client.search(baseDn, {
  filter: '(objectClass=group)',
  attributes: ['cn', 'dn', 'objectGUID'],
  explicitBufferAttributes: ['objectGUID'],
  scope: 'sub',
});
const guidHex = (entry['objectGUID'] as Buffer).toString('hex');

Pro-Gruppe-Fehlersammlung ohne Lauf-Abbruch (Analog: syncGroupMembershipsForTenant-Loop, Zeilen 860-934, siehe unten) — dasselbe try/catch-pro-Element-Muster gilt auch hier, insbesondere um eine ConflictException aus einer Namenskollision (Pitfall 4 in RESEARCH.md) in result.errors zu sammeln statt zu werfen.


apps/api/src/ldap/ldap.service.ts — syncBoundGroupsForTenant() (service, batch/reconciliation)

Analog: syncGroupMembershipsForTenant(), Zeilen 839-935 (vollständig gelesen) — MUSS laut D-21-Reihenfolge vor diesem bestehenden Schritt in syncUsersForTenant() eingehängt werden (aktuell Aufruf bei Zeile 708-715).

Bestehender Aufrufpunkt in syncUsersForTenant() (Zeilen 705-715):

// 5b. D-21: reconcile GroupMembership rows for every AD-bound Group in
// this same run — no separate sync job, no second button. Runs behind
// the Base-DN no-op guard above, exactly like the rest of this method.
await this.syncGroupMembershipsForTenant(
  client, config, sanitizedFilter, attributes, tenantId, result,
);

→ Neuer Schritt 5a unmittelbar davor einfügen: await this.syncBoundGroupsForTenant(client, config, tenantId, result); — Reihenfolge ist die zentrale Pitfall-1-Vorgabe aus RESEARCH.md.

Bound-Groups-Query + Pro-Gruppe-try/catch (Zeilen 839-864, 927-934):

private async syncGroupMembershipsForTenant(
  client: Client, config: LdapConfigData, sanitizedFilter: string,
  attributes: string[], tenantId: string, result: LdapSyncResult,
): Promise<void> {
  const tenantPrisma = forTenant(this.prisma, tenantId) as any;
  const boundGroups: { id: string; name: string; ldapDn: string | null }[] =
    await tenantPrisma.group.findMany({
      where: { tenantId, ldapDn: { not: null } },
      select: { id: true, name: true, ldapDn: true },
    });
  if (boundGroups.length === 0) { return; }

  for (const group of boundGroups) {
    try {
      // ... AD-Suche, Reconciliation ...
    } catch (groupError: unknown) {
      const msg = groupError instanceof Error ? groupError.message : 'Unknown error syncing group';
      result.errors.push(`Gruppe ${group.name}: ${msg}`);
    }
  }
}

syncBoundGroupsForTenant() fragt stattdessen where: { tenantId, ldapObjectGuid: { not: null } } ab (Identität statt ldapDn, siehe Anti-Pattern in RESEARCH.md), sucht pro Gruppe per binärem objectGUID-Filter (RESEARCH.md Pattern 3), und verzweigt: Treffer mit geändertem cn → Group.update({ name, ldapDn }); kein Treffer → D-06-Handoff dann group.delete().

Binärer Existenz-Sweep-Filter (RESEARCH.md Pattern 3, [ASSUMED], vor Prod-Schärfung gegen echtes AD zu prüfen):

function escapeLdapFilterBuffer(buf: Buffer): string {
  return Array.from(buf).map((b) => '\\' + b.toString(16).padStart(2, '0')).join('');
}
const filter = `(objectGUID=${escapeLdapFilterBuffer(storedGuidBuffer)})`;

Default-Marker-Handoff-Transaktion (Analog: GroupsService.update(), apps/api/src/groups/groups.service.ts:123-136):

if (data.isDefault === true) {
  const [, updated] = await this.prisma.$transaction([
    this.prisma.group.updateMany({ where: { tenantId, isDefault: true }, data: { isDefault: false } }),
    this.prisma.group.update({ where: { id }, data: { ...updateData, isDefault: true } }),
  ]);
  return updated;
}

→ D-06-Handoff braucht eine NEUE Methode (z.B. GroupsService.reassignDefaultBeforeDelete(tenantId, groupId)), die die Zielgruppe selbst bestimmt (zuerst "Alle Benutzer" per findFirst({ where: { tenantId, name: 'Alle Benutzer' } }), sonst irgendeine andere per findFirst({ where: { tenantId, id: { not: groupId } } })), dann dieselbe Zwei-Schritt-Transaktionsform anwendet, BEVOR group.delete() läuft. Nach jeder Löschung ensureDefaultGroup(tenantId) als Fallback aufrufen (Pitfall 5), siehe apps/api/src/groups/groups.service.ts:277-328.

Escaping-Baustein (bestehend, zwingend wiederverwenden für String-Filter, z.B. beim cn-Vergleich):

static escapeLdapFilterValue(value: string): string {
  return value
    .replace(/\\/g, '\\5c')
    .replace(/\*/g, '\\2a')
    .replace(/\(/g, '\\28')
    .replace(/\)/g, '\\29')
    .replace(/\x00/g, '\\00');
}

(apps/api/src/ldap/ldap.service.ts:977-984)

Result-Interface wächst additiv (Zeilen 10-20):

export interface LdapSyncResult {
  created: number;
  updated: number;
  deactivated: number;
  groupMembershipsAdded: number;
  groupMembershipsRemoved: number;
  errors: string[];
}

→ neue Felder groupsImported, groupsRenamed, groupsDeleted, defaultMarkerMoved (alle number) additiv anhängen, exakt wie D-21 es bereits für groupMembershipsAdded/groupMembershipsRemoved getan hat.


apps/api/src/ldap/ldap.controller.ts — POST /ldap/groups/import (controller, request-response)

Analog: POST /ldap/users/import (importUsers()), Zeilen 210-245:

/**
 * POST /ldap/users/import - Import specific AD users by DN (from the user
 * search). Idempotent and non-deactivating: existing users are skipped, so
 * a later department/group sync never creates a duplicate.
 */
@Post('users/import')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async importUsers(@Req() req: any, @Body() dto: ImportUsersDto) {
  const tenantId = req.tenantId;
  if (!tenantId) {
    throw new BadRequestException('No tenant context');
  }

  const config = await this.ldapConfigService.getConfig(tenantId);
  if (!config) {
    throw new NotFoundException('No LDAP config found for this tenant');
  }

  return this.ldapService.importUsersByDn(
    { id: config.id, tenantId: config.tenantId, serverUrl: config.serverUrl, ... },
    tenantId,
    dto.dns,
  );
}

→ POST 'groups/import' mit @Roles(Role.ADMIN, Role.SUPER_ADMIN), identischem tenantId/config-Guard-Präfix, ruft ldapService.importGroupsByDn(config, tenantId, dto.dns).

Routen-Reihenfolge-Hinweis (Projektregel, siehe Memory project_nest_route_order): Der bestehende GET 'groups'-Endpoint (Zeile 158) ist bereits eine statische Route ohne :id-Segment — die neue POST 'groups/import'-Route hat kein Kollisionsrisiko mit einer :id-Route, da dieser Controller (anders als z.B. UsersController) keine @Get(':id')-Route auf Gruppen-Ebene definiert. Trotzdem: neue statische Routen (groups/import) vor jeder eventuell später hinzugefügten :id-Route platzieren.


apps/api/src/ldap/dto/ldap-config.dto.ts — ImportGroupsDto (model/DTO, request-response)

Analog: ImportUsersDto, Zeilen 109-114:

/**
 * DTO for POST /ldap/users/import — the DNs of AD users to import individually.
 */
export class ImportUsersDto {
  @IsArray()
  @ArrayNotEmpty()
  @IsString({ each: true })
  dns!: string[];
}

→ ImportGroupsDto ist strukturell identisch (dns!: string[]), ggf. mit demselben Docblock-Stil, nur auf Gruppen umbenannt.


apps/api/src/groups/groups.service.ts — internalName, Name-Lock, Default-Handoff (service, CRUD)

Analog: update() (Zeilen 99-154), findOwned() (78-86), create() (54-72), ensureDefaultGroup() (277-328) — alle vollständig gelesen.

Ownership-Check-Muster, zwingend für jeden neuen Codepfad mit groupId:

private async findOwned(tenantId: string, id: string) {
  const group = await this.prisma.group.findFirst({ where: { id, tenantId } });
  if (!group) {
    throw new NotFoundException(`Gruppe '${id}' nicht gefunden`);
  }
  return group;
}

Name-Lock-Durchsetzung (RESEARCH.md Open Question 1 — muss serverseitig, nicht nur im UI, erzwungen werden): in update() VOR dem bestehenden if (data.name !== undefined)-Block (Zeile 112) eine Prüfung einfügen, die data.name ablehnt, wenn die geladene Gruppe (findOwned) ldapObjectGuid gesetzt hat — Antwortform identisch zum bestehenden BadRequestException-Stil:

if (data.name !== undefined) {
  const trimmed = data.name.trim();
  if (!trimmed) {
    throw new BadRequestException('Gruppenname darf nicht leer sein');
  }
  updateData.name = trimmed;
}

internalName läuft als eigenständiges, IMMER erlaubtes Feld daneben — keine Sperre, da D-04 es gerade unabhängig vom Sync hält.

P2002-Übersetzung (Namenskollision, für Sync UND für internalName-Kollisionsfreiheit falls gewünscht):

} catch (err: any) {
  if (err?.code === 'P2002') {
    throw new ConflictException(
      `Eine Gruppe mit dem Namen '${updateData.name}' existiert bereits in diesem Mandanten`,
    );
  }
  throw err;
}

Der neue Sync-Code darf diese Exception laut Pitfall 4 (RESEARCH.md) NICHT ungefangen durchreichen — eigenes try/catch pro Gruppe, Nachricht in result.errors sammeln (siehe syncBoundGroupsForTenant-Pattern oben).

Anzeige-Select internalName ?? name: listForTenant() (Zeilen 28-45) projiziert aktuell name: g.name explizit — hier internalName: g.internalName ergänzen (rohes Feld ausliefern, Fallback-Entscheidung bleibt im Frontend bzw. wird analog zu module-grants.service.ts serverseitig aufgelöst, je nach Planungsentscheidung).

ensureDefaultGroup() als Fallback-Baustein für D-06/Pitfall 5:

async ensureDefaultGroup(tenantId: string) {
  const existingCount = await this.prisma.group.count({ where: { tenantId } });
  if (existingCount > 0) { return null; }
  try {
    return await this.prisma.$transaction(async (tx) => {
      const group = await tx.group.create({ data: { tenantId, name: 'Alle Benutzer', isDefault: true } });
      // ... Backfill Mitglieder + Grants ...
      return group;
    });
  } catch (err: any) {
    if (err?.code === 'P2002') { return null; }
    throw err;
  }
}

→ am Ende von syncBoundGroupsForTenant() (bzw. nach jeder Gruppen-Löschung) unbedingt aufrufen, damit ein Mandant nie ohne jede Gruppe dasteht.


apps/api/src/groups/module-grants.service.ts (Zeilen ~249/264) — internalName ?? name (service, transform)

Analog: dieselbe Datei, getUserAccess()-Ausschnitt, Zeilen 244-267 (vollständig gelesen):

const groupNamesByModule = new Map<string, string[]>();
for (const g of groupGrants as any[]) {
  if (!g.group) continue;
  const names = groupNamesByModule.get(g.moduleId) ?? [];
  names.push(g.group.name);                              // Zeile 249 — zu ändern in g.group.internalName ?? g.group.name
  groupNamesByModule.set(g.moduleId, names);
}
...
const groups = (memberships as any[])
  .filter((m) => m.group)
  .map((m) => ({
    id: m.group.id as string,
    name: m.group.name as string,                         // Zeile 264 — zu ändern in m.group.internalName ?? m.group.name
    source: m.source as string,
  }))
  .sort((a, b) => a.name.localeCompare(b.name));

Beide include: { group: ... }-Queries (Zeilen 224-227, 238-241) müssen internalName: true mit selektieren, sonst ist das Feld zur Laufzeit undefined.


apps/api/src/ldap/ldap.service.spec.ts (test, batch)

Analog: bestehende Mock-Struktur für syncGroupMembershipsForTenant/D-21 im selben File (nicht komplett gelesen, aber laut RESEARCH.md Test-Map bei Zeile ~665 als "ignores a Tessera group with no ldapDn"-Testmuster referenziert). Neue describe-Blöcke importGroupsByDn, syncBoundGroupsForTenant (Rename/Delete/Default-Handoff/Namenskollision) folgen demselben Mock-Prisma-Client-Aufbau wie die bestehenden D-21-Tests in dieser Datei — prisma.group.findFirst/findMany als vi.fn()-Mocks mit mockResolvedValue.

apps/api/src/groups/groups.service.spec.ts (test, CRUD)

Analog: bestehende Tests für GroupsService.update/findOwned im selben File. Wichtiger Hinweis aus dem Auftrag: ein gemockter findFirst() muss in Gruppen-Queries einen optionalen ID-Parameter akzeptieren — beim Erweitern der Mocks für die neue Name-Lock-Logik darauf achten, dass findOwned(tenantId, id) weiterhin exakt EIN findFirst-Aufrufmuster erwartet (kein zweiter, versehentlich inkompatibler Mock-Aufruf für dieselbe Methode).


apps/web/src/app/(portal)/admin/ldap/page.tsx — Sektion "AD-Gruppen importieren" (component, request-response)

Analog: Sektion 2.55 "Einzelbenutzer suchen & importieren", Zeilen 808-909 (vollständig gelesen) — 1:1-Vorbild für Suchfeld → Ergebnisliste mit Checkbox → Import-Button → Ergebniszeile.

Interfaces (Zeilen 33-59, zu erweitern):

interface LdapDirectoryEntry {
  dn: string;
  name: string;
  type: 'group' | 'ou';
}

interface UserImportResult {
  created: number;
  updated: number;
  skipped: number;
  errors: string[];
}

interface SyncResult {
  created: number;
  updated: number;
  deactivated: number;
  errors: string[];
}

→ neu: LdapGroupSearchResult extends LdapDirectoryEntry (oder eigenständig) mit alreadyImported: boolean; GroupImportResult analog zu UserImportResult; SyncResult additiv um groupMembershipsAdded, groupMembershipsRemoved, groupsImported, groupsRenamed, groupsDeleted, defaultMarkerMoved erweitern (Pitfall 3 aus RESEARCH.md — diese Lücke besteht bereits für D-21 und muss in derselben Änderung geschlossen werden).

Ergebnislisten-Markup mit alreadyImported-Badge (Zeilen 841-877):

{userSearchResults && userSearchResults.length > 0 && (
  <div className="mb-4 max-h-64 overflow-y-auto rounded-md border border-border divide-y divide-border">
    {userSearchResults.map((u) => (
      <label
        key={u.dn}
        className={`flex items-center gap-3 px-4 py-2 text-sm ${
          u.alreadyImported ? 'opacity-60' : 'hover:bg-muted/30 cursor-pointer'
        }`}
      >
        <input
          type="checkbox"
          disabled={u.alreadyImported}
          checked={selectedUserDns.includes(u.dn)}
          onChange={() => toggleUserDn(u.dn)}
        />
        <span className="font-medium text-foreground">{u.displayName || u.username}</span>
        <span className="text-xs text-muted-foreground">{u.username}</span>
        {u.alreadyImported && (
          <span className="ml-auto shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
            {t('userSearch.alreadyImported')}
          </span>
        )}
      </label>
    ))}
  </div>
)}

Import-Button + Ergebniszeile (Zeilen 885-908):

{userSearchResults && userSearchResults.length > 0 && (
  <button
    type="button"
    onClick={() => handleImportUsers(selectedUserDns)}
    disabled={importingUsers || selectedUserDns.length === 0}
    className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
  >
    {importingUsers ? tCommon('loading') : `${t('userSearch.importSelected')} (${selectedUserDns.length})`}
  </button>
)}
{userImportResult && (
  <p className="mt-3 text-sm text-muted-foreground">
    {userImportResult.created} {t('userSearch.created')},{' '}
    {userImportResult.skipped} {t('userSearch.skipped')}
    {userImportResult.errors.length > 0 && (
      <>, {userImportResult.errors.length} {t('userSearch.errors')}</>
    )}
  </p>
)}

Handler-Grundform, ABER mit sichtbarem Fehlerzustand (Owner-Entscheidung 2026-08-06 — NICHT das bestehende stumme catch {} kopieren):

// Bestehendes (zu vermeidendes) Muster, z.B. handleDiscoverGroups (Zeilen 286-301):
const handleDiscoverGroups = async () => {
  setDiscovering(true);
  try {
    const res = await fetch(`${API_URL}/ldap/groups`, { credentials: 'include' });
    if (res.ok) {
      const data = await res.json();
      setDiscovered(data);
    }
  } catch {
    // silently fail   <-- NICHT für die neuen Gruppen-Import-Handler übernehmen
  } finally {
    setDiscovering(false);
  }
};

→ Für die neuen Handler (handleDiscoverGroupsToImport, handleImportGroups) stattdessen: bei !res.ok UND im catch-Zweig einen text-sm text-destructive-Fehlerstate setzen (admin.ldap.groupImport.discoverError bzw. .importError), analog zum Fehlerbanner-Vorbild aus groups/page.tsx:135-139 (siehe unten). handleSync (Zeilen 231-249) liefert bereits das Vorbild für "Fehler setzt ein Result-Objekt statt zu schweigen":

const handleSync = async () => {
  setSyncing(true);
  setSyncResult(null);
  try {
    const res = await fetch(`${API_URL}/ldap/sync`, { method: 'POST', credentials: 'include' });
    if (res.ok) {
      const data = await res.json();
      setSyncResult(data);
      await fetchConfig();
    }
  } catch {
    setSyncResult({ created: 0, updated: 0, deactivated: 0, errors: ['Network error'] });
  } finally {
    setSyncing(false);
  }
};

Für den Sync-Request-Fehler (admin.ldap.sync.requestError) reicht laut UI-SPEC ein separater sichtbarer text-sm text-destructive-Zustand anstelle des Berichts-Containers — nicht zwingend über syncResult.errors.

Sync-Bericht-Container, additiv um weitere <p>-Zeilen zu erweitern (Zeilen 1069-1088):

{syncResult && (
  <div className="rounded-md border border-border bg-muted/30 p-4">
    <p className="text-sm font-medium text-foreground mb-2">
      {t('sync.result', { created: syncResult.created, updated: syncResult.updated, deactivated: syncResult.deactivated })}
    </p>
    {syncResult.errors.length > 0 && (
      <div className="mt-2 space-y-1">
        {syncResult.errors.map((err, i) => (
          <p key={i} className="text-xs text-destructive">{err}</p>
        ))}
      </div>
    )}
  </div>
)}

→ zusätzliche <p>-Zeilen für Gruppenmitgliedschaften/Gruppen/Default-Marker-Handoff exakt wie in UI-SPEC Surface Contract 2 spezifiziert, gleicher Container.


apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx — Entfernung Radio-Block, Name-Lock, interner Name (component, request-response)

Zu entfernender Block: Zeilen 9-13 (LdapDirectoryEntry-Interface, nur für diesen Zweck importiert), 37-40 (Discovery-State), 42-67 (fetchLdapGroups + Effekt), 147-227 (kompletter AD-Bindungs-Block im JSX) sowie der zweistufige Create-Flow in handleSubmit (Zeilen 93-119, insbesondere der bindRes-PATCH-Zweig Zeilen 107-118).

Bestehendes Namensfeld als Analog für sowohl das freie Create-Feld als auch das Grundgerüst des gesperrten Edit-Felds (Zeilen 136-145):

<div className="space-y-2">
  <label className="text-sm font-medium text-foreground">{t('name')}</label>
  <input
    type="text"
    required
    value={name}
    onChange={(e) => setName(e.target.value)}
    className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
  />
</div>

→ für Zustand (c) (importierte Gruppe) disabled ergänzen (disabled + bestehende disabled:opacity-50-Konvention, siehe UI-SPEC Surface Contract 4) und value={group?.name ?? ''} beibehalten, ohne onChange-Wirkung auf ein zu sendendes Feld.

saveError-Banner ist BEREITS das gesuchte Fehler-Vorbild — hier schon vorhanden, nur konsequent auf JEDEN Fehlerfall auszuweiten (Zeilen 122-124, 229-233):

} catch {
  setSaveError(t('saveError'));
} finally {
  setSaving(false);
}
{saveError && (
  <div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
    {saveError}
  </div>
)}

Dieses Muster bleibt unverändert bestehen und wird nach D-07 einfacher (kein discoverError-Sonderfall mehr, da der gesamte AD-Discovery-Codepfad in diesem Modal entfällt). Bei Namenskollision zusätzlich admin.groups.saveErrorNameTaken statt saveError an derselben Stelle rendern (UI-SPEC Copywriting Contract).

Neues Feld "Interner Name" für Zustand (c): identisches Input-Markup wie das Namensfeld oben, aber editierbar, mit internalName-State und PATCH-Payload { internalName } statt { name, ldapDn } (bestehende Payload-Zeile 87 als Vorbild für die PATCH-Aufrufform):

const res = await fetch(`${API_URL}/groups/${group.id}`, {
  method: 'PATCH',
  headers: { 'Content-Type': 'application/json' },
  credentials: 'include',
  body: JSON.stringify({ name, ldapDn }),   // → wird für importierte Gruppen zu { internalName }
});

Neuer Hinweis-Link im Create-Zustand (Analog für text-primary hover:underline-Link-Stil: apps/web/src/app/(portal)/admin/modules/grants/page.tsx:184-189):

<Link href="/admin/modules" className="mt-2 inline-block text-sm font-medium text-primary hover:underline">
  {t('emptyModulesLink')}
</Link>

apps/web/src/app/(portal)/admin/groups/page.tsx — Namensspalte + Fehlerbanner (component, CRUD-Anzeige)

Analog: dieselbe Datei, Namens-/Badge-Zellen, Zeilen 179-190 (vollständig gelesen):

<td className="px-4 py-3 font-medium text-foreground">{group.name}</td>
<td className="px-4 py-3">
  <span
    className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${
      group.ldapDn
        ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
        : 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500'
    }`}
  >
    {group.ldapDn ? t('boundBadge') : t('manualBadge')}
  </span>
</td>

→ Namenszelle wird zu {group.internalName ?? group.name} mit zusätzlichem title={group.name} auf dem umschließenden <span> (UI-SPEC Surface Contract 3). Badge-Zelle bleibt unverändert — sie ist nach D-07 der alleinige "importiert"-Marker.

Fehlerbanner-Vorbild, das die UI-SPEC ausdrücklich als Analog für die neuen ldap/page.tsx-Fehlerzustände benennt (Zeilen 135-139):

{error && (
  <div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
    {error}
  </div>
)}

Dieses rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive-Muster ist die Ziel-Klassenkombination für admin.ldap.groupImport.discoverError, .importError und admin.ldap.sync.requestError — siehe Shared Patterns unten.


apps/web/src/app/(portal)/admin/modules/grants/page.tsx (Zeilen ~206-213) — Spaltenkopf-Namensanzeige (component, transform)

Analog: dieselbe Datei, th-Block, Zeilen 206-213 (vollständig gelesen):

{filteredGroups.map((g) => (
  <th
    key={g.id}
    title={g.name}
    className="sticky top-0 z-10 min-w-[120px] max-w-[160px] truncate border-b border-border bg-card px-4 py-3 text-left text-xs font-medium text-muted-foreground"
  >
    {g.name}
  </th>
))}

→ title={g.name} und {g.name} werden zu title={g.internalName ?? g.name} und {g.internalName ?? g.name} — reiner Textwert-Wechsel, keine Klassenänderung.


Shared Patterns

LDAP-Client-Verbindungsauf-/-abbau

Source: apps/api/src/ldap/ldap.service.ts:372-374, 431-437 (u.v.a. — dasselbe try/finally-Muster an jeder Client-Nutzung in dieser Datei) Apply to: importGroupsByDn(), syncBoundGroupsForTenant()

const client = new Client(this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized));
try {
  await this.bind(client, config.bindDn, config.bindPassword);
  // ...
} finally {
  try {
    await client.unbind();
  } catch {
    // Ignore unbind errors
  }
}

LDAP-Filter-Escaping (String)

Source: apps/api/src/ldap/ldap.service.ts:977-984 (static escapeLdapFilterValue) Apply to: jede neue Filter-Interpolation mit String-Werten (z.B. cn-Vergleich beim Rename-Check)

LDAP-Filter-Escaping (binär, objectGUID)

Source: RESEARCH.md Pattern 3 ([ASSUMED], vor Prod-Schärfung gegen echtes AD zu verifizieren) Apply to: syncBoundGroupsForTenant()-Existenz-Sweep

Mandantenscoping bei Schreibzugriffen im Sync

Source: apps/api/src/ldap/ldap.service.ts:596, 847 (forTenant(this.prisma, tenantId) as any) Apply to: importGroupsByDn(), syncBoundGroupsForTenant() — jeder DB-Schreibzugriff im Sync-Kontext

Ownership-Check bei jeder groupId-Route

Source: apps/api/src/groups/groups.service.ts:78-86 (findOwned) Apply to: jede neue GroupsService-Methode, die eine groupId entgegennimmt

Pro-Element-try/catch mit gesammelten Fehlern statt Lauf-Abbruch

Source: apps/api/src/ldap/ldap.service.ts:860-934 (Loop in syncGroupMembershipsForTenant) Apply to: importGroupsByDn(), syncBoundGroupsForTenant() — inkl. Abfangen einer ConflictException aus GroupsService.create()/update() bei Namenskollision (Pitfall 4)

Sichtbarer Fehlerzustand statt stummem catch {} (Owner-Entscheidung 2026-08-06, NUR für die neuen Codepfade)

Source (Ziel-Optik): apps/web/src/app/(portal)/admin/groups/page.tsx:135-139 (Fehlerbanner) und apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx:229-233 (saveError) Source (zu vermeidende Altlast, NICHT für neue Handler kopieren): die zahlreichen catch { /* silently fail */ }-Blöcke in apps/web/src/app/(portal)/admin/ldap/page.tsx (Zeilen 267-269, 281-283, 296-297, 332-333, 365-366, 396-397, 415-417) Apply to: handleDiscoverGroupsToImport, handleImportGroups (neue Handler in ldap/page.tsx), sowie — laut UI-SPEC-Owner-Entscheidung — auch handleSyncs Request-Fehlerpfad (separat vom bestehenden syncResult.errors-Netzwerkfehler-Fallback) und GroupFormModals handleSubmit (bereits vorhanden, nur konsequent für alle drei Zustände a/b/c beizubehalten)

<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
  {error}
</div>

Additive Sync-Report-Felder statt zweitem Report-Typ

Source: apps/api/src/ldap/ldap.service.ts:10-20 (LdapSyncResult), gespiegelt im Frontend-SyncResult-Interface apps/web/src/app/(portal)/admin/ldap/page.tsx:54-59 Apply to: Backend LdapSyncResult UND Frontend SyncResult — beide müssen im selben Schritt um dieselben Feldnamen wachsen, sonst wiederholt sich Pitfall 3 (Frontend kennt neue Backend-Felder nicht)

internalName ?? name-Fallback-Projektion

Source: noch kein bestehendes Beispiel im Repo (neues Muster dieser Phase) — konsistent an drei Stellen zu implementieren:

  • Backend: apps/api/src/groups/module-grants.service.ts:249, 264 (g.group.internalName ?? g.group.name)
  • Frontend: apps/web/src/app/(portal)/admin/groups/page.tsx Namenszelle, apps/web/src/app/(portal)/admin/modules/grants/page.tsx Spaltenkopf Apply to: jede Anzeige-Stelle, die heute group.name/g.name direkt rendert

No Analog Found

File Role Data Flow Reason
— — — Alle 14 Zielflächen dieser Phase haben einen direkten, strukturell passenden Analog im Repo — Phase 16 ist laut RESEARCH.md zu über 90% Erweiterung bestehender Muster, kein neues Fundament.

Metadata

Analog search scope: apps/api/src/ldap/, apps/api/src/groups/, apps/api/prisma/, apps/web/src/app/(portal)/admin/{ldap,groups,modules/grants}/, apps/web/src/messages/ Files scanned: ldap.service.ts (985 Zeilen, vollständig), ldap.controller.ts (320 Zeilen, vollständig), dto/ldap-config.dto.ts (114 Zeilen, vollständig), groups.service.ts (350 Zeilen, vollständig), module-grants.service.ts (Zeilen 220-278), schema.prisma (Group-Modell + Migrationen), admin/ldap/page.tsx (Zeilen 1-65, 231-421, 680-909, 1040-1093), GroupFormModal.tsx (255 Zeilen, vollständig), admin/groups/page.tsx (Zeilen 120-219), admin/modules/grants/page.tsx (Zeilen 175-225) Pattern extraction date: 2026-08-06