Files
opsxsql/frontend/src/features/rbac/components/BatchImportModal.tsx
T
opsx 6b81dbbd4a feat(ldap): LDAP 改为只读接入,彻底移除账号验证与批量导入写入功能
按需求(sql.astx.club/settings):

1. 移除「测试账号验证」按钮
   - LdapConfigForm.tsx 删按钮 + onOpenAccountTest prop
   - 不再渲染 LdapAccountTestDialog

2. 移除批量导入的 LDAP 写操作(BatchImportModal.tsx)
   - TABS 去掉「导入到 LDAP」「从 LDAP 导入」
   - 删除对应两个 JSX 代码块(tab === 'to-ldap' / 'from-ldap')
   - 删除「创建 OU」子弹窗
   - 只保留「本地导入」(CSV 建本地账号,不触碰 LDAP 目录)

3. 移除 LDAP 账号管理面板(含危险写操作)
   - 不再渲染 LdapManagePanel(新建 OU / 添加用户 / 删除用户)
   - 删除 LdapCreateOuDialog / LdapAddUserDialog / ConfirmDialog 引用
   - LdapConfigPage.tsx 421 → 340 行;BatchImportModal.tsx 557 → 329 行

4. 保留(只读用途)
   - LDAP 配置表单(服务器 / 绑定 / 搜索 / 属性映射)
   - 「测试连接」按钮(验证配置可用,不写 LDAP 目录)

设计原则:LDAP 仅用于域账号登录认证(只读 bind + search),
         目录维护请到域控侧操作。

验证(前端 bundle grep,全部归零):
  测试账号验证 0 | 导入到 LDAP 0 | 从 LDAP 导入 0
  新建 OU 0 | 创建 OU 0 | 危险操作 0 | 此操作不可逆 0
保留:
  测试连接 6 | 本地导入 1

镜像 sha256:3970db141d23
/health → {"backend":"go","status":"ok","version":"0.4.0"}
http://sql.astx.club/settings → HTTP 200
2026-10-26 17:49:34 +00:00

329 lines
12 KiB
TypeScript

/**
* 用途:批量导入用户弹窗(本地 CSV 导入 / 导入到 LDAP / 从 LDAP 导入 三个 Tab,含新建 OU 子弹窗)
* 必需 props:onClose(关闭)、onImported(导入有成功项后回调,页面负责关窗并刷新列表)
* 已知限制:CSV 预览仅展示前 50(本地)/ 30(到 LDAP)行;拼音用户名重复时以 username+序号作稳定 key
*/
import { useState, useEffect, useRef } from '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';
import { ModalOverlay } from '@/components/ModalOverlay';
interface BatchImportModalProps {
onClose: () => void;
onImported: () => void;
}
export function BatchImportModal({ onClose, onImported }: BatchImportModalProps) {
const [tab, setTab] = useState<'local' | 'to-ldap' | 'from-ldap'>('local');
const [preview, setPreview] = useState<any[]>([]);
const [skipExisting, setSkipExisting] = useState(true);
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<any>(null);
const fileRef = useRef<HTMLInputElement>(null);
// 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());
const [ldapLoading, setLdapLoading] = useState(false);
const [targetOu, setTargetOu] = useState('');
const [ldapReady, setLdapReady] = useState(false);
// Create OU dialog
const [showCreateOu, setShowCreateOu] = useState(false);
const [newOuName, setNewOuName] = useState('');
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);
});
}
}, [tab]);
// CSV parse
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
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 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;
}
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);
};
reader.readAsText(file);
e.target.value = '';
};
// LDAP OU loading
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 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);
}
};
// Create OU
const handleCreateOu = async () => {
if (!newOuName.trim()) {
toast('warning', '请输入 OU 名称');
return;
}
setCreatingOu(true);
try {
await ldapApi.createOu({ ou_name: newOuName.trim(), description: newOuDesc.trim() });
toast('success', `OU "${newOuName}" 创建成功`);
setShowCreateOu(false);
setNewOuName('');
setNewOuDesc('');
await loadLdapOus();
} catch (e: any) {
toast('error', e?.response?.data?.detail || '创建 OU 失败');
} 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;
}
setImporting(true);
try {
let res: any;
if (tab === 'local') {
res = await userApi.batchImport({ users: preview, import_type: 'local', skip_existing: skipExisting });
} else if (tab === 'to-ldap') {
res = await ldapApi.batchImportToLdap({ users: preview, ou_dn: targetOu });
} else {
const users = ldapUsers.filter((u) => selectedLdapUsers.has(u.dn));
res = await userApi.batchImportLdap({ users });
}
setResult(res);
if (res.success_count > 0 || res.success) onImported();
if (tab === 'to-ldap') {
toast('success', res.message || `成功 ${res.success_count} 个`);
} else {
toast('success', `成功导入 ${res.success_count} 个用户`);
}
} catch {
toast('error', '导入失败');
} finally {
setImporting(false);
}
};
const canImport = () => {
if (importing) return false;
if (tab === 'local') return preview.length > 0;
if (tab === 'to-ldap') return preview.length > 0 && !!targetOu;
return selectedLdapUsers.size > 0;
};
// LDAP 未配置时隐藏 LDAP 相关 Tab(本地 CSV 始终可用)
// LDAP 仅作只读接入:不再提供「导入到 LDAP」「从 LDAP 导入」
const TABS = [
{ key: 'local' as const, label: '本地导入', icon: Upload },
];
return (
<ModalOverlay open={true} onClose={onClose} width="max-w-2xl">
<div className="w-full max-h-[85vh] bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl shadow-xl flex flex-col">
{/* 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>
</div>
{/* Tabs */}
<div className="flex gap-1 px-6 pb-3">
{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>
);
})}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* ── Local Import ── */}
{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>
<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"
/>
跳过已存在用户
</label>
</div>
{preview.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
<th className="py-2 pr-3">姓名</th>
<th className="py-2 pr-3">用户名</th>
<th className="py-2 pr-3">邮箱</th>
<th className="py-2">角色</th>
</tr>
</thead>
<tbody>
{preview.slice(0, 50).map((r, i) => (
<tr key={`${r.username}-${i}`} className="border-b border-[var(--dg-border)]/50">
<td className="py-2 pr-3">{r.display_name}</td>
<td className="py-2 pr-3 text-[var(--dg-blue)]">{r.username}</td>
<td className="py-2 pr-3 text-[var(--dg-text-muted)]">{r.email || '-'}</td>
<td className="py-2 text-[var(--dg-text-muted)]">{r.role}</td>
</tr>
))}
{preview.length > 50 && (
<tr>
<td colSpan={4} className="py-2 text-center text-[var(--dg-text-muted)]">
... 共 {preview.length} 条
</td>
</tr>
)}
</tbody>
</table>
</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"
>
{importing ? '导入中...' : '确认导入'}
</button>
</div>
</div>
</ModalOverlay>
);
}