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 0a5966618 feat(auth): add user session overview and revoke action 
(#4060)
0a5966618 is described below

commit 0a59666189ba424ef77403b41f0f60df52f0a47c
Author: coder999o <[email protected]>
AuthorDate: Tue Sep 8 16:45:10 2026 +0800

    feat(auth): add user session overview and revoke action (#4060)
---
 .../rocketmq/studio/auth/AuthInterceptor.java      |   6 +-
 .../apache/rocketmq/studio/auth/AuthService.java   | 122 ++++++++++++++-
 .../rocketmq/studio/auth/StudioUserController.java |  26 +++-
 ...serVO.java => StudioUserSessionOverviewVO.java} |  29 +---
 ...oUserVO.java => StudioUserSessionRevokeVO.java} |  25 +--
 ...UserVO.java => StudioUserSessionSummaryVO.java} |  25 +--
 .../apache/rocketmq/studio/auth/StudioUserVO.java  |  12 ++
 .../rocketmq/studio/common/util/JdbcRowValues.java | 113 ++++++++++++++
 .../ops/audit/MybatisPlusAuditRepository.java      |  34 +---
 .../rocketmq/studio/auth/AuthInterceptorTest.java  |  30 ++++
 .../studio/auth/AuthServiceDatabaseTest.java       | 145 +++++++++++++++++
 .../AuthServiceSessionOverviewIntegrationTest.java | 113 ++++++++++++++
 .../studio/auth/StudioUserControllerTest.java      |  50 ++++++
 .../studio/common/util/JdbcRowValuesTest.java      | 139 +++++++++++++++++
 web/src/api/studioUsers.test.ts                    |  39 ++++-
 web/src/api/studioUsers.ts                         |  31 ++++
 web/src/pages/studio/UserManagement.tsx            | 171 ++++++++++++++++++---
 .../pages/studio/__tests__/UserManagement.test.tsx |  68 ++++++++
 18 files changed, 1061 insertions(+), 117 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java 
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
index fc552d119..8e430788b 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
@@ -125,13 +125,17 @@ public class AuthInterceptor implements 
HandlerInterceptor {
         String normalizedPath = normalizePath(stripPathParameters(path));
         return "/api/llm/config".equals(normalizedPath)
                 || "/api/llm/models".equals(normalizedPath)
-                || "/api/studio-users".equals(normalizedPath)
+                || isStudioUserPath(normalizedPath)
                 || isCloudCatalogPath(normalizedPath)
                 || "/api/acl/remote/rules".equals(normalizedPath)
                 || isCredentialRevealPath(normalizedPath, "/api/acl/users/")
                 || isCredentialRevealPath(normalizedPath, 
"/api/cloud-credentials/");
     }
 
