This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 9fced5f2d fix(acl): instance lifecycle, paged rule/user inventories 
and integrity hardening (#1725)
9fced5f2d is described below

commit 9fced5f2d4cdf10de337a004cb25b573e269a946
Author: aias00 <[email protected]>
AuthorDate: Wed Aug 19 15:41:20 2026 +0800

    fix(acl): instance lifecycle, paged rule/user inventories and integrity 
hardening (#1725)
    
    * fix: preserve ACL lifecycle and update integrity
    
    Signed-off-by: liuhy <[email protected]>
    
    * fix: preserve ACL instance context after rebase
    
    * fix: harden persisted plain ACL integrity
    
    * Restore ACL user API export parsing
    
    Constraint: Keep PR #2333 scoped to the verified syntax break in 
web/src/api/acl.ts.
    
    Rejected: Broader ACL API cleanup | user requested only the stray 
duplicated function line removal.
    
    Confidence: high
    
    Scope-risk: narrow
    
    Tested: npm run build; git diff --check
    Signed-off-by: liuhy <[email protected]>
    
    * feat: paginate searchable ACL user inventory
    
    * feat: paginate filtered ACL rule inventory
    
    ---------
    
    Signed-off-by: liuhy <[email protected]>
---
 .../studio/instance/acl/AclController.java         |  24 +-
 .../studio/instance/acl/AclRepository.java         |   4 +-
 .../rocketmq/studio/instance/acl/AclService.java   |  72 +++-
 .../instance/acl/MybatisPlusAclRepository.java     |  66 +++-
 server/src/main/resources/db/schema.sql            |   3 +-
 .../studio/instance/acl/AclControllerTest.java     |  34 +-
 .../studio/instance/acl/AclServiceTest.java        |  57 +++-
 .../instance/acl/MybatisPlusAclRepositoryTest.java | 122 ++++++-
 web/src/api/acl.test.ts                            |  19 +-
 web/src/api/acl.ts                                 |  30 +-
 web/src/i18n/translations.ts                       |  24 +-
 web/src/pages/instance/__tests__/AclPage.test.tsx  | 364 +++++++++++++++++----
 web/src/pages/instance/acl.tsx                     | 205 +++++++++---
 web/src/services/aclService.test.ts                |  16 +-
 web/src/services/aclService.ts                     |  47 ++-
 15 files changed, 919 insertions(+), 168 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclController.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclController.java
index 949e27dfc..e7a2a87fc 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclController.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclController.java
@@ -17,6 +17,7 @@
 package org.apache.rocketmq.studio.instance.acl;
 
 import org.apache.rocketmq.studio.common.domain.DeleteRequestDTO;
+import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.domain.Result;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import jakarta.validation.Valid;
@@ -55,11 +56,17 @@ public class AclController {
     }
 
     @GetMapping("/rules")
-    public Result<List<AclRuleVO>> listRules(
-            @RequestParam(required = false) String clusterId,
+    public Result<PageResult<AclRuleVO>> listRules(
             @RequestParam(required = false) String principal,
-            @RequestParam(required = false) String instanceId) {
-        return Result.ok(aclService.listRules(clusterId, principal, 
instanceId));
+            @RequestParam(required = false) String resource,
+            @RequestParam(required = false) String scope,
+            @RequestParam(required = false) String decision,
+            @RequestParam(required = false) String aclVersion,
+            @RequestParam(required = false) String instanceId,
+            @RequestParam(defaultValue = "1") Integer page,
+            @RequestParam(defaultValue = "20") Integer pageSize) {
+        return Result.ok(aclService.listRules(principal, resource, scope, 
decision, aclVersion,
+                instanceId, page, pageSize));
     }
 
     @PostMapping("/rules/create")
@@ -86,6 +93,15 @@ public class AclController {
         return Result.ok(aclService.listUsers(instanceId));
     }
 
+    @GetMapping("/users/page")
+    public Result<PageResult<AclUserVO>> pageUsers(
+            @RequestParam(required = false) String instanceId,
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int pageSize,
+            @RequestParam(required = false) String keyword) {
+        return Result.ok(aclService.pageUsers(instanceId, page, pageSize, 
keyword));
+    }
+
     @GetMapping("/users/{id}/credentials")
     public ResponseEntity<Result<AclUserVO>> getUserCredentials(@PathVariable 
String id,
             @RequestParam(required = false) String instanceId) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclRepository.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclRepository.java
index 8f1377b96..944cd4d80 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclRepository.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclRepository.java
@@ -16,12 +16,14 @@
  */
 package org.apache.rocketmq.studio.instance.acl;
 
+import org.apache.rocketmq.studio.common.domain.PageResult;
 
 import java.util.List;
 import java.util.Optional;
 
 public interface AclRepository {
-    List<AclRuleVO> findRules(String clusterId, String principal);
+    PageResult<AclRuleVO> findRulePage(String principal, String resource, 
String scope,
+            String decision, String aclVersion, int page, int pageSize);
 
     AclRuleVO saveRule(AclRuleVO rule);
 
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
index b10f945f3..26b818cac 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
@@ -18,7 +18,9 @@ package org.apache.rocketmq.studio.instance.acl;
 
 import org.springframework.util.StringUtils;
 
+import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.common.util.Pagination;
 import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
 import org.apache.rocketmq.studio.common.util.CredentialUtils;
 import org.apache.rocketmq.studio.common.util.EntityIds;
@@ -33,6 +35,7 @@ import org.springframework.stereotype.Service;
 
 import java.security.SecureRandom;
 import java.time.LocalDateTime;
+import java.util.Comparator;
 import java.util.List;
 import java.util.Locale;
 
@@ -42,6 +45,7 @@ import java.util.Locale;
 public class AclService {
 
     private static final SecureRandom CREDENTIAL_RANDOM = new SecureRandom();
+    private static final int DEFAULT_RULE_PAGE_SIZE = 20;
 
     private final AclRepository aclRepository;
     private final OperationAuditService operationAuditService;
@@ -64,12 +68,23 @@ public class AclService {
     }
 
 
-    public List<AclRuleVO> listRules(String clusterId, String principal, 
String instanceId) {
+    public PageResult<AclRuleVO> listRules(String principal, String resource, 
String scope, String decision,
+            String aclVersion, String instanceId, Integer page, Integer 
pageSize) {
+        int normalizedPage = normalizePage(page);
+        int normalizedPageSize = normalizePageSize(pageSize);
         if (isTencentInstance(instanceId)) {
-            return tencentAclService.listRules(instanceId, principal);
+            List<AclRuleVO> filtered = tencentAclService.listRules(instanceId, 
principal).stream()
+                    .filter(rule -> containsIgnoreCase(rule.getResource(), 
resource))
+                    .filter(rule -> equalsIgnoreCase(rule.getScope(), scope))
+                    .filter(rule -> equalsIgnoreCase(rule.getDecision(), 
decision))
+                    .filter(rule -> equalsIgnoreCase(rule.getAclVersion(), 
aclVersion))
+                    .toList();
+            return paginateRules(filtered, normalizedPage, normalizedPageSize);
         }
-        log.info("Listing ACL rules for clusterId={}, principal={}", 
clusterId, principal);
-        return aclRepository.findRules(clusterId, principal);
+        log.info("Listing ACL rules for principal={}, resource={}, scope={}, 
decision={}, aclVersion={}, page={}, pageSize={}",
+                principal, resource, scope, decision, aclVersion, 
normalizedPage, normalizedPageSize);
+        return aclRepository.findRulePage(principal, resource, scope, 
decision, aclVersion,
+                normalizedPage, normalizedPageSize);
     }
 
 
@@ -129,6 +144,25 @@ public class AclService {
                 .toList();
     }
 
+    public PageResult<AclUserVO> pageUsers(String instanceId, int page, int 
pageSize, String keyword) {
+        if (page < 1 || pageSize < 1 || pageSize > 100) {
+            throw new BusinessException(400, "page must be >= 1 and pageSize 
must be between 1 and 100");
+        }
+        String query = keyword == null ? "" : 
keyword.trim().toLowerCase(Locale.ROOT);
+        List<AclUserVO> users = (isTencentInstance(instanceId)
+                ? tencentAclService.listUsers(instanceId) : 
aclRepository.findUsers()).stream()
+                .filter(user -> query.isEmpty()
+                        || containsIgnoreCase(user.getUsername(), query)
+                        || containsIgnoreCase(user.getAccessKey(), query))
+                .sorted(Comparator.comparing(AclUserVO::getGmtCreate, 
Comparator.nullsLast(Comparator.reverseOrder()))
+                        .thenComparing(AclUserVO::getId, 
Comparator.nullsLast(Comparator.naturalOrder())))
+                .toList();
+        int from = (int) Math.min(Pagination.pageOffset(page, pageSize), 
users.size());
+        int to = Math.min(from + pageSize, users.size());
+        return PageResult.of(users.subList(from, 
to).stream().map(this::maskCredentials).toList(),
+                users.size(), page, pageSize);
+    }
+
 
     public AclUserVO createUser(AclUserVO user, String instanceId) {
         if (isTencentInstance(instanceId)) {
@@ -316,6 +350,36 @@ public class AclService {
                 .build();
     }
 
+    private static int normalizePage(Integer page) {
+        return page == null || page < 1 ? 1 : page;
+    }
+
+    private static int normalizePageSize(Integer pageSize) {
+        return pageSize == null || pageSize < 1 ? DEFAULT_RULE_PAGE_SIZE : 
pageSize;
+    }
+
+    private static boolean containsIgnoreCase(String value, String 
expectedFragment) {
+        if (!StringUtils.hasText(expectedFragment)) {
+            return true;
+        }
+        return StringUtils.hasText(value)
+                && 
value.toLowerCase(Locale.ROOT).contains(expectedFragment.toLowerCase(Locale.ROOT));
+    }
+
+    private static boolean equalsIgnoreCase(String value, String expected) {
+        if (!StringUtils.hasText(expected)) {
+            return true;
+        }
+        return StringUtils.hasText(value) && value.equalsIgnoreCase(expected);
+    }
+
+    private static PageResult<AclRuleVO> paginateRules(List<AclRuleVO> rules, 
int page, int pageSize) {
+        int total = rules.size();
+        int fromIndex = Math.min((page - 1) * pageSize, total);
+        int toIndex = Math.min(fromIndex + pageSize, total);
+        return PageResult.of(rules.subList(fromIndex, toIndex), total, page, 
pageSize);
+    }
+
     private void auditRule(String operation, AclRuleVO rule) {
         recordAudit(operation, "ACL_RULE", String.valueOf(rule.getId()), null,
                 "principal=" + rule.getPrincipal());
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepository.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepository.java
index a693e8f3c..4ab94bd05 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepository.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepository.java
@@ -16,8 +16,11 @@
  */
 package org.apache.rocketmq.studio.instance.acl;
 
+import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.common.util.CredentialUtils;
 import org.apache.rocketmq.studio.persistence.entity.RmqAclRule;
@@ -32,8 +35,10 @@ import lombok.RequiredArgsConstructor;
 import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Optional;
+import java.util.Set;
 import java.util.stream.Collectors;
 
 /**
@@ -48,14 +53,15 @@ public class MybatisPlusAclRepository implements 
AclRepository {
     private final RmqAclUserMapper userMapper;
 
     @Override
-    public List<AclRuleVO> findRules(String clusterId, String principal) {
-        QueryWrapper<RmqAclRule> query = new QueryWrapper<RmqAclRule>()
-                .eq(clusterId != null && !clusterId.isBlank(), "scope", 
clusterId)
-                .eq(principal != null && !principal.isBlank(), "principal", 
principal)
-                .orderByAsc("id");
-        return ruleMapper.selectList(query).stream()
+    public PageResult<AclRuleVO> findRulePage(String principal, String 
resource, String scope,
+            String decision, String aclVersion, int page, int pageSize) {
+        QueryWrapper<RmqAclRule> query = ruleQuery(principal, resource, scope, 
decision, aclVersion);
+        IPage<RmqAclRule> mapperPage = ruleMapper.selectPage(new Page<>(page, 
pageSize), query);
+        List<AclRuleVO> items = mapperPage.getRecords().stream()
                 .map(MybatisPlusAclRepository::toRuleVO)
                 .collect(Collectors.toList());
+        return PageResult.of(items, mapperPage.getTotal(), (int) 
mapperPage.getCurrent(),
+                (int) mapperPage.getSize());
     }
 
     @Override
@@ -126,7 +132,9 @@ public class MybatisPlusAclRepository implements 
AclRepository {
         }
         RmqAclUser entity = toUserEntity(user);
         entity.setGmtCreate(existing.getGmtCreate());
-        userMapper.updateById(entity);
+        if (userMapper.updateById(entity) == 0) {
+            return Optional.empty();
+        }
         user.setGmtCreate(existing.getGmtCreate());
         return Optional.of(user);
     }
@@ -169,8 +177,13 @@ public class MybatisPlusAclRepository implements 
AclRepository {
     @Override
     @Transactional
     public PlainAccessConfigVO 
createAndUpdatePlainAccessConfig(PlainAccessConfigVO config) {
-        RmqAclUser existing = userMapper.selectOne(
+        List<RmqAclUser> existingAccounts = userMapper.selectList(
                 new QueryWrapper<RmqAclUser>().eq("access_key", 
config.getAccessKey()));
+        if (existingAccounts.size() > 1) {
+            throw new BusinessException(409, "Multiple plain access accounts 
use accessKey: "
+                    + config.getAccessKey());
+        }
+        RmqAclUser existing = existingAccounts.isEmpty() ? null : 
existingAccounts.get(0);
         boolean secretProvided = StringUtils.hasText(config.getSecretKey());
         if (!secretProvided && existing == null) {
             throw new BusinessException(400, "secretKey is required for a new 
plain access account");
@@ -283,13 +296,16 @@ public class MybatisPlusAclRepository implements 
AclRepository {
     }
 
     private PlainAccessConfigVO toPlainAccessConfig(AclUserVO user) {
-        List<AclRuleVO> userRules = findRules(null, user.getAccessKey());
+        List<AclRuleVO> userRules = 
ruleMapper.selectList(ruleQuery(user.getAccessKey(), null, null, null, null))
+                .stream()
+                .map(MybatisPlusAclRepository::toRuleVO)
+                .collect(Collectors.toList());
         List<String> topicPerms = new ArrayList<>();
         List<String> groupPerms = new ArrayList<>();
         String defaultTopicPerm = null;
         String defaultGroupPerm = null;
         for (AclRuleVO rule : userRules) {
-            String actions = rule.getActions() == null ? "" : String.join(",", 
rule.getActions());
+            String actions = joinNormalizedCsv(rule.getActions());
             if ("Topic".equals(rule.getResourceType())) {
                 topicPerms.add(rule.getResource() + "=" + actions);
             } else if ("Group".equals(rule.getResourceType())) {
@@ -317,6 +333,18 @@ public class MybatisPlusAclRepository implements 
AclRepository {
                 .build();
     }
 
+    private static QueryWrapper<RmqAclRule> ruleQuery(String principal, String 
resource, String scope,
+            String decision, String aclVersion) {
+        return new QueryWrapper<RmqAclRule>()
+                .like(StringUtils.hasText(principal), "principal", principal)
+                .like(StringUtils.hasText(resource), "resource", resource)
+                .eq(StringUtils.hasText(scope), "scope", scope)
+                .eq(StringUtils.hasText(decision), "decision", decision)
+                .eq(StringUtils.hasText(aclVersion), "acl_version", aclVersion)
+                .orderByDesc("gmt_create")
+                .orderByDesc("id");
+    }
+
     private static String[] splitPerm(String entry) {
         if (entry == null) {
             return null;
@@ -352,7 +380,7 @@ public class MybatisPlusAclRepository implements 
AclRepository {
         entity.setResource(rule.getResource());
         entity.setResourceType(rule.getResourceType());
         entity.setResourcePattern(rule.getResourcePattern());
-        entity.setActions(rule.getActions() == null ? null : String.join(",", 
rule.getActions()));
+        entity.setActions(joinNormalizedCsv(rule.getActions()));
         entity.setDecision(rule.getDecision());
         entity.setScope(rule.getScope());
         entity.setAclVersion(rule.getAclVersion());
@@ -381,7 +409,7 @@ public class MybatisPlusAclRepository implements 
AclRepository {
         entity.setAccessKey(user.getAccessKey());
         entity.setSecretKey(CredentialUtils.encodeBase64(user.getSecretKey()));
         entity.setAdmin(user.isAdmin());
-        entity.setClusters(user.getClusters() == null ? null : 
String.join(",", user.getClusters()));
+        entity.setClusters(joinNormalizedCsv(user.getClusters()));
         entity.setGmtCreate(user.getGmtCreate());
         entity.setGmtModified(LocalDateTime.now());
         return entity;
@@ -394,6 +422,20 @@ public class MybatisPlusAclRepository implements 
AclRepository {
         return Arrays.stream(value.split(","))
                 .map(String::trim)
                 .filter(part -> !part.isEmpty())
+                .distinct()
                 .collect(Collectors.toList());
     }
+
+    private static String joinNormalizedCsv(List<String> values) {
+        if (values == null) {
+            return null;
+        }
+        Set<String> normalized = new LinkedHashSet<>();
+        for (String value : values) {
+            if (value != null && !value.isBlank()) {
+                normalized.add(value.trim());
+            }
+        }
+        return normalized.isEmpty() ? null : String.join(",", normalized);
+    }
 }
diff --git a/server/src/main/resources/db/schema.sql 
b/server/src/main/resources/db/schema.sql
index f20f859d6..74063aa21 100644
--- a/server/src/main/resources/db/schema.sql
+++ b/server/src/main/resources/db/schema.sql
@@ -235,7 +235,8 @@ CREATE TABLE IF NOT EXISTS rmq_acl_user (
   clusters VARCHAR(1024) COMMENT '逗号分隔的集群/实例 id',
   white_remote_address VARCHAR(255) COMMENT 'plain access 账号 IP 白名单,空表示不限制',
   PRIMARY KEY (`id`),
-  UNIQUE KEY uk_username (username)
+  UNIQUE KEY uk_username (username),
+  UNIQUE KEY uk_access_key (access_key)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
 -- 13. 告警规则
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclControllerTest.java
index bd88a91ac..60d6b14e8 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclControllerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclControllerTest.java
@@ -18,6 +18,7 @@
 package org.apache.rocketmq.studio.instance.acl;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
@@ -102,28 +103,41 @@ class AclControllerTest {
         rule.setId(1L);
         rule.setGmtCreate(LocalDateTime.of(2026, 1, 1, 0, 0));
 
-        when(aclService.listRules(isNull(), isNull(), 
isNull())).thenReturn(List.of(rule));
+        when(aclService.listRules(isNull(), isNull(), isNull(), isNull(), 
isNull(), isNull(), eq(1), eq(20)))
+                .thenReturn(PageResult.of(java.util.List.of(rule), 1, 1, 20));
 
         mockMvc.perform(get("/api/acl/rules"))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(200))
-                .andExpect(jsonPath("$.data").isArray())
-                .andExpect(jsonPath("$.data[0].id").value(1))
-                .andExpect(jsonPath("$.data[0].principal").value("user1"))
-                .andExpect(jsonPath("$.data[0].decision").value("ALLOW"));
+                .andExpect(jsonPath("$.data.items").isArray())
+                .andExpect(jsonPath("$.data.total").value(1))
+                .andExpect(jsonPath("$.data.page").value(1))
+                .andExpect(jsonPath("$.data.size").value(20))
+                .andExpect(jsonPath("$.data.items[0].id").value(1))
+                
.andExpect(jsonPath("$.data.items[0].principal").value("user1"))
+                
.andExpect(jsonPath("$.data.items[0].decision").value("ALLOW"));
     }
 
     @Test
     void listRulesShouldPassQueryParams() throws Exception {
-        when(aclService.listRules(eq("cluster-1"), eq("user1"), 
isNull())).thenReturn(List.of());
+        when(aclService.listRules(eq("user1"), eq("topic-a"), eq("namespace"), 
eq("DENY"),
+                eq("1.0"), isNull(), eq(3), 
eq(5))).thenReturn(PageResult.empty(3, 5));
 
         mockMvc.perform(get("/api/acl/rules")
-                        .param("clusterId", "cluster-1")
-                        .param("principal", "user1"))
+                        .param("principal", "user1")
+                        .param("resource", "topic-a")
+                        .param("scope", "namespace")
+                        .param("decision", "DENY")
+                        .param("aclVersion", "1.0")
+                        .param("page", "3")
+                        .param("pageSize", "5"))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data").isArray());
+                .andExpect(jsonPath("$.data.items").isArray())
+                .andExpect(jsonPath("$.data.page").value(3))
+                .andExpect(jsonPath("$.data.size").value(5));
 
-        verify(aclService).listRules(eq("cluster-1"), eq("user1"), isNull());
+        verify(aclService).listRules(eq("user1"), eq("topic-a"), 
eq("namespace"), eq("DENY"),
+                eq("1.0"), isNull(), eq(3), eq(5));
     }
 
     @Test
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
index 0142d97ff..c951bdafb 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
@@ -17,6 +17,7 @@
 
 package org.apache.rocketmq.studio.instance.acl;
 
+import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.audit.OperationAuditService;
 import org.apache.rocketmq.studio.model.Acl2PolicyContext;
@@ -88,13 +89,16 @@ class AclServiceTest {
                 
AclRuleVO.builder().principal("user1").resource("topic-1").decision("ALLOW").build(),
                 
AclRuleVO.builder().principal("user2").resource("topic-2").decision("DENY").build()
         );
-        when(aclRepository.findRules("cluster-1", "user1")).thenReturn(rules);
+        when(aclRepository.findRulePage("user1", "topic", "cluster", "ALLOW", 
"2.0", 2, 5))
+                .thenReturn(PageResult.of(rules, 12, 2, 5));
 
-        List<AclRuleVO> result = aclService.listRules("cluster-1", "user1", 
null);
+        PageResult<AclRuleVO> result = aclService.listRules("user1", "topic", 
"cluster",
+                "ALLOW", "2.0", null, 2, 5);
 
-        assertThat(result).hasSize(2);
-        assertThat(result.get(0).getPrincipal()).isEqualTo("user1");
-        verify(aclRepository).findRules("cluster-1", "user1");
+        assertThat(result.getItems()).hasSize(2);
+        assertThat(result.getItems().get(0).getPrincipal()).isEqualTo("user1");
+        assertThat(result.getTotal()).isEqualTo(12);
+        verify(aclRepository).findRulePage("user1", "topic", "cluster", 
"ALLOW", "2.0", 2, 5);
     }
 
     @Test
@@ -128,12 +132,24 @@ class AclServiceTest {
 
     @Test
     void listRulesShouldPassNullFilters() {
-        when(aclRepository.findRules(null, null)).thenReturn(List.of());
+        when(aclRepository.findRulePage(null, null, null, null, null, 1, 20))
+                .thenReturn(PageResult.empty(1, 20));
+
+        PageResult<AclRuleVO> result = aclService.listRules(null, null, null, 
null, null,
+                null, null, null);
+
+        assertThat(result.getItems()).isEmpty();
+        verify(aclRepository).findRulePage(null, null, null, null, null, 1, 
20);
+    }
+
+    @Test
+    void listRulesShouldNormalizeInvalidPaginationBounds() {
+        when(aclRepository.findRulePage(null, null, null, null, null, 1, 20))
+                .thenReturn(PageResult.empty(1, 20));
 
-        List<AclRuleVO> result = aclService.listRules(null, null, null);
+        aclService.listRules(null, null, null, null, null, null, 0, 0);
 
-        assertThat(result).isEmpty();
-        verify(aclRepository).findRules(null, null);
+        verify(aclRepository).findRulePage(null, null, null, null, null, 1, 
20);
     }
 
     @Test
@@ -212,7 +228,8 @@ class AclServiceTest {
     @Test
     void updateRuleShouldRejectUnknownIdInsteadOfCreatingRule() {
         
when(aclRepository.replaceRule(any(AclRuleVO.class))).thenReturn(Optional.empty());
-        when(aclRepository.findRules(null, null)).thenReturn(List.of());
+        when(aclRepository.findRulePage(null, null, null, null, null, 1, 20))
+                .thenReturn(PageResult.empty(1, 20));
         AclRuleVO update = AclRuleVO.builder()
                 .id(999L)
                 .principal("orders")
@@ -224,7 +241,7 @@ class AclServiceTest {
                 .isInstanceOf(BusinessException.class)
                 .hasMessage("ACL rule not found: 999")
                 .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(404));
-        assertThat(aclService.listRules(null, null, null)).isEmpty();
+        assertThat(aclService.listRules(null, null, null, null, null, null, 1, 
20).getItems()).isEmpty();
         verify(aclRepository, never()).saveRule(any(AclRuleVO.class));
     }
 
@@ -498,7 +515,23 @@ class AclServiceTest {
                 .isInstanceOf(BusinessException.class)
                 .hasMessage("ACL user not found: 999")
                 .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(404));
-        verify(aclRepository, never()).saveUser(any(AclUserVO.class));
+        verify(aclRepository, never()).replaceUser(any(AclUserVO.class));
+    }
+
+    @Test
+    void updateUserShouldRejectConcurrentDeletion() {
+        UpdateAclUserDTO input = new UpdateAclUserDTO();
+        input.setId(1L);
+        input.setUsername("renamed");
+        
when(aclRepository.findUserById(1L)).thenReturn(Optional.of(existingUser));
+        
when(aclRepository.replaceUser(any(AclUserVO.class))).thenReturn(Optional.empty());
+
+        assertThatThrownBy(() -> aclService.updateUser(input, null))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("ACL user not found: 1")
+                .satisfies(error -> assertThat(((BusinessException) 
error).getCode()).isEqualTo(404));
+
+        verify(operationAuditService, never()).record(any(), any(), any(), 
any(), any(), any(), any());
     }
 
     @Test
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepositoryTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepositoryTest.java
index efde63eee..a7a5fc195 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepositoryTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepositoryTest.java
@@ -18,6 +18,10 @@ package org.apache.rocketmq.studio.instance.acl;
 
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.common.util.CredentialUtils;
 import org.apache.rocketmq.studio.persistence.entity.RmqAclRule;
@@ -34,6 +38,7 @@ import org.mockito.Mockito;
 import org.mockito.junit.jupiter.MockitoExtension;
 
 import java.time.LocalDateTime;
+import java.util.Arrays;
 import java.util.List;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -57,6 +62,43 @@ class MybatisPlusAclRepositoryTest {
     @InjectMocks
     private MybatisPlusAclRepository repository;
 
+    @Test
+    void findRulePageShouldApplyFiltersAndPreserveFilteredTotal() {
+        RmqAclRule entity = new RmqAclRule();
+        entity.setId(7L);
+        entity.setPrincipal("user-orders");
+        entity.setResource("orders-*");
+        entity.setResourceType("Topic");
+        entity.setResourcePattern("PREFIX");
+        entity.setActions("PUB,SUB");
+        entity.setDecision("ALLOW");
+        entity.setScope("cluster");
+        entity.setAclVersion("2.0");
+        entity.setGmtCreate(LocalDateTime.of(2026, 8, 17, 10, 30));
+        Page<RmqAclRule> mapperPage = new Page<RmqAclRule>(2, 5)
+                .setRecords(List.of(entity))
+                .setTotal(17);
+        when(ruleMapper.selectPage(any(IPage.class), 
any(Wrapper.class))).thenReturn(mapperPage);
+
+        PageResult<AclRuleVO> result = repository.findRulePage(
+                "user", "orders", "cluster", "ALLOW", "2.0", 2, 5);
+
+        ArgumentCaptor<IPage<RmqAclRule>> pageCaptor = 
ArgumentCaptor.forClass(IPage.class);
+        ArgumentCaptor<Wrapper<RmqAclRule>> queryCaptor = 
ArgumentCaptor.forClass(Wrapper.class);
+        verify(ruleMapper).selectPage(pageCaptor.capture(), 
queryCaptor.capture());
+        assertThat(pageCaptor.getValue().getCurrent()).isEqualTo(2);
+        assertThat(pageCaptor.getValue().getSize()).isEqualTo(5);
+        assertThat(result.getTotal()).isEqualTo(17);
+        assertThat(result.getPage()).isEqualTo(2);
+        assertThat(result.getSize()).isEqualTo(5);
+        assertThat(result.getItems()).singleElement().satisfies(rule -> {
+            assertThat(rule.getPrincipal()).isEqualTo("user-orders");
+            assertThat(rule.getResource()).isEqualTo("orders-*");
+        });
+        assertThat(queryCaptor.getValue().getSqlSegment())
+                .contains("principal", "resource", "scope", "decision", 
"acl_version");
+    }
+
     @Test
     void replaceRuleShouldReturnEmptyWhenConcurrentDeleteWins() {
         RmqAclRule existing = new RmqAclRule();
@@ -74,9 +116,28 @@ class MybatisPlusAclRepositoryTest {
         assertThat(repository.replaceRule(replacement)).isEmpty();
     }
 
+    @Test
+    void replaceUserShouldNotRecreateAConcurrentlyDeletedUser() {
+        RmqAclUser existing = new RmqAclUser();
+        existing.setId(1L);
+        existing.setGmtCreate(LocalDateTime.of(2026, 1, 1, 0, 0));
+        when(userMapper.selectById(1L)).thenReturn(existing);
+        when(userMapper.updateById(any(RmqAclUser.class))).thenReturn(0);
+        AclUserVO replacement = AclUserVO.builder()
+                .id(1L)
+                .username("renamed")
+                .accessKey("access-key")
+                .secretKey("secret-key")
+                .build();
+
+        assertThat(repository.replaceUser(replacement)).isEmpty();
+
+        verify(userMapper, never()).insert(any(RmqAclUser.class));
+    }
+
     @Test
     void upsertShouldAssignUniqueRuleIdPerPermission() {
-        when(userMapper.selectOne(any(QueryWrapper.class))).thenReturn(null);
+        
when(userMapper.selectList(any(QueryWrapper.class))).thenReturn(List.of());
         when(userMapper.insert(any(RmqAclUser.class))).thenReturn(1);
         when(ruleMapper.delete(any(QueryWrapper.class))).thenReturn(0);
         java.util.concurrent.atomic.AtomicLong ruleSequence = new 
java.util.concurrent.atomic.AtomicLong();
@@ -114,7 +175,7 @@ class MybatisPlusAclRepositoryTest {
 
     @Test
     void upsertShouldReplacePreviousRulesBeforeInsertingNewOnes() {
-        when(userMapper.selectOne(any(QueryWrapper.class))).thenReturn(null);
+        
when(userMapper.selectList(any(QueryWrapper.class))).thenReturn(List.of());
         when(userMapper.insert(any(RmqAclUser.class))).thenReturn(1);
         when(ruleMapper.delete(any(QueryWrapper.class))).thenReturn(2);
         when(ruleMapper.insert(any(RmqAclRule.class))).thenReturn(1);
@@ -134,7 +195,7 @@ class MybatisPlusAclRepositoryTest {
 
     @Test
     void createShouldRejectBlankSecretForNewAccount() {
-        when(userMapper.selectOne(any(QueryWrapper.class))).thenReturn(null);
+        
when(userMapper.selectList(any(QueryWrapper.class))).thenReturn(List.of());
 
         PlainAccessConfigVO config = PlainAccessConfigVO.builder()
                 .accessKey("svc-new")
@@ -153,7 +214,7 @@ class MybatisPlusAclRepositoryTest {
     void updateWithBlankSecretShouldKeepStoredSecret() {
         RmqAclUser existing = userEntity(1L, "svc-x",
                 CredentialUtils.encodeBase64("kept-secret-value"));
-        
when(userMapper.selectOne(any(QueryWrapper.class))).thenReturn(existing);
+        
when(userMapper.selectList(any(QueryWrapper.class))).thenReturn(List.of(existing));
         when(userMapper.updateById(any(RmqAclUser.class))).thenReturn(1);
         when(ruleMapper.delete(any(QueryWrapper.class))).thenReturn(0);
 
@@ -175,7 +236,7 @@ class MybatisPlusAclRepositoryTest {
 
     @Test
     void createShouldPersistWhiteRemoteAddressAndTrimBlanks() {
-        when(userMapper.selectOne(any(QueryWrapper.class))).thenReturn(null);
+        
when(userMapper.selectList(any(QueryWrapper.class))).thenReturn(List.of());
         when(userMapper.insert(any(RmqAclUser.class))).thenReturn(1);
         when(ruleMapper.delete(any(QueryWrapper.class))).thenReturn(0);
 
@@ -195,7 +256,7 @@ class MybatisPlusAclRepositoryTest {
 
     @Test
     void createShouldStoreBlankWhiteRemoteAddressAsNull() {
-        when(userMapper.selectOne(any(QueryWrapper.class))).thenReturn(null);
+        
when(userMapper.selectList(any(QueryWrapper.class))).thenReturn(List.of());
         when(userMapper.insert(any(RmqAclUser.class))).thenReturn(1);
         when(ruleMapper.delete(any(QueryWrapper.class))).thenReturn(0);
 
@@ -217,7 +278,7 @@ class MybatisPlusAclRepositoryTest {
         RmqAclUser existing = userEntity(1L, "svc-x",
                 CredentialUtils.encodeBase64("kept-secret-value"));
         existing.setWhiteRemoteAddress("10.0.1.0/24");
-        
when(userMapper.selectOne(any(QueryWrapper.class))).thenReturn(existing);
+        
when(userMapper.selectList(any(QueryWrapper.class))).thenReturn(List.of(existing));
         when(userMapper.updateById(any(RmqAclUser.class))).thenReturn(1);
         when(userMapper.update(isNull(), 
any(UpdateWrapper.class))).thenReturn(1);
         when(ruleMapper.delete(any(QueryWrapper.class))).thenReturn(0);
@@ -277,6 +338,53 @@ class MybatisPlusAclRepositoryTest {
         assertThat(config.isAclEnabled()).isTrue();
     }
 
+    @Test
+    void plainAccessUpsertShouldRejectDuplicateAccessKeys() {
+        RmqAclUser first = userEntity(1L, "svc-duplicate", 
CredentialUtils.encodeBase64("secret-a"));
+        RmqAclUser second = userEntity(2L, "svc-duplicate", 
CredentialUtils.encodeBase64("secret-b"));
+        
when(userMapper.selectList(any(QueryWrapper.class))).thenReturn(List.of(first, 
second));
+
+        assertThatThrownBy(() -> 
repository.createAndUpdatePlainAccessConfig(PlainAccessConfigVO.builder()
+                        .accessKey("svc-duplicate")
+                        .secretKey("replacement")
+                        .build()))
+                .isInstanceOf(BusinessException.class)
+                .satisfies(error -> assertThat(((BusinessException) 
error).getCode()).isEqualTo(409));
+
+        verify(userMapper, never()).insert(any(RmqAclUser.class));
+        verify(userMapper, never()).updateById(any(RmqAclUser.class));
+    }
+
+    @Test
+    void saveRuleShouldNormalizeActionCollectionsBeforePersistence() {
+        when(ruleMapper.selectById(1L)).thenReturn(null);
+        when(ruleMapper.insert(any(RmqAclRule.class))).thenReturn(1);
+        AclRuleVO rule = 
AclRuleVO.builder().id(1L).principal("svc-a").resource("orders")
+                .actions(Arrays.asList(" PUB ", null, "", "SUB", "PUB"))
+                .build();
+
+        repository.saveRule(rule);
+
+        ArgumentCaptor<RmqAclRule> captor = 
ArgumentCaptor.forClass(RmqAclRule.class);
+        verify(ruleMapper).insert(captor.capture());
+        assertThat(captor.getValue().getActions()).isEqualTo("PUB,SUB");
+    }
+
+    @Test
+    void saveUserShouldNormalizeClusterCollectionsBeforePersistence() {
+        when(userMapper.selectById(1L)).thenReturn(null);
+        when(userMapper.insert(any(RmqAclUser.class))).thenReturn(1);
+        AclUserVO user = 
AclUserVO.builder().id(1L).username("svc-a").accessKey("svc-a")
+                .secretKey("secret").clusters(Arrays.asList(" cluster-a ", 
null, "", "cluster-b", "cluster-a"))
+                .build();
+
+        repository.saveUser(user);
+
+        ArgumentCaptor<RmqAclUser> captor = 
ArgumentCaptor.forClass(RmqAclUser.class);
+        verify(userMapper).insert(captor.capture());
+        
assertThat(captor.getValue().getClusters()).isEqualTo("cluster-a,cluster-b");
+    }
+
     private static RmqAclUser userEntity(Long id, String accessKey, String 
encodedSecret) {
         RmqAclUser entity = new RmqAclUser();
         entity.setId(id);
diff --git a/web/src/api/acl.test.ts b/web/src/api/acl.test.ts
index 67b38ee63..abf4c357d 100644
--- a/web/src/api/acl.test.ts
+++ b/web/src/api/acl.test.ts
@@ -44,13 +44,26 @@ describe('ACL API contract', () => {
   });
 
   it('uses the controller-supported ACL rule filters', async () => {
-    const params = { clusterId: 'cluster-a', principal: 'orders' };
+    const params = {
+      principal: 'orders',
+      resource: 'orders-*',
+      scope: 'cluster',
+      decision: 'ALLOW',
+      aclVersion: '2.0',
+      page: 2,
+      pageSize: 10,
+    };
     mock.onGet('/acl/rules').reply((config) => {
       expect(config.params).toEqual(params);
-      return [200, { code: 200, data: [] }];
+      return [200, { code: 200, data: { items: [], total: 21, page: 2, size: 
10 } }];
     });
 
-    await expect(listAclRules(params)).resolves.toEqual([]);
+    await expect(listAclRules(params)).resolves.toEqual({
+      items: [],
+      total: 21,
+      page: 2,
+      size: 10,
+    });
   });
 
   it('returns records created by rule and user APIs', async () => {
diff --git a/web/src/api/acl.ts b/web/src/api/acl.ts
index 9fc171e7b..cf85446bd 100644
--- a/web/src/api/acl.ts
+++ b/web/src/api/acl.ts
@@ -1,6 +1,13 @@
 import client from './client';
 
 // Matches mock/acl.ts
+export interface PageResult<T> {
+  items: T[];
+  total: number;
+  page: number;
+  size: number;
+}
+
 export interface AclRule {
   id: number;
   principal: string;
@@ -15,17 +22,29 @@ export interface AclRule {
 }
 
 export interface AclRuleQuery {
-  clusterId?: string;
   principal?: string;
   instanceId?: string;
+  resource?: string;
+  scope?: string;
+  decision?: string;
+  aclVersion?: string;
+  page?: number;
+  pageSize?: number;
 }
 
 // Users list query
-interface AclUserQuery {
+export interface AclUserQuery {
   keyword?: string;
   instanceId?: string;
 }
 
+export interface AclUserPage {
+  items: AclUser[];
+  total: number;
+  page: number;
+  size: number;
+}
+
 export interface AclUser {
   id: number;
   username: string;
@@ -39,7 +58,7 @@ export interface AclUser {
 }
 
 export async function listAclRules(params?: AclRuleQuery) {
-  const res = await client.get<{ data: AclRule[] }>('/acl/rules', { params });
+  const res = await client.get<{ data: PageResult<AclRule> }>('/acl/rules', { 
params });
   return res.data.data;
 }
 
@@ -62,6 +81,11 @@ export async function listAclUsers(params?: AclUserQuery) {
   return res.data.data;
 }
 
+export async function pageAclUsers(params: AclUserQuery & { page: number; 
pageSize: number }) {
+  const res = await client.get<{ data: AclUserPage }>('/acl/users/page', { 
params });
+  return res.data.data;
+}
+
 export async function getAclUserCredentials(id: number, instanceId?: string) {
   const res = await client.get<{ data: AclUser }>(
     `/acl/users/${encodeURIComponent(id)}/credentials`,
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index edfbab170..e77f6a69c 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -322,7 +322,10 @@ const translations: Record<string, Record<Lang, string>> = 
{
   // ─── AI Page ───
   'ai.title': { zh: 'AI 交互', en: 'AI Chat' },
   'ai.commonCommands': { zh: '常用指令', en: 'Common Commands' },
-  'ai.mockProviderDisabled': { zh: 'Mock 模式已禁用 AI Provider 调用', en: 'Mock mode 
disables AI provider calls' },
+  'ai.mockProviderDisabled': {
+    zh: 'Mock 模式已禁用 AI Provider 调用',
+    en: 'Mock mode disables AI provider calls',
+  },
   'ai.mockProviderDisabledDescription': {
     zh: '切换到真实数据模式并配置 LLM Provider 后,才会加载模型、工具目录和对话能力。',
     en: 'Models, the tool catalog and chat capabilities are loaded only after 
you switch to real data mode and configure an LLM provider.',
@@ -342,13 +345,22 @@ const translations: Record<string, Record<Lang, string>> 
= {
   'ai.responseStopped': { zh: '回答已停止。', en: 'Response stopped.' },
   'ai.requestFailed': { zh: 'AI 请求失败', en: 'AI request failed' },
   'ai.runtimeLoadFailed': { zh: 'AI 配置加载失败', en: 'Failed to load AI 
configuration' },
-  'ai.providerRequired': { zh: '请先配置并启用 LLM Provider', en: 'Configure and 
enable an LLM provider first' },
+  'ai.providerRequired': {
+    zh: '请先配置并启用 LLM Provider',
+    en: 'Configure and enable an LLM provider first',
+  },
   'ai.providerNotReadyDescription': {
     zh: '请先在 设置 → AI 助手 中配置并启用 LLM Provider,启用前不会发送请求或返回 stub 回复。',
     en: 'Configure and enable an LLM provider under Settings → AI Assistant 
first. No requests are sent and stub replies may be returned until it is 
enabled.',
   },
-  'ai.toolCatalogLoadFailed': { zh: 'AI 工具目录加载失败', en: 'Failed to load the AI 
tool catalog' },
-  'ai.clusterListLoadFailed': { zh: '集群列表加载失败,已显示全局工具', en: 'Failed to load 
clusters; showing global tools' },
+  'ai.toolCatalogLoadFailed': {
+    zh: 'AI 工具目录加载失败',
+    en: 'Failed to load the AI tool catalog',
+  },
+  'ai.clusterListLoadFailed': {
+    zh: '集群列表加载失败,已显示全局工具',
+    en: 'Failed to load clusters; showing global tools',
+  },
 
   // ─── Home Page ───
   'home.banner': {
@@ -402,7 +414,9 @@ const translations: Record<string, Record<Lang, string>> = {
   'acl.admin': { zh: '管理员', en: 'Admin' },
   'acl.username': { zh: '用户名', en: 'Username' },
   'acl.associatedClusters': { zh: '关联集群', en: 'Associated Clusters' },
-  'acl.searchPrincipal': { zh: '搜索主体或资源', en: 'Search principal or resource' },
+  'acl.searchPrincipal': { zh: '搜索主体', en: 'Search principal' },
+  'acl.searchResource': { zh: '搜索资源', en: 'Search resource' },
+  'acl.allScopes': { zh: '全部范围', en: 'All Scopes' },
   'acl.allVersions': { zh: '全部版本', en: 'All Versions' },
   'acl.allDecisions': { zh: '全部决策', en: 'All Decisions' },
   'acl.totalRules': { zh: '共 {n} 条规则', en: '{n} rules total' },
diff --git a/web/src/pages/instance/__tests__/AclPage.test.tsx 
b/web/src/pages/instance/__tests__/AclPage.test.tsx
index b189abbc9..2d0f86287 100644
--- a/web/src/pages/instance/__tests__/AclPage.test.tsx
+++ b/web/src/pages/instance/__tests__/AclPage.test.tsx
@@ -15,14 +15,15 @@
  * limitations under the License.
  */
 
-import { App } from 'antd';
-import { fireEvent, render, screen, waitFor, within } from 
'@testing-library/react';
+import { App, message } from 'antd';
+import { act, fireEvent, render, screen, waitFor, within } from 
'@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import type React from 'react';
 import { MemoryRouter } from 'react-router-dom';
 import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
 import { LangProvider } from '../../../i18n/LangContext';
 import * as aclService from '../../../services/aclService';
+import * as instanceService from '../../../services/instanceService';
 import AclPage from '../acl';
 
 vi.mock('../../../services/aclService', () => ({
@@ -31,9 +32,11 @@ vi.mock('../../../services/aclService', () => ({
   createAndUpdatePlainAccessConfig: vi.fn(),
   deleteAclRule: vi.fn(),
   deleteAclUser: vi.fn(),
+  getAclUserCredentials: vi.fn(),
   examineBrokerClusterAclConfig: vi.fn(),
   listAclRules: vi.fn(),
   listAclUsers: vi.fn(),
+  pageAclUsers: vi.fn(),
   updateAclRule: vi.fn(),
   updateAclUser: vi.fn(),
 }));
@@ -57,56 +60,119 @@ beforeAll(() => {
   });
 });
 
-const renderWithProviders = (ui: React.ReactElement) =>
+const renderWithProviders = (ui: React.ReactElement, initialEntry = '/') =>
   render(
     <App>
       <LangProvider>
-        <MemoryRouter>{ui}</MemoryRouter>
+        <MemoryRouter initialEntries={[initialEntry]}>{ui}</MemoryRouter>
       </LangProvider>
     </App>,
   );
 
+const deferred = <T,>() => {
+  let resolve!: (value: T) => void;
+  let reject!: (reason?: unknown) => void;
+  const promise = new Promise<T>((resolvePromise, rejectPromise) => {
+    resolve = resolvePromise;
+    reject = rejectPromise;
+  });
+  return { promise, resolve, reject };
+};
+
 describe('ACL page', () => {
   beforeEach(() => {
     vi.clearAllMocks();
-    vi.mocked(aclService.listAclRules).mockResolvedValue([
+    vi.mocked(instanceService.listInstances).mockResolvedValue([]);
+
+    vi.mocked(aclService.listAclRules).mockResolvedValue({
+      items: [
+        {
+          id: 1,
+          principal: 'remote-user',
+          resource: 'remote-topic',
+          resourceType: 'Topic',
+          resourcePattern: 'LITERAL',
+          actions: ['PUB'],
+          decision: 'ALLOW',
+          scope: 'cluster',
+          aclVersion: 2,
+          gmtCreate: '2026-07-23T00:00:00Z',
+        },
+      ],
+      total: 1,
+      page: 1,
+      size: 20,
+    });
+    vi.mocked(aclService.pageAclUsers).mockResolvedValue({
+      items: [
+        {
+          id: 11,
+          username: 'remote-admin',
+          accessKey: 'acce****3456',
+          secretKey: 'secr****7654',
+          admin: true,
+          clusters: ['cluster-a'],
+          gmtCreate: '2026-07-23T00:00:00Z',
+        },
+      ],
+      total: 1,
+      page: 1,
+      size: 20,
+    });
+  });
+
+  it('loads ACL rules and users through the service layer', async () => {
+    renderWithProviders(<AclPage />);
+
+    expect(await screen.findByText('remote-user')).toBeInTheDocument();
+    expect(screen.getByText('remote-topic')).toBeInTheDocument();
+    expect(aclService.listAclRules).toHaveBeenCalledTimes(1);
+    expect(aclService.pageAclUsers).toHaveBeenCalledTimes(1);
+  });
+
+  it('closes an ACL rule dialog when switching to another instance', async () 
=> {
+    const user = userEvent.setup();
+    vi.mocked(instanceService.listInstances).mockResolvedValue([
       {
         id: 1,
-        principal: 'remote-user',
-        resource: 'remote-topic',
-        resourceType: 'Topic',
-        resourcePattern: 'LITERAL',
-        actions: ['PUB'],
-        decision: 'ALLOW',
-        scope: 'cluster',
-        aclVersion: 2,
-        gmtCreate: '2026-07-23T00:00:00Z',
+        name: 'Instance A',
+        type: 'DIRECT',
+        endpoint: '127.0.0.1:9876',
+        remark: '',
+        topicCount: 0,
+        consumerGroupCount: 0,
+        gmtCreate: '',
+        gmtModified: '',
       },
-    ]);
-    vi.mocked(aclService.listAclUsers).mockResolvedValue([
       {
-        id: 11,
-        username: 'remote-admin',
-        accessKey: 'acce****3456',
-        secretKey: 'secr****7654',
-        admin: true,
-        clusters: ['cluster-a'],
-        gmtCreate: '2026-07-23T00:00:00Z',
+        id: 2,
+        name: 'Instance B',
+        type: 'DIRECT',
+        endpoint: '127.0.0.2:9876',
+        remark: '',
+        topicCount: 0,
+        consumerGroupCount: 0,
+        gmtCreate: '',
+        gmtModified: '',
       },
     ]);
-  });
+    renderWithProviders(<AclPage />, '/instance/1/acl');
 
-  it('loads ACL rules and users through the service layer', async () => {
-    renderWithProviders(<AclPage />);
+    expect(await screen.findByText('remote-user')).toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: /添加规则/ }));
+    const dialog = await screen.findByRole('dialog');
+
+    await user.click(screen.getAllByRole('combobox')[0]);
+    await user.click(
+      await screen.findByText('Instance B', { selector: 
'.ant-select-item-option-content' }),
+    );
 
     expect(await screen.findByText('remote-user')).toBeInTheDocument();
-    expect(screen.getByText('remote-topic')).toBeInTheDocument();
-    expect(aclService.listAclRules).toHaveBeenCalledTimes(1);
-    expect(aclService.listAclUsers).toHaveBeenCalledTimes(1);
+    await waitFor(() => expect(dialog).not.toBeInTheDocument());
   });
 
   it('keeps rules available when loading users fails', async () => {
-    vi.mocked(aclService.listAclUsers).mockRejectedValue(new Error('users 
unavailable'));
+    vi.mocked(aclService.pageAclUsers).mockRejectedValue(new Error('users 
unavailable'));
     renderWithProviders(<AclPage />);
 
     expect(await screen.findByText('remote-user')).toBeInTheDocument();
@@ -143,31 +209,41 @@ describe('ACL page', () => {
 
   it('shows missing backend timestamps as unavailable', async () => {
     const user = userEvent.setup();
-    vi.mocked(aclService.listAclRules).mockResolvedValue([
-      {
-        id: 2,
-        principal: 'no-time-rule',
-        resource: 'topic-a',
-        resourceType: 'Topic',
-        resourcePattern: 'LITERAL',
-        actions: ['PUB'],
-        decision: 'ALLOW',
-        scope: 'cluster',
-        aclVersion: 2,
-        gmtCreate: null,
-      },
-    ]);
-    vi.mocked(aclService.listAclUsers).mockResolvedValue([
-      {
-        id: 12,
-        username: 'no-time-user',
-        accessKey: 'acce****3456',
-        secretKey: 'secr****7654',
-        admin: false,
-        clusters: [],
-        gmtCreate: null,
-      },
-    ]);
+    vi.mocked(aclService.listAclRules).mockResolvedValue({
+      items: [
+        {
+          id: 2,
+          principal: 'no-time-rule',
+          resource: 'topic-a',
+          resourceType: 'Topic',
+          resourcePattern: 'LITERAL',
+          actions: ['PUB'],
+          decision: 'ALLOW',
+          scope: 'cluster',
+          aclVersion: 2,
+          gmtCreate: null,
+        },
+      ],
+      total: 1,
+      page: 1,
+      size: 20,
+    });
+    vi.mocked(aclService.pageAclUsers).mockResolvedValue({
+      items: [
+        {
+          id: 12,
+          username: 'no-time-user',
+          accessKey: 'acce****3456',
+          secretKey: 'secr****7654',
+          admin: false,
+          clusters: [],
+          gmtCreate: null,
+        },
+      ],
+      total: 1,
+      page: 1,
+      size: 20,
+    });
     renderWithProviders(<AclPage />);
 
     expect(await screen.findByText('no-time-rule')).toBeInTheDocument();
@@ -366,6 +442,184 @@ describe('ACL page', () => {
     expect(aclService.examineBrokerClusterAclConfig).toHaveBeenCalledTimes(1);
   });
 
+  it('keeps cluster config ownership with the latest examine request', async 
() => {
+    const firstExamine =
+      deferred<Awaited<ReturnType<typeof 
aclService.examineBrokerClusterAclConfig>>>();
+    const secondExamine =
+      deferred<Awaited<ReturnType<typeof 
aclService.examineBrokerClusterAclConfig>>>();
+    const thirdExamine =
+      deferred<Awaited<ReturnType<typeof 
aclService.examineBrokerClusterAclConfig>>>();
+    const successSpy = vi.spyOn(message, 
'success').mockImplementation(vi.fn());
+    const errorSpy = vi.spyOn(message, 'error').mockImplementation(vi.fn());
+    const user = userEvent.setup();
+    vi.mocked(aclService.examineBrokerClusterAclConfig)
+      .mockReturnValueOnce(firstExamine.promise)
+      .mockReturnValueOnce(secondExamine.promise)
+      .mockReturnValueOnce(thirdExamine.promise);
+    renderWithProviders(<AclPage />);
+
+    await user.click(await screen.findByText('集群 ACL 配置'));
+    const clusterInput = screen.getByPlaceholderText('请输入集群 ID');
+    const examineButton = screen.getByRole('button', { name: /检\s*查\s*配\s*置/ 
});
+
+    fireEvent.change(clusterInput, { target: { value: 'cluster-old-1' } });
+    await user.click(examineButton);
+    await waitFor(() =>
+      
expect(aclService.examineBrokerClusterAclConfig).toHaveBeenNthCalledWith(1, 
'cluster-old-1'),
+    );
+
+    await user.clear(clusterInput);
+    await user.type(clusterInput, 'cluster-old-2{enter}');
+    await waitFor(() =>
+      
expect(aclService.examineBrokerClusterAclConfig).toHaveBeenNthCalledWith(2, 
'cluster-old-2'),
+    );
+
+    await user.clear(clusterInput);
+    await user.type(clusterInput, 'cluster-latest{enter}');
+    await waitFor(() =>
+      
expect(aclService.examineBrokerClusterAclConfig).toHaveBeenNthCalledWith(3, 
'cluster-latest'),
+    );
+
+    await act(async () => {
+      firstExamine.resolve({
+        clusterId: 'cluster-old-1',
+        aclEnabled: true,
+        aclVersion: 'ACL stale-1',
+        globalWhiteRemoteAddresses: [],
+        accounts: [
+          {
+            accessKey: 'stale-account-1',
+            admin: true,
+            defaultTopicPerm: 'ALL',
+            defaultGroupPerm: 'ALL',
+            topicPerms: [],
+            groupPerms: [],
+          },
+        ],
+        accountCount: 1,
+      });
+    });
+
+    expect(screen.queryByText('stale-account-1')).not.toBeInTheDocument();
+    expect(successSpy).not.toHaveBeenCalled();
+    expect(errorSpy).not.toHaveBeenCalled();
+    expect(examineButton).toHaveClass('ant-btn-loading');
+
+    await act(async () => {
+      secondExamine.reject(new Error('stale failure'));
+    });
+
+    expect(screen.queryByText('stale-account-1')).not.toBeInTheDocument();
+    expect(successSpy).not.toHaveBeenCalled();
+    expect(errorSpy).not.toHaveBeenCalled();
+    expect(examineButton).toHaveClass('ant-btn-loading');
+
+    await act(async () => {
+      thirdExamine.resolve({
+        clusterId: 'cluster-latest',
+        aclEnabled: true,
+        aclVersion: 'ACL latest',
+        globalWhiteRemoteAddresses: ['10.0.0.0/8'],
+        accounts: [
+          {
+            accessKey: 'latest-account',
+            admin: false,
+            defaultTopicPerm: 'PUB',
+            defaultGroupPerm: 'SUB',
+            topicPerms: ['topic=PUB'],
+            groupPerms: ['group=SUB'],
+          },
+        ],
+        accountCount: 1,
+      });
+    });
+
+    expect(await screen.findByText('latest-account')).toBeInTheDocument();
+    expect(screen.getByText('ACL latest')).toBeInTheDocument();
+    await waitFor(() => 
expect(examineButton).not.toHaveClass('ant-btn-loading'));
+    expect(successSpy).toHaveBeenCalledTimes(1);
+    expect(errorSpy).not.toHaveBeenCalled();
+  });
+
+  it('keeps credential reveal ownership with the latest user generation', 
async () => {
+    type CredentialResponse = Awaited<ReturnType<typeof 
aclService.getAclUserCredentials>>;
+    const firstReveal = deferred<CredentialResponse>();
+    const secondReveal = deferred<CredentialResponse>();
+    const thirdReveal = deferred<CredentialResponse>();
+    const errorSpy = vi.spyOn(message, 'error').mockImplementation(vi.fn());
+    const user = userEvent.setup();
+    vi.mocked(aclService.getAclUserCredentials)
+      .mockReturnValueOnce(firstReveal.promise)
+      .mockReturnValueOnce(secondReveal.promise)
+      .mockReturnValueOnce(thirdReveal.promise);
+    renderWithProviders(<AclPage />);
+
+    await user.click(await screen.findByText('用户管理'));
+    const row = await screen.findByRole('row', { name: /remote-admin/ });
+    const secretCell = screen.getByText('••••••••••••').closest('td');
+    expect(secretCell).not.toBeNull();
+    const revealButton = within(secretCell as HTMLElement).getByRole('button');
+
+    await user.click(revealButton);
+    await waitFor(() => 
expect(aclService.getAclUserCredentials).toHaveBeenCalledTimes(1));
+    expect(within(row).getByText('加载中…')).toBeInTheDocument();
+
+    await user.click(revealButton);
+    expect(within(row).queryByText('加载中…')).not.toBeInTheDocument();
+
+    await user.click(revealButton);
+    await waitFor(() => 
expect(aclService.getAclUserCredentials).toHaveBeenCalledTimes(2));
+    expect(within(row).getByText('加载中…')).toBeInTheDocument();
+
+    await user.click(revealButton);
+    expect(within(row).queryByText('加载中…')).not.toBeInTheDocument();
+
+    await user.click(revealButton);
+    await waitFor(() => 
expect(aclService.getAclUserCredentials).toHaveBeenCalledTimes(3));
+    expect(within(row).getByText('加载中…')).toBeInTheDocument();
+
+    await act(async () => {
+      firstReveal.resolve({
+        id: 1,
+        username: 'remote-admin',
+        accessKey: 'stale-access-key',
+        secretKey: 'stale-secret-key',
+        admin: true,
+        clusters: ['cluster-a'],
+        gmtCreate: '2026-07-23T00:00:00Z',
+      });
+    });
+
+    expect(within(row).getByText('加载中…')).toBeInTheDocument();
+    expect(screen.queryByText('stale-secret-key')).not.toBeInTheDocument();
+    expect(screen.queryByText('stale-access-key')).not.toBeInTheDocument();
+    expect(errorSpy).not.toHaveBeenCalled();
+
+    await act(async () => {
+      secondReveal.reject(new Error('stale reveal failure'));
+    });
+
+    expect(within(row).getByText('加载中…')).toBeInTheDocument();
+    expect(errorSpy).not.toHaveBeenCalled();
+
+    await act(async () => {
+      thirdReveal.resolve({
+        id: 1,
+        username: 'remote-admin',
+        accessKey: 'latest-access-key',
+        secretKey: 'latest-secret-key',
+        admin: true,
+        clusters: ['cluster-a'],
+        gmtCreate: '2026-07-23T00:00:00Z',
+      });
+    });
+
+    expect(await 
within(row).findByText('latest-secret-key')).toBeInTheDocument();
+    expect(within(row).getByText('latest-access-key')).toBeInTheDocument();
+    expect(screen.queryByText('stale-secret-key')).not.toBeInTheDocument();
+    expect(errorSpy).not.toHaveBeenCalled();
+  });
+
   it('creates a plain access account', async () => {
     const user = userEvent.setup();
     vi.mocked(aclService.createAndUpdatePlainAccessConfig).mockResolvedValue({
diff --git a/web/src/pages/instance/acl.tsx b/web/src/pages/instance/acl.tsx
index b6f55ee98..b3d1984e0 100644
--- a/web/src/pages/instance/acl.tsx
+++ b/web/src/pages/instance/acl.tsx
@@ -59,7 +59,7 @@ import {
   getAclUserCredentials,
   examineBrokerClusterAclConfig,
   listAclRules,
-  listAclUsers,
+  pageAclUsers,
   updateAclRule,
   updateAclUser,
 } from '../../services/aclService';
@@ -103,21 +103,41 @@ const isFormValidationError = (error: unknown) =>
 /* ═══════════════════════════════════════════
    ACL Management Page
    ═══════════════════════════════════════════ */
-const AclPage = () => {
+type AclPageContentProps = Pick<
+  ReturnType<typeof useInstanceFilter>,
+  'selectedInstanceId' | 'selectInstance' | 'instanceOptions' | 'instances'
+>;
+
+const AclPageContent = ({
+  selectedInstanceId,
+  selectInstance,
+  instanceOptions,
+  instances,
+}: AclPageContentProps) => {
   const { t } = useLang();
-  const { selectedInstanceId, selectInstance, instanceOptions, instances } = 
useInstanceFilter();
+  const hasSelectedInstance = Boolean(selectedInstanceId);
 
   /* ─── State ─── */
   const [rules, setRules] = useState<AclRule[]>([]);
   const [users, setUsers] = useState<AclUser[]>([]);
-  const [rulesLoading, setRulesLoading] = useState(true);
-  const [usersLoading, setUsersLoading] = useState(true);
+  const [rulesLoading, setRulesLoading] = useState(hasSelectedInstance);
+  const [usersLoading, setUsersLoading] = useState(hasSelectedInstance);
+  const [userPage, setUserPage] = useState(1);
+  const [userPageSize, setUserPageSize] = useState(20);
+  const [userTotal, setUserTotal] = useState(0);
+  const [userKeyword, setUserKeyword] = useState('');
   const [ruleSubmitting, setRuleSubmitting] = useState(false);
   const [userSubmitting, setUserSubmitting] = useState(false);
   const [activeTab, setActiveTab] = useState('rules');
+  const [ruleRefreshKey, setRuleRefreshKey] = useState(0);
+  const [ruleTotal, setRuleTotal] = useState(0);
+  const [rulePage, setRulePage] = useState(1);
+  const [rulePageSize, setRulePageSize] = useState(20);
 
   // Rule filters
-  const [ruleSearch, setRuleSearch] = useState('');
+  const [rulePrincipalFilter, setRulePrincipalFilter] = useState('');
+  const [ruleResourceFilter, setRuleResourceFilter] = useState('');
+  const [ruleScopeFilter, setRuleScopeFilter] = useState<string>('all');
   const [ruleVersionFilter, setRuleVersionFilter] = useState<string>('all');
   const [ruleDecisionFilter, setRuleDecisionFilter] = useState<string>('all');
 
@@ -135,6 +155,7 @@ const AclPage = () => {
   const [revealedKeys, setRevealedKeys] = useState<Set<number>>(new Set());
   const [adminUpdatingIds, setAdminUpdatingIds] = useState<Set<number>>(() => 
new Set());
   const adminUpdateInFlightRef = useRef<Set<number>>(new Set());
+  const revealRequestGenerationRef = useRef<Record<number, number>>({});
   const [credentialsByUser, setCredentialsByUser] = useState<
     Record<number, { accessKey: string; secretKey: string }>
   >({});
@@ -143,6 +164,7 @@ const AclPage = () => {
   const [clusterConfig, setClusterConfig] = useState<AclClusterConfig | 
null>(null);
   const [configLoading, setConfigLoading] = useState(false);
   const [clusterIdInput, setClusterIdInput] = useState('DefaultCluster');
+  const examineRequestGenerationRef = useRef(0);
 
   // Plain access config modal
   const [plainModalOpen, setPlainModalOpen] = useState(false);
@@ -153,9 +175,20 @@ const AclPage = () => {
   useEffect(() => {
     let mounted = true;
 
-    void listAclRules({ instanceId: selectedInstanceId })
+    void listAclRules({
+      instanceId: selectedInstanceId,
+      principal: rulePrincipalFilter || undefined,
+      resource: ruleResourceFilter || undefined,
+      scope: ruleScopeFilter === 'all' ? undefined : ruleScopeFilter,
+      aclVersion: ruleVersionFilter === 'all' ? undefined : ruleVersionFilter,
+      decision: ruleDecisionFilter === 'all' ? undefined : ruleDecisionFilter,
+      page: rulePage,
+      pageSize: rulePageSize,
+    })
       .then((nextRules) => {
-        if (mounted) setRules(nextRules.map(normalizeRule));
+        if (!mounted) return;
+        setRules(nextRules.items.map(normalizeRule));
+        setRuleTotal(nextRules.total);
       })
       .catch(() => {
         if (mounted) message.error(t('common.fetchDataFailed'));
@@ -164,9 +197,17 @@ const AclPage = () => {
         if (mounted) setRulesLoading(false);
       });
 
-    void listAclUsers({ instanceId: selectedInstanceId })
-      .then((nextUsers) => {
-        if (mounted) setUsers(nextUsers.map(normalizeUser));
+    void pageAclUsers({
+      instanceId: selectedInstanceId,
+      page: userPage,
+      pageSize: userPageSize,
+      keyword: userKeyword || undefined,
+    })
+      .then((result) => {
+        if (mounted) {
+          setUsers(result.items.map(normalizeUser));
+          setUserTotal(result.total);
+        }
       })
       .catch(() => {
         if (mounted) message.error(t('common.fetchDataFailed'));
@@ -178,19 +219,21 @@ const AclPage = () => {
     return () => {
       mounted = false;
     };
-  }, [t, selectedInstanceId]);
-
-  /* ─── Filtered rules ─── */
-  const filteredRules = rules.filter((r) => {
-    const aclVersion = String(r.aclVersion);
-    const matchSearch =
-      !ruleSearch ||
-      r.principal.toLowerCase().includes(ruleSearch.toLowerCase()) ||
-      r.resource.toLowerCase().includes(ruleSearch.toLowerCase());
-    const matchVersion = ruleVersionFilter === 'all' || aclVersion === 
ruleVersionFilter;
-    const matchDecision = ruleDecisionFilter === 'all' || r.decision === 
ruleDecisionFilter;
-    return matchSearch && matchVersion && matchDecision;
-  });
+  }, [
+    t,
+    selectedInstanceId,
+    rulePrincipalFilter,
+    ruleResourceFilter,
+    ruleScopeFilter,
+    ruleVersionFilter,
+    ruleDecisionFilter,
+    rulePage,
+    rulePageSize,
+    ruleRefreshKey,
+    userPage,
+    userPageSize,
+    userKeyword,
+  ]);
 
   /* ─── Rule helpers ─── */
   const isAdmin = (principal: string) =>
@@ -239,23 +282,22 @@ const AclPage = () => {
       const values = (await ruleForm.validateFields()) as AclRuleFormValues;
       setRuleSubmitting(true);
       if (editingRule) {
-        const updated = await updateAclRule({
+        await updateAclRule({
           ...editingRule,
           ...values,
           instanceId: selectedInstanceId,
         });
-        const normalized = normalizeRule(updated);
-        setRules((prev) => prev.map((r) => (r.id === editingRule.id ? 
normalized : r)));
         message.success(t('acl.ruleUpdated'));
       } else {
-        const created = await createAclRule({
+        await createAclRule({
           ...values,
           aclVersion: '2.0',
           instanceId: selectedInstanceId,
         });
-        setRules((prev) => [normalizeRule(created), ...prev]);
+        setRulePage(1);
         message.success(t('acl.ruleAdded'));
       }
+      setRuleRefreshKey((prev) => prev + 1);
       setRuleModalOpen(false);
     } catch (error) {
       if (isFormValidationError(error)) return;
@@ -268,7 +310,11 @@ const AclPage = () => {
   const handleDeleteRule = async (id: number) => {
     try {
       await deleteAclRule(id, selectedInstanceId);
-      setRules((prev) => prev.filter((r) => r.id !== id));
+      if (rules.length === 1 && rulePage > 1) {
+        setRulePage((prev) => prev - 1);
+      } else {
+        setRuleRefreshKey((prev) => prev + 1);
+      }
       message.success(t('acl.ruleDeleted'));
     } catch {
       message.error(t('common.operationFailed'));
@@ -278,6 +324,8 @@ const AclPage = () => {
   /* ─── User helpers ─── */
   const toggleRevealKey = async (userId: number) => {
     const revealing = !revealedKeys.has(userId);
+    const revealGeneration = (revealRequestGenerationRef.current[userId] ?? 0) 
+ 1;
+    revealRequestGenerationRef.current[userId] = revealGeneration;
     setRevealedKeys((prev) => {
       const next = new Set(prev);
       if (next.has(userId)) {
@@ -290,6 +338,7 @@ const AclPage = () => {
     if (!revealing || credentialsByUser[userId]) return;
     try {
       const credentials = await getAclUserCredentials(userId, 
selectedInstanceId);
+      if (revealRequestGenerationRef.current[userId] !== revealGeneration) 
return;
       setCredentialsByUser((prev) => ({
         ...prev,
         [userId]: {
@@ -298,6 +347,7 @@ const AclPage = () => {
         },
       }));
     } catch {
+      if (revealRequestGenerationRef.current[userId] !== revealGeneration) 
return;
       setRevealedKeys((prev) => {
         const next = new Set(prev);
         next.delete(userId);
@@ -402,15 +452,21 @@ const AclPage = () => {
       message.warning(t('acl.inputRequired', { field: t('acl.examineCluster') 
}));
       return;
     }
+    const requestGeneration = examineRequestGenerationRef.current + 1;
+    examineRequestGenerationRef.current = requestGeneration;
     try {
       setConfigLoading(true);
       const config = await examineBrokerClusterAclConfig(clusterId);
+      if (examineRequestGenerationRef.current !== requestGeneration) return;
       setClusterConfig(config);
       message.success(t('acl.configExamined'));
     } catch {
+      if (examineRequestGenerationRef.current !== requestGeneration) return;
       message.error(t('common.operationFailed'));
     } finally {
-      setConfigLoading(false);
+      if (examineRequestGenerationRef.current === requestGeneration) {
+        setConfigLoading(false);
+      }
     }
   };
 
@@ -913,18 +969,47 @@ const AclPage = () => {
                       options={instanceOptions}
                       style={{ width: 220 }}
                     />
-                    <Input.Search
+                    <Input
                       placeholder={t('acl.searchPrincipal')}
                       prefix={<MagnifyingGlass size={14} color="#9CA3AF" />}
-                      value={ruleSearch}
-                      onChange={(e) => setRuleSearch(e.target.value)}
-                      onSearch={setRuleSearch}
+                      value={rulePrincipalFilter}
+                      onChange={(e) => {
+                        setRulePage(1);
+                        setRulePrincipalFilter(e.target.value);
+                      }}
                       allowClear
-                      style={{ width: 260 }}
+                      style={{ width: 220 }}
+                    />
+                    <Input
+                      placeholder={t('acl.searchResource')}
+                      prefix={<MagnifyingGlass size={14} color="#9CA3AF" />}
+                      value={ruleResourceFilter}
+                      onChange={(e) => {
+                        setRulePage(1);
+                        setRuleResourceFilter(e.target.value);
+                      }}
+                      allowClear
+                      style={{ width: 220 }}
+                    />
+                    <Select
+                      value={ruleScopeFilter}
+                      onChange={(value) => {
+                        setRulePage(1);
+                        setRuleScopeFilter(value);
+                      }}
+                      style={{ width: 180 }}
+                      options={[
+                        { value: 'all', label: t('acl.allScopes') },
+                        { value: 'cluster', label: t('acl.clusterScope') },
+                        { value: 'namespace', label: t('acl.namespaceScope') },
+                      ]}
                     />
                     <Select
                       value={ruleVersionFilter}
-                      onChange={setRuleVersionFilter}
+                      onChange={(value) => {
+                        setRulePage(1);
+                        setRuleVersionFilter(value);
+                      }}
                       style={{ width: 140 }}
                       options={[
                         { value: 'all', label: t('acl.allVersions') },
@@ -934,7 +1019,10 @@ const AclPage = () => {
                     />
                     <Select
                       value={ruleDecisionFilter}
-                      onChange={setRuleDecisionFilter}
+                      onChange={(value) => {
+                        setRulePage(1);
+                        setRuleDecisionFilter(value);
+                      }}
                       style={{ width: 140 }}
                       options={[
                         { value: 'all', label: t('acl.allDecisions') },
@@ -947,13 +1035,23 @@ const AclPage = () => {
                   {/* Rules table */}
                   <Table
                     columns={ruleColumns}
-                    dataSource={filteredRules}
+                    dataSource={rules}
                     rowKey="id"
                     loading={rulesLoading}
                     pagination={{
-                      pageSize: 20,
+                      current: rulePage,
+                      pageSize: rulePageSize,
+                      total: ruleTotal,
                       showSizeChanger: true,
                       showTotal: (total) => t('acl.totalRules', { n: total }),
+                      onChange: (page, pageSize) => {
+                        if (pageSize !== rulePageSize) {
+                          setRulePage(1);
+                          setRulePageSize(pageSize);
+                          return;
+                        }
+                        setRulePage(page);
+                      },
                     }}
                     size="small"
                   />
@@ -979,6 +1077,15 @@ const AclPage = () => {
                       >
                         {t('acl.addUser')}
                       </Button>
+                      <Input.Search
+                        value={userKeyword}
+                        onChange={(event) => {
+                          setUserPage(1);
+                          setUserKeyword(event.target.value);
+                        }}
+                        allowClear
+                        style={{ width: 240 }}
+                      />
                     </Space>
                   </div>
 
@@ -988,9 +1095,15 @@ const AclPage = () => {
                     rowKey="id"
                     loading={usersLoading}
                     pagination={{
-                      pageSize: 20,
+                      current: userPage,
+                      pageSize: userPageSize,
+                      total: userTotal,
                       showSizeChanger: true,
                       showTotal: (total) => t('acl.totalUsers', { n: total }),
+                      onChange: (page, pageSize) => {
+                        setUserPage(page);
+                        setUserPageSize(pageSize);
+                      },
                     }}
                     size="small"
                   />
@@ -1334,4 +1447,14 @@ const AclPage = () => {
   );
 };
 
+const AclPage = () => {
+  const instanceFilter = useInstanceFilter();
+  return (
+    <AclPageContent
+      key={instanceFilter.selectedInstanceId || 'no-selected-instance'}
+      {...instanceFilter}
+    />
+  );
+};
+
 export default AclPage;
diff --git a/web/src/services/aclService.test.ts 
b/web/src/services/aclService.test.ts
index df01d70f0..98d180c5f 100644
--- a/web/src/services/aclService.test.ts
+++ b/web/src/services/aclService.test.ts
@@ -35,15 +35,15 @@ vi.mock('../config', () => ({
 describe('ACL service mock data', () => {
   it('returns copied ACL rule rows', async () => {
     const first = await listAclRules({ principal: 'user-admin' });
-    expect(first[0].principal).toBe('user-admin');
+    expect(first.items[0].principal).toBe('user-admin');
 
-    first[0].principal = 'mutated-principal';
-    first[0].actions.push('MUTATED');
+    first.items[0].principal = 'mutated-principal';
+    first.items[0].actions.push('MUTATED');
 
     const second = await listAclRules({ principal: 'user-admin' });
-    expect(second[0].principal).toBe('user-admin');
-    expect(second[0].actions).toEqual(['ALL']);
-    expect(second[0]).not.toBe(first[0]);
+    expect(second.items[0].principal).toBe('user-admin');
+    expect(second.items[0].actions).toEqual(['ALL']);
+    expect(second.items[0]).not.toBe(first.items[0]);
   });
 
   it('copies ACL rule arrays on create and update', async () => {
@@ -57,7 +57,7 @@ describe('ACL service mock data', () => {
     created.actions.push('MUTATED');
 
     const afterCreate = await listAclRules({ principal: 
'user-created-copy-test' });
-    expect(afterCreate[0].actions).toEqual(['PUB']);
+    expect(afterCreate.items[0].actions).toEqual(['PUB']);
 
     const updateActions = ['SUB'];
     const updated = await updateAclRule({ id: created.id, actions: 
updateActions });
@@ -65,7 +65,7 @@ describe('ACL service mock data', () => {
     updated.actions.push('MUTATED');
 
     const afterUpdate = await listAclRules({ principal: 
'user-created-copy-test' });
-    expect(afterUpdate[0].actions).toEqual(['SUB']);
+    expect(afterUpdate.items[0].actions).toEqual(['SUB']);
   });
 
   it('returns copied ACL user rows', async () => {
diff --git a/web/src/services/aclService.ts b/web/src/services/aclService.ts
index 3adcb3e23..580ea995e 100644
--- a/web/src/services/aclService.ts
+++ b/web/src/services/aclService.ts
@@ -2,8 +2,10 @@ import { isMockMode } from './dataMode';
 import * as aclApi from '../api/acl';
 import type {
   AclRule,
+  PageResult,
   AclRuleQuery,
   AclUser,
+  AclUserPage,
   AclClusterConfig,
   PlainAccessConfig,
 } from '../api/acl';
@@ -37,14 +39,36 @@ function copyAclUser(user: AclUser): AclUser {
   };
 }
 
-export async function listAclRules(params?: AclRuleQuery): Promise<AclRule[]> {
+export async function listAclRules(params?: AclRuleQuery): 
Promise<PageResult<AclRule>> {
   if (isMockMode()) {
     let result = [...aclRulesState];
     if (params?.principal) {
       const principal = params.principal.toLowerCase();
       result = result.filter((rule) => 
rule.principal.toLowerCase().includes(principal));
     }
-    return result.map(copyAclRule);
+    if (params?.resource) {
+      const resource = params.resource.toLowerCase();
+      result = result.filter((rule) => 
rule.resource.toLowerCase().includes(resource));
+    }
+    if (params?.scope) {
+      result = result.filter((rule) => rule.scope === params.scope);
+    }
+    if (params?.decision) {
+      result = result.filter((rule) => rule.decision === params.decision);
+    }
+    if (params?.aclVersion) {
+      result = result.filter((rule) => String(rule.aclVersion) === 
params.aclVersion);
+    }
+    const page = Math.max(params?.page ?? 1, 1);
+    const pageSize = Math.max(params?.pageSize ?? 20, 1);
+    const fromIndex = Math.min((page - 1) * pageSize, result.length);
+    const toIndex = Math.min(fromIndex + pageSize, result.length);
+    return {
+      items: result.slice(fromIndex, toIndex).map(copyAclRule),
+      total: result.length,
+      page,
+      size: pageSize,
+    };
   }
   return aclApi.listAclRules(params);
 }
@@ -64,6 +88,25 @@ export async function listAclUsers(params?: {
   return aclApi.listAclUsers(params);
 }
 
+export async function pageAclUsers(params: {
+  keyword?: string;
+  instanceId?: string;
+  page: number;
+  pageSize: number;
+}): Promise<AclUserPage> {
+  if (isMockMode()) {
+    const users = await listAclUsers(params);
+    const from = (params.page - 1) * params.pageSize;
+    return {
+      items: users.slice(from, from + params.pageSize),
+      total: users.length,
+      page: params.page,
+      size: params.pageSize,
+    };
+  }
+  return aclApi.pageAclUsers(params);
+}
+
 export async function getAclUserCredentials(id: number, instanceId?: string): 
Promise<AclUser> {
   if (isMockMode()) {
     const user = aclUsersState.find((u) => u.id === id);

Reply via email to