feat(ldap): ad/openldap dual compat and config gating
- multi-round user filters (config filter first, then objectClass=inetOrgPerson, then AD sAMAccountName/userPrincipalName fallback) applied to login search, user listing and batch import; previously pure-AD directories found no users - attribute fallback chains (uid/sAMAccountName/userPrincipalName, cn/displayName, telephoneNumber/mobile) via ldapEntryAttr - import errors explain likely cause instead of empty result - AES-encrypted admin password kept (aiops stores plaintext) - frontend gating per aiops pattern: LDAP account management panel hidden with hint until a config is actually saved (was gated on unsaved form values); batch-import modal hides to/from-LDAP tabs when unconfigured, local CSV always available
This commit is contained in:
1 parent
2c96902fbd
commit
13fb3aceb3
6 files changed
+487
-266
No files matched your search
@@ -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 项目;测试数据仍在独立临时库,结束清理。
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,9 +3,13 @@ import api from '@/api/request';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import {
|
||||
type LdapConfigData, type OuItem, type LdapUser,
|
||||
type LdapTestResult, type LdapAccountTestResult,
|
||||
type CreateOuFormState, type AddUserFormState,
|
||||
type LdapConfigData,
|
||||
type OuItem,
|
||||
type LdapUser,
|
||||
type LdapTestResult,
|
||||
type LdapAccountTestResult,
|
||||
type CreateOuFormState,
|
||||
type AddUserFormState,
|
||||
defaultLdapConfig,
|
||||
} from '@/features/system/ldap/types';
|
||||
import { LdapConfigForm } from '@/features/system/ldap/components/LdapConfigForm';
|
||||
@@ -20,6 +24,8 @@ import { LdapAccountTestDialog } from '@/features/system/ldap/components/LdapAcc
|
||||
*/
|
||||
export default function LdapConfigPage() {
|
||||
const [config, setConfig] = useState<LdapConfigData>(defaultLdapConfig);
|
||||
/** 后端已存在已保存的 LDAP 配置(账号管理/导入入口的门控依据) */
|
||||
const [configured, setConfigured] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasStoredPwd, setHasStoredPwd] = useState(false);
|
||||
@@ -52,7 +58,13 @@ export default function LdapConfigPage() {
|
||||
|
||||
const [showAddUser, setShowAddUser] = useState(false);
|
||||
const [addingUser, setAddingUser] = useState(false);
|
||||
const [addUserForm, setAddUserForm] = useState<AddUserFormState>({ username: '', displayName: '', email: '', password: '', ouDn: '' });
|
||||
const [addUserForm, setAddUserForm] = useState<AddUserFormState>({
|
||||
username: '',
|
||||
displayName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
ouDn: '',
|
||||
});
|
||||
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
@@ -61,9 +73,10 @@ export default function LdapConfigPage() {
|
||||
const fetchConfig = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await api.get('/ldap-config') as any;
|
||||
const res = (await api.get('/ldap-config')) as any;
|
||||
const data = res?.data ?? res;
|
||||
if (data && typeof data === 'object' && data.ldap_host) {
|
||||
setConfigured(true);
|
||||
setConfig({
|
||||
ldap_host: data.ldap_host || '',
|
||||
ldap_port: data.ldap_port || 389,
|
||||
@@ -72,9 +85,10 @@ export default function LdapConfigPage() {
|
||||
admin_password: '',
|
||||
search_filter: data.search_filter || defaultLdapConfig.search_filter,
|
||||
base_dn: data.base_dn || '',
|
||||
user_mapping: typeof data.user_mapping === 'string'
|
||||
? JSON.parse(data.user_mapping)
|
||||
: data.user_mapping || defaultLdapConfig.user_mapping,
|
||||
user_mapping:
|
||||
typeof data.user_mapping === 'string'
|
||||
? JSON.parse(data.user_mapping)
|
||||
: data.user_mapping || defaultLdapConfig.user_mapping,
|
||||
});
|
||||
setHasStoredPwd(!!data.admin_password);
|
||||
}
|
||||
@@ -85,7 +99,9 @@ export default function LdapConfigPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchConfig(); }, [fetchConfig]);
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
/* ── 测试服务器连接(先测后存) ── */
|
||||
const handleTestServer = async () => {
|
||||
@@ -101,10 +117,14 @@ export default function LdapConfigPage() {
|
||||
if (config.admin_password) setHasStoredPwd(true);
|
||||
|
||||
// 测试连接
|
||||
const res = await api.post('/ldap-config/test', {
|
||||
test_user: config.admin_dn,
|
||||
test_password: config.admin_password || '__use_stored__',
|
||||
}, { timeout: 10000 }) as any;
|
||||
const res = (await api.post(
|
||||
'/ldap-config/test',
|
||||
{
|
||||
test_user: config.admin_dn,
|
||||
test_password: config.admin_password || '__use_stored__',
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
)) as any;
|
||||
const data = res?.data ?? res;
|
||||
setTestResult({ ok: true, msg: data?.message || 'LDAP 服务器连接成功' });
|
||||
setServerConnected(true);
|
||||
@@ -127,6 +147,7 @@ export default function LdapConfigPage() {
|
||||
}
|
||||
await api.put('/ldap-config', payload);
|
||||
if (config.admin_password) setHasStoredPwd(true);
|
||||
setConfigured(true);
|
||||
toast('success', 'LDAP 配置保存成功');
|
||||
} catch (e: any) {
|
||||
toast('error', e?.response?.data?.detail || '保存失败');
|
||||
@@ -140,10 +161,14 @@ export default function LdapConfigPage() {
|
||||
setTestingAccount(true);
|
||||
setAccountResult(null);
|
||||
try {
|
||||
const res = await api.post('/ldap-config/test', {
|
||||
test_user: testUser,
|
||||
test_password: testPass,
|
||||
}, { timeout: 10000 }) as any;
|
||||
const res = (await api.post(
|
||||
'/ldap-config/test',
|
||||
{
|
||||
test_user: testUser,
|
||||
test_password: testPass,
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
)) as any;
|
||||
const data = res?.data ?? res;
|
||||
if (data?.success === false) {
|
||||
setAccountResult({ ok: false, msg: data?.message || '验证失败' });
|
||||
@@ -161,7 +186,7 @@ export default function LdapConfigPage() {
|
||||
const fetchOus = async () => {
|
||||
setLoadingOu(true);
|
||||
try {
|
||||
const res = await api.post('/ldap-config/ou-list', {}) as any;
|
||||
const res = (await api.post('/ldap-config/ou-list', {})) as any;
|
||||
const data = res?.data ?? res;
|
||||
setOuList(Array.isArray(data?.ous) ? data.ous : Array.isArray(data) ? data : []);
|
||||
} catch {
|
||||
@@ -177,7 +202,7 @@ export default function LdapConfigPage() {
|
||||
setOuUsers([]);
|
||||
setSelectedDns(new Set());
|
||||
try {
|
||||
const res = await api.post('/ldap-config/user-list', { ou_dn: ou }) as any;
|
||||
const res = (await api.post('/ldap-config/user-list', { ou_dn: ou })) as any;
|
||||
const data = res?.data ?? res;
|
||||
setOuUsers(Array.isArray(data?.users) ? data.users : Array.isArray(data) ? data : []);
|
||||
} catch {
|
||||
@@ -241,7 +266,8 @@ export default function LdapConfigPage() {
|
||||
const confirmDelete = async () => {
|
||||
setShowDeleteConfirm(false);
|
||||
setDeleting(true);
|
||||
let ok = 0, fail = 0;
|
||||
let ok = 0,
|
||||
fail = 0;
|
||||
for (const dn of selectedDns) {
|
||||
try {
|
||||
await api.post('/ldap-config/delete-user', { dn });
|
||||
@@ -269,14 +295,12 @@ export default function LdapConfigPage() {
|
||||
|
||||
/* 打开添加用户弹框:预填当前 OU(与原行为一致) */
|
||||
const openAddUser = () => {
|
||||
setAddUserForm(prev => ({ ...prev, ouDn: selectedOu || config.base_dn }));
|
||||
setAddUserForm((prev) => ({ ...prev, ouDn: selectedOu || config.base_dn }));
|
||||
setShowAddUser(true);
|
||||
};
|
||||
|
||||
const patchCreateOuForm = (patch: Partial<CreateOuFormState>) =>
|
||||
setCreateOuForm(prev => ({ ...prev, ...patch }));
|
||||
const patchAddUserForm = (patch: Partial<AddUserFormState>) =>
|
||||
setAddUserForm(prev => ({ ...prev, ...patch }));
|
||||
const patchCreateOuForm = (patch: Partial<CreateOuFormState>) => setCreateOuForm((prev) => ({ ...prev, ...patch }));
|
||||
const patchAddUserForm = (patch: Partial<AddUserFormState>) => setAddUserForm((prev) => ({ ...prev, ...patch }));
|
||||
|
||||
/* 账号测试:输入变更清空上次结果,关闭时清空结果但保留凭据 */
|
||||
const handleTestUserChange = (value: string) => {
|
||||
@@ -316,8 +340,13 @@ export default function LdapConfigPage() {
|
||||
onOpenAccountTest={() => setShowAccountTest(true)}
|
||||
/>
|
||||
|
||||
{/* ── LDAP 账号管理(折叠区,连接成功后显示) ── */}
|
||||
{config.ldap_host && config.base_dn && (
|
||||
{/* ── LDAP 账号管理(未保存配置前隐藏,保存后出现) ── */}
|
||||
{!loading && !configured && (
|
||||
<div className="px-4 py-3 rounded-lg border border-dashed border-[var(--dg-border)] text-xs text-[var(--dg-text-muted)]">
|
||||
LDAP 未启用:保存基础连接配置后,这里会出现 LDAP 账号管理与用户导入入口
|
||||
</div>
|
||||
)}
|
||||
{configured && (
|
||||
<LdapManagePanel
|
||||
expanded={showManage}
|
||||
onToggle={toggleManage}
|
||||
@@ -385,7 +414,9 @@ export default function LdapConfigPage() {
|
||||
description={`确认删除 ${selectedDns.size} 个 LDAP 用户?此操作不可逆。`}
|
||||
confirmText="确认删除"
|
||||
variant="danger"
|
||||
onConfirm={() => { confirmDelete(); }}
|
||||
onConfirm={() => {
|
||||
confirmDelete();
|
||||
}}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user