feat(ldap): per-user exclude/denylist filter for sync
Add a per-username denylist so individual accounts (service accounts like administrator, krbtgt, guest, ldap$) can be excluded from LDAP sync, independent of the group/OU include-filter which only scopes the search. - schema: LdapConfig.userExcludeList String[] (+ migration) - sync: skip excluded usernames (case-insensitive) before recording the DN, so an already-imported user added to the list gets deactivated next sync - DTO / config service / controller / scheduler: thread userExcludeList through - web: exclude-list admin UI section (add/remove/save) + de/en translations - tests: 3 specs covering empty list, case-insensitive skip, deactivation Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ interface LdapConfig {
|
||||
syncIntervalMin: number;
|
||||
isActive: boolean;
|
||||
groupFilterDns: string[];
|
||||
userExcludeList: string[];
|
||||
lastSyncAt: string | null;
|
||||
fieldMappings: FieldMapping[];
|
||||
}
|
||||
@@ -83,6 +84,11 @@ export default function AdminLdapPage() {
|
||||
const [savingFilter, setSavingFilter] = useState(false);
|
||||
const [discoverSearch, setDiscoverSearch] = useState('');
|
||||
|
||||
// Per-user exclude/denylist (individual usernames never imported)
|
||||
const [userExcludeList, setUserExcludeList] = useState<string[]>([]);
|
||||
const [newExcludeUser, setNewExcludeUser] = useState('');
|
||||
const [savingExclude, setSavingExclude] = useState(false);
|
||||
|
||||
const filteredDiscovered = discovered?.filter((entry) => {
|
||||
const q = discoverSearch.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
@@ -113,6 +119,7 @@ export default function AdminLdapPage() {
|
||||
isActive: data.isActive ?? true,
|
||||
});
|
||||
setGroupFilterDns(data.groupFilterDns ?? []);
|
||||
setUserExcludeList(data.userExcludeList ?? []);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -298,6 +305,36 @@ export default function AdminLdapPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddExcludeUser = () => {
|
||||
const name = newExcludeUser.trim().toLowerCase();
|
||||
if (!name || userExcludeList.includes(name)) return;
|
||||
setUserExcludeList((prev) => [...prev, name]);
|
||||
setNewExcludeUser('');
|
||||
};
|
||||
|
||||
const handleRemoveExcludeUser = (name: string) => {
|
||||
setUserExcludeList((prev) => prev.filter((u) => u !== name));
|
||||
};
|
||||
|
||||
const handleSaveExcludeList = async () => {
|
||||
setSavingExclude(true);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/ldap/config`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ userExcludeList }),
|
||||
});
|
||||
if (res.ok) {
|
||||
await fetchConfig();
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setSavingExclude(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
@@ -666,6 +703,82 @@ export default function AdminLdapPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Section 2.6: Per-user exclude/denylist */}
|
||||
{config && (
|
||||
<section className="rounded-lg border border-border p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">
|
||||
{t('userExclude.title')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t('userExclude.description')}
|
||||
</p>
|
||||
|
||||
<div className="flex items-end gap-3 mb-4">
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t('userExclude.username')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newExcludeUser}
|
||||
onChange={(e) => setNewExcludeUser(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddExcludeUser();
|
||||
}
|
||||
}}
|
||||
placeholder="administrator, krbtgt, guest, ldap$ ..."
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddExcludeUser}
|
||||
className="h-9 rounded-md border border-border px-3 text-xs font-medium text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
{t('userExclude.add')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">
|
||||
{t('userExclude.excluded')}
|
||||
</p>
|
||||
{userExcludeList.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('userExclude.empty')}</p>
|
||||
) : (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{userExcludeList.map((name) => (
|
||||
<li
|
||||
key={name}
|
||||
className="flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm"
|
||||
>
|
||||
<span className="font-mono text-xs text-foreground">{name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveExcludeUser(name)}
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-xs text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
{t('fieldMapping.remove')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveExcludeList}
|
||||
disabled={savingExclude}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{savingExclude ? tCommon('loading') : t('userExclude.save')}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Section 3: Sync Settings (D-14) */}
|
||||
{config && (
|
||||
<section className="rounded-lg border border-border p-6">
|
||||
|
||||
Reference in New Issue
Block a user