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:
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user