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
@@ -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">