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) <noreply@anthropic.com>
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user