feat(ldap): individual user search + selective import with dedup
Tessera CI/CD / Lint & Type Check (push) Successful in 49s
Tessera CI/CD / Tests (push) Successful in 47s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m45s

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 13:52:50 +02:00
parent 0dd104054b
commit 38face43b4
7 changed files with 744 additions and 59 deletions
+11
View File
@@ -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[];
}
+67
View File
@@ -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.
+152
View File
@@ -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();
});
});
+305 -59
View File
@@ -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<string, unknown>,
fieldMappings: { ldapField: string; tesseraField: string }[],
): { username?: string; mappedData: Record<string, string> } {
const mappedData: Record<string, string> = {};
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<string, string>,
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<LdapUserSearchResult[]> {
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<LdapUserImportResult> {
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<string, unknown>,
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<string, string> = {};
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<string, unknown>,
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 =
@@ -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<string[]>([]);
const [importingUsers, setImportingUsers] = useState(false);
const [userImportResult, setUserImportResult] =
useState<UserImportResult | null>(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>
)}
{/* Section 2.55: Individual user search & import */}
{config && (
<section className="rounded-lg border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
{t('userSearch.title')}
</h2>
<p className="text-sm text-muted-foreground mb-4">
{t('userSearch.description')}
</p>
<div className="flex items-end gap-3 mb-4">
<div className="flex-1 space-y-1">
<input
type="text"
value={userSearchQuery}
onChange={(e) => 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"
/>
</div>
<button
type="button"
onClick={handleSearchUsers}
disabled={userSearching || !userSearchQuery.trim()}
className="h-9 rounded-md border border-border px-4 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
>
{userSearching ? tCommon('loading') : t('userSearch.search')}
</button>
</div>
{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.email && (
<span className="truncate text-xs text-muted-foreground">
{u.email}
</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>
)}
{userSearchResults && userSearchResults.length === 0 && (
<p className="mb-4 text-sm text-muted-foreground">
{t('userSearch.noResults')}
</p>
)}
{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>
)}
</section>
)}
{/* Section 2.6: Per-user exclude/denylist */}
{config && (
<section className="rounded-lg border border-border p-6">
+13
View File
@@ -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"
}
}
},
+13
View File
@@ -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"
}
}
},