From 13fb3aceb3c8a102d4c987d54f5f7e187e27528a Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Mon, 26 Oct 2026 20:08:46 +0800 Subject: [PATCH] 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 --- ...ss_bc7da99a-6d01-4954-8e96-069e6ebb2857.md | 78 +---- backend/internal/handler/auth.go | 51 +-- backend/internal/handler/ldap.go | 101 +++--- backend/internal/handler/ldap_filter.go | 111 ++++++ .../rbac/components/BatchImportModal.tsx | 325 +++++++++++++----- frontend/src/pages/LdapConfigPage.tsx | 87 +++-- 6 files changed, 487 insertions(+), 266 deletions(-) create mode 100644 backend/internal/handler/ldap_filter.go diff --git a/.zcode/plans/plan-sess_bc7da99a-6d01-4954-8e96-069e6ebb2857.md b/.zcode/plans/plan-sess_bc7da99a-6d01-4954-8e96-069e6ebb2857.md index ca48e39..09a969f 100644 --- a/.zcode/plans/plan-sess_bc7da99a-6d01-4954-8e96-069e6ebb2857.md +++ b/.zcode/plans/plan-sess_bc7da99a-6d01-4954-8e96-069e6ebb2857.md @@ -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 &&
}` 模式) +- **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 项目;测试数据仍在独立临时库,结束清理。 \ No newline at end of file diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index cd61c52..54e572f 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -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 diff --git a/backend/internal/handler/ldap.go b/backend/internal/handler/ldap.go index fca4cb6..9de86e1 100644 --- a/backend/internal/handler/ldap.go +++ b/backend/internal/handler/ldap.go @@ -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 diff --git a/backend/internal/handler/ldap_filter.go b/backend/internal/handler/ldap_filter.go new file mode 100644 index 0000000..fa360a8 --- /dev/null +++ b/backend/internal/handler/ldap_filter.go @@ -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 +} diff --git a/frontend/src/features/rbac/components/BatchImportModal.tsx b/frontend/src/features/rbac/components/BatchImportModal.tsx index e2c0b79..6a15987 100644 --- a/frontend/src/features/rbac/components/BatchImportModal.tsx +++ b/frontend/src/features/rbac/components/BatchImportModal.tsx @@ -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(null); const fileRef = useRef(null); - // LDAP state + // LDAP state(ldapEnabled:后端已保存 LDAP 配置才显示相关 Tab) + const [ldapEnabled, setLdapEnabled] = useState(false); const [ldapOus, setLdapOus] = useState([]); const [ldapUsers, setLdapUsers] = useState([]); const [selectedLdapUsers, setSelectedLdapUsers] = useState>(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 */}

批量导入用户

- +
{/* Tabs */}
- {TABS.map(t => { + {TABS.map((t) => { const Icon = t.icon; return ( - ); })} @@ -197,12 +256,21 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps) {tab === 'local' && ( <>
-
@@ -227,7 +295,11 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps) ))} {preview.length > 50 && ( - ... 共 {preview.length} 条 + + + ... 共 {preview.length} 条 + + )} @@ -253,29 +325,45 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)
- -
{/* File upload (same CSV format) */}
- - CSV 需包含"姓名"列,系统自动生成拼音用户名和密码 + + CSV 需包含"姓名"列,系统自动生成拼音用户名和密码 +
{preview.length > 0 && (
@@ -296,7 +384,11 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps) ))} {preview.length > 30 && ( - ... 共 {preview.length} 条 + + + ... 共 {preview.length} 条 + + )} @@ -319,8 +411,12 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps) {!ldapLoading && ldapReady && ldapOus.length > 0 && ldapUsers.length === 0 && (

选择组织单元:

- {ldapOus.map(ou => ( -
- {ldapUsers.map(u => ( -
)} {!ldapLoading && ldapReady && ldapOus.length === 0 && ( -

LDAP 目录中无可用 OU,请先创建 OU 或检查配置

+

+ LDAP 目录中无可用 OU,请先创建 OU 或检查配置 +

)} )} @@ -359,17 +477,30 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps) {result.skipped_count > 0 &&

跳过: {result.skipped_count}

} {result.failed_count > 0 &&

失败: {result.failed_count}

} {result.failed_users?.map((f: any, i: number) => ( -

{f.username}: {f.error}

+

+ {f.username}: {f.error} +

))} - {result.message && !result.success_count &&

{result.message}

} + {result.message && !result.success_count && ( +

{result.message}

+ )} )} {/* Footer */}
- - +
@@ -380,20 +511,44 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)

创建新的组织单位 (OU)

- +
- 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="例如:研发部、市场部" /> + 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="例如:研发部、市场部" + />
- 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="可选描述" /> + 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="可选描述" + />
- - +
diff --git a/frontend/src/pages/LdapConfigPage.tsx b/frontend/src/pages/LdapConfigPage.tsx index 42921db..b5b1953 100644 --- a/frontend/src/pages/LdapConfigPage.tsx +++ b/frontend/src/pages/LdapConfigPage.tsx @@ -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(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({ username: '', displayName: '', email: '', password: '', ouDn: '' }); + const [addUserForm, setAddUserForm] = useState({ + 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) => - setCreateOuForm(prev => ({ ...prev, ...patch })); - const patchAddUserForm = (patch: Partial) => - setAddUserForm(prev => ({ ...prev, ...patch })); + const patchCreateOuForm = (patch: Partial) => setCreateOuForm((prev) => ({ ...prev, ...patch })); + const patchAddUserForm = (patch: Partial) => 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 && ( +
+ LDAP 未启用:保存基础连接配置后,这里会出现 LDAP 账号管理与用户导入入口 +
+ )} + {configured && ( { confirmDelete(); }} + onConfirm={() => { + confirmDelete(); + }} onCancel={() => setShowDeleteConfirm(false)} />