+    private boolean isStudioUserPath(String path) {
+        return "/api/studio-users".equals(path) || 
path.startsWith("/api/studio-users/");
+    }
+
     private boolean isCloudCatalogPath(String path) {
         return path.startsWith("/api/cloud/aliyun/")
                 || path.startsWith("/api/cloud/tencent/");
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java 
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
index 41409d03c..ce93012f9 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
@@ -19,10 +19,12 @@ package org.apache.rocketmq.studio.auth;
 
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Constants;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.common.util.JdbcRowValues;
 import org.apache.rocketmq.studio.persistence.entity.RmqStudioSession;
 import org.apache.rocketmq.studio.persistence.entity.RmqStudioUser;
 import org.apache.rocketmq.studio.persistence.mapper.RmqStudioSessionMapper;
@@ -44,7 +46,10 @@ import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.ZoneOffset;
 import java.util.Base64;
+import java.util.Collection;
+import java.util.Collections;
 import java.util.HashSet;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -67,9 +72,13 @@ public class AuthService {
     private static final int MIN_SESSION_TIMEOUT_MINUTES = 5;
     private static final int MAX_SESSION_TIMEOUT_MINUTES = 1440;
     private static final Duration LAST_SEEN_UPDATE_INTERVAL = 
Duration.ofMinutes(5);
+    private static final Duration SESSION_EXPIRING_SOON_WINDOW = 
Duration.ofMinutes(5);
+    private static final Duration STALE_SESSION_THRESHOLD = 
Duration.ofMinutes(15);
     private static final int MAX_USER_PAGE_SIZE = 100;
     private static final int MAX_USER_SEARCH_LENGTH = 128;
     private static final String TOKEN_PREFIX = "Bearer ";
+    private static final String EXPIRING_SOON_CUTOFF_PARAM = 
"expiringSoonCutoff";
+    private static final String STALE_CUTOFF_PARAM = "staleCutoff";
     private static final SecureRandom TOKEN_RANDOM = new SecureRandom();
 
     private final AuthProperties authProperties;
@@ -188,6 +197,83 @@ public class AuthService {
         return PageResult.of(result.getRecords(), result.getTotal(), page, 
pageSize);
     }
 
+    public Map<Long, StudioUserSessionSummaryVO> listActiveSessionSummaries(
+            Collection<Long> userIds) {
+        requireDatabaseBacked();
+        Set<Long> normalizedUserIds = normalizeUserIds(userIds);
+        if (normalizedUserIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        LocalDateTime current = now();
+        QueryWrapper<RmqStudioSession> query = new 
QueryWrapper<RmqStudioSession>()
+                .select("user_id",
+                        "COUNT(*) AS active_session_count",
+                        "MAX(last_seen_at) AS last_session_seen_at",
+                        "MIN(expires_at) AS nearest_session_expires_at")
+                .in("user_id", normalizedUserIds)
+                .isNull("revoked_at")
+                .gt("expires_at", current)
+                .groupBy("user_id");
+        Map<Long, StudioUserSessionSummaryVO> summaries = new 
ConcurrentHashMap<>();
+        for (Map<String, Object> row : sessionMapper.selectMaps(query)) {
+            Long userId = JdbcRowValues.longValue(row, "user_id");
+            if (userId == null) {
+                continue;
+            }
+            summaries.put(userId, StudioUserSessionSummaryVO.builder()
+                    .userId(userId)
+                    .activeSessionCount(JdbcRowValues.intValueOrZero(row, 
"active_session_count"))
+                    .lastSessionSeenAt(JdbcRowValues.dateTimeValue(row, 
"last_session_seen_at"))
+                    .nearestSessionExpiresAt(
+                            JdbcRowValues.dateTimeValue(row, 
"nearest_session_expires_at"))
+                    .build());
+        }
+        return summaries;
+    }
+
+    /**
+     * Aggregates the session overview with a single database round trip.
+     *
+     * <p>The active total, the distinct active user count and both risk 
buckets come from one
+     * aggregate statement instead of four separate COUNT(*) queries, the same 
trade-off
+     * {@code MybatisPlusAuditRepository#summarize} documents for its result 
buckets. The aggregate
+     * returns exactly one row, so no session is materialized in the 
application to be counted
+     * here, and the bucket boundaries are bound as wrapper parameters instead 
of being inlined
+     * into the SQL text.</p>
+     */
+    public StudioUserSessionOverviewVO getSessionOverview() {
+        requireDatabaseBacked();
+        LocalDateTime current = now();
+        QueryWrapper<RmqStudioSession> query = activeSessionQuery(current)
+                .select("COUNT(*) AS active_session_count",
+                        "COUNT(DISTINCT user_id) AS active_user_count",
+                        "SUM(CASE WHEN expires_at <= " + 
wrapperParam(EXPIRING_SOON_CUTOFF_PARAM)
+                                + " THEN 1 ELSE 0 END) AS 
expiring_soon_session_count",
+                        "SUM(CASE WHEN last_seen_at < " + 
wrapperParam(STALE_CUTOFF_PARAM)
+                                + " THEN 1 ELSE 0 END) AS 
stale_session_count");
+        query.getParamNameValuePairs().put(EXPIRING_SOON_CUTOFF_PARAM,
+                current.plus(SESSION_EXPIRING_SOON_WINDOW));
+        query.getParamNameValuePairs().put(STALE_CUTOFF_PARAM, 
current.minus(STALE_SESSION_THRESHOLD));
+
+        List<Map<String, Object>> rows = sessionMapper.selectMaps(query);
+        Map<String, Object> row = rows.isEmpty() ? Collections.emptyMap() : 
rows.get(0);
+        return StudioUserSessionOverviewVO.builder()
+                .activeSessionCount(JdbcRowValues.longValueOrZero(row, 
"active_session_count"))
+                .activeUserCount(JdbcRowValues.longValueOrZero(row, 
"active_user_count"))
+                .expiringSoonSessionCount(
+                        JdbcRowValues.longValueOrZero(row, 
"expiring_soon_session_count"))
+                .staleSessionCount(JdbcRowValues.longValueOrZero(row, 
"stale_session_count"))
+                
.expiringSoonWindowMinutes(SESSION_EXPIRING_SOON_WINDOW.toMinutes())
+                
.staleSessionThresholdMinutes(STALE_SESSION_THRESHOLD.toMinutes())
+                .build();
+    }
+
+    public int revokeSessionsForUser(Long userId) {
+        requireDatabaseBacked();
+        getUser(userId);
+        return revokeUserSessions(userId);
+    }
+
     public RmqStudioUser createUser(String username, String password, boolean 
admin) {
         requireDatabaseBacked();
         validateUsername(username);
@@ -378,11 +464,41 @@ public class AuthService {
         return update;
     }
 
-    private void revokeUserSessions(Long userId) {
-        sessionMapper.update(null, new UpdateWrapper<RmqStudioSession>()
+    private int revokeUserSessions(Long userId) {
+        LocalDateTime current = now();
+        return sessionMapper.update(null, new UpdateWrapper<RmqStudioSession>()
                 .eq("user_id", userId)
                 .isNull("revoked_at")
-                .set("revoked_at", now()));
+                .gt("expires_at", current)
+                .set("revoked_at", current));
+    }
+
+    private Set<Long> normalizeUserIds(Collection<Long> userIds) {
+        if (userIds == null || userIds.isEmpty()) {
+            return Collections.emptySet();
+        }
+        Set<Long> normalized = new LinkedHashSet<>();
+        for (Long userId : userIds) {
+            if (userId != null) {
+                normalized.add(userId);
+            }
+        }
+        return normalized;
+    }
+
+    private QueryWrapper<RmqStudioSession> activeSessionQuery(LocalDateTime 
current) {
+        return new QueryWrapper<RmqStudioSession>()
+                .isNull("revoked_at")
+                .gt("expires_at", current);
+    }
+
+    /**
+     * Renders the bind-variable reference MyBatis-Plus resolves for a value 
registered in the
+     * wrapper's {@code paramNameValuePairs}, so an aggregate expression can 
be parameterized
+     * instead of having a literal inlined into the SQL text.
+     */
+    private static String wrapperParam(String name) {
+        return "#{" + Constants.WRAPPER + Constants.WRAPPER_PARAM_MIDDLE + 
name + "}";
     }
 
     private void validateLogin(LoginDTO request) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserController.java
 
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserController.java
index 5350e00a2..7019e34de 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserController.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserController.java
@@ -29,6 +29,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
+import java.util.List;
+import java.util.Map;
+
 @RestController
 @RequestMapping("/api/studio-users")
 @RequiredArgsConstructor
@@ -36,6 +39,11 @@ public class StudioUserController {
 
     private final AuthService authService;
 
+    @GetMapping("/sessions/overview")
+    public Result<StudioUserSessionOverviewVO> sessionOverview() {
+        return Result.ok(authService.getSessionOverview());
+    }
+
     @GetMapping
     public Result<PageResult<StudioUserVO>> list(
             @RequestParam(required = false) String search,
@@ -45,8 +53,15 @@ public class StudioUserController {
             @RequestParam(defaultValue = "20") int pageSize) {
         PageResult<RmqStudioUser> result = authService.listUsers(
                 search, admin, enabled, page, pageSize);
+        List<RmqStudioUser> users = result.getItems();
+        Map<Long, StudioUserSessionSummaryVO> sessionSummaries =
+                authService.listActiveSessionSummaries(users.stream()
+                        .map(RmqStudioUser::getId)
+                        .toList());
         return Result.ok(PageResult.of(
-                result.getItems().stream().map(StudioUserVO::from).toList(),
+                users.stream()
+                        .map(user -> StudioUserVO.from(user, 
sessionSummaries.get(user.getId())))
+                        .toList(),
                 result.getTotal(), result.getPage(), result.getSize()));
     }
 
@@ -68,4 +83,13 @@ public class StudioUserController {
         authService.changePassword(userId, null, request.getNewPassword(), 
false);
         return Result.ok();
     }
+
+    @PostMapping("/{userId}/sessions/revoke")
+    public Result<StudioUserSessionRevokeVO> revokeSessions(@PathVariable Long 
userId) {
+        int revokedSessionCount = authService.revokeSessionsForUser(userId);
+        return Result.ok(StudioUserSessionRevokeVO.builder()
+                .userId(userId)
+                .revokedSessionCount(revokedSessionCount)
+                .build());
+    }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserVO.java 
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSessionOverviewVO.java
similarity index 51%
copy from server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserVO.java
copy to 
server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSessionOverviewVO.java
index a5323a737..e3e9385a1 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserVO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSessionOverviewVO.java
@@ -18,30 +18,15 @@ package org.apache.rocketmq.studio.auth;
 
 import lombok.Builder;
 import lombok.Data;
-import org.apache.rocketmq.studio.persistence.entity.RmqStudioUser;
-
-import java.time.LocalDateTime;
 
 @Data
 @Builder
-public class StudioUserVO {
-    private Long id;
-    private String username;
-    private boolean admin;
-    private boolean enabled;
-    private LocalDateTime passwordChangedAt;
-    private LocalDateTime gmtCreate;
-    private LocalDateTime gmtModified;
+public class StudioUserSessionOverviewVO {
 
-    public static StudioUserVO from(RmqStudioUser user) {
-        return StudioUserVO.builder()
-                .id(user.getId())
-                .username(user.getUsername())
-                .admin(Boolean.TRUE.equals(user.getAdmin()))
-                .enabled(Boolean.TRUE.equals(user.getEnabled()))
-                .passwordChangedAt(user.getPasswordChangedAt())
-                .gmtCreate(user.getGmtCreate())
-                .gmtModified(user.getGmtModified())
-                .build();
-    }
+    private long activeSessionCount;
+    private long activeUserCount;
+    private long expiringSoonSessionCount;
+    private long staleSessionCount;
+    private long expiringSoonWindowMinutes;
+    private long staleSessionThresholdMinutes;
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserVO.java 
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSessionRevokeVO.java
similarity index 51%
copy from server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserVO.java
copy to 
server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSessionRevokeVO.java
index a5323a737..a846e5ecd 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserVO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSessionRevokeVO.java
@@ -18,30 +18,11 @@ package org.apache.rocketmq.studio.auth;
 
 import lombok.Builder;
 import lombok.Data;
-import org.apache.rocketmq.studio.persistence.entity.RmqStudioUser;
-
-import java.time.LocalDateTime;
 
 @Data
 @Builder
-public class StudioUserVO {
-    private Long id;
-    private String username;
-    private boolean admin;
-    private boolean enabled;
-    private LocalDateTime passwordChangedAt;
-    private LocalDateTime gmtCreate;
-    private LocalDateTime gmtModified;
+public class StudioUserSessionRevokeVO {
 
-    public static StudioUserVO from(RmqStudioUser user) {
-        return StudioUserVO.builder()
-                .id(user.getId())
-                .username(user.getUsername())
-                .admin(Boolean.TRUE.equals(user.getAdmin()))
-                .enabled(Boolean.TRUE.equals(user.getEnabled()))
-                .passwordChangedAt(user.getPasswordChangedAt())
-                .gmtCreate(user.getGmtCreate())
-                .gmtModified(user.getGmtModified())
-                .build();
-    }
+    private Long userId;
+    private int revokedSessionCount;
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserVO.java 
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSessionSummaryVO.java
similarity index 53%
copy from server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserVO.java
copy to 
server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSessionSummaryVO.java
index a5323a737..100787424 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserVO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSessionSummaryVO.java
@@ -18,30 +18,15 @@ package org.apache.rocketmq.studio.auth;
 
 import lombok.Builder;
 import lombok.Data;
-import org.apache.rocketmq.studio.persistence.entity.RmqStudioUser;
 
 import java.time.LocalDateTime;
 
 @Data
 @Builder
-public class StudioUserVO {
-    private Long id;
-    private String username;
-    private boolean admin;
-    private boolean enabled;
-    private LocalDateTime passwordChangedAt;
-    private LocalDateTime gmtCreate;
-    private LocalDateTime gmtModified;
+public class StudioUserSessionSummaryVO {
 
-    public static StudioUserVO from(RmqStudioUser user) {
-        return StudioUserVO.builder()
-                .id(user.getId())
-                .username(user.getUsername())
-                .admin(Boolean.TRUE.equals(user.getAdmin()))
-                .enabled(Boolean.TRUE.equals(user.getEnabled()))
-                .passwordChangedAt(user.getPasswordChangedAt())
-                .gmtCreate(user.getGmtCreate())
-                .gmtModified(user.getGmtModified())
-                .build();
-    }
+    private Long userId;
+    private int activeSessionCount;
+    private LocalDateTime lastSessionSeenAt;
+    private LocalDateTime nearestSessionExpiresAt;
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserVO.java 
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserVO.java
index a5323a737..cba1d8f33 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserVO.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserVO.java
@@ -29,16 +29,28 @@ public class StudioUserVO {
     private String username;
     private boolean admin;
     private boolean enabled;
+    private int activeSessionCount;
+    private LocalDateTime lastSessionSeenAt;
+    private LocalDateTime nearestSessionExpiresAt;
     private LocalDateTime passwordChangedAt;
     private LocalDateTime gmtCreate;
     private LocalDateTime gmtModified;
 
     public static StudioUserVO from(RmqStudioUser user) {
+        return from(user, null);
+    }
+
+    public static StudioUserVO from(RmqStudioUser user, 
StudioUserSessionSummaryVO sessionSummary) {
         return StudioUserVO.builder()
                 .id(user.getId())
                 .username(user.getUsername())
                 .admin(Boolean.TRUE.equals(user.getAdmin()))
                 .enabled(Boolean.TRUE.equals(user.getEnabled()))
+                .activeSessionCount(sessionSummary == null ? 0 : 
sessionSummary.getActiveSessionCount())
+                .lastSessionSeenAt(sessionSummary == null ? null : 
sessionSummary.getLastSessionSeenAt())
+                .nearestSessionExpiresAt(sessionSummary == null
+                        ? null
+                        : sessionSummary.getNearestSessionExpiresAt())
                 .passwordChangedAt(user.getPasswordChangedAt())
                 .gmtCreate(user.getGmtCreate())
                 .gmtModified(user.getGmtModified())
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/common/util/JdbcRowValues.java
 
b/server/src/main/java/org/apache/rocketmq/studio/common/util/JdbcRowValues.java
new file mode 100644
index 000000000..447d84fe1
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/common/util/JdbcRowValues.java
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.common.util;
+
+import java.sql.Timestamp;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.Date;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * Reads computed / aggregate columns out of the {@code Map<String, Object>} 
rows returned by
+ * MyBatis-Plus {@code selectMaps}.
+ *
+ * <p>Every lookup matches the requested label case-insensitively and ignores 
underscores,
+ * because JDBC drivers are free to return result-set label casing differently 
(MySQL echoes the
+ * alias exactly as written, other drivers upper-case it) and MyBatis may or 
may not translate a
+ * {@code snake_case} label into a {@code camelCase} map key depending on 
configuration. Callers
+ * therefore never have to hard-code a pair of candidate keys.</p>
+ */
+public final class JdbcRowValues {
+
+    private JdbcRowValues() {
+    }
+
+    /** Returns the value behind {@code key}, or {@code null} when no non-null 
value matches. */
+    public static Object value(Map<String, Object> row, String key) {
+        if (row == null || row.isEmpty() || key == null) {
+            return null;
+        }
+        Object direct = row.get(key);
+        if (direct != null) {
+            return direct;
+        }
+        String normalizedKey = normalize(key);
+        for (Map.Entry<String, Object> entry : row.entrySet()) {
+            if (entry.getValue() != null && 
normalizedKey.equals(normalize(entry.getKey()))) {
+                return entry.getValue();
+            }
+        }
+        return null;
+    }
+
+    /** Returns the value behind {@code key} as text, or {@code ""} when it is 
absent or null. */
+    public static String stringValue(Map<String, Object> row, String key) {
+        Object value = value(row, key);
+        return value == null ? "" : value.toString();
+    }
+
+    /**
+     * Returns the value behind {@code key} as a {@link Long}, or {@code null} 
when it is absent,
+     * null or blank. Numeric text is parsed so drivers that stringify 
aggregates still work.
+     */
+    public static Long longValue(Map<String, Object> row, String key) {
+        Object value = value(row, key);
+        if (value instanceof Number number) {
+            return number.longValue();
+        }
+        if (value instanceof String text && !text.isBlank()) {
+            return Long.parseLong(text.trim());
+        }
+        return null;
+    }
+
+    /** Returns the value behind {@code key} as a {@code long}, defaulting to 
zero. */
+    public static long longValueOrZero(Map<String, Object> row, String key) {
+        Long value = longValue(row, key);
+        return value == null ? 0L : value;
+    }
+
+    /** Returns the value behind {@code key} as an {@code int}, defaulting to 
zero. */
+    public static int intValueOrZero(Map<String, Object> row, String key) {
+        Long value = longValue(row, key);
+        return value == null ? 0 : value.intValue();
+    }
+
+    /** Returns the value behind {@code key} as a {@link LocalDateTime}, or 
{@code null}. */
+    public static LocalDateTime dateTimeValue(Map<String, Object> row, String 
key) {
+        Object value = value(row, key);
+        if (value instanceof LocalDateTime localDateTime) {
+            return localDateTime;
+        }
+        if (value instanceof Timestamp timestamp) {
+            return timestamp.toLocalDateTime();
+        }
+        if (value instanceof Date date) {
+            return LocalDateTime.ofInstant(date.toInstant(), ZoneOffset.UTC);
+        }
+        if (value instanceof String text && !text.isBlank()) {
+            return LocalDateTime.parse(text.trim());
+        }
+        return null;
+    }
+
+    private static String normalize(String key) {
+        return key == null ? "" : key.replace("_", 
"").toLowerCase(Locale.ROOT);
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/MybatisPlusAuditRepository.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/MybatisPlusAuditRepository.java
index 06128053e..88de212fe 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/MybatisPlusAuditRepository.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/MybatisPlusAuditRepository.java
@@ -19,6 +19,7 @@ package org.apache.rocketmq.studio.ops.audit;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import org.apache.rocketmq.studio.common.domain.PageResult;
+import org.apache.rocketmq.studio.common.util.JdbcRowValues;
 import org.apache.rocketmq.studio.persistence.entity.RmqOperationAudit;
 import org.apache.rocketmq.studio.persistence.mapper.RmqOperationAuditMapper;
 import org.springframework.stereotype.Repository;
@@ -146,8 +147,8 @@ public class MybatisPlusAuditRepository implements 
AuditRepository {
         filters.accept(query);
         Map<String, Long> counts = new LinkedHashMap<>();
         for (Map<String, Object> row : auditMapper.selectMaps(query)) {
-            String key = mapValue(row, "result");
-            counts.merge(key, parseCount(row, "result_count"), Long::sum);
+            String key = JdbcRowValues.stringValue(row, "result");
+            counts.merge(key, JdbcRowValues.longValueOrZero(row, 
"result_count"), Long::sum);
         }
         return counts;
     }
@@ -161,8 +162,7 @@ public class MybatisPlusAuditRepository implements 
AuditRepository {
         if (rows.isEmpty()) {
             return 0L;
         }
-        String value = mapValue(rows.get(0), "operator_count");
-        return value.isEmpty() ? 0L : Long.parseLong(value);
+        return JdbcRowValues.longValueOrZero(rows.get(0), "operator_count");
     }
 
     private LocalDateTime 
latestOperatedAt(Consumer<QueryWrapper<RmqOperationAudit>> filters) {
@@ -182,8 +182,8 @@ public class MybatisPlusAuditRepository implements 
AuditRepository {
         filters.accept(query);
         return auditMapper.selectMaps(query).stream()
                 .map(row -> AuditSummaryBucketVO.builder()
-                        .name(mapValue(row, "bucket_name"))
-                        .count(parseCount(row, "bucket_count"))
+                        .name(JdbcRowValues.stringValue(row, "bucket_name"))
+                        .count(JdbcRowValues.longValueOrZero(row, 
"bucket_count"))
                         .build())
                 .filter(bucket -> StringUtils.hasText(bucket.getName()))
                 .sorted((left, right) -> {
@@ -194,28 +194,6 @@ public class MybatisPlusAuditRepository implements 
AuditRepository {
                 .toList();
     }
 
-    /**
-     * Reads an aggregate column value from a result row using 
case-insensitive key
-     * matching, because JDBC drivers are free to return label casing 
differently.
-     */
-    private long parseCount(Map<String, Object> row, String key) {
-        String value = mapValue(row, key);
-        if (value.isEmpty()) {
-            return 0L;
-        }
-        return Long.parseLong(value);
-    }
-
-    private String mapValue(Map<String, Object> row, String key) {
-        return row.entrySet().stream()
-                .filter(entry -> key.equalsIgnoreCase(entry.getKey()))
-                .map(Map.Entry::getValue)
-                .filter(Objects::nonNull)
-                .map(Object::toString)
-                .findFirst()
-                .orElse("");
-    }
-
     private void applyFilters(QueryWrapper<RmqOperationAudit> query, String 
search,
                               String operationType, String resourceType, 
String clusterId,
                               LocalDateTime startDate, LocalDateTime endDate, 
String result) {
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
index 5da15ed52..dddad393d 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
@@ -315,6 +315,36 @@ class AuthInterceptorTest {
         assertThat(allowed).isTrue();
     }
 
+    @ParameterizedTest
+    @ValueSource(strings = {
+        "/api/studio-users",
+        "/api/studio-users/",
+        "/api/studio-users/sessions/overview"
+    })
+    void shouldRejectStudioUserReadsForNonAdminUser(String path) throws 
Exception {
+        TestSession session = login(false);
+        MockHttpServletRequest request = authenticatedRequest("GET", path, 
session.token());
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean allowed = session.interceptor().preHandle(request, response, 
new Object());
+
+        assertThat(allowed).isFalse();
+        assertThat(response.getStatus()).isEqualTo(403);
+        assertThat(response.getContentAsString()).contains("Admin permission 
required");
+    }
+
+    @Test
+    void shouldAllowStudioSessionOverviewForAdminUser() throws Exception {
+        TestSession session = login(true);
+        MockHttpServletRequest request = authenticatedRequest(
+                "GET", "/api/studio-users/sessions/overview", session.token());
+
+        boolean allowed = session.interceptor().preHandle(
+                request, new MockHttpServletResponse(), new Object());
+
+        assertThat(allowed).isTrue();
+    }
+
     @Test
     void shouldRejectLlmModelDiscoveryForNonAdminUser() throws Exception {
         TestSession session = login(false);
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
index 5ef2f9eb1..d359046fe 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
@@ -18,6 +18,7 @@ package org.apache.rocketmq.studio.auth;
 
 import com.baomidou.mybatisplus.core.conditions.Wrapper;
 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;
@@ -34,7 +35,9 @@ import java.time.Clock;
 import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.ZoneOffset;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -111,6 +114,113 @@ class AuthServiceDatabaseTest {
         verifyNoInteractions(userMapper, sessionMapper);
     }
 
+    @Test
+    void listActiveSessionSummariesReturnsOnlyActiveSessionRollups() {
+        when(sessionMapper.selectMaps(any(Wrapper.class))).thenReturn(List.of(
+                row("user_id", 1L,
+                        "active_session_count", 2L,
+                        "last_session_seen_at", 
LocalDateTime.parse("2026-08-13T00:05:00"),
+                        "nearest_session_expires_at", 
LocalDateTime.parse("2026-08-13T00:20:00")),
+                row("userId", 2L,
+                        "activeSessionCount", "1",
+                        "lastSessionSeenAt", "2026-08-13T00:02:00",
+                        "nearestSessionExpiresAt", "2026-08-13T00:25:00")));
+
+        Map<Long, StudioUserSessionSummaryVO> summaries =
+                
authService.listActiveSessionSummaries(java.util.Arrays.asList(1L, 2L, 1L, 
null));
+
+        assertThat(summaries).containsOnlyKeys(1L, 2L);
+        assertThat(summaries.get(1L).getActiveSessionCount()).isEqualTo(2);
+        assertThat(summaries.get(1L).getLastSessionSeenAt())
+                .isEqualTo(LocalDateTime.parse("2026-08-13T00:05:00"));
+        assertThat(summaries.get(2L).getNearestSessionExpiresAt())
+                .isEqualTo(LocalDateTime.parse("2026-08-13T00:25:00"));
+        org.mockito.ArgumentCaptor<QueryWrapper<RmqStudioSession>> queryCaptor 
=
+                org.mockito.ArgumentCaptor.forClass(QueryWrapper.class);
+        verify(sessionMapper).selectMaps(queryCaptor.capture());
+        assertThat(queryCaptor.getValue().getSqlSelect()).contains("COUNT(*)");
+        assertThat(queryCaptor.getValue().getSqlSegment())
+                .contains("user_id", "revoked_at IS NULL", "expires_at", 
"GROUP BY user_id");
+    }
+
+    @Test
+    void listActiveSessionSummariesSkipsDatabaseWhenThereAreNoUsers() {
+        
assertThat(authService.listActiveSessionSummaries(List.of())).isEmpty();
+
+        verifyNoInteractions(userMapper, sessionMapper);
+    }
+
+    @Test
+    void getSessionOverviewCountsActiveSessionRiskBucketsInOneQuery() {
+        when(sessionMapper.selectMaps(any(Wrapper.class))).thenReturn(List.of(
+                row("active_session_count", 5L,
+                        "active_user_count", 3L,
+                        "expiring_soon_session_count", 1L,
+                        "stale_session_count", 2L)));
+
+        StudioUserSessionOverviewVO overview = 
authService.getSessionOverview();
+
+        assertThat(overview.getActiveSessionCount()).isEqualTo(5);
+        assertThat(overview.getActiveUserCount()).isEqualTo(3);
+        assertThat(overview.getExpiringSoonSessionCount()).isEqualTo(1);
+        assertThat(overview.getStaleSessionCount()).isEqualTo(2);
+        assertThat(overview.getExpiringSoonWindowMinutes()).isEqualTo(5);
+        assertThat(overview.getStaleSessionThresholdMinutes()).isEqualTo(15);
+
+        // The overview is one aggregate statement, not four separate COUNT(*) 
round trips.
+        verify(sessionMapper, never()).selectCount(any(Wrapper.class));
+        org.mockito.ArgumentCaptor<QueryWrapper<RmqStudioSession>> queryCaptor 
=
+                org.mockito.ArgumentCaptor.forClass(QueryWrapper.class);
+        verify(sessionMapper).selectMaps(queryCaptor.capture());
+        QueryWrapper<RmqStudioSession> query = queryCaptor.getValue();
+        assertThat(query.getSqlSelect())
+                .contains("COUNT(*) AS active_session_count",
+                        "COUNT(DISTINCT user_id) AS active_user_count",
+                        "CASE WHEN expires_at <=",
+                        "CASE WHEN last_seen_at <");
+        assertThat(query.getSqlSegment()).contains("revoked_at IS NULL", 
"expires_at");
+        // The bucket boundaries stay bound parameters instead of literals 
inlined into the SQL.
+        assertThat(query.getParamNameValuePairs())
+                .containsEntry("expiringSoonCutoff", 
LocalDateTime.parse("2026-08-13T00:05:00"))
+                .containsEntry("staleCutoff", 
LocalDateTime.parse("2026-08-12T23:45:00"));
+        assertThat(query.getSqlSelect()).doesNotContain("2026-08-13");
+    }
+
+    @Test
+    void 
getSessionOverviewReadsAggregateLabelsWhateverCasingTheDriverReturns() {
+        // JDBC drivers are free to return result-set label casing 
differently, and MyBatis may
+        // hand the map back with camelCase keys; neither may change the 
reported numbers.
+        when(sessionMapper.selectMaps(any(Wrapper.class))).thenReturn(List.of(
+                row("ACTIVE_SESSION_COUNT", "5",
+                        "activeUserCount", 3L,
+                        "Expiring_Soon_Session_Count", 1L,
+                        "stale_session_count", 2L)));
+
+        StudioUserSessionOverviewVO overview = 
authService.getSessionOverview();
+
+        assertThat(overview.getActiveSessionCount()).isEqualTo(5);
+        assertThat(overview.getActiveUserCount()).isEqualTo(3);
+        assertThat(overview.getExpiringSoonSessionCount()).isEqualTo(1);
+        assertThat(overview.getStaleSessionCount()).isEqualTo(2);
+    }
+
+    @Test
+    void getSessionOverviewReportsZeroesWhenNoSessionIsActive() {
+        // COUNT(*) over an empty set is 0, while both SUM(...) buckets come 
back as SQL NULL.
+        when(sessionMapper.selectMaps(any(Wrapper.class))).thenReturn(List.of(
+                row("active_session_count", 0L,
+                        "active_user_count", 0L,
+                        "expiring_soon_session_count", null,
+                        "stale_session_count", null)));
+
+        StudioUserSessionOverviewVO overview = 
authService.getSessionOverview();
+
+        assertThat(overview.getActiveSessionCount()).isZero();
+        assertThat(overview.getActiveUserCount()).isZero();
+        assertThat(overview.getExpiringSoonSessionCount()).isZero();
+        assertThat(overview.getStaleSessionCount()).isZero();
+    }
+
     @Test
     void databaseLoginPersistsOnlyTokenHashAndReturnsImmutableUserId() {
         RmqStudioUser user = user(1L, "operator", true, true, "password-1");
@@ -144,6 +254,33 @@ class AuthServiceDatabaseTest {
         verify(sessionMapper).update(isNull(), any(Wrapper.class));
     }
 
+    @Test
+    void revokeSessionsForUserRevokesOnlyTheSelectedUsersOpenSessions() {
+        RmqStudioUser user = user(1L, "operator", false, true, "password-1");
+        when(userMapper.selectById(1L)).thenReturn(user);
+        when(sessionMapper.update(isNull(), any(Wrapper.class))).thenReturn(3);
+
+        int revoked = authService.revokeSessionsForUser(1L);
+
+        assertThat(revoked).isEqualTo(3);
+        org.mockito.ArgumentCaptor<UpdateWrapper<RmqStudioSession>> 
updateCaptor =
+                org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
+        verify(sessionMapper).update(isNull(), updateCaptor.capture());
+        assertThat(updateCaptor.getValue().getSqlSegment())
+                .contains("user_id", "revoked_at IS NULL", "expires_at");
+    }
+
+    @Test
+    void revokeSessionsForUserRejectsMissingUsersBeforeSessionUpdate() {
+        when(userMapper.selectById(404L)).thenReturn(null);
+
+        assertThatThrownBy(() -> authService.revokeSessionsForUser(404L))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("User not found");
+
+        verify(sessionMapper, never()).update(isNull(), any(Wrapper.class));
+    }
+
     @Test
     void disablingLastEnabledAdministratorIsRejected() {
         RmqStudioUser user = user(1L, "admin", true, true, "password-1");
@@ -294,6 +431,14 @@ class AuthServiceDatabaseTest {
         return session;
     }
 
+    private Map<String, Object> row(Object... values) {
+        Map<String, Object> row = new HashMap<>();
+        for (int index = 0; index < values.length; index += 2) {
+            row.put((String) values[index], values[index + 1]);
+        }
+        return row;
+    }
+
     private RmqStudioUser user(Long id, String username, boolean admin, 
boolean enabled, String password) {
         RmqStudioUser user = new RmqStudioUser();
         user.setId(id);
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceSessionOverviewIntegrationTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceSessionOverviewIntegrationTest.java
new file mode 100644
index 000000000..462f993bb
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceSessionOverviewIntegrationTest.java
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.auth;
+
+import org.apache.rocketmq.studio.persistence.entity.RmqStudioSession;
+import org.apache.rocketmq.studio.persistence.entity.RmqStudioUser;
+import org.apache.rocketmq.studio.persistence.mapper.RmqStudioSessionMapper;
+import org.apache.rocketmq.studio.persistence.mapper.RmqStudioUserMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import java.security.SecureRandom;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.HexFormat;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Runs the session overview aggregate against the real database, because the 
bucket boundaries
+ * are bound as wrapper parameters inside the SELECT list and only an executed 
statement can prove
+ * the driver accepts them.
+ */
+@SpringBootTest(properties = "studio.auth.login-required=false")
+class AuthServiceSessionOverviewIntegrationTest {
+
+    private static final SecureRandom RANDOM = new SecureRandom();
+
+    @Autowired
+    private AuthService authService;
+
+    @Autowired
+    private RmqStudioUserMapper userMapper;
+
+    @Autowired
+    private RmqStudioSessionMapper sessionMapper;
+
+    @Test
+    void sessionOverviewAggregatesEveryBucketFromOneExecutedQueryTest() {
+        LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
+        RmqStudioUser user = new RmqStudioUser();
+        user.setUsername("session-overview-it-" + System.nanoTime());
+        user.setPasswordHash("not-a-real-password-hash");
+        user.setAdmin(false);
+        user.setEnabled(true);
+        user.setPasswordChangedAt(now);
+        userMapper.insert(user);
+        // The in-memory dev database is shared across test classes in one 
JVM, so assert on the
+        // delta this user's sessions add instead of on absolute counts.
+        StudioUserSessionOverviewVO baseline = 
authService.getSessionOverview();
+        List<RmqStudioSession> inserted = new ArrayList<>();
+        try {
+            // Two of the four active sessions expire inside the 5 minute 
window, and two of them
+            // have not been seen for longer than the 15 minute stale 
threshold.
+            inserted.add(session(user.getId(), now.plusMinutes(30), now, 
null));
+            inserted.add(session(user.getId(), now.plusMinutes(2), now, null));
+            inserted.add(session(user.getId(), now.plusMinutes(30), 
now.minusMinutes(20), null));
+            inserted.add(session(user.getId(), now.plusMinutes(2), 
now.minusMinutes(20), null));
+            // A revoked session and an already expired one must not be 
counted at all.
+            inserted.add(session(user.getId(), now.plusMinutes(30), now, now));
+            inserted.add(session(user.getId(), now.minusMinutes(1), 
now.minusMinutes(30), null));
+
+            StudioUserSessionOverviewVO overview = 
authService.getSessionOverview();
+
+            
assertThat(overview.getActiveSessionCount()).isEqualTo(baseline.getActiveSessionCount()
 + 4);
+            
assertThat(overview.getActiveUserCount()).isEqualTo(baseline.getActiveUserCount()
 + 1);
+            assertThat(overview.getExpiringSoonSessionCount())
+                    .isEqualTo(baseline.getExpiringSoonSessionCount() + 2);
+            
assertThat(overview.getStaleSessionCount()).isEqualTo(baseline.getStaleSessionCount()
 + 2);
+        } finally {
+            for (RmqStudioSession session : inserted) {
+                sessionMapper.deleteById(session.getId());
+            }
+            userMapper.deleteById(user.getId());
+        }
+    }
+
+    private RmqStudioSession session(Long userId, LocalDateTime expiresAt, 
LocalDateTime lastSeenAt,
+                                     LocalDateTime revokedAt) {
+        RmqStudioSession session = new RmqStudioSession();
+        session.setUserId(userId);
+        session.setTokenHash(tokenHash());
+        session.setExpiresAt(expiresAt);
+        session.setLastSeenAt(lastSeenAt);
+        session.setRevokedAt(revokedAt);
+        sessionMapper.insert(session);
+        return session;
+    }
+
+    /** Fills the CHAR(64) token_hash column with a unique SHA-256 shaped 
value. */
+    private static String tokenHash() {
+        byte[] bytes = new byte[32];
+        RANDOM.nextBytes(bytes);
+        return HexFormat.of().formatHex(bytes);
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/auth/StudioUserControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/auth/StudioUserControllerTest.java
index f9c9d133c..b2f062e41 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/auth/StudioUserControllerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/auth/StudioUserControllerTest.java
@@ -31,10 +31,12 @@ import org.springframework.test.web.servlet.MockMvc;
 
 import java.time.LocalDateTime;
 import java.util.List;
+import java.util.Map;
 
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
 import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
 import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 
@@ -69,6 +71,13 @@ class StudioUserControllerTest {
         user.setGmtCreate(LocalDateTime.parse("2026-08-22T08:00:00"));
         when(authService.listUsers("oper", false, true, 2, 20))
                 .thenReturn(PageResult.of(List.of(user), 21, 2, 20));
+        when(authService.listActiveSessionSummaries(List.of(7L)))
+                .thenReturn(Map.of(7L, StudioUserSessionSummaryVO.builder()
+                        .userId(7L)
+                        .activeSessionCount(2)
+                        
.lastSessionSeenAt(LocalDateTime.parse("2026-08-22T09:30:00"))
+                        
.nearestSessionExpiresAt(LocalDateTime.parse("2026-08-22T10:00:00"))
+                        .build()));
 
         mockMvc.perform(get("/api/studio-users")
                         .param("search", "oper")
@@ -80,17 +89,25 @@ class StudioUserControllerTest {
                 .andExpect(jsonPath("$.data.items[0].id").value(7))
                 
.andExpect(jsonPath("$.data.items[0].username").value("operator"))
                 
.andExpect(jsonPath("$.data.items[0].passwordHash").doesNotExist())
+                
.andExpect(jsonPath("$.data.items[0].activeSessionCount").value(2))
+                .andExpect(jsonPath("$.data.items[0].lastSessionSeenAt")
+                        .value("2026-08-22T09:30:00"))
+                .andExpect(jsonPath("$.data.items[0].nearestSessionExpiresAt")
+                        .value("2026-08-22T10:00:00"))
                 .andExpect(jsonPath("$.data.total").value(21))
                 .andExpect(jsonPath("$.data.page").value(2))
                 .andExpect(jsonPath("$.data.size").value(20));
 
         verify(authService).listUsers("oper", false, true, 2, 20);
+        verify(authService).listActiveSessionSummaries(List.of(7L));
     }
 
     @Test
     void listUsesBoundedDefaults() throws Exception {
         when(authService.listUsers(null, null, null, 1, 20))
                 .thenReturn(PageResult.empty(1, 20));
+        when(authService.listActiveSessionSummaries(List.of()))
+                .thenReturn(Map.of());
 
         mockMvc.perform(get("/api/studio-users"))
                 .andExpect(status().isOk())
@@ -98,4 +115,37 @@ class StudioUserControllerTest {
                 .andExpect(jsonPath("$.data.page").value(1))
                 .andExpect(jsonPath("$.data.size").value(20));
     }
+
+    @Test
+    void sessionOverviewReturnsActiveSessionCounts() throws Exception {
+        
when(authService.getSessionOverview()).thenReturn(StudioUserSessionOverviewVO.builder()
+                .activeSessionCount(5)
+                .activeUserCount(3)
+                .expiringSoonSessionCount(1)
+                .staleSessionCount(2)
+                .expiringSoonWindowMinutes(5)
+                .staleSessionThresholdMinutes(15)
+                .build());
+
+        mockMvc.perform(get("/api/studio-users/sessions/overview"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.activeSessionCount").value(5))
+                .andExpect(jsonPath("$.data.activeUserCount").value(3))
+                
.andExpect(jsonPath("$.data.expiringSoonSessionCount").value(1))
+                .andExpect(jsonPath("$.data.staleSessionCount").value(2));
+
+        verify(authService).getSessionOverview();
+    }
+
+    @Test
+    void revokeSessionsReturnsTheRevokedSessionCount() throws Exception {
+        when(authService.revokeSessionsForUser(7L)).thenReturn(3);
+
+        mockMvc.perform(post("/api/studio-users/7/sessions/revoke"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.userId").value(7))
+                .andExpect(jsonPath("$.data.revokedSessionCount").value(3));
+
+        verify(authService).revokeSessionsForUser(7L);
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/common/util/JdbcRowValuesTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/common/util/JdbcRowValuesTest.java
new file mode 100644
index 000000000..274d65595
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/common/util/JdbcRowValuesTest.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.common.util;
+
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.sql.Timestamp;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class JdbcRowValuesTest {
+
+    @Test
+    void readsCountsWhateverLabelCasingTheDriverReturnsTest() {
+        assertThat(JdbcRowValues.longValueOrZero(row("result_count", 5L), 
"result_count")).isEqualTo(5L);
+        assertThat(JdbcRowValues.longValueOrZero(row("RESULT_COUNT", 5L), 
"result_count")).isEqualTo(5L);
+        assertThat(JdbcRowValues.longValueOrZero(row("Result_Count", 5L), 
"result_count")).isEqualTo(5L);
+        assertThat(JdbcRowValues.stringValue(row("RESULT", "SUCCESS"), 
"result")).isEqualTo("SUCCESS");
+    }
+
+    @Test
+    void readsCountsWhateverLabelWordSeparationTheDriverReturnsTest() {
+        // MyBatis hands the map back with either the raw snake_case label or 
a camelCase key
+        // depending on configuration; callers must not have to know which one 
they got.
+        assertThat(JdbcRowValues.longValue(row("user_id", 7L), 
"user_id")).isEqualTo(7L);
+        assertThat(JdbcRowValues.longValue(row("userId", 7L), 
"user_id")).isEqualTo(7L);
+        assertThat(JdbcRowValues.intValueOrZero(row("activeSessionCount", 
"2"), "active_session_count"))
+                .isEqualTo(2);
+    }
+
+    @Test
+    void parsesNumericTextAndDecimalAggregatesTest() {
+        assertThat(JdbcRowValues.longValueOrZero(row("bucket_count", "12"), 
"bucket_count")).isEqualTo(12L);
+        assertThat(JdbcRowValues.longValueOrZero(row("bucket_count", " 12 "), 
"bucket_count")).isEqualTo(12L);
+        assertThat(JdbcRowValues.longValueOrZero(row("bucket_count", 
BigDecimal.valueOf(12)), "bucket_count"))
+                .isEqualTo(12L);
+        assertThat(JdbcRowValues.intValueOrZero(row("bucket_count", 12), 
"bucket_count")).isEqualTo(12);
+    }
+
+    @Test
+    void returnsNullLongForMissingBlankAndNullValuesTest() {
+        assertThat(JdbcRowValues.longValue(row("other", 1L), 
"result_count")).isNull();
+        assertThat(JdbcRowValues.longValue(row("result_count", ""), 
"result_count")).isNull();
+        assertThat(JdbcRowValues.longValue(row("result_count", "  "), 
"result_count")).isNull();
+        assertThat(JdbcRowValues.longValue(rowOfNulls("result_count"), 
"result_count")).isNull();
+        assertThat(JdbcRowValues.longValue(null, "result_count")).isNull();
+    }
+
+    @Test
+    void defaultsMissingCountsToZeroTest() {
+        assertThat(JdbcRowValues.longValueOrZero(row("other", 1L), 
"result_count")).isZero();
+        // SUM(...) over an empty result set comes back as SQL NULL rather 
than 0.
+        
assertThat(JdbcRowValues.longValueOrZero(rowOfNulls("stale_session_count"), 
"stale_session_count"))
+                .isZero();
+        assertThat(JdbcRowValues.intValueOrZero(row("other", 1L), 
"active_session_count")).isZero();
+    }
+
+    @Test
+    void keepsSearchingWhenTheExactKeyHoldsANullValueTest() {
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("result_count", null);
+        row.put("RESULT_COUNT", 4L);
+
+        assertThat(JdbcRowValues.longValueOrZero(row, 
"result_count")).isEqualTo(4L);
+    }
+
+    @Test
+    void returnsEmptyTextForMissingValuesTest() {
+        assertThat(JdbcRowValues.stringValue(row("other", 1L), 
"result")).isEmpty();
+        assertThat(JdbcRowValues.stringValue(rowOfNulls("result"), 
"result")).isEmpty();
+        assertThat(JdbcRowValues.stringValue(null, "result")).isEmpty();
+        assertThat(JdbcRowValues.stringValue(row("result", 12L), 
"result")).isEqualTo("12");
+    }
+
+    @Test
+    void convertsEveryTemporalTypeADriverMayReturnTest() {
+        LocalDateTime expected = LocalDateTime.parse("2026-08-13T00:05:00");
+
+        assertThat(JdbcRowValues.dateTimeValue(row("last_seen_at", expected), 
"last_seen_at"))
+                .isEqualTo(expected);
+        assertThat(JdbcRowValues.dateTimeValue(
+                row("last_seen_at", Timestamp.valueOf(expected)), 
"last_seen_at")).isEqualTo(expected);
+        assertThat(JdbcRowValues.dateTimeValue(
+                row("last_seen_at", 
Date.from(expected.toInstant(ZoneOffset.UTC))), "last_seen_at"))
+                .isEqualTo(expected);
+        assertThat(JdbcRowValues.dateTimeValue(row("last_seen_at", 
"2026-08-13T00:05:00"), "last_seen_at"))
+                .isEqualTo(expected);
+        assertThat(JdbcRowValues.dateTimeValue(row("last_seen_at", 5L), 
"last_seen_at")).isNull();
+        assertThat(JdbcRowValues.dateTimeValue(row("other", expected), 
"last_seen_at")).isNull();
+    }
+
+    @Test
+    void exposesTheRawValueBehindAMatchedLabelTest() {
+        Object raw = new Object();
+
+        assertThat(JdbcRowValues.value(row("detail", raw), 
"DETAIL")).isSameAs(raw);
+        assertThat(JdbcRowValues.value(row("detail", raw), 
"unknown")).isNull();
+        assertThat(JdbcRowValues.value(Map.of(), "detail")).isNull();
+        assertThat(JdbcRowValues.value(row("detail", raw), null)).isNull();
+    }
+
+    private static Map<String, Object> row(Object... keyValues) {
+        Map<String, Object> row = new LinkedHashMap<>();
+        for (int index = 0; index < keyValues.length; index += 2) {
+            row.put((String) keyValues[index], keyValues[index + 1]);
+        }
+        return row;
+    }
+
+    /** Builds a row whose only key maps to SQL NULL, which JDBC reports as a 
Java null. */
+    private static Map<String, Object> rowOfNulls(String... keys) {
+        Map<String, Object> row = new HashMap<>();
+        for (String key : keys) {
+            row.put(key, null);
+        }
+        return row;
+    }
+}
diff --git a/web/src/api/studioUsers.test.ts b/web/src/api/studioUsers.test.ts
index 041b7c2a6..4172d1585 100644
--- a/web/src/api/studioUsers.test.ts
+++ b/web/src/api/studioUsers.test.ts
@@ -18,7 +18,12 @@
 import MockAdapter from 'axios-mock-adapter';
 import { afterEach, beforeEach, describe, expect, it } from 'vitest';
 import client from './client';
-import { listAllStudioUsers as loadStudioUsersForExport, listStudioUsers } 
from './studioUsers';
+import {
+  getStudioUserSessionOverview,
+  listAllStudioUsers as loadStudioUsersForExport,
+  listStudioUsers,
+  revokeStudioUserSessions,
+} from './studioUsers';
 
 const mock = new MockAdapter(client);
 const exportQuery = { search: 'op', admin: false };
@@ -89,4 +94,36 @@ describe('studio users API', () => {
       exportRequestParams[1],
     ]);
   });
+
+  it('loads the global Studio session overview', async () => {
+    mock.onGet('/studio-users/sessions/overview').reply(200, {
+      code: 200,
+      data: {
+        activeSessionCount: 5,
+        activeUserCount: 3,
+        expiringSoonSessionCount: 1,
+        staleSessionCount: 2,
+        expiringSoonWindowMinutes: 5,
+        staleSessionThresholdMinutes: 15,
+      },
+    });
+
+    const overview = await getStudioUserSessionOverview();
+
+    expect(overview.activeSessionCount).toBe(5);
+    expect(overview.activeUserCount).toBe(3);
+    expect(mock.history.get[0].url).toBe('/studio-users/sessions/overview');
+  });
+
+  it('revokes a users active Studio sessions', async () => {
+    mock.onPost('/studio-users/7/sessions/revoke').reply(200, {
+      code: 200,
+      data: { userId: 7, revokedSessionCount: 3 },
+    });
+
+    const result = await revokeStudioUserSessions(7);
+
+    expect(result).toEqual({ userId: 7, revokedSessionCount: 3 });
+    expect(mock.history.post[0].url).toBe('/studio-users/7/sessions/revoke');
+  });
 });
diff --git a/web/src/api/studioUsers.ts b/web/src/api/studioUsers.ts
index bf644a801..f70a37b05 100644
--- a/web/src/api/studioUsers.ts
+++ b/web/src/api/studioUsers.ts
@@ -23,6 +23,9 @@ export interface StudioUser {
   username: string;
   admin: boolean;
   enabled: boolean;
+  activeSessionCount: number;
+  lastSessionSeenAt?: string | null;
+  nearestSessionExpiresAt?: string | null;
   passwordChangedAt: string;
   gmtCreate: string;
   gmtModified: string;
@@ -45,6 +48,20 @@ export interface StudioUserQuery {
 
 type StudioUserExportQuery = Omit<StudioUserQuery, 'page' | 'pageSize'>;
 type CreateStudioUserRequest = Pick<StudioUser, 'username' | 'admin'> & { 
password: string };
+export interface StudioUserSessionRevokeResult {
+  userId: number;
+  revokedSessionCount: number;
+}
+
+export interface StudioUserSessionOverview {
+  activeSessionCount: number;
+  activeUserCount: number;
+  expiringSoonSessionCount: number;
+  staleSessionCount: number;
+  expiringSoonWindowMinutes: number;
+  staleSessionThresholdMinutes: number;
+}
+
 export async function listStudioUsers(query: StudioUserQuery = {}) {
   const response = await client.get<{ data: StudioUserPage }>('/studio-users', 
{
     params: query,
@@ -52,6 +69,13 @@ export async function listStudioUsers(query: StudioUserQuery 
= {}) {
   return response.data.data;
 }
 
+export async function getStudioUserSessionOverview() {
+  const response = await client.get<{ data: StudioUserSessionOverview }>(
+    '/studio-users/sessions/overview',
+  );
+  return response.data.data;
+}
+
 export const listAllStudioUsers = async (
   query: StudioUserExportQuery = {},
 ): Promise<StudioUser[]> => {
@@ -84,3 +108,10 @@ export async function setStudioUserEnabled(userId: number, 
enabled: boolean) {
 export async function resetStudioUserPassword(userId: number, newPassword: 
string) {
   await client.post(`/studio-users/${userId}/password`, { newPassword });
 }
+
+export async function revokeStudioUserSessions(userId: number) {
+  const response = await client.post<{ data: StudioUserSessionRevokeResult }>(
+    `/studio-users/${userId}/sessions/revoke`,
+  );
+  return response.data.data;
+}
diff --git a/web/src/pages/studio/UserManagement.tsx 
b/web/src/pages/studio/UserManagement.tsx
index 412d5c9bb..636a26654 100644
--- a/web/src/pages/studio/UserManagement.tsx
+++ b/web/src/pages/studio/UserManagement.tsx
@@ -22,29 +22,35 @@ import {
   Form,
   Input,
   Modal,
+  Popconfirm,
   Select,
   Space,
+  Statistic,
   Switch,
   Table,
   Tag,
   message,
 } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
-import { DownloadSimple, Key, Plus } from '@phosphor-icons/react';
+import { DownloadSimple, Key, Plus, SignOut } from '@phosphor-icons/react';
 import { useNavigate } from 'react-router-dom';
 import PageHeader from '../../components/PageHeader';
 import InfoBanner from '../../components/InfoBanner';
 import { changePassword } from '../../api/auth';
 import {
   createStudioUser,
+  getStudioUserSessionOverview,
   listAllStudioUsers as exportStudioUsers,
   listStudioUsers,
   resetStudioUserPassword,
+  revokeStudioUserSessions,
   setStudioUserEnabled,
   type StudioUser,
+  type StudioUserSessionOverview,
 } from '../../api/studioUsers';
 import useAuthStore from '../../stores/authStore';
 import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
+import { tableScrollX } from '../../utils/table';
 
 interface CreateFormValues {
   username: string;
@@ -68,6 +74,15 @@ const STUDIO_USER_EXPORT_COLUMNS: CsvColumn<StudioUser>[] = [
   { header: 'Username', value: (user) => user.username },
   { header: 'Role', value: (user) => (user.admin ? 'Admin' : 'User') },
   { header: 'Status', value: (user) => (user.enabled ? 'Enabled' : 'Disabled') 
},
+  { header: 'Active Sessions', value: (user) => user.activeSessionCount ?? 0 },
+  {
+    header: 'Last Session Seen At',
+    value: (user) => dateTime(user.lastSessionSeenAt ?? undefined),
+  },
+  {
+    header: 'Nearest Session Expires At',
+    value: (user) => dateTime(user.nearestSessionExpiresAt ?? undefined),
+  },
   { header: 'Password Changed At', value: (user) => 
dateTime(user.passwordChangedAt) },
   { header: 'Created At', value: (user) => dateTime(user.gmtCreate) },
   { header: 'Modified At', value: (user) => dateTime(user.gmtModified) },
@@ -86,6 +101,7 @@ const UserManagementPage = () => {
   const [roleFilter, setRoleFilter] = useState<RoleFilter>();
   const [statusFilter, setStatusFilter] = useState<StatusFilter>();
   const [loading, setLoading] = useState(false);
+  const [sessionOverview, setSessionOverview] = 
useState<StudioUserSessionOverview | null>(null);
   const [createOpen, setCreateOpen] = useState(false);
   const [passwordTarget, setPasswordTarget] = useState<StudioUser | 
null>(null);
   const [userExporting, setUserExporting] = useState(false);
@@ -105,6 +121,7 @@ const UserManagementPage = () => {
       requestSeqRef.current += 1;
       setUsers([]);
       setTotal(0);
+      setSessionOverview(null);
       return;
     }
     const requestId = ++requestSeqRef.current;
@@ -112,14 +129,18 @@ const UserManagementPage = () => {
       if (requestId === requestSeqRef.current) setLoading(true);
     });
     try {
-      const result = await listStudioUsers({
-        search: debouncedSearch || undefined,
-        admin: roleFilter === undefined ? undefined : roleFilter === 'admin',
-        enabled: statusFilter === undefined ? undefined : statusFilter === 
'enabled',
-        page,
-        pageSize,
-      });
+      const [result, overview] = await Promise.all([
+        listStudioUsers({
+          search: debouncedSearch || undefined,
+          admin: roleFilter === undefined ? undefined : roleFilter === 'admin',
+          enabled: statusFilter === undefined ? undefined : statusFilter === 
'enabled',
+          page,
+          pageSize,
+        }),
+        getStudioUserSessionOverview().catch(() => null),
+      ]);
       if (requestId !== requestSeqRef.current) return;
+      setSessionOverview(overview);
       if (result.items.length === 0 && result.total > 0 && page > 1) {
         const lastPage = Math.max(1, Math.ceil(result.total / result.size));
         if (page > lastPage) {
@@ -164,22 +185,40 @@ const UserManagementPage = () => {
     }
   };
 
-  const setEnabled = async (record: StudioUser, enabled: boolean) => {
-    if (mutatingUserIdsRef.current.has(record.id)) return;
-    mutatingUserIdsRef.current.add(record.id);
+  /**
+   * Runs one mutation for a single user behind the shared in-flight guard, so 
a row can never
+   * have a status update and a session revocation overlapping. The guard is 
released on both the
+   * success and the failure path.
+   */
+  const runUserMutation = async (
+    targetUserId: number,
+    action: () => Promise<void>,
+    errorMessage: string,
+  ) => {
+    if (mutatingUserIdsRef.current.has(targetUserId)) return;
+    mutatingUserIdsRef.current.add(targetUserId);
     setMutatingUserIds(new Set(mutatingUserIdsRef.current));
     try {
-      await setStudioUserEnabled(record.id, enabled);
-      message.success(enabled ? '用户已启用' : '用户已禁用,全部会话已注销');
-      await loadUsers();
+      await action();
     } catch {
-      message.error('更新用户状态失败');
+      message.error(errorMessage);
     } finally {
-      mutatingUserIdsRef.current.delete(record.id);
+      mutatingUserIdsRef.current.delete(targetUserId);
       setMutatingUserIds(new Set(mutatingUserIdsRef.current));
     }
   };
 
+  const setEnabled = (record: StudioUser, enabled: boolean) =>
+    runUserMutation(
+      record.id,
+      async () => {
+        await setStudioUserEnabled(record.id, enabled);
+        message.success(enabled ? '用户已启用' : '用户已禁用,全部会话已注销');
+        await loadUsers();
+      },
+      '更新用户状态失败',
+    );
+
   const updatePassword = async () => {
     if (!passwordTarget) return;
     const values = await passwordForm.validateFields();
@@ -199,6 +238,27 @@ const UserManagementPage = () => {
       message.error('修改密码失败');
     }
   };
+
+  const revokeSessions = (record: StudioUser) =>
+    runUserMutation(
+      record.id,
+      async () => {
+        const result = await revokeStudioUserSessions(record.id);
+        if (result.revokedSessionCount > 0) {
+          message.success(`已注销 ${result.revokedSessionCount} 个活跃会话`);
+        } else {
+          message.success('没有可注销的活跃会话');
+        }
+        if (record.id === userId && result.revokedSessionCount > 0) {
+          clearAuth();
+          navigate('/login', { replace: true });
+          return;
+        }
+        await loadUsers();
+      },
+      '注销用户会话失败',
+    );
+
   const openCreateUserModal = () => setCreateOpen(true);
   const handleExportUsers = useCallback(async () => {
     if (!admin) return;
@@ -220,29 +280,84 @@ const UserManagementPage = () => {
     }
     setUserExporting(false);
   }, [admin, roleFilter, search, statusFilter]);
+  // Declared widths total 1116px, which stays inside the usable content width 
of a normal
+  // 1440px viewport (220px Sider plus page and Card padding), so the table 
does not show a
+  // horizontal scrollbar by default. Columns whose text can be longer than 
that truncate with
+  // the full value on hover instead of wrapping.
   const columns: ColumnsType<StudioUser> = [
-    { title: '用户名', dataIndex: 'username' },
-    { title: '用户 ID', dataIndex: 'id', width: 100 },
+    { title: '用户名', dataIndex: 'username', width: 120, ellipsis: true },
+    { title: '用户 ID', dataIndex: 'id', width: 88 },
     {
       title: '权限',
       dataIndex: 'admin',
+      width: 92,
       render: (value: boolean) => (value ? <Tag color="blue">管理员</Tag> : 
<Tag>普通用户</Tag>),
     },
     {
       title: '状态',
       dataIndex: 'enabled',
+      width: 92,
       render: (value: boolean) =>
         value ? <Tag color="green">已启用</Tag> : <Tag color="default">已禁用</Tag>,
     },
-    { title: '创建时间', dataIndex: 'gmtCreate', render: dateTime },
+    {
+      title: '活跃会话',
+      dataIndex: 'activeSessionCount',
+      width: 84,
+      render: (value?: number) => {
+        const count = value ?? 0;
+        return <Tag color={count > 0 ? 'processing' : 'default'}>{count}</Tag>;
+      },
+    },
+    {
+      title: '最近活跃',
+      dataIndex: 'lastSessionSeenAt',
+      width: 140,
+      ellipsis: true,
+      render: dateTime,
+    },
+    {
+      title: '最近过期',
+      dataIndex: 'nearestSessionExpiresAt',
+      width: 140,
+      ellipsis: true,
+      render: dateTime,
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'gmtCreate',
+      width: 140,
+      ellipsis: true,
+      render: dateTime,
+    },
     {
       title: '操作',
       key: 'actions',
+      width: 220,
       render: (_, record) => (
         <Space>
           <Button size="small" icon={<Key size={14} />} onClick={() => 
setPasswordTarget(record)}>
             改密
           </Button>
+          <Popconfirm
+            title={`注销 ${record.username} 的活跃会话?`}
+            description="用户需要重新登录,账号状态不会改变。"
+            okText="注销"
+            cancelText="取消"
+            okButtonProps={{ danger: true }}
+            disabled={(record.activeSessionCount ?? 0) === 0}
+            onConfirm={() => void revokeSessions(record)}
+          >
+            <Button
+              size="small"
+              danger
+              icon={<SignOut size={14} />}
+              disabled={(record.activeSessionCount ?? 0) === 0}
+              loading={mutatingUserIds.has(record.id)}
+            >
+              会话
+            </Button>
+          </Popconfirm>
           <Switch
             checked={record.enabled}
             loading={mutatingUserIds.has(record.id)}
@@ -294,6 +409,7 @@ const UserManagementPage = () => {
               username: '',
               admin: !!admin,
               enabled: true,
+              activeSessionCount: 0,
               passwordChangedAt: '',
               gmtCreate: '',
               gmtModified: '',
@@ -303,6 +419,22 @@ const UserManagementPage = () => {
           修改我的密码
         </Button>
       </Card>
+      {admin && sessionOverview && (
+        <Card title="会话概览" style={{ marginBottom: 16 }}>
+          <Flex gap={32} wrap>
+            <Statistic title="活跃会话" value={sessionOverview.activeSessionCount} 
/>
+            <Statistic title="活跃用户" value={sessionOverview.activeUserCount} />
+            <Statistic
+              title={`未来 ${sessionOverview.expiringSoonWindowMinutes} 分钟过期`}
+              value={sessionOverview.expiringSoonSessionCount}
+            />
+            <Statistic
+              title={`${sessionOverview.staleSessionThresholdMinutes} 分钟未活跃`}
+              value={sessionOverview.staleSessionCount}
+            />
+          </Flex>
+        </Card>
+      )}
       {admin && (
         <Card>
           <Flex gap={12} wrap style={{ marginBottom: 16 }}>
@@ -352,6 +484,7 @@ const UserManagementPage = () => {
             loading={loading}
             columns={columns}
             dataSource={users}
+            scroll={{ x: tableScrollX(columns) }}
             pagination={{
               current: page,
               pageSize,
diff --git a/web/src/pages/studio/__tests__/UserManagement.test.tsx 
b/web/src/pages/studio/__tests__/UserManagement.test.tsx
index 136cb79b9..d3743a01d 100644
--- a/web/src/pages/studio/__tests__/UserManagement.test.tsx
+++ b/web/src/pages/studio/__tests__/UserManagement.test.tsx
@@ -21,8 +21,10 @@ import { act, fireEvent, render, screen, waitFor } from 
'@testing-library/react'
 import userEvent, { type UserEvent } from '@testing-library/user-event';
 import { MemoryRouter } from 'react-router-dom';
 import {
+  getStudioUserSessionOverview,
   listAllStudioUsers as downloadStudioUsers,
   listStudioUsers,
+  revokeStudioUserSessions,
   setStudioUserEnabled,
   type StudioUser,
 } from '../../../api/studioUsers';
@@ -32,9 +34,11 @@ import UserManagementPage from '../UserManagement';
 type MockAuthState = { admin: boolean; userId: number; logout: () => void };
 vi.mock('../../../api/studioUsers', () => ({
   createStudioUser: vi.fn(),
+  getStudioUserSessionOverview: vi.fn(),
   listAllStudioUsers: vi.fn(),
   listStudioUsers: vi.fn(),
   resetStudioUserPassword: vi.fn(),
+  revokeStudioUserSessions: vi.fn(),
   setStudioUserEnabled: vi.fn(),
 }));
 
@@ -58,6 +62,9 @@ const studioUserPage = {
       username: 'operator',
       admin: false,
       enabled: true,
+      activeSessionCount: 2,
+      lastSessionSeenAt: '2026-08-22T09:30:00',
+      nearestSessionExpiresAt: '2026-08-22T10:00:00',
       passwordChangedAt: '2026-08-22T08:00:00',
       gmtCreate: '2026-08-22T08:00:00',
       gmtModified: '2026-08-22T08:00:00',
@@ -109,7 +116,19 @@ describe('UserManagementPage', () => {
   beforeEach(() => {
     vi.clearAllMocks();
     vi.mocked(listStudioUsers).mockResolvedValue(studioUserPage);
+    vi.mocked(getStudioUserSessionOverview).mockResolvedValue({
+      activeSessionCount: 5,
+      activeUserCount: 3,
+      expiringSoonSessionCount: 1,
+      staleSessionCount: 2,
+      expiringSoonWindowMinutes: 5,
+      staleSessionThresholdMinutes: 15,
+    });
     vi.mocked(downloadStudioUsers).mockResolvedValue(studioUserPage.items);
+    vi.mocked(revokeStudioUserSessions).mockResolvedValue({
+      userId: 7,
+      revokedSessionCount: 2,
+    });
   });
 
   it('loads a bounded first page and renders the server total', async () => {
@@ -124,6 +143,9 @@ describe('UserManagementPage', () => {
       pageSize: 20,
     });
     expect(screen.getByText('共 21 个用户')).toBeInTheDocument();
+    expect(getStudioUserSessionOverview).toHaveBeenCalledTimes(1);
+    expect(screen.getAllByText('活跃会话').length).toBeGreaterThan(0);
+    expect(screen.getByText('未来 5 分钟过期')).toBeInTheDocument();
   });
 
   it('debounces username search and sends role and status filters', async () 
=> {
@@ -158,6 +180,52 @@ describe('UserManagementPage', () => {
     expect(exportedCsv).toContain('"operator"');
     expect(exportedCsv).toContain('"User"');
     expect(exportedCsv).toContain('"Enabled"');
+    expect(exportedCsv).toContain('Active Sessions');
+    expect(exportedCsv).toContain('"2"');
+  });
+
+  it('renders active session metadata and revokes sessions after 
confirmation', async () => {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderPage();
+
+    await screen.findByText('operator');
+    expect(screen.getAllByText('2').length).toBeGreaterThan(0);
+    expect(screen.getByText(new 
Date('2026-08-22T09:30:00').toLocaleString())).toBeInTheDocument();
+
+    await user.click(screen.getByRole('button', { name: '会话' }));
+    await screen.findByText('注销 operator 的活跃会话?');
+    await user.click(screen.getByRole('button', { name: /注\s*销/ }));
+
+    await waitFor(() => 
expect(revokeStudioUserSessions).toHaveBeenCalledWith(7));
+    expect(listStudioUsers).toHaveBeenCalledTimes(2);
+  });
+
+  it('blocks the row status switch while the same user revocation is in 
flight', async () => {
+    let resolveRevoke!: () => void;
+    vi.mocked(revokeStudioUserSessions).mockImplementationOnce(
+      () =>
+        new Promise<{ userId: number; revokedSessionCount: number }>((resolve) 
=> {
+          resolveRevoke = () => resolve({ userId: 7, revokedSessionCount: 2 });
+        }),
+    );
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderPage();
+    await screen.findByText('operator');
+
+    await user.click(screen.getByRole('button', { name: '会话' }));
+    await screen.findByText('注销 operator 的活跃会话?');
+    await user.click(screen.getByRole('button', { name: /注\s*销/ }));
+    await waitFor(() => 
expect(revokeStudioUserSessions).toHaveBeenCalledWith(7));
+
+    // Revocation and status updates share one in-flight guard, so the row is 
blocked meanwhile.
+    const toggle = screen.getByRole('switch');
+    expect(toggle).toBeDisabled();
+    fireEvent.click(toggle);
+    expect(setStudioUserEnabled).not.toHaveBeenCalled();
+
+    await act(async () => resolveRevoke());
+    await waitFor(() => expect(screen.getByRole('switch')).not.toBeDisabled());
+    expect(setStudioUserEnabled).not.toHaveBeenCalled();
   });
 
   it('does not overlap status updates for the same user', async () => {

Reply via email to