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

@@ -1,67 +1,21 @@
# OpsxSQL 查询体验与工单流程优化(借鉴 opsxdb / opsx-sec)
# LDAP:借鉴 aiops 的 AD 兼容与配置门控 + 连不上根因说明
**用户已确认**:① 移除工单创建页死的「定时执行」字段 ② 全部四块都做。
**不做**(明确出界):多 tab 页签系统、草稿自动保存、批量审批、Redis Hash 值级 HSCAN 分页——后续迭代。
研究结论:LDAP 参数本身没问题(本机对 8.217.205.92:389 实测连接/导入/登录全通,aiops 种子配置与 opsxsql 同源)。「连不了」的根因在**你正在跑的旧后端实例**:① 其 sqlite 只读(实测报 `attempt to write a readonly database`,LDAP 配置根本存不进去);② 旧二进制还有 CORS 白名单 bug(非 5173 端口的前端所有写请求空 403)。需重启到新构建后端+可写 DB。本次按 aiops 补两块真差距:
---
## 1. 后端借鉴 aiops(controller/ldap.go)——AD/OpenLDAP 双兼容
opsxsql 只支持 OpenLDAP(objectClass=inetOrgPerson + uid),AD 域(sAMAccountName)会搜不到。移植 aiops 的:
- **多轮过滤器**(ldapUserFilter/ldapADUserFilter/ldapUserListFilters):配置过滤器优先 → `(objectClass=inetOrgPerson)` → AD `(&(objectClass=user)(|(sAMAccountName=*)...))` 兜底;登录时 OpenLDAP 过滤器 + AD 过滤器两轮尝试
- **属性回退链**(ldapEntryAttr):uid→sAMAccountName→userPrincipalName、cn→displayName、mail、telephoneNumber→mobile,映射解析按回退取值
- 应用于 tryLDAPAuth(handler/auth.go)、ListLDAPUsers、BatchImportLDAP
- 保留 opsxsql 的 AES 密码加密(aiops 是明文,不倒退)
## Phase 1 — 后端 Redis 扫描服务化(借鉴 opsxdb server/internal/db/redis.go)
## 2. 前端配置门控(aiops `{ldapConfig && <Section/>}` 模式)
- **LdapConfigPage**:GetLDAPConfig 返回 null(未配置)时,隐藏「LDAP 账号管理」(LdapManagePanel/LdapUserTable/建 OU/建用户/账号测试),显示"未配置,先保存基础连接"引导态;保存成功后管理区出现
- **BatchImportModal**(UsersPage):未配置 LDAP 时隐藏「导入到 LDAP / 从 LDAP 导入」两个 tab,仅保留本地 CSV 导入
改造 `backend/internal/handler/redis.go`:
## 3. 实测验证(测试后端,独立 DB)
- 全新库未配置态:LdapConfigPage 管理区隐藏 + 导入弹窗无 LDAP tab
- 写入配置(8.217.205.92:389/dc=opsx,dc=vip/admin):管理区出现 → OU(devops/test)/用户列表(OpenLDAP 过滤器)→ 批量导入 → logan LDAP 登录复测
- 全量门禁(tsc/build/28 测试)后提交
1. **SCAN 增量游标分页**(对标 opsxdb 的不透明游标设计):
- `keyScanPosition{Pattern, DB, Nodes, Index, Cursor}` JSON→base64 编码回传;下次请求带回,服务端校验 pattern/db/节点拓扑一致,不一致报「搜索位置已失效」
- 每页上限 200 键,单页最多 20 次 SCAN 调用,完整批次一起返回
- 响应:`{keys:[{name,type,ttl}], next_cursor, exact?}` — **无通配符输入时先 TYPE 精确探测,命中作为 exact 项置顶**
2. **pattern 规则**:`redisBrowsePattern` — 无 `*?[` 通配符自动包 `*关键词*`;有则按 glob 原样
3. **pipeline 批量 TYPE/TTL**:替换现逐 key 串行 N+1(大库必超时);type 筛选参数真正生效(pipeline 取到 type 后过滤)
4. **集群归一**:`ScanAllKeys` 改为 ForEachMaster 各分片**增量** SCAN 归并排序;cluster 模式只暴露 DB0;MOVED 错误追加「请将部署模式切为集群并填种子节点」提示
5. **CLI 修复**(现为必 403 的死功能):后端 `RedisExecuteCommand` 接受 `{db, command}` 整串——按空白拆 args(引号感知),白名单匹配首词;前端契约同步(api/datasource/redis.ts);危险命令清单二段确认在前端做
6. 更新 key 的 DEL+重写改为按类型的原子命令(Hash 用 HSET/HDEL 行级;保留整 key 重写兜底)
验证:go build/vet/test + curl 实测(单机 redis-server 本地起,若有;否则用实例 fixture 手测游标翻页)
## Phase 2 — 前端 Redis 键浏览重构
新建 `src/features/redis/keyBrowser/` 共享模块(详情页键管理 tab 与 QueriesPage 的 SchemaSidebar Redis 分支两处接入):
1. **hooks/useRedisKeyBrowser.ts**:游标加载状态机(首页/加载更多/重置)、请求序号防竞态(过期响应丢弃)、按键名 Map 增量合并、db/pattern/type 参数
2. **utils/keyTree.ts**(对标 opsxdb src/redis.ts):
- `buildRedisKeyTree` 按 `:` 分隔建 folder 树(文件夹计数徽标、numeric 排序、无分隔符留根层、exact 置顶)
- `formatRedisTtl`(-1 永久/-2 不存在/45s/5m30s/2d5h)、`redisTypeColor` 八类型色点、`removeRedisKeyFromTree` 本地移除(删键不重拉)
3. **搜索框交互**:回车/按钮触发 SCAN(不做防抖——SCAN 有成本,opsxdb 同款取舍);有关键字时显示「清除」;占位提示支持 `*` 通配
4. **树渲染**:叶子只显示末段(hover title 全名)、行尾淡显 `{type} · {ttl}`、底部「加载更多」按钮(title 提示游标扫描语义);DB 切换下拉 cluster 恒 DB0
5. **类型筛选**下拉接真实后端过滤(修死功能)
6. RedisDetailPage 键管理 tab 与 SchemaSidebar Redis 分支替换为新模块(保持各自布局壳)
## Phase 3 — MySQL/PG 查询便捷性(借鉴 opsxdb ObjectTree/SqlEditor)
1. **通用右键菜单组件** `src/components/ContextMenu.tsx`:视口边界翻转、Esc/外点关闭、菜单项分隔线
2. **SchemaSidebar 增强**:
- 表右键菜单:查看数据(生成方言化 SELECT 进编辑器并执行)/ 生成 SQL 子菜单(SELECT/INSERT/UPDATE/DELETE,全列展开+复合主键进 WHERE)/ 复制表名 / 复制建表 DDL(已有 DDL API 则接,无则隐藏)
- 双击表 = 生成 SELECT 预览;列右键:复制列名/复制 table.column
- 搜索框:本地已加载过滤保留
3. **SqlEditor 增强**:「执行选中优先」——Mod-Enter/执行按钮改为选区优先、否则光标所在语句(按分号切分,简单引号感知)
4. **查询历史持久化**(后端 API 已存在闲置):执行成功后自动 saveQueryHistory;历史抽屉改为后端数据源(分页拉取+关键字过滤),点击回填、▶ 回填并执行;收藏 id 集(localStorage)叠加在后端历史上
5. **自定义模板**:QueryTemplatePanel 支持用户自定义片段(localStorage,{name, sql}),与内置并列
## Phase 4 — 工单流程 step 优化(借鉴 opsx-sec)
1. **状态驱动步骤条**:`features/ticket/components/TicketStageSteps.tsx`——工单详情页(及详情弹窗)顶部步骤条:提交→初审→复审(按 workflow 实际 steps 动态生成)→执行→完成;active 由工单 status 推导(数据驱动,非可点击回跳);驳回/失败/回滚显示终态错误色;下方快捷入口按钮(查看 SQL/审批/执行日志)
2. **审批时间线修复**:DetailTimeline 的 ACTION_CONFIG 键改为后端实际 action 值(created/approve/reject/pending_review/executed/...),图标+颜色按事件类型分色(成功绿/警告琥珀/失败红)
3. **审批交互统一**(详情弹窗与详情页行为对齐):
- 通过/驳回:弹出行内评论输入(可选)→确认;列表页行内快捷通过保留但加轻量确认与评论入口;`alert()` 全部换 toast
- 执行:二次确认复述影响面「将对实例 X 库 Y 执行 N 条语句,确认执行?」(opsx-sec 带影响面摘要模式)
- 禁用按钮可解释:不可执行时 disabled+tooltip 原因(等待审批/非执行人/已执行等),`disabledReason()` 纯函数
4. **创建页修正**(TicketCreatePage):
- 移除死的「定时执行」字段;新增「回滚 SQL」采集(后端已支持 rollback_sql);priority 下拉真实可选
- **审批流预览**:选定实例后展示其 workflow 步骤与审批人名单(数据已有,创建前可见谁会审)
- SQL 检测通过才可提交的 gate 保留(已是两段式)
5. **空态引导**:暂无工单空态内嵌「创建变更工单」主按钮(EmptyState 引导动作)
## 实施与验证
- 每 Phase 独立 commit;前端每步过 `tsc --noEmit` + `npm run build` + `audit:pages`(页面不超 700 行),后端过 `go build/vet/test`
- Phase 1/2 完成后:本地起 Go 服务(独立端口+DB),curl 验证游标翻页/pattern/type 过滤/集群归一;浏览器实测 Redis 键浏览(搜索/分组树/加载更多/DB 切换)
- Phase 3/4 完成后:浏览器实测右键菜单/生成 SQL/历史持久化回填、工单步骤条/审批评论/禁用原因/创建页新字段
- 全部完成后跑全量门禁(vitest/tsc/build/go),收尾总结
不改 aiops 项目;测试数据仍在独立临时库,结束清理。
+10 -41
View File
@@ -227,35 +227,11 @@ func tryLDAPAuth(username, password string) (*ldap.Conn, *LDAPAuthInfo, error) {
return nil, nil, fmt.Errorf("LDAP admin bind failed: %w", err)
}
// 4. Build search filter
userFilter := ""
baseFilter := "(objectClass=inetOrgPerson)"
if cfg.SearchFilter != nil && *cfg.SearchFilter != "" {
sf := *cfg.SearchFilter
if strings.Contains(sf, "%s") {
userFilter = strings.ReplaceAll(sf, "%s", ldap.EscapeFilter(username))
} else {
baseFilter = sf
userFilter = fmt.Sprintf("(&%s(uid=%s))", baseFilter, ldap.EscapeFilter(username))
}
} else {
userFilter = fmt.Sprintf("(&%s(uid=%s))", baseFilter, ldap.EscapeFilter(username))
// 4-5. 多轮过滤器搜索用户(OpenLDAP + AD 兜底,借鉴 aiops)
entry, err := ldapSearchFirstUser(conn, &cfg, username)
if err != nil {
return nil, nil, err
}
// 5. Search user
searchReq := ldap.NewSearchRequest(
cfg.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
1, 10, false,
userFilter,
[]string{"uid", "cn", "mail", "telephoneNumber"},
nil,
)
sr, err := conn.Search(searchReq)
if err != nil || len(sr.Entries) == 0 {
return nil, nil, fmt.Errorf("user not found in LDAP")
}
entry := sr.Entries[0]
userDN := entry.DN
// 6. Verify password by binding as user
@@ -273,22 +249,15 @@ func tryLDAPAuth(username, password string) (*ldap.Conn, *LDAPAuthInfo, error) {
json.Unmarshal([]byte(*cfg.UserMapping), &userMapping)
}
getAttr := func(key string) string {
if v := entry.GetAttributeValue(key); v != "" {
return v
}
return ""
}
// 7. 属性回退链提取身份(OpenLDAP 与 AD 字段差异由 ldapEntryAttr 抹平)
identUser, identName, identEmail := ldapIdentityFromEntry(entry, username)
info := &LDAPAuthInfo{
Username: getAttr(userMapping["username"]),
DisplayName: getAttr(userMapping["display_name"]),
Username: identUser,
DisplayName: identName,
}
if email := getAttr(userMapping["email"]); email != "" {
info.Email = &email
}
if info.Username == "" {
info.Username = username
if identEmail != "" {
info.Email = &identEmail
}
return nil, info, nil // conn already closed by defer
+51 -50
View File
@@ -333,41 +333,33 @@ func ListLDAPUsers(c *gin.Context) {
searchBase = req.OuDn
}
baseFilter := "(objectClass=inetOrgPerson)"
if cfg.SearchFilter != nil && *cfg.SearchFilter != "" {
sf := *cfg.SearchFilter
if strings.Contains(sf, "%s") {
baseFilter = strings.ReplaceAll(sf, "%s", "*")
} else {
baseFilter = sf
}
}
sr, err := conn.Search(ldap.NewSearchRequest(
searchBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
200, 0, false, baseFilter,
[]string{"uid", "cn", "mail", "dn"},
nil,
))
if err != nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
// 多轮过滤器(配置优先 → OpenLDAP → AD 兜底),命中后去重合并
type LdapUser struct {
DN string `json:"dn"`
UID string `json:"uid"`
CN string `json:"cn"`
Mail string `json:"mail"`
}
seen := map[string]bool{}
var users []LdapUser
for _, e := range sr.Entries {
users = append(users, LdapUser{
DN: e.DN,
UID: e.GetAttributeValue("uid"),
CN: e.GetAttributeValue("cn"),
Mail: e.GetAttributeValue("mail"),
})
for _, filter := range ldapUserListFilters(&cfg) {
sr, err := conn.Search(ldap.NewSearchRequest(
searchBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
200, 0, false, filter,
[]string{"uid", "sAMAccountName", "userPrincipalName", "cn", "displayName", "mail", "dn"},
nil,
))
if err != nil {
continue
}
for _, e := range sr.Entries {
if seen[e.DN] {
continue
}
seen[e.DN] = true
uid, cn, mail := ldapIdentityFromEntry(e, "")
users = append(users, LdapUser{DN: e.DN, UID: uid, CN: cn, Mail: mail})
}
}
c.JSON(http.StatusOK, users)
}
@@ -531,23 +523,35 @@ func BatchImportLDAP(c *gin.Context) {
searchBase = req.OuDn
}
baseFilter := "(objectClass=inetOrgPerson)"
if cfg.SearchFilter != nil && *cfg.SearchFilter != "" {
sf := *cfg.SearchFilter
if strings.Contains(sf, "%s") {
baseFilter = strings.ReplaceAll(sf, "%s", "*")
} else {
baseFilter = sf
// 多轮过滤器搜索(配置优先 → OpenLDAP → AD 兜底),按 DN 去重合并
type ldapEntryUser struct {
uid, cn, mail string
}
var candidates []ldapEntryUser
seen := map[string]bool{}
for _, filter := range ldapUserListFilters(&cfg) {
sr, err := conn.Search(ldap.NewSearchRequest(
searchBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
500, 0, false, filter,
[]string{"uid", "sAMAccountName", "userPrincipalName", "cn", "displayName", "mail"}, nil,
))
if err != nil {
continue
}
for _, entry := range sr.Entries {
if seen[entry.DN] {
continue
}
seen[entry.DN] = true
uid, cn, mail := ldapIdentityFromEntry(entry, "")
if uid == "" {
continue
}
candidates = append(candidates, ldapEntryUser{uid: uid, cn: cn, mail: mail})
}
}
sr, err := conn.Search(ldap.NewSearchRequest(
searchBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
500, 0, false, baseFilter,
[]string{"uid", "cn", "mail"}, nil,
))
if err != nil {
resp.BadRequest(c, "LDAP 搜索失败: "+err.Error())
if len(candidates) == 0 {
resp.BadRequest(c, "LDAP 搜索无结果:请检查 Base DN/过滤器是否匹配目录结构")
return
}
@@ -556,11 +560,8 @@ func BatchImportLDAP(c *gin.Context) {
skipped := 0
var results []map[string]string
for _, entry := range sr.Entries {
uid := entry.GetAttributeValue("uid")
if uid == "" {
continue
}
for _, entry := range candidates {
uid := entry.uid
if len(req.UserIDs) > 0 {
found := false
for _, id := range req.UserIDs {
@@ -574,8 +575,8 @@ func BatchImportLDAP(c *gin.Context) {
}
}
cn := entry.GetAttributeValue("cn")
mail := entry.GetAttributeValue("mail")
cn := entry.cn
mail := entry.mail
var existing model.User
err := model.DB.Where("username = ?", uid).First(&existing).Error
+111
View File
@@ -0,0 +1,111 @@
package handler
// LDAP OpenLDAP/ActiveDirectory 双兼容助手(移植自 aiops controller/ldap.go):
// - 多轮过滤器:配置过滤器优先,再 OpenLDAP,再 AD 兜底
// - 属性回退链:uid→sAMAccountName→userPrincipalName / cn→displayName / mobile
// 密码仍走本项目的 AES 加密存储(aiops 为明文,不倒退)。
import (
"fmt"
"strings"
"sql-platform/internal/model"
"github.com/go-ldap/ldap/v3"
)
// 用户属性回退集:OpenLDAP 一套、AD 一套
var ldapUserAttrSets = [][]string{
{"uid", "sAMAccountName", "userPrincipalName", "cn"}, // username 回退链
{"displayName", "cn"}, // display_name 回退链
{"mail"},
{"telephoneNumber", "mobile"},
}
// ldapEntryAttr 按属性名回退读取,兼容 OpenLDAP 与 AD 常见字段
func ldapEntryAttr(entry *ldap.Entry, names ...string) string {
for _, name := range names {
if v := entry.GetAttributeValue(name); v != "" {
return v
}
}
return ""
}
// ldapUserFilter 构造兼容 OpenLDAP 与 AD 的用户搜索过滤器
func ldapUserFilter(cfg *model.LDAPConfig, username string) string {
escaped := ldap.EscapeFilter(username)
if cfg.SearchFilter != nil && strings.TrimSpace(*cfg.SearchFilter) != "" {
sf := strings.TrimSpace(*cfg.SearchFilter)
if strings.Contains(sf, "%s") {
return strings.ReplaceAll(sf, "%s", escaped)
}
return fmt.Sprintf("(&%s(|(uid=%s)(sAMAccountName=%s)(userPrincipalName=%s)(cn=%s)))", sf, escaped, escaped, escaped, escaped)
}
return fmt.Sprintf("(|(&(objectClass=inetOrgPerson)(uid=%s))(&(objectClass=user)(|(sAMAccountName=%s)(userPrincipalName=%s)(cn=%s))))",
escaped, escaped, escaped, escaped)
}
// ldapADUserFilter AD 兜底过滤器
func ldapADUserFilter(username string) string {
escaped := ldap.EscapeFilter(username)
return fmt.Sprintf("(|(sAMAccountName=%s)(userPrincipalName=%s)(cn=%s))", escaped, escaped, escaped)
}
// ldapUserListFilters 目录用户列表过滤器:先尊重配置,再补 OpenLDAP/AD 兜底
func ldapUserListFilters(cfg *model.LDAPConfig) []string {
filters := []string{}
if cfg.SearchFilter != nil && strings.TrimSpace(*cfg.SearchFilter) != "" {
sf := strings.TrimSpace(*cfg.SearchFilter)
if strings.Contains(sf, "%s") {
filters = append(filters, strings.ReplaceAll(sf, "%s", "*"))
} else {
filters = append(filters, sf)
}
}
return append(filters,
"(objectClass=inetOrgPerson)",
"(&(objectClass=user)(|(sAMAccountName=*)(userPrincipalName=*)))",
)
}
// ldapSearchFirstUser 多轮过滤器搜索第一个命中的用户(登录认证用)
func ldapSearchFirstUser(conn *ldap.Conn, cfg *model.LDAPConfig, username string) (*ldap.Entry, error) {
for _, filter := range []string{ldapUserFilter(cfg, username), ldapADUserFilter(username)} {
sr, err := conn.Search(ldap.NewSearchRequest(
cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
1, 10, false, filter,
[]string{"uid", "sAMAccountName", "userPrincipalName", "cn", "displayName", "mail", "telephoneNumber", "mobile"},
nil,
))
if err != nil {
continue // 单轮失败(如 AD 过滤器在纯 OpenLDAP 上报错)继续兜底
}
if len(sr.Entries) > 0 {
return sr.Entries[0], nil
}
}
return nil, fmt.Errorf("user not found in LDAP")
}
// ldapIdentityFromEntry 按属性回退链提取用户身份信息
func ldapIdentityFromEntry(entry *ldap.Entry, fallbackUsername string) (username, displayName, email string) {
username = ldapEntryAttr(entry, ldapUserAttrSets[0]...)
if username == "" {
username = fallbackUsername
}
displayName = ldapEntryAttr(entry, ldapUserAttrSets[1]...)
if displayName == "" {
displayName = username
}
email = ldapEntryAttr(entry, ldapUserAttrSets[2]...)
return
}
// ldapIdentityMailPtr 取邮箱指针(无则 nil)
func ldapEmailPtr(entry *ldap.Entry) *string {
if v := ldapEntryAttr(entry, "mail"); v != "" {
return &v
}
return nil
}
@@ -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>