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.
This commit is contained in:
2026-08-06 15:09:35 +02:00
parent c4a25511f3
commit 3523e43a13
8 changed files with 767 additions and 31 deletions
@@ -34,6 +34,7 @@ interface LdapDirectoryEntry {
dn: string;
name: string;
type: 'group' | 'ou';
alreadyImported?: boolean;
}
interface LdapUserSearchResult {
@@ -51,6 +52,13 @@ interface UserImportResult {
errors: string[];
}
interface GroupImportResult {
imported: number;
skipped: number;
nameCollisions: string[];
errors: string[];
}
interface SyncResult {
created: number;
updated: number;
@@ -117,6 +125,32 @@ export default function AdminLdapPage() {
const [userImportResult, setUserImportResult] =
useState<UserImportResult | null>(null);
// AD group import (SC-1/SC-2, D-01/D-02) — own state, own discovery call,
// independent of Section 2.5's groupFilterDns picker (different purpose).
const [groupImportCandidates, setGroupImportCandidates] = useState<
LdapDirectoryEntry[] | null
>(null);
const [groupImportSearch, setGroupImportSearch] = useState('');
const [discoveringGroupImport, setDiscoveringGroupImport] = useState(false);
const [selectedImportDns, setSelectedImportDns] = useState<string[]>([]);
const [importingGroups, setImportingGroups] = useState(false);
const [groupImportResult, setGroupImportResult] =
useState<GroupImportResult | null>(null);
const [groupImportError, setGroupImportError] = useState<string | null>(
null,
);
const filteredGroupImportCandidates = groupImportCandidates?.filter(
(entry) => {
const q = groupImportSearch.trim().toLowerCase();
if (!q) return true;
return (
entry.name.toLowerCase().includes(q) ||
entry.dn.toLowerCase().includes(q)
);
},
);
const filteredDiscovered = discovered?.filter((entry) => {
const q = discoverSearch.trim().toLowerCase();
if (!q) return true;
@@ -400,6 +434,60 @@ export default function AdminLdapPage() {
}
};
const handleDiscoverGroupsToImport = async () => {
setDiscoveringGroupImport(true);
setGroupImportError(null);
try {
const res = await fetch(`${API_URL}/ldap/groups`, {
credentials: 'include',
});
if (res.ok) {
const data: LdapDirectoryEntry[] = await res.json();
// Only AD groups are importable — OUs are not selectable here.
setGroupImportCandidates(data.filter((entry) => entry.type === 'group'));
} else {
setGroupImportError(t('groupImport.discoverError'));
}
} catch {
setGroupImportError(t('groupImport.discoverError'));
} finally {
setDiscoveringGroupImport(false);
}
};
const toggleImportDn = (dn: string) => {
setSelectedImportDns((prev) =>
prev.includes(dn) ? prev.filter((d) => d !== dn) : [...prev, dn],
);
};
const handleImportGroups = async () => {
if (selectedImportDns.length === 0) return;
setImportingGroups(true);
setGroupImportError(null);
try {
const res = await fetch(`${API_URL}/ldap/groups/import`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ dns: selectedImportDns }),
});
if (res.ok) {
const data: GroupImportResult = await res.json();
setGroupImportResult(data);
setSelectedImportDns([]);
// Re-run discovery so alreadyImported flags refresh immediately.
await handleDiscoverGroupsToImport();
} else {
setGroupImportError(t('groupImport.importError'));
}
} catch {
setGroupImportError(t('groupImport.importError'));
} finally {
setImportingGroups(false);
}
};
const handleSaveExcludeList = async () => {
setSavingExclude(true);
try {
@@ -805,6 +893,140 @@ export default function AdminLdapPage() {
</section>
)}
{/* Section 2.52: AD group import (SC-1/SC-2, D-01/D-02) */}
{config && (
<section className="rounded-lg border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
{t('groupImport.title')}
</h2>
<p className="text-sm text-muted-foreground mb-4">
{t('groupImport.description')}
</p>
<div className="flex items-center gap-3 mb-4">
<button
type="button"
onClick={handleDiscoverGroupsToImport}
disabled={discoveringGroupImport}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
>
{discoveringGroupImport
? tCommon('loading')
: t('groupImport.discover')}
</button>
</div>
{groupImportError && (
<div className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{groupImportError}
</div>
)}
{groupImportCandidates && groupImportCandidates.length > 0 && (
<div className="mb-4 space-y-2">
<input
type="text"
value={groupImportSearch}
onChange={(e) => setGroupImportSearch(e.target.value)}
placeholder={t('groupImport.searchPlaceholder')}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
/>
<div className="max-h-64 overflow-y-auto rounded-md border border-border divide-y divide-border">
{filteredGroupImportCandidates &&
filteredGroupImportCandidates.length > 0 ? (
filteredGroupImportCandidates.map((entry) => (
<label
key={entry.dn}
className={`flex items-center gap-3 px-4 py-2 text-sm ${
entry.alreadyImported
? 'opacity-60'
: 'hover:bg-muted/30 cursor-pointer'
}`}
>
<input
type="checkbox"
disabled={entry.alreadyImported}
checked={selectedImportDns.includes(entry.dn)}
onChange={() => toggleImportDn(entry.dn)}
/>
<span className="font-medium text-foreground">
{entry.name}
</span>
<span className="font-mono text-xs text-muted-foreground truncate">
{entry.dn}
</span>
{entry.alreadyImported && (
<span className="ml-auto shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
{t('groupImport.alreadyImported')}
</span>
)}
</label>
))
) : (
<p className="px-4 py-3 text-sm text-muted-foreground">
{t('groupImport.noMatches')}
</p>
)}
</div>
</div>
)}
{groupImportCandidates && groupImportCandidates.length === 0 && (
<p className="mb-4 text-sm text-muted-foreground">
{t('groupImport.noneFound')}
</p>
)}
{groupImportCandidates && groupImportCandidates.length > 0 && (
<button
type="button"
onClick={handleImportGroups}
disabled={importingGroups || selectedImportDns.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"
>
{importingGroups
? tCommon('loading')
: `${t('groupImport.importSelected')} (${selectedImportDns.length})`}
</button>
)}
{groupImportResult && (
<div className="mt-3">
<p className="text-sm text-muted-foreground">
{t('groupImport.resultSummary', {
imported: groupImportResult.imported,
skipped: groupImportResult.skipped,
errors:
groupImportResult.errors.length +
groupImportResult.nameCollisions.length,
})}
</p>
{groupImportResult.nameCollisions.length > 0 && (
<div className="mt-2 space-y-1">
{groupImportResult.nameCollisions.map((name, i) => (
<p key={`collision-${i}`} className="text-xs text-destructive">
{t('groupImport.nameCollisionError', { name })}
</p>
))}
</div>
)}
{groupImportResult.errors.length > 0 && (
<div className="mt-2 space-y-1">
{groupImportResult.errors.map((err, i) => (
<p key={`error-${i}`} className="text-xs text-destructive">
{err}
</p>
))}
</div>
)}
<p className="mt-2 text-xs text-muted-foreground">
{t('groupImport.membershipHint')}
</p>
</div>
)}
</section>
)}
{/* Section 2.55: Individual user search & import */}
{config && (
<section className="rounded-lg border border-border p-6">