docs(16): reassign PERM-02 to phase 16 with import semantics
This commit is contained in:
@@ -0,0 +1,715 @@
|
||||
# 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`
|
||||
|
||||
```prisma
|
||||
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`:
|
||||
```prisma
|
||||
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:
|
||||
```sql
|
||||
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):
|
||||
```bash
|
||||
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):
|
||||
```typescript
|
||||
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:**
|
||||
```typescript
|
||||
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):
|
||||
```typescript
|
||||
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):
|
||||
```typescript
|
||||
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):
|
||||
```typescript
|
||||
// 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):
|
||||
```typescript
|
||||
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):
|
||||
```typescript
|
||||
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`):
|
||||
```typescript
|
||||
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):
|
||||
```typescript
|
||||
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):
|
||||
```typescript
|
||||
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:
|
||||
```typescript
|
||||
/**
|
||||
* 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:
|
||||
```typescript
|
||||
/**
|
||||
* 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`:**
|
||||
```typescript
|
||||
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:
|
||||
```typescript
|
||||
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):**
|
||||
```typescript
|
||||
} 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:**
|
||||
```typescript
|
||||
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):
|
||||
```typescript
|
||||
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):
|
||||
```typescript
|
||||
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):
|
||||
```tsx
|
||||
{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):
|
||||
```tsx
|
||||
{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):**
|
||||
```typescript
|
||||
// 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":
|
||||
```typescript
|
||||
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):
|
||||
```tsx
|
||||
{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):
|
||||
```tsx
|
||||
<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):
|
||||
```typescript
|
||||
} catch {
|
||||
setSaveError(t('saveError'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
```
|
||||
```tsx
|
||||
{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):
|
||||
```typescript
|
||||
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`):
|
||||
```tsx
|
||||
<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):
|
||||
```tsx
|
||||
<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):
|
||||
```tsx
|
||||
{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):
|
||||
```tsx
|
||||
{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()`
|
||||
```typescript
|
||||
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 `handleSync`s Request-Fehlerpfad (separat vom bestehenden `syncResult.errors`-Netzwerkfehler-Fallback) und `GroupFormModal`s `handleSubmit` (bereits vorhanden, nur konsequent für alle drei Zustände a/b/c beizubehalten)
|
||||
```tsx
|
||||
<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
|
||||
Reference in New Issue
Block a user