From 38face43b4eb6fa6fe76e9edd18b1af40d95315e Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 22 Jul 2026 13:52:50 +0200 Subject: [PATCH] feat(ldap): individual user search + selective import with dedup Add an AD single-user search (by cn/sAMAccountName/displayName/mail) and a selective import to the LDAP admin page, alongside the existing group/OU filter. Imported users are deduped against existing ones by (ldapDn, then username): a manually-imported user carries its ldapDn, so a later department/group sync matches and updates it in place instead of creating a duplicate. Search results flag alreadyImported; import skips existing users and links a missing ldapDn. Extracted shared mapEntry/upsertMappedUser helpers so sync and manual import resolve identity identically. Backend: GET /ldap/users/search, POST /ldap/users/import (RFC-4515 escaped query, ADMIN-guarded). 6 new service specs (search flags, create, skip, ldapDn-link, denylist). Full API suite 215 green, both apps tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/ldap/dto/ldap-config.dto.ts | 11 + apps/api/src/ldap/ldap.controller.ts | 67 ++++ apps/api/src/ldap/ldap.service.spec.ts | 152 ++++++++ apps/api/src/ldap/ldap.service.ts | 364 +++++++++++++++--- apps/web/src/app/(portal)/admin/ldap/page.tsx | 183 +++++++++ apps/web/src/messages/de.json | 13 + apps/web/src/messages/en.json | 13 + 7 files changed, 744 insertions(+), 59 deletions(-) diff --git a/apps/api/src/ldap/dto/ldap-config.dto.ts b/apps/api/src/ldap/dto/ldap-config.dto.ts index 81a6e06..593c8a6 100644 --- a/apps/api/src/ldap/dto/ldap-config.dto.ts +++ b/apps/api/src/ldap/dto/ldap-config.dto.ts @@ -1,5 +1,6 @@ import { PartialType } from '@nestjs/mapped-types'; import { + ArrayNotEmpty, IsArray, IsBoolean, IsInt, @@ -93,3 +94,13 @@ export class CreateFieldMappingDto { @IsOptional() isDefault?: boolean; } + +/** + * DTO for POST /ldap/users/import — the DNs of AD users to import individually. + */ +export class ImportUsersDto { + @IsArray() + @ArrayNotEmpty() + @IsString({ each: true }) + dns!: string[]; +} diff --git a/apps/api/src/ldap/ldap.controller.ts b/apps/api/src/ldap/ldap.controller.ts index ff53989..5608f8a 100644 --- a/apps/api/src/ldap/ldap.controller.ts +++ b/apps/api/src/ldap/ldap.controller.ts @@ -8,6 +8,7 @@ import { Param, Patch, Post, + Query, Req, } from '@nestjs/common'; import { Role } from '@prisma/client'; @@ -15,6 +16,7 @@ import { Roles } from '../auth/decorators/roles.decorator'; import { CreateFieldMappingDto, CreateLdapConfigDto, + ImportUsersDto, TestConnectionDto, UpdateLdapConfigDto, } from './dto/ldap-config.dto'; @@ -166,6 +168,71 @@ export class LdapController { }); } + /** + * GET /ldap/users/search?q=... - Search AD for individual users by a + * free-text query. Read-only. Each result is flagged `alreadyImported`. + */ + @Get('users/search') + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async searchUsers(@Req() req: any, @Query('q') q: string) { + 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.searchUsers( + { + serverUrl: config.serverUrl, + baseDn: config.baseDn, + bindDn: config.bindDn, + bindPassword: config.bindPassword, + }, + tenantId, + q ?? '', + ); + } + + /** + * 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, + baseDn: config.baseDn, + bindDn: config.bindDn, + bindPassword: config.bindPassword, + searchFilter: config.searchFilter, + groupFilterDns: config.groupFilterDns, + userExcludeList: config.userExcludeList, + fieldMappings: config.fieldMappings, + }, + tenantId, + dto.dns, + ); + } + /** * POST /ldap/sync - Trigger manual sync (D-14 "LDAP synchronisieren" button). * Returns sync results with created/updated/deactivated counts. diff --git a/apps/api/src/ldap/ldap.service.spec.ts b/apps/api/src/ldap/ldap.service.spec.ts index af08a61..5319953 100644 --- a/apps/api/src/ldap/ldap.service.spec.ts +++ b/apps/api/src/ldap/ldap.service.spec.ts @@ -113,3 +113,155 @@ describe('LdapService.syncUsersForTenant — per-user exclude list', () => { expect(result.deactivated).toBe(1); }); }); + +describe('LdapService — individual user search & import (dedup)', () => { + let service: LdapService; + let prisma: any; + let userService: any; + + const cfg = { + id: 'cfg1', + tenantId: 't1', + serverUrl: 'ldap://example', + baseDn: 'dc=example,dc=com', + searchFilter: '(objectClass=person)', + groupFilterDns: [] as string[], + userExcludeList: [] as string[], + fieldMappings: [ + { ldapField: 'sAMAccountName', tesseraField: 'username' }, + { ldapField: 'displayName', tesseraField: 'displayName' }, + { ldapField: 'mail', tesseraField: 'email' }, + ], + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockBind.mockResolvedValue(undefined); + mockUnbind.mockResolvedValue(undefined); + prisma = { + user: { + findFirst: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValue([]), + update: vi.fn().mockResolvedValue({}), + }, + ldapConfig: { update: vi.fn().mockResolvedValue({}) }, + }; + userService = { create: vi.fn().mockResolvedValue({}) }; + service = new LdapService(prisma, userService); + }); + + it('searchUsers flags results already present by username or ldapDn', async () => { + mockSearch.mockResolvedValue({ + searchEntries: [ + { + dn: 'cn=alice,dc=example,dc=com', + sAMAccountName: 'alice', + displayName: 'Alice A', + mail: 'alice@x', + }, + { + dn: 'cn=bob,dc=example,dc=com', + sAMAccountName: 'bob', + displayName: 'Bob B', + mail: 'bob@x', + }, + ], + }); + prisma.user.findMany.mockResolvedValue([{ ldapDn: null, username: 'alice' }]); + + const res = await service.searchUsers(cfg as any, 't1', 'a'); + + expect(res).toHaveLength(2); + expect(res.find((r) => r.username === 'alice')?.alreadyImported).toBe(true); + expect(res.find((r) => r.username === 'bob')?.alreadyImported).toBe(false); + }); + + it('searchUsers returns [] for an empty query without binding', async () => { + const res = await service.searchUsers(cfg as any, 't1', ' '); + expect(res).toEqual([]); + expect(mockSearch).not.toHaveBeenCalled(); + }); + + it('importUsersByDn creates a new user with ldapDn set', async () => { + mockSearch.mockResolvedValue({ + searchEntries: [ + { dn: 'cn=carol,dc=example,dc=com', sAMAccountName: 'carol', mail: 'carol@x' }, + ], + }); + prisma.user.findFirst.mockResolvedValue(null); + + const res = await service.importUsersByDn(cfg as any, 't1', [ + 'cn=carol,dc=example,dc=com', + ]); + + expect(res.created).toBe(1); + expect(res.skipped).toBe(0); + expect(userService.create).toHaveBeenCalledWith( + expect.objectContaining({ + username: 'carol', + ldapDn: 'cn=carol,dc=example,dc=com', + tenantId: 't1', + }), + ); + }); + + it('importUsersByDn skips an already-imported user (no duplicate)', async () => { + mockSearch.mockResolvedValue({ + searchEntries: [{ dn: 'cn=dave,dc=example,dc=com', sAMAccountName: 'dave' }], + }); + prisma.user.findFirst.mockResolvedValue({ + id: 'u9', + username: 'dave', + ldapDn: 'cn=dave,dc=example,dc=com', + }); + + const res = await service.importUsersByDn(cfg as any, 't1', [ + 'cn=dave,dc=example,dc=com', + ]); + + expect(res.skipped).toBe(1); + expect(res.created).toBe(0); + expect(userService.create).not.toHaveBeenCalled(); + }); + + it('importUsersByDn links ldapDn on a user previously matched only by username', async () => { + mockSearch.mockResolvedValue({ + searchEntries: [{ dn: 'cn=erin,dc=example,dc=com', sAMAccountName: 'erin' }], + }); + prisma.user.findFirst.mockResolvedValue({ + id: 'u10', + username: 'erin', + ldapDn: null, + }); + + const res = await service.importUsersByDn(cfg as any, 't1', [ + 'cn=erin,dc=example,dc=com', + ]); + + expect(res.skipped).toBe(1); + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'u10' }, + data: { ldapDn: 'cn=erin,dc=example,dc=com' }, + }), + ); + expect(userService.create).not.toHaveBeenCalled(); + }); + + it('importUsersByDn respects the userExcludeList denylist', async () => { + mockSearch.mockResolvedValue({ + searchEntries: [ + { dn: 'cn=svc,dc=example,dc=com', sAMAccountName: 'Administrator' }, + ], + }); + + const res = await service.importUsersByDn( + { ...cfg, userExcludeList: ['administrator'] } as any, + 't1', + ['cn=svc,dc=example,dc=com'], + ); + + expect(res.skipped).toBe(1); + expect(userService.create).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/ldap/ldap.service.ts b/apps/api/src/ldap/ldap.service.ts index 8cb818b..7a1e25a 100644 --- a/apps/api/src/ldap/ldap.service.ts +++ b/apps/api/src/ldap/ldap.service.ts @@ -43,6 +43,28 @@ export interface LdapDirectoryEntry { type: 'group' | 'ou'; } +/** + * A single AD user matched by searchUsers() for the admin to import + * individually. `alreadyImported` is true when a Tessera user for this + * tenant already exists with the same ldapDn or username — so the UI can + * show it as already present and importUsersByDn() skips it (no duplicates). + */ +export interface LdapUserSearchResult { + dn: string; + username: string; + displayName: string; + email: string; + alreadyImported: boolean; +} + +/** Result of importUsersByDn() — a manual, non-deactivating single-user import. */ +export interface LdapUserImportResult { + created: number; + updated: number; + skipped: number; + errors: string[]; +} + /** * LDAP Service - DIRECTORY SYNC ONLY. * @@ -150,6 +172,272 @@ export class LdapService { } } + /** + * Map one LDAP entry to Tessera fields via the configured fieldMappings. + * + * ldapts represents a missing/absent attribute as an empty array ([]), + * not undefined -- naively doing String(value[0]) on that produces the + * literal string "undefined", identical across every entry lacking the + * attribute (e.g. no `mail` set), which then collides on unique constraints + * like email. Resolve to the first array element (or the raw value) and skip + * when it's actually missing/empty. Username is lowercased so logins stay + * case-insensitive regardless of AD casing. + * + * Shared by syncUsersForTenant() and importUsersByDn() so both derive the + * same identity from an entry. + */ + private mapEntry( + entry: Record, + fieldMappings: { ldapField: string; tesseraField: string }[], + ): { username?: string; mappedData: Record } { + const mappedData: Record = {}; + for (const mapping of fieldMappings) { + const value = entry[mapping.ldapField]; + const resolved = Array.isArray(value) ? value[0] : value; + if (resolved !== undefined && resolved !== null && resolved !== '') { + mappedData[mapping.tesseraField] = String(resolved); + } + } + return { username: mappedData['username']?.toLowerCase(), mappedData }; + } + + /** + * Find-or-upsert one LDAP user by (ldapDn, then username) within a tenant. + * + * Shared by syncUsersForTenant() and importUsersByDn() so both use IDENTICAL + * identity resolution: a user imported one way is NEVER duplicated by the + * other. A manually-imported user (ldapDn set) is matched by ldapDn on a + * later department/group sync and updated in place, not re-created. + */ + private async upsertMappedUser( + dn: string, + username: string, + mappedData: Record, + tenantId: string, + ): Promise<'created' | 'updated'> { + const existingByDn = await this.prisma.user.findFirst({ + where: { ldapDn: dn, tenantId }, + }); + const existingByUsername = existingByDn + ? null + : await this.prisma.user.findFirst({ where: { username, tenantId } }); + const existing = existingByDn || existingByUsername; + + if (existing) { + await this.prisma.user.update({ + where: { id: existing.id }, + data: { + ...(mappedData['displayName'] && { + displayName: mappedData['displayName'], + }), + ...(mappedData['email'] && { email: mappedData['email'] }), + ...(mappedData['username'] && { username }), + ldapDn: dn, + isActive: true, + }, + }); + return 'updated'; + } + + await this.userService.create({ + username, + email: mappedData['email'] || `${username}@ldap.local`, + displayName: mappedData['displayName'], + role: 'USER', + tenantId, + ldapDn: dn, + }); + return 'created'; + } + + /** + * Search AD for individual users by a free-text query (substring match on + * cn, sAMAccountName, displayName, mail). Read-only, service-account bind. + * Each result is flagged `alreadyImported` so the admin sees who is already + * present and cannot import a duplicate. The query is RFC-4515-escaped + * before interpolation (T-02-16, LDAP injection prevention). + */ + async searchUsers( + config: { + serverUrl: string; + baseDn: string; + bindDn?: string | null; + bindPassword?: string | null; + }, + tenantId: string, + query: string, + ): Promise { + const trimmed = (query ?? '').trim(); + if (trimmed.length === 0) { + return []; + } + + const client = new Client({ url: config.serverUrl }); + const first = (v: unknown): string => + Array.isArray(v) ? String(v[0] ?? '') : v != null ? String(v) : ''; + + try { + await this.bind(client, config.bindDn, config.bindPassword); + + const q = LdapService.escapeLdapFilterValue(trimmed); + const filter = `(&(objectClass=person)(|(cn=*${q}*)(sAMAccountName=*${q}*)(displayName=*${q}*)(mail=*${q}*)))`; + + const { searchEntries } = await client.search(config.baseDn, { + filter, + attributes: ['cn', 'displayName', 'sAMAccountName', 'mail', 'dn'], + scope: 'sub', + sizeLimit: 50, + }); + + const entries = searchEntries.map((entry) => ({ + dn: entry.dn, + username: first(entry['sAMAccountName']), + displayName: first(entry['displayName']) || first(entry['cn']), + email: first(entry['mail']), + })); + + // Flag entries already present for this tenant (by ldapDn or username) in + // a single query, so the UI marks them and import stays idempotent. + const dns = entries.map((e) => e.dn); + const usernames = entries + .map((e) => e.username.toLowerCase()) + .filter(Boolean); + 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), + ); + const usernameSet = new Set( + existing.map((u) => u.username.toLowerCase()), + ); + + return entries.map((e) => ({ + ...e, + alreadyImported: + dnSet.has(e.dn) || + (!!e.username && usernameSet.has(e.username.toLowerCase())), + })); + } finally { + try { + await client.unbind(); + } catch { + // Ignore unbind errors + } + } + } + + /** + * Import specific AD users by DN (from searchUsers results). Idempotent and + * NON-deactivating: unlike syncUsersForTenant this never deactivates other + * users. A user that already exists (by ldapDn or username) is SKIPPED — its + * ldapDn is linked if missing so a later department/group sync recognizes it + * and never creates a duplicate. Respects the userExcludeList denylist. + */ + async importUsersByDn( + config: LdapConfigData, + tenantId: string, + dns: string[], + ): Promise { + const result: LdapUserImportResult = { + created: 0, + updated: 0, + skipped: 0, + errors: [], + }; + + const client = new Client({ url: config.serverUrl }); + const excludeSet = new Set( + (config.userExcludeList ?? []) + .map((u) => u.trim().toLowerCase()) + .filter(Boolean), + ); + + try { + await this.bind(client, config.bindDn, config.bindPassword); + + const attributes = config.fieldMappings.map((m) => m.ldapField); + if (!attributes.includes('dn')) { + attributes.push('dn'); + } + + 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; + } + + const { username, mappedData } = this.mapEntry( + searchEntries[0] as Record, + config.fieldMappings, + ); + if (!username) { + result.errors.push( + `${dn}: no username mapped (check sAMAccountName mapping)`, + ); + continue; + } + if (excludeSet.has(username)) { + result.skipped++; + continue; + } + + // Dedup: if a user already exists (by ldapDn or username) skip it, + // but link the ldapDn so a later group/OU sync matches it and never + // duplicates. + const existing = await this.prisma.user.findFirst({ + where: { tenantId, OR: [{ ldapDn: dn }, { username }] }, + }); + if (existing) { + if (existing.ldapDn !== dn) { + await this.prisma.user.update({ + where: { id: existing.id }, + data: { ldapDn: dn }, + }); + } + result.skipped++; + continue; + } + + await this.userService.create({ + username, + email: mappedData['email'] || `${username}@ldap.local`, + displayName: mappedData['displayName'], + role: 'USER', + tenantId, + ldapDn: dn, + }); + result.created++; + } 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; + } + /** * Sync users from LDAP directory for a specific tenant. * @@ -222,26 +510,12 @@ export class LdapService { try { const dn = entry.dn; - // Map LDAP fields to Tessera fields. - // ldapts represents a missing/absent attribute as an empty array - // ([]), not undefined -- naively doing String(value[0]) on that - // produces the literal string "undefined", identical across every - // entry lacking the attribute (e.g. no `mail` set), which then - // collides on unique constraints like email. Resolve to the first - // array element (or the raw value) and skip when it's actually - // missing/empty. - const mappedData: Record = {}; - for (const mapping of config.fieldMappings) { - const value = entry[mapping.ldapField]; - const resolved = Array.isArray(value) ? value[0] : value; - if (resolved !== undefined && resolved !== null && resolved !== '') { - mappedData[mapping.tesseraField] = String(resolved); - } - } - - // Require at minimum a username. Normalize to lowercase so - // logins stay case-insensitive regardless of AD casing. - const username = mappedData['username']?.toLowerCase(); + // Map LDAP fields to Tessera fields (shared mapEntry helper); + // username is lowercased for case-insensitive logins. + const { username, mappedData } = this.mapEntry( + entry as Record, + config.fieldMappings, + ); // Skip excluded users before recording the DN as synced. Leaving an // excluded entry out of syncedDns means that if the admin adds an @@ -260,46 +534,18 @@ export class LdapService { continue; } - // Check if user exists by ldapDn or username - const existingByDn = await this.prisma.user.findFirst({ - where: { ldapDn: dn, tenantId }, - }); - - const existingByUsername = existingByDn - ? null - : await this.prisma.user.findFirst({ - where: { username, tenantId }, - }); - - const existing = existingByDn || existingByUsername; - - if (existing) { - // Update existing user - await this.prisma.user.update({ - where: { id: existing.id }, - data: { - ...(mappedData['displayName'] && { - displayName: mappedData['displayName'], - }), - ...(mappedData['email'] && { email: mappedData['email'] }), - ...(mappedData['username'] && { username }), - ldapDn: dn, - isActive: true, - }, - }); - result.updated++; - } else { - // Create new user with role USER, passwordHash null (LDAP-only per A6) - await this.userService.create({ - username, - email: mappedData['email'] || `${username}@ldap.local`, - displayName: mappedData['displayName'], - role: 'USER', - tenantId, - ldapDn: dn, - // No password: LDAP-only user - }); + // Find-or-upsert by (ldapDn, then username) via the shared helper so + // sync and manual import dedupe identically (never a duplicate row). + const status = await this.upsertMappedUser( + dn, + username, + mappedData, + tenantId, + ); + if (status === 'created') { result.created++; + } else { + result.updated++; } } catch (entryError: unknown) { const msg = diff --git a/apps/web/src/app/(portal)/admin/ldap/page.tsx b/apps/web/src/app/(portal)/admin/ldap/page.tsx index 66a5c52..cf751ec 100644 --- a/apps/web/src/app/(portal)/admin/ldap/page.tsx +++ b/apps/web/src/app/(portal)/admin/ldap/page.tsx @@ -35,6 +35,21 @@ interface LdapDirectoryEntry { type: 'group' | 'ou'; } +interface LdapUserSearchResult { + dn: string; + username: string; + displayName: string; + email: string; + alreadyImported: boolean; +} + +interface UserImportResult { + created: number; + updated: number; + skipped: number; + errors: string[]; +} + interface SyncResult { created: number; updated: number; @@ -89,6 +104,17 @@ export default function AdminLdapPage() { const [newExcludeUser, setNewExcludeUser] = useState(''); const [savingExclude, setSavingExclude] = useState(false); + // Individual user search & import + const [userSearchQuery, setUserSearchQuery] = useState(''); + const [userSearchResults, setUserSearchResults] = useState< + LdapUserSearchResult[] | null + >(null); + const [userSearching, setUserSearching] = useState(false); + const [selectedUserDns, setSelectedUserDns] = useState([]); + const [importingUsers, setImportingUsers] = useState(false); + const [userImportResult, setUserImportResult] = + useState(null); + const filteredDiscovered = discovered?.filter((entry) => { const q = discoverSearch.trim().toLowerCase(); if (!q) return true; @@ -316,6 +342,59 @@ export default function AdminLdapPage() { setUserExcludeList((prev) => prev.filter((u) => u !== name)); }; + const handleSearchUsers = async () => { + const q = userSearchQuery.trim(); + if (!q) return; + setUserSearching(true); + setUserImportResult(null); + try { + const res = await fetch( + `${API_URL}/ldap/users/search?q=${encodeURIComponent(q)}`, + { credentials: 'include' }, + ); + if (res.ok) { + const data = await res.json(); + setUserSearchResults(data); + setSelectedUserDns([]); + } + } catch { + // silently fail + } finally { + setUserSearching(false); + } + }; + + const toggleUserDn = (dn: string) => { + setSelectedUserDns((prev) => + prev.includes(dn) ? prev.filter((d) => d !== dn) : [...prev, dn], + ); + }; + + const handleImportUsers = async (dns: string[]) => { + if (dns.length === 0) return; + setImportingUsers(true); + setUserImportResult(null); + try { + const res = await fetch(`${API_URL}/ldap/users/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ dns }), + }); + if (res.ok) { + const data = await res.json(); + setUserImportResult(data); + setSelectedUserDns([]); + // Re-run the search so alreadyImported flags refresh. + await handleSearchUsers(); + } + } catch { + // silently fail + } finally { + setImportingUsers(false); + } + }; + const handleSaveExcludeList = async () => { setSavingExclude(true); try { @@ -703,6 +782,110 @@ export default function AdminLdapPage() { )} + {/* Section 2.55: Individual user search & import */} + {config && ( +
+

+ {t('userSearch.title')} +

+

+ {t('userSearch.description')} +

+ +
+
+ setUserSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSearchUsers(); + }} + placeholder={t('userSearch.placeholder')} + className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm" + /> +
+ +
+ + {userSearchResults && userSearchResults.length > 0 && ( +
+ {userSearchResults.map((u) => ( + + ))} +
+ )} + + {userSearchResults && userSearchResults.length === 0 && ( +

+ {t('userSearch.noResults')} +

+ )} + + {userSearchResults && userSearchResults.length > 0 && ( + + )} + + {userImportResult && ( +

+ {userImportResult.created} {t('userSearch.created')},{' '} + {userImportResult.skipped} {t('userSearch.skipped')} + {userImportResult.errors.length > 0 && ( + <> + , {userImportResult.errors.length} {t('userSearch.errors')} + + )} +

+ )} +
+ )} + {/* Section 2.6: Per-user exclude/denylist */} {config && (
diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 8b31a14..bd5083a 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -334,6 +334,19 @@ "excluded": "Ausgeschlossen", "empty": "Keine ausgeschlossen - alle gefundenen Benutzer werden importiert.", "save": "Ausschlussliste speichern" + }, + "userSearch": { + "title": "Einzelbenutzer suchen & importieren", + "description": "Sucht einzelne AD-Benutzer und importiert sie gezielt. Bereits vorhandene Benutzer werden uebersprungen - kein Doppelimport, auch wenn spaeter die Abteilung synchronisiert wird.", + "placeholder": "Name, Benutzername oder E-Mail...", + "search": "Suchen", + "noResults": "Keine Benutzer gefunden.", + "alreadyImported": "Bereits importiert", + "import": "Importieren", + "importSelected": "Ausgewaehlte importieren", + "created": "importiert", + "skipped": "uebersprungen", + "errors": "Fehler" } } }, diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 9de3947..9c5fab4 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -334,6 +334,19 @@ "excluded": "Excluded", "empty": "None excluded - every discovered user is imported.", "save": "Save exclude list" + }, + "userSearch": { + "title": "Search & import individual users", + "description": "Search for individual AD users and import them selectively. Users that already exist are skipped - no duplicate import, even when their department is synced later.", + "placeholder": "Name, username or email...", + "search": "Search", + "noResults": "No users found.", + "alreadyImported": "Already imported", + "import": "Import", + "importSelected": "Import selected", + "created": "imported", + "skipped": "skipped", + "errors": "errors" } } },