feat(ldap): ad/openldap dual compat and config gating

- multi-round user filters (config filter first, then
  objectClass=inetOrgPerson, then AD sAMAccountName/userPrincipalName
  fallback) applied to login search, user listing and batch import;
  previously pure-AD directories found no users
- attribute fallback chains (uid/sAMAccountName/userPrincipalName,
  cn/displayName, telephoneNumber/mobile) via ldapEntryAttr
- import errors explain likely cause instead of empty result
- AES-encrypted admin password kept (aiops stores plaintext)
- frontend gating per aiops pattern: LDAP account management panel
  hidden with hint until a config is actually saved (was gated on
  unsaved form values); batch-import modal hides to/from-LDAP tabs
  when unconfigured, local CSV always available
This commit is contained in:
Logan.Li committed 2026-10-26 20:08:46 +08:00
1 parent 2c96902fbd
commit 13fb3aceb3
6 files changed
+487 -266

No files matched your search

@@ -5,9 +5,7 @@
*/
import { useState, useEffect, useRef } from 'react';
import {
Upload, X, RotateCcw, Plus, Users as UsersIcon, Server,
} from 'lucide-react';
import { Upload, X, RotateCcw, Plus, Users as UsersIcon, Server } from 'lucide-react';
import { userApi, ldapApi } from '@/api/user';
import { nameToUsername } from '@/utils/pinyin';
import { toast } from '@/hooks/useToast';
@@ -26,7 +24,8 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)
const [result, setResult] = useState<any>(null);
const fileRef = useRef<HTMLInputElement>(null);
// LDAP state
// LDAP state(ldapEnabled:后端已保存 LDAP 配置才显示相关 Tab)
const [ldapEnabled, setLdapEnabled] = useState(false);
const [ldapOus, setLdapOus] = useState<any[]>([]);
const [ldapUsers, setLdapUsers] = useState<any[]>([]);
const [selectedLdapUsers, setSelectedLdapUsers] = useState<Set<string>>(new Set());
@@ -40,21 +39,34 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)
const [newOuDesc, setNewOuDesc] = useState('');
const [creatingOu, setCreatingOu] = useState(false);
// 挂载即探测配置(决定是否显示 LDAP Tab)
useEffect(() => {
ldapApi
.getConfig()
.then((res: any) => {
setLdapEnabled(!!res?.ldap_host);
})
.catch(() => setLdapEnabled(false));
}, []);
// Check LDAP readiness + load OUs
useEffect(() => {
if (tab === 'to-ldap' || tab === 'from-ldap') {
setLdapLoading(true);
ldapApi.getConfig().then((res: any) => {
const ready = !!res?.ldap_host;
setLdapReady(ready);
if (ready && ldapOus.length === 0) {
return loadLdapOus();
}
setLdapLoading(false);
}).catch(() => {
setLdapReady(false);
setLdapLoading(false);
});
ldapApi
.getConfig()
.then((res: any) => {
const ready = !!res?.ldap_host;
setLdapReady(ready);
if (ready && ldapOus.length === 0) {
return loadLdapOus();
}
setLdapLoading(false);
})
.catch(() => {
setLdapReady(false);
setLdapLoading(false);
});
}
}, [tab]);
@@ -65,27 +77,36 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)
const reader = new FileReader();
reader.onload = () => {
const text = reader.result as string;
const lines = text.split(/\r?\n/).filter(l => l.trim());
if (lines.length < 2) { toast('error', '文件为空'); return; }
const lines = text.split(/\r?\n/).filter((l) => l.trim());
if (lines.length < 2) {
toast('error', '文件为空');
return;
}
const header = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
const nameIdx = header.findIndex(h => /姓名|name|昵称|显示名/.test(h));
const emailIdx = header.findIndex(h => /邮箱|email|mail/.test(h));
const roleIdx = header.findIndex(h => /角色|role/.test(h));
const header = lines[0].split(',').map((h) => h.trim().replace(/"/g, ''));
const nameIdx = header.findIndex((h) => /姓名|name|昵称|显示名/.test(h));
const emailIdx = header.findIndex((h) => /邮箱|email|mail/.test(h));
const roleIdx = header.findIndex((h) => /角色|role/.test(h));
if (nameIdx < 0) { toast('error', 'CSV 需要包含"姓名"列'); return; }
if (nameIdx < 0) {
toast('error', 'CSV 需要包含"姓名"列');
return;
}
const rows = lines.slice(1).map(line => {
const cols = line.split(',').map(c => c.trim().replace(/^"|"$/g, ''));
const name = cols[nameIdx] || '';
return {
display_name: name,
username: nameToUsername(name),
password: nameToUsername(name),
email: emailIdx >= 0 ? cols[emailIdx] : '',
role: roleIdx >= 0 ? cols[roleIdx] || 'developer' : 'developer',
};
}).filter(r => r.display_name);
const rows = lines
.slice(1)
.map((line) => {
const cols = line.split(',').map((c) => c.trim().replace(/^"|"$/g, ''));
const name = cols[nameIdx] || '';
return {
display_name: name,
username: nameToUsername(name),
password: nameToUsername(name),
email: emailIdx >= 0 ? cols[emailIdx] : '',
role: roleIdx >= 0 ? cols[roleIdx] || 'developer' : 'developer',
};
})
.filter((r) => r.display_name);
setPreview(rows);
setResult(null);
};
@@ -97,24 +118,33 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)
const loadLdapOus = async () => {
setLdapLoading(true);
try {
const res = await ldapApi.listOus() as any;
setLdapOus(Array.isArray(res) ? res : (res?.ous || res?.data || []));
} catch { toast('error', '加载 LDAP OU 失败'); }
finally { setLdapLoading(false); }
const res = (await ldapApi.listOus()) as any;
setLdapOus(Array.isArray(res) ? res : res?.ous || res?.data || []);
} catch {
toast('error', '加载 LDAP OU 失败');
} finally {
setLdapLoading(false);
}
};
const loadLdapUsers = async (baseDn: string) => {
setLdapLoading(true);
try {
const res = await ldapApi.listUsers({ base_dn: baseDn }) as any;
setLdapUsers(Array.isArray(res) ? res : (res?.users || res?.data || []));
} catch { toast('error', '加载 LDAP 用户失败'); }
finally { setLdapLoading(false); }
const res = (await ldapApi.listUsers({ base_dn: baseDn })) as any;
setLdapUsers(Array.isArray(res) ? res : res?.users || res?.data || []);
} catch {
toast('error', '加载 LDAP 用户失败');
} finally {
setLdapLoading(false);
}
};
// Create OU
const handleCreateOu = async () => {
if (!newOuName.trim()) { toast('warning', '请输入 OU 名称'); return; }
if (!newOuName.trim()) {
toast('warning', '请输入 OU 名称');
return;
}
setCreatingOu(true);
try {
await ldapApi.createOu({ ou_name: newOuName.trim(), description: newOuDesc.trim() });
@@ -125,15 +155,29 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)
await loadLdapOus();
} catch (e: any) {
toast('error', e?.response?.data?.detail || '创建 OU 失败');
} finally { setCreatingOu(false); }
} finally {
setCreatingOu(false);
}
};
// Import
const handleImport = async () => {
if (tab === 'local' && preview.length === 0) { toast('warning', '请先上传文件'); return; }
if (tab === 'to-ldap' && preview.length === 0) { toast('warning', '请先上传文件'); return; }
if (tab === 'to-ldap' && !targetOu) { toast('warning', '请选择目标 OU'); return; }
if (tab === 'from-ldap' && selectedLdapUsers.size === 0) { toast('warning', '请选择用户'); return; }
if (tab === 'local' && preview.length === 0) {
toast('warning', '请先上传文件');
return;
}
if (tab === 'to-ldap' && preview.length === 0) {
toast('warning', '请先上传文件');
return;
}
if (tab === 'to-ldap' && !targetOu) {
toast('warning', '请选择目标 OU');
return;
}
if (tab === 'from-ldap' && selectedLdapUsers.size === 0) {
toast('warning', '请选择用户');
return;
}
setImporting(true);
try {
@@ -143,7 +187,7 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)
} else if (tab === 'to-ldap') {
res = await ldapApi.batchImportToLdap({ users: preview, ou_dn: targetOu });
} else {
const users = ldapUsers.filter(u => selectedLdapUsers.has(u.dn));
const users = ldapUsers.filter((u) => selectedLdapUsers.has(u.dn));
res = await userApi.batchImportLdap({ users });
}
setResult(res);
@@ -153,8 +197,11 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)
} else {
toast('success', `成功导入 ${res.success_count} 个用户`);
}
} catch { toast('error', '导入失败'); }
finally { setImporting(false); }
} catch {
toast('error', '导入失败');
} finally {
setImporting(false);
}
};
const canImport = () => {
@@ -164,10 +211,15 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)
return selectedLdapUsers.size > 0;
};
// LDAP 未配置时隐藏 LDAP 相关 Tab(本地 CSV 始终可用)
const TABS = [
{ key: 'local' as const, label: '本地导入', icon: Upload },
{ key: 'to-ldap' as const, label: '导入到 LDAP', icon: Server },
{ key: 'from-ldap' as const, label: '从 LDAP 导入', icon: UsersIcon },
...(ldapEnabled
? [
{ key: 'to-ldap' as const, label: '导入到 LDAP', icon: Server },
{ key: 'from-ldap' as const, label: '从 LDAP 导入', icon: UsersIcon },
]
: []),
];
return (
@@ -176,16 +228,23 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)
{/* Header */}
<div className="flex items-center justify-between px-6 py-4">
<h2 className="text-lg font-semibold">批量导入用户</h2>
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><X className="w-5 h-5" /></button>
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
<X className="w-5 h-5" />
</button>
</div>
{/* Tabs */}
<div className="flex gap-1 px-6 pb-3">
{TABS.map(t => {
{TABS.map((t) => {
const Icon = t.icon;
return (
<button key={t.key} onClick={() => setTab(t.key)} className={`flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg transition-colors ${tab === t.key ? 'bg-[var(--dg-blue)] text-white' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'}`}>
<Icon className="w-4 h-4" />{t.label}
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg transition-colors ${tab === t.key ? 'bg-[var(--dg-blue)] text-white' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'}`}
>
<Icon className="w-4 h-4" />
{t.label}
</button>
);
})}
@@ -197,12 +256,21 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)
{tab === 'local' && (
<>
<div className="flex items-center gap-3">
<button onClick={() => fileRef.current?.click()} className="flex items-center gap-2 px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">
<Upload className="w-4 h-4" />选择 CSV 文件
<button
onClick={() => fileRef.current?.click()}
className="flex items-center gap-2 px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]"
>
<Upload className="w-4 h-4" />
选择 CSV 文件
</button>
<input ref={fileRef} type="file" accept=".csv" className="hidden" onChange={handleFile} />
<label className="flex items-center gap-2 text-sm text-[var(--dg-text-muted)]">
<input type="checkbox" checked={skipExisting} onChange={e => setSkipExisting(e.target.checked)} className="rounded" />
<input
type="checkbox"
checked={skipExisting}
onChange={(e) => setSkipExisting(e.target.checked)}
className="rounded"
/>
跳过已存在用户
</label>
</div>
@@ -227,7 +295,11 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)
</tr>
))}
{preview.length > 50 && (
<tr><td colSpan={4} className="py-2 text-center text-[var(--dg-text-muted)]">... 共 {preview.length} 条</td></tr>
<tr>
<td colSpan={4} className="py-2 text-center text-[var(--dg-text-muted)]">
... 共 {preview.length} 条
</td>
</tr>
)}
</tbody>
</table>
@@ -253,29 +325,45 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)
<div className="flex items-center gap-2">
<select
value={targetOu}
onChange={e => setTargetOu(e.target.value)}
onChange={(e) => setTargetOu(e.target.value)}
className="flex-1 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
>
<option value="">Base DN (根)</option>
{ldapOus.map((ou: any) => (
<option key={ou.dn} value={ou.dn}>{ou.name} ({ou.dn})</option>
<option key={ou.dn} value={ou.dn}>
{ou.name} ({ou.dn})
</option>
))}
</select>
<button onClick={loadLdapOus} className="p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)]" title="刷新 OU">
<button
onClick={loadLdapOus}
className="p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)]"
title="刷新 OU"
>
<RotateCcw className="w-4 h-4" />
</button>
<button onClick={() => setShowCreateOu(true)} className="flex items-center gap-1 px-3 py-2 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-sm hover:bg-[var(--dg-bg-hover)]">
<Plus className="w-3.5 h-3.5" />新建 OU
<button
onClick={() => setShowCreateOu(true)}
className="flex items-center gap-1 px-3 py-2 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-sm hover:bg-[var(--dg-bg-hover)]"
>
<Plus className="w-3.5 h-3.5" />
新建 OU
</button>
</div>
</div>
{/* File upload (same CSV format) */}
<div className="flex items-center gap-3">
<button onClick={() => fileRef.current?.click()} className="flex items-center gap-2 px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">
<Upload className="w-4 h-4" />选择 CSV 文件
<button
onClick={() => fileRef.current?.click()}
className="flex items-center gap-2 px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]"
>
<Upload className="w-4 h-4" />
选择 CSV 文件
</button>
<input ref={fileRef} type="file" accept=".csv" className="hidden" onChange={handleFile} />
<span className="text-xs text-[var(--dg-text-muted)]">CSV 需包含"姓名"列,系统自动生成拼音用户名和密码</span>
<span className="text-xs text-[var(--dg-text-muted)]">
CSV 需包含"姓名"列,系统自动生成拼音用户名和密码
</span>
</div>
{preview.length > 0 && (
<div className="overflow-x-auto">
@@ -296,7 +384,11 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)
</tr>
))}
{preview.length > 30 && (
<tr><td colSpan={3} className="py-2 text-center text-[var(--dg-text-muted)]">... 共 {preview.length} 条</td></tr>
<tr>
<td colSpan={3} className="py-2 text-center text-[var(--dg-text-muted)]">
... 共 {preview.length} 条
</td>
</tr>
)}
</tbody>
</table>
@@ -319,8 +411,12 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)
{!ldapLoading && ldapReady && ldapOus.length > 0 && ldapUsers.length === 0 && (
<div className="space-y-1">
<p className="text-sm text-[var(--dg-text-muted)] mb-2">选择组织单元:</p>
{ldapOus.map(ou => (
<button key={ou.dn} onClick={() => loadLdapUsers(ou.dn)} className="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] flex items-center gap-2">
{ldapOus.map((ou) => (
<button
key={ou.dn}
onClick={() => loadLdapUsers(ou.dn)}
className="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] flex items-center gap-2"
>
<UsersIcon className="w-4 h-4 text-[var(--dg-text-muted)]" />
<span>{ou.name}</span>
{ou.description && <span className="text-[var(--dg-text-muted)]">— {ou.description}</span>}
@@ -332,13 +428,33 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm text-[var(--dg-text-muted)]">{ldapUsers.length} 个用户</p>
<button onClick={() => setSelectedLdapUsers(prev => prev.size === ldapUsers.length ? new Set() : new Set(ldapUsers.map(u => u.dn)))} className="text-xs text-[var(--dg-blue)]">
<button
onClick={() =>
setSelectedLdapUsers((prev) =>
prev.size === ldapUsers.length ? new Set() : new Set(ldapUsers.map((u) => u.dn)),
)
}
className="text-xs text-[var(--dg-blue)]"
>
{selectedLdapUsers.size === ldapUsers.length ? '取消全选' : '全选'}
</button>
</div>
{ldapUsers.map(u => (
<label key={u.dn} className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-[var(--dg-bg-hover)] cursor-pointer">
<input type="checkbox" checked={selectedLdapUsers.has(u.dn)} onChange={() => setSelectedLdapUsers(prev => { const n = new Set(prev); n.has(u.dn) ? n.delete(u.dn) : n.add(u.dn); return n; })} />
{ldapUsers.map((u) => (
<label
key={u.dn}
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-[var(--dg-bg-hover)] cursor-pointer"
>
<input
type="checkbox"
checked={selectedLdapUsers.has(u.dn)}
onChange={() =>
setSelectedLdapUsers((prev) => {
const n = new Set(prev);
n.has(u.dn) ? n.delete(u.dn) : n.add(u.dn);
return n;
})
}
/>
<span className="text-sm font-medium">{u.cn || u.nickname || u.uid}</span>
<span className="text-xs text-[var(--dg-text-muted)]">@{u.uid || u.username}</span>
{u.mail && <span className="text-xs text-[var(--dg-text-muted)]">{u.mail}</span>}
@@ -347,7 +463,9 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)
</div>
)}
{!ldapLoading && ldapReady && ldapOus.length === 0 && (
<p className="text-sm text-[var(--dg-text-muted)] text-center py-4">LDAP 目录中无可用 OU,请先创建 OU 或检查配置</p>
<p className="text-sm text-[var(--dg-text-muted)] text-center py-4">
LDAP 目录中无可用 OU,请先创建 OU 或检查配置
</p>
)}
</>
)}
@@ -359,17 +477,30 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)
{result.skipped_count > 0 && <p className="text-yellow-400">跳过: {result.skipped_count}</p>}
{result.failed_count > 0 && <p className="text-red-400">失败: {result.failed_count}</p>}
{result.failed_users?.map((f: any, i: number) => (
<p key={`${f.username}-${i}`} className="text-red-400 text-xs">{f.username}: {f.error}</p>
<p key={`${f.username}-${i}`} className="text-red-400 text-xs">
{f.username}: {f.error}
</p>
))}
{result.message && !result.success_count && <p className="text-[var(--dg-text-secondary)]">{result.message}</p>}
{result.message && !result.success_count && (
<p className="text-[var(--dg-text-secondary)]">{result.message}</p>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4">
<button onClick={onClose} className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)]">关闭</button>
<button onClick={handleImport} disabled={!canImport()} className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-blue)] text-white disabled:opacity-50">
<button
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)]"
>
关闭
</button>
<button
onClick={handleImport}
disabled={!canImport()}
className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-blue)] text-white disabled:opacity-50"
>
{importing ? '导入中...' : '确认导入'}
</button>
</div>
@@ -380,20 +511,44 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)
<div className="w-full bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl">
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold">创建新的组织单位 (OU)</h3>
<button onClick={() => setShowCreateOu(false)} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><X className="w-5 h-5" /></button>
<button
onClick={() => setShowCreateOu(false)}
className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">OU 名称 *</label>
<input value={newOuName} onChange={e => setNewOuName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="例如:研发部、市场部" />
<input
value={newOuName}
onChange={(e) => setNewOuName(e.target.value)}
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
placeholder="例如:研发部、市场部"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">描述</label>
<input value={newOuDesc} onChange={e => setNewOuDesc(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="可选描述" />
<input
value={newOuDesc}
onChange={(e) => setNewOuDesc(e.target.value)}
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
placeholder="可选描述"
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<button onClick={() => setShowCreateOu(false)} className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)]">取消</button>
<button onClick={handleCreateOu} disabled={creatingOu || !newOuName.trim()} className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-blue)] text-white disabled:opacity-50">
<button
onClick={() => setShowCreateOu(false)}
className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)]"
>
取消
</button>
<button
onClick={handleCreateOu}
disabled={creatingOu || !newOuName.trim()}
className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-blue)] text-white disabled:opacity-50"
>
{creatingOu ? '创建中...' : '创建 OU'}
</button>
</div>
+59 -28
View File
@@ -3,9 +3,13 @@ import api from '@/api/request';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { toast } from '@/hooks/useToast';
import {
type LdapConfigData, type OuItem, type LdapUser,
type LdapTestResult, type LdapAccountTestResult,
type CreateOuFormState, type AddUserFormState,
type LdapConfigData,
type OuItem,
type LdapUser,
type LdapTestResult,
type LdapAccountTestResult,
type CreateOuFormState,
type AddUserFormState,
defaultLdapConfig,
} from '@/features/system/ldap/types';
import { LdapConfigForm } from '@/features/system/ldap/components/LdapConfigForm';
@@ -20,6 +24,8 @@ import { LdapAccountTestDialog } from '@/features/system/ldap/components/LdapAcc
*/
export default function LdapConfigPage() {
const [config, setConfig] = useState<LdapConfigData>(defaultLdapConfig);
/** 后端已存在已保存的 LDAP 配置(账号管理/导入入口的门控依据) */
const [configured, setConfigured] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [hasStoredPwd, setHasStoredPwd] = useState(false);
@@ -52,7 +58,13 @@ export default function LdapConfigPage() {
const [showAddUser, setShowAddUser] = useState(false);
const [addingUser, setAddingUser] = useState(false);
const [addUserForm, setAddUserForm] = useState<AddUserFormState>({ username: '', displayName: '', email: '', password: '', ouDn: '' });
const [addUserForm, setAddUserForm] = useState<AddUserFormState>({
username: '',
displayName: '',
email: '',
password: '',
ouDn: '',
});
const [deleting, setDeleting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -61,9 +73,10 @@ export default function LdapConfigPage() {
const fetchConfig = useCallback(async () => {
try {
setLoading(true);
const res = await api.get('/ldap-config') as any;
const res = (await api.get('/ldap-config')) as any;
const data = res?.data ?? res;
if (data && typeof data === 'object' && data.ldap_host) {
setConfigured(true);
setConfig({
ldap_host: data.ldap_host || '',
ldap_port: data.ldap_port || 389,
@@ -72,9 +85,10 @@ export default function LdapConfigPage() {
admin_password: '',
search_filter: data.search_filter || defaultLdapConfig.search_filter,
base_dn: data.base_dn || '',
user_mapping: typeof data.user_mapping === 'string'
? JSON.parse(data.user_mapping)
: data.user_mapping || defaultLdapConfig.user_mapping,
user_mapping:
typeof data.user_mapping === 'string'
? JSON.parse(data.user_mapping)
: data.user_mapping || defaultLdapConfig.user_mapping,
});
setHasStoredPwd(!!data.admin_password);
}
@@ -85,7 +99,9 @@ export default function LdapConfigPage() {
}
}, []);
useEffect(() => { fetchConfig(); }, [fetchConfig]);
useEffect(() => {
fetchConfig();
}, [fetchConfig]);
/* ── 测试服务器连接(先测后存) ── */
const handleTestServer = async () => {
@@ -101,10 +117,14 @@ export default function LdapConfigPage() {
if (config.admin_password) setHasStoredPwd(true);
// 测试连接
const res = await api.post('/ldap-config/test', {
test_user: config.admin_dn,
test_password: config.admin_password || '__use_stored__',
}, { timeout: 10000 }) as any;
const res = (await api.post(
'/ldap-config/test',
{
test_user: config.admin_dn,
test_password: config.admin_password || '__use_stored__',
},
{ timeout: 10000 },
)) as any;
const data = res?.data ?? res;
setTestResult({ ok: true, msg: data?.message || 'LDAP 服务器连接成功' });
setServerConnected(true);
@@ -127,6 +147,7 @@ export default function LdapConfigPage() {
}
await api.put('/ldap-config', payload);
if (config.admin_password) setHasStoredPwd(true);
setConfigured(true);
toast('success', 'LDAP 配置保存成功');
} catch (e: any) {
toast('error', e?.response?.data?.detail || '保存失败');
@@ -140,10 +161,14 @@ export default function LdapConfigPage() {
setTestingAccount(true);
setAccountResult(null);
try {
const res = await api.post('/ldap-config/test', {
test_user: testUser,
test_password: testPass,
}, { timeout: 10000 }) as any;
const res = (await api.post(
'/ldap-config/test',
{
test_user: testUser,
test_password: testPass,
},
{ timeout: 10000 },
)) as any;
const data = res?.data ?? res;
if (data?.success === false) {
setAccountResult({ ok: false, msg: data?.message || '验证失败' });
@@ -161,7 +186,7 @@ export default function LdapConfigPage() {
const fetchOus = async () => {
setLoadingOu(true);
try {
const res = await api.post('/ldap-config/ou-list', {}) as any;
const res = (await api.post('/ldap-config/ou-list', {})) as any;
const data = res?.data ?? res;
setOuList(Array.isArray(data?.ous) ? data.ous : Array.isArray(data) ? data : []);
} catch {
@@ -177,7 +202,7 @@ export default function LdapConfigPage() {
setOuUsers([]);
setSelectedDns(new Set());
try {
const res = await api.post('/ldap-config/user-list', { ou_dn: ou }) as any;
const res = (await api.post('/ldap-config/user-list', { ou_dn: ou })) as any;
const data = res?.data ?? res;
setOuUsers(Array.isArray(data?.users) ? data.users : Array.isArray(data) ? data : []);
} catch {
@@ -241,7 +266,8 @@ export default function LdapConfigPage() {
const confirmDelete = async () => {
setShowDeleteConfirm(false);
setDeleting(true);
let ok = 0, fail = 0;
let ok = 0,
fail = 0;
for (const dn of selectedDns) {
try {
await api.post('/ldap-config/delete-user', { dn });
@@ -269,14 +295,12 @@ export default function LdapConfigPage() {
/* 打开添加用户弹框:预填当前 OU(与原行为一致) */
const openAddUser = () => {
setAddUserForm(prev => ({ ...prev, ouDn: selectedOu || config.base_dn }));
setAddUserForm((prev) => ({ ...prev, ouDn: selectedOu || config.base_dn }));
setShowAddUser(true);
};
const patchCreateOuForm = (patch: Partial<CreateOuFormState>) =>
setCreateOuForm(prev => ({ ...prev, ...patch }));
const patchAddUserForm = (patch: Partial<AddUserFormState>) =>
setAddUserForm(prev => ({ ...prev, ...patch }));
const patchCreateOuForm = (patch: Partial<CreateOuFormState>) => setCreateOuForm((prev) => ({ ...prev, ...patch }));
const patchAddUserForm = (patch: Partial<AddUserFormState>) => setAddUserForm((prev) => ({ ...prev, ...patch }));
/* 账号测试:输入变更清空上次结果,关闭时清空结果但保留凭据 */
const handleTestUserChange = (value: string) => {
@@ -316,8 +340,13 @@ export default function LdapConfigPage() {
onOpenAccountTest={() => setShowAccountTest(true)}
/>
{/* ── LDAP 账号管理(折叠区,连接成功后显示) ── */}
{config.ldap_host && config.base_dn && (
{/* ── LDAP 账号管理(未保存配置前隐藏,保存后出现) ── */}
{!loading && !configured && (
<div className="px-4 py-3 rounded-lg border border-dashed border-[var(--dg-border)] text-xs text-[var(--dg-text-muted)]">
LDAP 未启用:保存基础连接配置后,这里会出现 LDAP 账号管理与用户导入入口
</div>
)}
{configured && (
<LdapManagePanel
expanded={showManage}
onToggle={toggleManage}
@@ -385,7 +414,9 @@ export default function LdapConfigPage() {
description={`确认删除 ${selectedDns.size} 个 LDAP 用户?此操作不可逆。`}
confirmText="确认删除"
variant="danger"
onConfirm={() => { confirmDelete(); }}
onConfirm={() => {
confirmDelete();
}}
onCancel={() => setShowDeleteConfirm(false)}
/>
</div>