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 () => {