feat: LDAP 账号管理 — OU管理/添加用户/删除用户/批量导入
后端新增 4 个 LDAP 管理端点: - POST /ldap-config/create-ou: 创建组织单元 - POST /ldap-config/create-user: 创建单个用户 (SSHA加密) - POST /ldap-config/delete-user: 删除用户 - POST /ldap-config/batch-import-ldap: 批量导入用户 - POST /ldap-config/user-list: 查询用户列表(恢复) 前端 LdapConfigPage 增强: - LDAP 账号管理折叠区(配置存在即显示) - OU 下拉选择 + 根节点选项 + 数量统计 - 用户列表表格(全选/单选/DN显示) - 新建 OU 弹框(名称+父级DN+描述) - 添加用户弹框(用户名+密码+显示名+邮箱+目标OU) - 删除选中用户按钮(确认后批量删除) 前端 UsersPage BatchImportModal 三 Tab: - 本地导入 / 导入到 LDAP / 从 LDAP 导入 测试修复: - test_user_endpoint.py: 分页格式兼容 - test_users.py: 分页格式兼容 - test_system_settings.py: 精简后配置项匹配
This commit is contained in:
1 parent
344c519209
commit
be2d6f3894
20 files changed
+2229
-1019
No files matched your search
+480
-110
@@ -1,12 +1,15 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Plus, Search, Edit2, Trash2, XCircle, Eye, Download, Upload,
|
||||
CheckSquare, Square, X, RotateCcw, KeyRound, ChevronDown,
|
||||
UserCheck, UserX, Filter,
|
||||
UserCheck, UserX, Filter, ChevronLeft, ChevronRight, FileText,
|
||||
Users as UsersIcon, Server,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { userApi } from '@/api/user';
|
||||
import { userApi, ldapApi } from '@/api/user';
|
||||
import { instanceGroupApi, type InstanceGroup } from '@/api/instanceGroup';
|
||||
import { nameToUsername } from '@/utils/pinyin';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
/* ───────── Types ───────── */
|
||||
|
||||
@@ -41,14 +44,14 @@ const roleNameMap: Record<string, string> = {
|
||||
admin: '超级管理员',
|
||||
dba: '数据库管理员',
|
||||
developer: '开发人员',
|
||||
guest: '访客',
|
||||
auditor: '审计员',
|
||||
};
|
||||
|
||||
const roleTagStyle: Record<string, string> = {
|
||||
admin: 'bg-purple-500/15 text-purple-400',
|
||||
dba: 'bg-blue-500/15 text-blue-400',
|
||||
developer: 'bg-green-500/15 text-green-400',
|
||||
guest: 'bg-[var(--dg-text-muted)]/15 text-[var(--dg-text-muted)]',
|
||||
auditor: 'bg-orange-500/15 text-orange-400',
|
||||
};
|
||||
|
||||
const roleOptions = [
|
||||
@@ -119,8 +122,10 @@ export default function UsersPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [loginTimeFilter, setLoginTimeFilter] = useState('');
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(20);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showDetail, setShowDetail] = useState<UserItem | null>(null);
|
||||
@@ -128,45 +133,30 @@ export default function UsersPage() {
|
||||
const [showResetPwd, setShowResetPwd] = useState<UserItem | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [showBatchMenu, setShowBatchMenu] = useState(false);
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await userApi.list();
|
||||
const data = (res as any)?.items || res;
|
||||
setUsers(Array.isArray(data) ? data : []);
|
||||
const params: any = { page, page_size: pageSize };
|
||||
if (search) params.search = search;
|
||||
if (roleFilter) params.role = roleFilter;
|
||||
if (statusFilter === 'active') params.is_active = true;
|
||||
else if (statusFilter === 'disabled') params.is_active = false;
|
||||
const res = await userApi.list(params);
|
||||
const data = res as any;
|
||||
setUsers(Array.isArray(data?.items) ? data.items : Array.isArray(data) ? data : []);
|
||||
setTotal(data?.total ?? data?.length ?? 0);
|
||||
} catch {
|
||||
setToast({ message: '获取用户列表失败', type: 'error' });
|
||||
toast('error', '获取用户列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [page, pageSize, search, roleFilter, statusFilter]);
|
||||
|
||||
useEffect(() => { fetchUsers(); }, [fetchUsers]);
|
||||
|
||||
// Auto-dismiss toast
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
const t = setTimeout(() => setToast(null), 3000);
|
||||
return () => clearTimeout(t);
|
||||
}, [toast]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return users.filter(u => {
|
||||
if (roleFilter && u.role !== roleFilter) return false;
|
||||
if (statusFilter === 'active' && !u.is_active) return false;
|
||||
if (statusFilter === 'disabled' && u.is_active) return false;
|
||||
if (search && !u.username.toLowerCase().includes(search.toLowerCase()) && !(u.email || '').toLowerCase().includes(search.toLowerCase()) && !(u.display_name || '').toLowerCase().includes(search.toLowerCase())) return false;
|
||||
if (loginTimeFilter && u.last_login) {
|
||||
const days = Math.floor((Date.now() - new Date(u.last_login).getTime()) / 86400000);
|
||||
if (loginTimeFilter === '7d' && days > 7) return false;
|
||||
if (loginTimeFilter === '30d' && days > 30) return false;
|
||||
if (loginTimeFilter === '90d' && days > 90) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [users, roleFilter, statusFilter, search, loginTimeFilter]);
|
||||
const filtered = users;
|
||||
|
||||
const allSelected = filtered.length > 0 && filtered.every(u => selectedIds.has(u.id));
|
||||
|
||||
@@ -192,11 +182,9 @@ export default function UsersPage() {
|
||||
const handleToggleActive = async (user: UserItem) => {
|
||||
try {
|
||||
await userApi.update(user.id, { is_active: !user.is_active });
|
||||
setToast({ message: `用户已${!user.is_active ? '启用' : '禁用'}`, type: 'success' });
|
||||
toast('success', `用户已${!user.is_active ? '启用' : '禁用'}`);
|
||||
fetchUsers();
|
||||
} catch {
|
||||
setToast({ message: '操作失败', type: 'error' });
|
||||
}
|
||||
} catch { toast('error', '操作失败'); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
@@ -204,11 +192,9 @@ export default function UsersPage() {
|
||||
try {
|
||||
await userApi.delete(id);
|
||||
setSelectedIds(prev => { const n = new Set(prev); n.delete(id); return n; });
|
||||
setToast({ message: '用户已删除', type: 'success' });
|
||||
toast('success', '用户已删除');
|
||||
fetchUsers();
|
||||
} catch {
|
||||
setToast({ message: '删除失败', type: 'error' });
|
||||
}
|
||||
} catch { toast('error', '删除失败'); }
|
||||
};
|
||||
|
||||
const handleBatchAction = async (action: string) => {
|
||||
@@ -218,51 +204,33 @@ export default function UsersPage() {
|
||||
try {
|
||||
if (action === 'enable') {
|
||||
await Promise.all(ids.map(id => userApi.update(id, { is_active: true })));
|
||||
setToast({ message: `已启用 ${ids.length} 个用户`, type: 'success' });
|
||||
toast('success', `已启用 ${ids.length} 个用户`);
|
||||
} else if (action === 'disable') {
|
||||
await Promise.all(ids.map(id => userApi.update(id, { is_active: false })));
|
||||
setToast({ message: `已禁用 ${ids.length} 个用户`, type: 'success' });
|
||||
toast('success', `已禁用 ${ids.length} 个用户`);
|
||||
} else if (action === 'delete') {
|
||||
await Promise.all(ids.map(id => userApi.delete(id)));
|
||||
setToast({ message: `已删除 ${ids.length} 个用户`, type: 'success' });
|
||||
toast('success', `已删除 ${ids.length} 个用户`);
|
||||
}
|
||||
setSelectedIds(new Set());
|
||||
fetchUsers();
|
||||
} catch {
|
||||
setToast({ message: '批量操作失败', type: 'error' });
|
||||
}
|
||||
} catch { toast('error', '批量操作失败'); }
|
||||
};
|
||||
|
||||
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
try {
|
||||
// Simulate import
|
||||
setToast({ message: `文件 "${file.name}" 导入成功`, type: 'success' });
|
||||
fetchUsers();
|
||||
} catch {
|
||||
setToast({ message: '导入失败,请检查文件格式', type: 'error' });
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = '';
|
||||
const handleDownloadTemplate = async () => {
|
||||
try {
|
||||
const blob = await userApi.downloadTemplate() as any;
|
||||
const url = URL.createObjectURL(new Blob([blob], { type: 'text/csv' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'users_import_template.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch { toast('error', '下载模板失败'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className={`fixed top-4 right-4 z-[100] px-4 py-3 rounded-lg text-sm flex items-center gap-2 shadow-lg border ${
|
||||
toast.type === 'success' ? 'bg-green-500/10 text-green-400 border-green-500/20' : 'bg-red-500/10 text-red-400 border-red-500/20'
|
||||
}`}>
|
||||
{toast.type === 'success' ? <CheckSquare className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
|
||||
{toast.message}
|
||||
<button onClick={() => setToast(null)} className="ml-2 hover:opacity-70"><X className="w-3 h-3" /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -270,10 +238,18 @@ export default function UsersPage() {
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">管理系统用户,分配角色和数据库访问权限</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all cursor-pointer">
|
||||
<button
|
||||
onClick={handleDownloadTemplate}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
|
||||
>
|
||||
<FileText className="w-4 h-4" /> 模板
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowImport(true)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
|
||||
>
|
||||
<Upload className="w-4 h-4" /> 导入
|
||||
<input type="file" accept=".csv" className="hidden" onChange={handleImport} />
|
||||
</label>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportUsersToCSV(filtered)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
|
||||
@@ -282,7 +258,7 @@ export default function UsersPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-blue)] rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> 新建用户
|
||||
</button>
|
||||
@@ -317,19 +293,9 @@ export default function UsersPage() {
|
||||
>
|
||||
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
||||
value={loginTimeFilter}
|
||||
onChange={e => setLoginTimeFilter(e.target.value)}
|
||||
>
|
||||
<option value="">全部登录时间</option>
|
||||
<option value="7d">7天内登录</option>
|
||||
<option value="30d">30天内登录</option>
|
||||
<option value="90d">90天内登录</option>
|
||||
</select>
|
||||
{(search || roleFilter || statusFilter || loginTimeFilter) && (
|
||||
{(search || roleFilter || statusFilter) && (
|
||||
<button
|
||||
onClick={() => { setSearch(''); setRoleFilter(''); setStatusFilter(''); setLoginTimeFilter(''); }}
|
||||
onClick={() => { setSearch(''); setRoleFilter(''); setStatusFilter(''); setPage(1); }}
|
||||
className="flex items-center gap-1 text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" /> 重置
|
||||
@@ -386,9 +352,9 @@ export default function UsersPage() {
|
||||
<div className="p-10 text-center text-[var(--dg-text-muted)]">
|
||||
<Search className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">没有找到匹配的用户</p>
|
||||
{(search || roleFilter || statusFilter || loginTimeFilter) && (
|
||||
{(search || roleFilter || statusFilter) && (
|
||||
<button
|
||||
onClick={() => { setSearch(''); setRoleFilter(''); setStatusFilter(''); setLoginTimeFilter(''); }}
|
||||
onClick={() => { setSearch(''); setRoleFilter(''); setStatusFilter(''); }}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 mt-2 transition-colors"
|
||||
>
|
||||
清除筛选条件
|
||||
@@ -431,9 +397,6 @@ export default function UsersPage() {
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex items-center gap-3 cursor-pointer" onClick={() => setShowDetail(user)}>
|
||||
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-sm font-semibold">
|
||||
{(user.display_name || user.username).charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium hover:text-blue-400 transition-colors">{user.display_name || user.username}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">@{user.username}</div>
|
||||
@@ -504,9 +467,30 @@ export default function UsersPage() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-t border-[var(--dg-border)]">
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">
|
||||
共 <strong className="text-[var(--dg-text-primary)]">{filtered.length}</strong> 条记录
|
||||
共 <strong className="text-[var(--dg-text-primary)]">{total}</strong> 条
|
||||
{selectedIds.size > 0 && `,已选择 ${selectedIds.size} 项`}
|
||||
</span>
|
||||
{total > pageSize && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm text-[var(--dg-text-secondary)] px-2">
|
||||
{page} / {Math.ceil(total / pageSize)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(Math.ceil(total / pageSize), p + 1))}
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -515,7 +499,7 @@ export default function UsersPage() {
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreate && (
|
||||
<CreateUserModal onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchUsers(); }} setToast={setToast} />
|
||||
<CreateUserModal onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchUsers(); }} />
|
||||
)}
|
||||
|
||||
{/* Detail Modal */}
|
||||
@@ -525,12 +509,17 @@ export default function UsersPage() {
|
||||
|
||||
{/* Edit Modal */}
|
||||
{showEdit && (
|
||||
<EditUserModal user={showEdit} onClose={() => setShowEdit(null)} onUpdated={() => { setShowEdit(null); fetchUsers(); }} setToast={setToast} />
|
||||
<EditUserModal user={showEdit} onClose={() => setShowEdit(null)} onUpdated={() => { setShowEdit(null); fetchUsers(); }} />
|
||||
)}
|
||||
|
||||
{/* Reset Password Modal */}
|
||||
{showResetPwd && (
|
||||
<ResetPasswordModal user={showResetPwd} onClose={() => setShowResetPwd(null)} setToast={setToast} />
|
||||
<ResetPasswordModal user={showResetPwd} onClose={() => setShowResetPwd(null)} />
|
||||
)}
|
||||
|
||||
{/* Batch Import Modal */}
|
||||
{showImport && (
|
||||
<BatchImportModal onClose={() => setShowImport(false)} onImported={() => { setShowImport(false); fetchUsers(); }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -538,7 +527,7 @@ export default function UsersPage() {
|
||||
|
||||
/* ───────── Create User Modal ───────── */
|
||||
|
||||
function CreateUserModal({ onClose, onCreated, setToast }: { onClose: () => void; onCreated: () => void; setToast: (t: { message: string; type: 'success' | 'error' }) => void }) {
|
||||
function CreateUserModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -555,10 +544,10 @@ function CreateUserModal({ onClose, onCreated, setToast }: { onClose: () => void
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await userApi.create({ username, display_name: displayName, password, email: email || undefined, role });
|
||||
setToast({ message: '用户创建成功', type: 'success' });
|
||||
toast('success', '用户创建成功');
|
||||
onCreated();
|
||||
} catch (e: any) {
|
||||
setToast({ message: e?.response?.data?.detail || '创建失败', type: 'error' });
|
||||
toast('error', e?.response?.data?.detail || '创建失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -614,7 +603,7 @@ function CreateUserModal({ onClose, onCreated, setToast }: { onClose: () => void
|
||||
<option value="admin">超级管理员</option>
|
||||
<option value="dba">数据库管理员</option>
|
||||
<option value="developer">开发人员</option>
|
||||
<option value="guest">访客</option>
|
||||
<option value="auditor">审计员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
@@ -631,7 +620,7 @@ function CreateUserModal({ onClose, onCreated, setToast }: { onClose: () => void
|
||||
|
||||
/* ───────── Edit User Modal ───────── */
|
||||
|
||||
function EditUserModal({ user, onClose, onUpdated, setToast }: { user: UserItem; onClose: () => void; onUpdated: () => void; setToast: (t: { message: string; type: 'success' | 'error' }) => void }) {
|
||||
function EditUserModal({ user, onClose, onUpdated }: { user: UserItem; onClose: () => void; onUpdated: () => void }) {
|
||||
const [displayName, setDisplayName] = useState(user.display_name || '');
|
||||
const [email, setEmail] = useState(user.email || '');
|
||||
const [role, setRole] = useState(user.role);
|
||||
@@ -663,10 +652,10 @@ function EditUserModal({ user, onClose, onUpdated, setToast }: { user: UserItem;
|
||||
if (selectedGroupIds.length >= 0) {
|
||||
await instanceGroupApi.updateUserGroups(user.id, selectedGroupIds);
|
||||
}
|
||||
setToast({ message: '用户更新成功', type: 'success' });
|
||||
toast('success', '用户更新成功');
|
||||
onUpdated();
|
||||
} catch {
|
||||
setToast({ message: '更新失败', type: 'error' });
|
||||
toast('error', '更新失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -703,7 +692,7 @@ function EditUserModal({ user, onClose, onUpdated, setToast }: { user: UserItem;
|
||||
<option value="admin">超级管理员</option>
|
||||
<option value="dba">数据库管理员</option>
|
||||
<option value="developer">开发人员</option>
|
||||
<option value="guest">访客</option>
|
||||
<option value="auditor">审计员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -745,7 +734,7 @@ function EditUserModal({ user, onClose, onUpdated, setToast }: { user: UserItem;
|
||||
|
||||
/* ───────── Reset Password Modal ───────── */
|
||||
|
||||
function ResetPasswordModal({ user, onClose, setToast }: { user: UserItem; onClose: () => void; setToast: (t: { message: string; type: 'success' | 'error' }) => void }) {
|
||||
function ResetPasswordModal({ user, onClose }: { user: UserItem; onClose: () => void }) {
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -760,10 +749,10 @@ function ResetPasswordModal({ user, onClose, setToast }: { user: UserItem; onClo
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await userApi.update(user.id, { password: newPassword });
|
||||
setToast({ message: '密码重置成功', type: 'success' });
|
||||
toast('success', '密码重置成功');
|
||||
onClose();
|
||||
} catch {
|
||||
setToast({ message: '密码重置失败', type: 'error' });
|
||||
toast('error', '密码重置失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -875,3 +864,384 @@ function Clock(props: React.SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── Batch Import Modal ───────── */
|
||||
|
||||
function BatchImportModal({ onClose, onImported }: { onClose: () => void; onImported: () => void }) {
|
||||
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
|
||||
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);
|
||||
|
||||
// Check LDAP readiness
|
||||
useEffect(() => {
|
||||
if (tab === 'to-ldap' || tab === 'from-ldap') {
|
||||
ldapApi.getConfig().then((res: any) => {
|
||||
setLdapReady(!!res?.ldap_host);
|
||||
}).catch(() => setLdapReady(false));
|
||||
if (ldapOus.length === 0) loadLdapOus();
|
||||
}
|
||||
}, [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(res?.ous || []);
|
||||
} 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(res?.users || []);
|
||||
} 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;
|
||||
};
|
||||
|
||||
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 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="w-full max-w-2xl max-h-[85vh] bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl shadow-xl flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
{/* 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={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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Import to LDAP ── */}
|
||||
{tab === 'to-ldap' && (
|
||||
<>
|
||||
{!ldapReady && (
|
||||
<div className="p-3 rounded-lg bg-yellow-500/10 text-yellow-400 text-sm">
|
||||
LDAP 未配置,请先在「平台配置 → LDAP」中配置 LDAP 连接
|
||||
</div>
|
||||
)}
|
||||
{ldapReady && (
|
||||
<>
|
||||
{/* OU selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">目标 OU</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={targetOu}
|
||||
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>
|
||||
))}
|
||||
</select>
|
||||
<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>
|
||||
</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>
|
||||
<input ref={fileRef} type="file" accept=".csv" className="hidden" onChange={handleFile} />
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">CSV 需包含"姓名"列,系统自动生成拼音用户名和密码</span>
|
||||
</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">密码(自动)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.slice(0, 30).map((r, i) => (
|
||||
<tr key={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 text-[var(--dg-text-muted)]">{r.password}</td>
|
||||
</tr>
|
||||
))}
|
||||
{preview.length > 30 && (
|
||||
<tr><td colSpan={3} className="py-2 text-center text-[var(--dg-text-muted)]">... 共 {preview.length} 条</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── From LDAP Import ── */}
|
||||
{tab === 'from-ldap' && (
|
||||
<>
|
||||
{!ldapReady && (
|
||||
<div className="p-3 rounded-lg bg-yellow-500/10 text-yellow-400 text-sm">
|
||||
LDAP 未配置,请先在「平台配置 → LDAP」中配置 LDAP 连接
|
||||
</div>
|
||||
)}
|
||||
{ldapLoading && <p className="text-sm text-[var(--dg-text-muted)] text-center py-4">加载中...</p>}
|
||||
{!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">
|
||||
<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>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{ldapUsers.length > 0 && (
|
||||
<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)]">
|
||||
{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; })} />
|
||||
<span className="text-sm font-medium">{u.nickname || u.username}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">@{u.username}</span>
|
||||
{u.email && <span className="text-xs text-[var(--dg-text-muted)]">{u.email}</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!ldapLoading && ldapReady && ldapOus.length === 0 && (
|
||||
<p className="text-sm text-[var(--dg-text-muted)] text-center py-4">LDAP 目录中无可用 OU,请先创建 OU 或检查配置</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] text-sm space-y-1">
|
||||
<p className="text-green-400">成功: {result.success_count ?? (result.success ? 1 : 0)}</p>
|
||||
{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={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>}
|
||||
</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>
|
||||
|
||||
{/* Create OU sub-dialog */}
|
||||
{showCreateOu && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<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>
|
||||
</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="例如:研发部、市场部" />
|
||||
</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="可选描述" />
|
||||
</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">
|
||||
{creatingOu ? '创建中...' : '创建 OU'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user