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 33b540746 feat(auth): complete the studio account lifecycle: role 
change, deletion, lockout visibility, forced rotation (#7312)
33b540746 is described below

commit 33b5407464542ea0aacc4330376c5e8515e9b006
Author: xdz <[email protected]>
AuthorDate: Mon Oct 26 18:04:45 2026 +0800

    feat(auth): complete the studio account lifecycle: role change, deletion, 
lockout visibility, forced rotation (#7312)
    
    * feat(auth): complete the studio account lifecycle: role change, deletion, 
lockout visibility, forced rotation
    Completes the studio account lifecycle: guarded role changes and deletions, 
login-lockout visibility for administrators, and forced rotation of passwords 
chosen by someone else.
    * fix(auth): enforce the outstanding password change on the server too
    The rotation was only prompted by the login page, so an API client that 
held on to
    the token issued at login could use the whole surface with a credential 
somebody
    else chose. Refuse every path except the rotation itself and logout while 
the flag
    is set — 403 "Password change required" — and cover it with an interceptor 
case that
    goes red the moment the gate stops applying to administrators.
    Also document the three new endpoints in api-spec.md §17.4-17.6 (role 
change, account
    deletion, login lockout snapshot) with their 400 and 409 boundaries, and 
widen §17 to
    the account surface they belong to.
---
 docs/api-spec.md                                   |  51 ++++-
 .../rocketmq/studio/auth/AuthInterceptor.java      |  16 ++
 .../apache/rocketmq/studio/auth/AuthService.java   |  94 ++++++++-
 .../rocketmq/studio/auth/LoginRateLimiter.java     |  29 +++
 .../org/apache/rocketmq/studio/auth/LoginVO.java   |   6 +
 .../{LoginVO.java => StudioLoginLockoutVO.java}    |  30 +--
 .../rocketmq/studio/auth/StudioUserController.java |  17 ++
 .../studio/auth/StudioUserSchemaMigration.java     |  79 ++++++++
 .../apache/rocketmq/studio/auth/StudioUserVO.java  |   3 +
 .../{LoginVO.java => UpdateStudioUserRoleDTO.java} |  28 +--
 .../studio/persistence/entity/RmqStudioUser.java   |   7 +
 server/src/main/resources/db/schema.sql            |   1 +
 .../rocketmq/studio/auth/AuthInterceptorTest.java  |  28 +++
 .../studio/auth/AuthServiceDatabaseTest.java       | 211 +++++++++++++++++++++
 .../rocketmq/studio/auth/LoginRateLimiterTest.java |  54 ++++++
 .../studio/auth/StudioUserControllerTest.java      |  53 ++++++
 .../studio/auth/StudioUserSchemaMigrationTest.java |  86 +++++++++
 web/src/api/auth.ts                                |   2 +
 web/src/api/client.ts                              |   5 +-
 web/src/api/studioUsers.ts                         |  22 +++
 web/src/i18n/translations.ts                       |  64 ++++++-
 .../pages/instance/__tests__/ConsumerPage.test.tsx |   4 +-
 web/src/pages/login/index.test.tsx                 |  33 +++-
 web/src/pages/login/index.tsx                      | 174 +++++++++++++----
 web/src/pages/studio/UserManagement.tsx            | 123 +++++++++++-
 .../pages/studio/__tests__/UserManagement.test.tsx | 124 +++++++++++-
 26 files changed, 1232 insertions(+), 112 deletions(-)

diff --git a/docs/api-spec.md b/docs/api-spec.md
index b9dd7b37d..8c9782aa3 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -165,6 +165,9 @@
 | 121 | POST | `/api/ai/conversations/:id/followups` | AI 
追问建议(一次小补全;无内容或失败返回空数组) |
 | 122 | POST | `/api/studio-users/sessions/revoke-all` | 注销其他账号的全部有效会话 |
 | 123 | DELETE | `/api/studio-users/sessions/:sessionId` | 注销单条会话 |
+| 124 | POST | `/api/studio-users/:userId/role` | 修改账号的管理员角色 |
+| 125 | DELETE | `/api/studio-users/:userId` | 删除账号及其全部会话 |
+| 126 | GET | `/api/studio-users/login-lockouts` | 登录锁定快照 |
 
 ## 通用响应格式
 
@@ -3521,9 +3524,10 @@ GET /api/metrics/grafana/dashboards/export
 
 ---
 
-## 17. Studio 会话注销 Studio User Sessions
+## 17. Studio 账号与会话 Studio Users and Sessions
 
-> 本节只记录会话注销与其归因字段;`/api/studio-users` 的账号管理面尚未整体入档。
+> 本节记录账号角色变更、账号删除、登录锁定快照与会话注销;`/api/studio-users` 的账号列表与创建面
+> 尚未整体入档。
 
 ### 17.1 注销其他账号的全部会话
 
@@ -3566,6 +3570,49 @@ DELETE /api/studio-users/sessions/:sessionId
 | `clientIp` | `string?` | 签发会话的客户端地址;经反向代理时取 `X-Forwarded-For` 
首跳。本次改动之前签发的会话为 `null` |
 | `userAgent` | `string?` | 签发会话的客户端 User-Agent。本次改动之前签发的会话为 `null` |
 
+### 17.4 修改账号角色
+
+```
+POST /api/studio-users/:userId/role
+```
+
+**Request Body:**
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `admin` | `boolean` | 是 | 是否授予管理员角色;缺省返回 **400** |
+
+最后一名处于启用状态的管理员不能被降级(**409**)。仅管理员可调用。角色变更后该账号已有的会话
+按服务端策略处理,不隐式续期。
+
+**Response `data`:** 变更后的 `StudioUser`(字段同账号列表单条)。
+
+### 17.5 删除账号
+
+```
+DELETE /api/studio-users/:userId
+```
+
+永久删除账号及其全部会话。**不能删除当前登录账号**(**400**,提示改为禁用);最后一名处于启用
+状态的管理员同样不能被删除(**409**)。仅管理员可调用。**Response `data`:** `null`。
+
+### 17.6 登录锁定快照
+
+```
+GET /api/studio-users/login-lockouts
+```
+
+当前被登录失败限流锁定的用户名与剩余秒数,按剩余时间排序。锁定**一律自行到期解除**,没有解锁
+端点;快照只反映**本实例**的内存态(限流器与其他登录保护状态一样是进程内的),多副本部署下每个
+副本各有一份。仅管理员可调用。
+
+**Response `data`:**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `username` | `string` | 被锁定的用户名 |
+| `remainingSeconds` | `number` | 剩余锁定秒数 |
+
 ---
 
 ## 附录 A:枚举值速查
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 c03c95f74..6f9eee0ff 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
@@ -35,6 +35,12 @@ import java.util.Set;
 
 public class AuthInterceptor implements HandlerInterceptor {
 
+    /**
+     * The paths a session with an outstanding password change may still call: 
rotating the password
+     * is how the flag clears, and signing out must stay available.
+     */
+    private static final Set<String> ROTATION_PATHS = 
Set.of("/api/auth/password", "/api/auth/logout");
+
     private static final String TOOL_EXECUTION_PREFIX = "/api/ai/tools/";
     private static final String TOOL_EXECUTION_SUFFIX = "/execute";
 
@@ -76,6 +82,16 @@ public class AuthInterceptor implements HandlerInterceptor {
             return false;
         }
 
+        // A credential chosen by somebody else — an account's initial 
password or an administrator's
+        // reset — has to be replaced before the session may do anything else. 
The login page prompts
+        // for the rotation; this is the gate that makes the prompt mandatory 
rather than advisory, so
+        // an API client that skips the UI gets no further than the rotation 
itself.
+        if (authenticatedUser.isMustChangePassword()
+                && 
!ROTATION_PATHS.contains(normalizePath(requestPath(request)))) {
+            writeError(response, HttpStatus.FORBIDDEN, "Password change 
required");
+            return false;
+        }
+
         if (requiresAdmin(request, requestPath(request))
                 && !authenticatedUser.isAdmin()) {
             writeError(response, HttpStatus.FORBIDDEN, "Admin permission 
required");
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 148c05394..0d6c349bb 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
@@ -317,6 +317,21 @@ public class AuthService {
         return revokeUserSessions(userId);
     }
 
+    /**
+     * Currently locked logins, sorted by remaining lock time. Read-only 
visibility into the
+     * brute-force limiter: locks always lift by themselves, and the snapshot 
reflects this
+     * instance only (the limiter is in-memory like every other 
login-protection state).
+     */
+    public List<StudioLoginLockoutVO> listLoginLockouts() {
+        requireDatabaseBacked();
+        return loginRateLimiter.activeLockouts().stream()
+                .map(lockout -> StudioLoginLockoutVO.builder()
+                        .username(lockout.username())
+                        .remainingSeconds(lockout.remainingSeconds())
+                        .build())
+                .toList();
+    }
+
     /**
      * Revokes every active session in one statement. The operator's own 
sessions are spared:
      * the incident responder who pulls this lever must not log themselves out 
mid-response,
@@ -372,6 +387,9 @@ public class AuthService {
         user.setAdmin(admin);
         user.setEnabled(true);
         user.setPasswordChangedAt(now());
+        // The initial password was chosen by the creator, not the owner: the 
owner rotates it
+        // at first login.
+        user.setPasswordMustChange(true);
         try {
             userMapper.insert(user);
         } catch (DuplicateKeyException exception) {
@@ -408,6 +426,70 @@ public class AuthService {
         return user;
     }
 
+    /**
+     * Grants or revokes the administrator role for an existing user. Revoking 
from the last
+     * enabled administrator is refused with the same row-locking guard as 
disabling, so two
+     * concurrent revokes cannot strip every administrator at once. Any role 
change revokes the
+     * user's sessions: the authenticated session snapshots the admin flag, 
and forcing a
+     * re-login is the only way the new role takes effect immediately.
+     */
+    @Transactional
+    public RmqStudioUser setUserAdmin(Long userId, boolean admin) {
+        requireDatabaseBacked();
+        RmqStudioUser user = getUser(userId);
+        if (Boolean.valueOf(admin).equals(user.getAdmin())) {
+            return user;
+        }
+        if (!admin && Boolean.TRUE.equals(user.getAdmin())) {
+            List<RmqStudioUser> enabledAdmins = userMapper.selectList(new 
QueryWrapper<RmqStudioUser>()
+                    .eq("admin", true)
+                    .eq("enabled", true)
+                    .last("FOR UPDATE"));
+            boolean targetStillAdmin = enabledAdmins.stream()
+                    .anyMatch(candidate -> userId.equals(candidate.getId()));
+            if (targetStillAdmin && enabledAdmins.size() <= 1) {
+                throw new BusinessException(409, "The last enabled 
administrator cannot lose the role");
+            }
+        }
+        RmqStudioUser update = new RmqStudioUser();
+        update.setId(user.getId());
+        update.setAdmin(admin);
+        userMapper.updateById(update);
+        revokeUserSessions(user.getId());
+        user.setAdmin(admin);
+        return user;
+    }
+
+    /**
+     * Deletes a studio account and its sessions. The operator's own account 
cannot be deleted
+     * (the session would turn into a confusing mid-request failure — disable 
it instead), and
+     * the last enabled administrator is protected by the same row-locking 
guard the disable
+     * path uses. Sessions are removed in the same transaction: the table has 
no foreign key,
+     * so rows would otherwise linger as unreachable tokens.
+     */
+    @Transactional
+    public void deleteUser(Long userId) {
+        requireDatabaseBacked();
+        RmqStudioUser user = getUser(userId);
+        String currentUserId = AuthenticatedUserContext.currentUserId();
+        if (currentUserId != null && 
currentUserId.equals(String.valueOf(userId))) {
+            throw new BusinessException(400, "The current account cannot 
delete itself; disable it instead");
+        }
+        if (Boolean.TRUE.equals(user.getAdmin()) && 
Boolean.TRUE.equals(user.getEnabled())) {
+            List<RmqStudioUser> enabledAdmins = userMapper.selectList(new 
QueryWrapper<RmqStudioUser>()
+                    .eq("admin", true)
+                    .eq("enabled", true)
+                    .last("FOR UPDATE"));
+            boolean targetStillEnabled = enabledAdmins.stream()
+                    .anyMatch(admin -> userId.equals(admin.getId()));
+            if (targetStillEnabled && enabledAdmins.size() <= 1) {
+                throw new BusinessException(409, "The last enabled 
administrator cannot be deleted");
+            }
+        }
+        sessionMapper.delete(new 
QueryWrapper<RmqStudioSession>().eq("user_id", userId));
+        userMapper.deleteById(userId);
+    }
+
     /**
      * Replacing the hash and revoking the account's sessions are one logical 
change: a failure
      * between the two writes would leave the account on its new password 
while its existing
@@ -431,7 +513,10 @@ public class AuthService {
         userMapper.update(null, new UpdateWrapper<RmqStudioUser>()
                 .eq("id", user.getId())
                 .set("password_hash", passwordHasher.hash(newPassword))
-                .set("password_changed_at", now()));
+                .set("password_changed_at", now())
+                // A password chosen by its owner needs no rotation; one 
handed out by an
+                // administrator does, until the owner replaces it.
+                .set("password_must_change", !requireCurrentPassword));
         revokeUserSessions(user.getId());
     }
 
@@ -721,7 +806,12 @@ public class AuthService {
     }
 
     private LoginVO.UserInfo userInfo(RmqStudioUser user) {
-        return userInfo(user.getId(), user.getUsername(), 
Boolean.TRUE.equals(user.getAdmin()));
+        return LoginVO.UserInfo.builder()
+                .userId(user.getId())
+                .username(user.getUsername())
+                .admin(Boolean.TRUE.equals(user.getAdmin()))
+                
.mustChangePassword(Boolean.TRUE.equals(user.getPasswordMustChange()))
+                .build();
     }
 
     private LoginVO.UserInfo userInfo(Long userId, String username, boolean 
admin) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/auth/LoginRateLimiter.java 
b/server/src/main/java/org/apache/rocketmq/studio/auth/LoginRateLimiter.java
index e14a1f432..bd7de9aa4 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/LoginRateLimiter.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/LoginRateLimiter.java
@@ -16,8 +16,11 @@ import org.springframework.stereotype.Component;
 
 import java.time.Clock;
 import java.time.Duration;
+import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Comparator;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.concurrent.ThreadLocalRandom;
@@ -145,6 +148,32 @@ public class LoginRateLimiter {
         return attempts.size();
     }
 
+    /**
+     * Snapshot of the currently locked usernames and when their locks lift, 
sorted by
+     * remaining lock time. Only exact-tracker locks are listed: overflow 
buckets are shared
+     * hash buckets from a saturated tracker and carry no meaningful username. 
The state is
+     * per-instance, like the limiter itself.
+     */
+    public synchronized List<LoginLockout> activeLockouts() {
+        long now = clock.millis();
+        List<LoginLockout> lockouts = new ArrayList<>();
+        for (Map.Entry<String, AttemptState> entry : attempts.entrySet()) {
+            AttemptState state = entry.getValue();
+            if (state.lockedAt(now)) {
+                lockouts.add(new LoginLockout(entry.getKey(),
+                        (state.lockedUntilMillis() - now + 999) / 1000));
+            }
+        }
+        
lockouts.sort(Comparator.comparingLong(LoginLockout::remainingSeconds));
+        return lockouts;
+    }
+
+    /**
+     * A currently locked login: the normalized username and the seconds until 
its lock lifts.
+     */
+    public record LoginLockout(String username, long remainingSeconds) {
+    }
+
     synchronized int activeOverflowBucketCount() {
         int active = 0;
         long now = clock.millis();
diff --git a/server/src/main/java/org/apache/rocketmq/studio/auth/LoginVO.java 
b/server/src/main/java/org/apache/rocketmq/studio/auth/LoginVO.java
index 32ff60a42..187453127 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/LoginVO.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/LoginVO.java
@@ -43,5 +43,11 @@ public class LoginVO {
         private Long userId;
         private String username;
         private boolean admin;
+        /**
+         * The current password was chosen by someone other than the owner (an 
administrator's
+         * reset or the account's initial password); the login flow must make 
the owner rotate
+         * it before proceeding. Always false for properties-based (in-memory) 
users.
+         */
+        private boolean mustChangePassword;
     }
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/auth/LoginVO.java 
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioLoginLockoutVO.java
similarity index 61%
copy from server/src/main/java/org/apache/rocketmq/studio/auth/LoginVO.java
copy to 
server/src/main/java/org/apache/rocketmq/studio/auth/StudioLoginLockoutVO.java
index 32ff60a42..63e47de9d 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/LoginVO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioLoginLockoutVO.java
@@ -14,34 +14,20 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 package org.apache.rocketmq.studio.auth;
 
-import com.fasterxml.jackson.annotation.JsonInclude;
-import lombok.AllArgsConstructor;
 import lombok.Builder;
 import lombok.Data;
-import lombok.NoArgsConstructor;
-import lombok.ToString;
 
+/**
+ * A currently locked login: the normalized username that exhausted its 
failed-attempt budget
+ * and the seconds until the lock lifts by itself. The state is per-instance, 
like the
+ * in-memory limiter that produces it.
+ */
 @Data
 @Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class LoginVO {
-    @ToString.Exclude
-    @JsonInclude(JsonInclude.Include.NON_NULL)
-    private String token;
-    private int expiresIn;
-    private UserInfo user;
+public class StudioLoginLockoutVO {
 
-    @Data
-    @Builder
-    @NoArgsConstructor
-    @AllArgsConstructor
-    public static class UserInfo {
-        private Long userId;
-        private String username;
-        private boolean admin;
-    }
+    private String username;
+    private long remainingSeconds;
 }
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 21c5d9540..6db401336 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
@@ -45,6 +45,11 @@ public class StudioUserController {
         return Result.ok(authService.getSessionOverview());
     }
 
+    @GetMapping("/login-lockouts")
+    public Result<List<StudioLoginLockoutVO>> listLoginLockouts() {
+        return Result.ok(authService.listLoginLockouts());
+    }
+
     @GetMapping("/{userId}/sessions")
     public Result<List<StudioUserSessionDetailVO>> 
listActiveSessions(@PathVariable Long userId) {
         return Result.ok(authService.listActiveSessionsForUser(userId));
@@ -83,6 +88,12 @@ public class StudioUserController {
         return Result.ok(StudioUserVO.from(authService.setUserEnabled(userId, 
request.getEnabled())));
     }
 
+    @PostMapping("/{userId}/role")
+    public Result<StudioUserVO> updateRole(@PathVariable Long userId,
+                                           @Valid @RequestBody 
UpdateStudioUserRoleDTO request) {
+        return Result.ok(StudioUserVO.from(authService.setUserAdmin(userId, 
request.getAdmin())));
+    }
+
     @PostMapping("/{userId}/password")
     public Result<Void> resetPassword(@PathVariable Long userId,
                                       @Valid @RequestBody ResetPasswordDTO 
request) {
@@ -99,6 +110,12 @@ public class StudioUserController {
                 .build());
     }
 
+    @DeleteMapping("/{userId}")
+    public Result<Void> deleteUser(@PathVariable Long userId) {
+        authService.deleteUser(userId);
+        return Result.ok();
+    }
+
     @PostMapping("/sessions/revoke-all")
     public Result<StudioGlobalSessionRevokeVO> revokeAllSessions() {
         return Result.ok(StudioGlobalSessionRevokeVO.builder()
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSchemaMigration.java
 
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSchemaMigration.java
new file mode 100644
index 000000000..105c39213
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSchemaMigration.java
@@ -0,0 +1,79 @@
+/*
+ * 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 lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.stereotype.Component;
+
+import javax.sql.DataSource;
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+
+/**
+ * Adds the forced-rotation flag to Studio user rows created before the 
current schema.
+ * Existing accounts default to "no rotation pending": their passwords were 
set by their
+ * owners under the previous behavior, and an upgrade must not lock anyone out.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class StudioUserSchemaMigration implements ApplicationRunner {
+
+    private static final String TABLE = "rmq_studio_user";
+    private static final String COLUMN = "password_must_change";
+    private static final String DEFINITION = "TINYINT(1) NOT NULL DEFAULT 0";
+
+    private final DataSource dataSource;
+
+    @Override
+    public void run(ApplicationArguments args) throws Exception {
+        try (Connection connection = dataSource.getConnection(); Statement 
statement = connection.createStatement()) {
+            DatabaseMetaData metadata = connection.getMetaData();
+            String catalog = connection.getCatalog();
+            if (!hasTable(metadata, catalog) || hasColumn(metadata, catalog)) {
+                return;
+            }
+            try {
+                log.info("Adding studio user column {}.{}", TABLE, COLUMN);
+                statement.executeUpdate("ALTER TABLE " + TABLE + " ADD COLUMN 
" + COLUMN
+                        + " " + DEFINITION);
+            } catch (SQLException failure) {
+                if (!hasColumn(metadata, catalog)) {
+                    throw failure;
+                }
+            }
+        }
+    }
+
+    private boolean hasTable(DatabaseMetaData metadata, String catalog) throws 
Exception {
+        try (ResultSet tables = metadata.getTables(catalog, null, TABLE, new 
String[]{"TABLE"})) {
+            return tables.next();
+        }
+    }
+
+    private boolean hasColumn(DatabaseMetaData metadata, String catalog) 
throws Exception {
+        try (ResultSet columns = metadata.getColumns(catalog, null, TABLE, 
COLUMN)) {
+            return columns.next();
+        }
+    }
+}
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 cba1d8f33..a50b2efd4 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,6 +29,8 @@ public class StudioUserVO {
     private String username;
     private boolean admin;
     private boolean enabled;
+    /** The account's password awaits rotation by its owner (set by creation 
or admin reset). */
+    private boolean passwordMustChange;
     private int activeSessionCount;
     private LocalDateTime lastSessionSeenAt;
     private LocalDateTime nearestSessionExpiresAt;
@@ -46,6 +48,7 @@ public class StudioUserVO {
                 .username(user.getUsername())
                 .admin(Boolean.TRUE.equals(user.getAdmin()))
                 .enabled(Boolean.TRUE.equals(user.getEnabled()))
+                
.passwordMustChange(Boolean.TRUE.equals(user.getPasswordMustChange()))
                 .activeSessionCount(sessionSummary == null ? 0 : 
sessionSummary.getActiveSessionCount())
                 .lastSessionSeenAt(sessionSummary == null ? null : 
sessionSummary.getLastSessionSeenAt())
                 .nearestSessionExpiresAt(sessionSummary == null
diff --git a/server/src/main/java/org/apache/rocketmq/studio/auth/LoginVO.java 
b/server/src/main/java/org/apache/rocketmq/studio/auth/UpdateStudioUserRoleDTO.java
similarity index 59%
copy from server/src/main/java/org/apache/rocketmq/studio/auth/LoginVO.java
copy to 
server/src/main/java/org/apache/rocketmq/studio/auth/UpdateStudioUserRoleDTO.java
index 32ff60a42..9e2f1072d 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/LoginVO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/auth/UpdateStudioUserRoleDTO.java
@@ -14,34 +14,14 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 package org.apache.rocketmq.studio.auth;
 
-import com.fasterxml.jackson.annotation.JsonInclude;
-import lombok.AllArgsConstructor;
-import lombok.Builder;
+import jakarta.validation.constraints.NotNull;
 import lombok.Data;
-import lombok.NoArgsConstructor;
-import lombok.ToString;
 
 @Data
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class LoginVO {
-    @ToString.Exclude
-    @JsonInclude(JsonInclude.Include.NON_NULL)
-    private String token;
-    private int expiresIn;
-    private UserInfo user;
+public class UpdateStudioUserRoleDTO {
 
-    @Data
-    @Builder
-    @NoArgsConstructor
-    @AllArgsConstructor
-    public static class UserInfo {
-        private Long userId;
-        private String username;
-        private boolean admin;
-    }
+    @NotNull(message = "Admin is required")
+    private Boolean admin;
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqStudioUser.java
 
b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqStudioUser.java
index a822d2ac4..4691dd032 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqStudioUser.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqStudioUser.java
@@ -36,6 +36,13 @@ public class RmqStudioUser {
 
     private LocalDateTime passwordChangedAt;
 
+    /**
+     * Set when the current password was chosen by someone other than the 
account owner (an
+     * administrator's reset or the account's initial password): the owner 
must rotate it at
+     * the next login before the session can proceed.
+     */
+    private Boolean passwordMustChange;
+
     private LocalDateTime gmtCreate;
 
     private LocalDateTime gmtModified;
diff --git a/server/src/main/resources/db/schema.sql 
b/server/src/main/resources/db/schema.sql
index 0c5a768d8..2fadde0b6 100644
--- a/server/src/main/resources/db/schema.sql
+++ b/server/src/main/resources/db/schema.sql
@@ -37,6 +37,7 @@ CREATE TABLE IF NOT EXISTS rmq_studio_user (
   admin TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否管理员',
   enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
   password_changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 
'最近修改密码时间',
+  password_must_change TINYINT(1) NOT NULL DEFAULT 0 COMMENT 
'当前密码由他人设定(初始/管理员重置),所有者下次登录必须修改',
   PRIMARY KEY (`id`),
   UNIQUE KEY uk_studio_user_username (username)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
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 d0ee72f09..e35fb4e98 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
@@ -30,6 +30,7 @@ import org.springframework.mock.web.MockHttpServletRequest;
 import org.springframework.mock.web.MockHttpServletResponse;
 
 import java.util.List;
+import java.util.Optional;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.Mockito.clearInvocations;
@@ -66,6 +67,33 @@ class AuthInterceptorTest {
         verify(authService, never()).isAuthenticated("Bearer " + token);
     }
 
+    @Test
+    void 
shouldRefuseEveryPathButRotationWhileAPasswordChangeIsOutstandingTest() throws 
Exception {
+        AuthProperties properties = new AuthProperties();
+        properties.setLoginRequired(true);
+        AuthService authService = mock(AuthService.class);
+        when(authService.getAuthenticatedUser("Bearer 
token-1")).thenReturn(Optional.of(
+                LoginVO.UserInfo.builder()
+                        .userId(7L)
+                        .username("operator")
+                        .admin(true)
+                        .mustChangePassword(true)
+                        .build()));
+        AuthInterceptor interceptor = interceptor(properties, authService, 
settingsRepository());
+
+        MockHttpServletResponse blocked = new MockHttpServletResponse();
+        boolean allowed = interceptor.preHandle(
+                authenticatedRequest("GET", "/api/clusters", "token-1"), 
blocked, new Object());
+
+        assertThat(allowed).isFalse();
+        assertThat(blocked.getStatus()).isEqualTo(403);
+        // Rotating the password and signing out stay reachable, or the flag 
could never clear.
+        for (String path : List.of("/api/auth/password", "/api/auth/logout")) {
+            assertThat(interceptor.preHandle(authenticatedRequest("POST", 
path, "token-1"),
+                    new MockHttpServletResponse(), new Object())).isTrue();
+        }
+    }
+
     @AfterEach
     void clearAuthenticatedUser() {
         AuthenticatedUserContext.clear();
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 505c0658f..1cb77c6d1 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
@@ -304,6 +304,71 @@ class AuthServiceDatabaseTest {
         verify(sessionMapper).update(isNull(), any(Wrapper.class));
     }
 
+    @Test
+    void adminPasswordResetRequiresRotationTest() {
+        RmqStudioUser user = user(1L, "operator", false, true, "password-1");
+        when(userMapper.selectById(1L)).thenReturn(user);
+
+        // The admin-reset path: requireCurrentPassword is false, the new 
password was chosen
+        // by someone other than the owner, so the owner must rotate it at the 
next login.
+        authService.changePassword(1L, null, "password-2", false);
+
+        org.mockito.ArgumentCaptor<UpdateWrapper<RmqStudioUser>> updateCaptor =
+                org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
+        verify(userMapper).update(isNull(), updateCaptor.capture());
+        
assertThat(updateCaptor.getValue().getSqlSet()).contains("password_must_change");
+        
assertThat(updateCaptor.getValue().getParamNameValuePairs().values()).contains(true);
+    }
+
+    @Test
+    void selfServicePasswordChangeClearsTheRotationFlagTest() {
+        RmqStudioUser user = user(1L, "operator", false, true, "password-1");
+        when(userMapper.selectById(1L)).thenReturn(user);
+
+        authService.changePassword(1L, "password-1", "password-2", true);
+
+        org.mockito.ArgumentCaptor<UpdateWrapper<RmqStudioUser>> updateCaptor =
+                org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
+        verify(userMapper).update(isNull(), updateCaptor.capture());
+        
assertThat(updateCaptor.getValue().getSqlSet()).contains("password_must_change");
+        
assertThat(updateCaptor.getValue().getParamNameValuePairs().values()).contains(false);
+    }
+
+    @Test
+    void createdUsersMustRotateTheirInitialPasswordTest() {
+        when(userMapper.selectOne(any(Wrapper.class))).thenReturn(null);
+
+        authService.createUser("contractor", "initial-password", false);
+
+        org.mockito.ArgumentCaptor<RmqStudioUser> captor =
+                org.mockito.ArgumentCaptor.forClass(RmqStudioUser.class);
+        verify(userMapper).insert(captor.capture());
+        assertThat(captor.getValue().getPasswordMustChange()).isTrue();
+    }
+
+    @Test
+    void loginReportsWhenThePasswordMustBeRotatedTest() {
+        RmqStudioUser flagged = user(1L, "operator", false, true, 
"password-1");
+        flagged.setPasswordMustChange(true);
+        when(userMapper.selectCount(isNull())).thenReturn(1L);
+        when(userMapper.selectOne(any(Wrapper.class))).thenReturn(flagged);
+
+        LoginDTO request = new LoginDTO();
+        request.setUsername("operator");
+        request.setPassword("password-1");
+        LoginVO flaggedLogin = authService.login(request);
+
+        assertThat(flaggedLogin.getUser().isMustChangePassword()).isTrue();
+
+        RmqStudioUser settled = user(2L, "reader", false, true, "password-2");
+        when(userMapper.selectOne(any(Wrapper.class))).thenReturn(settled);
+        request.setUsername("reader");
+        request.setPassword("password-2");
+        LoginVO settledLogin = authService.login(request);
+
+        assertThat(settledLogin.getUser().isMustChangePassword()).isFalse();
+    }
+
     @Test
     void revokeSessionsForUserRevokesOnlyTheSelectedUsersOpenSessions() {
         RmqStudioUser user = user(1L, "operator", false, true, "password-1");
@@ -474,6 +539,152 @@ class AuthServiceDatabaseTest {
         verify(sessionMapper, never()).update(isNull(), any(Wrapper.class));
     }
 
+    @Test
+    void grantingTheAdministratorRoleUpdatesTheFlagAndRevokesSessionsTest() {
+        RmqStudioUser operator = user(2L, "operator", false, true, 
"password-1");
+        when(userMapper.selectById(2L)).thenReturn(operator);
+
+        RmqStudioUser result = authService.setUserAdmin(2L, true);
+
+        assertThat(result.getAdmin()).isTrue();
+        // Granting needs no last-admin guard, but the sessions must be 
revoked so the
+        // re-login picks up the elevated role.
+        verify(userMapper, never()).selectList(any(Wrapper.class));
+        verify(userMapper).updateById(argThat((RmqStudioUser update) -> 
update.getId().equals(2L)
+                && Boolean.TRUE.equals(update.getAdmin())));
+        verify(sessionMapper).update(isNull(), any(Wrapper.class));
+    }
+
+    @Test
+    void revokingTheRoleFromTheLastEnabledAdministratorIsRejectedTest() {
+        RmqStudioUser admin = user(1L, "solo-admin", true, true, "password-1");
+        when(userMapper.selectById(1L)).thenReturn(admin);
+        
when(userMapper.selectList(any(Wrapper.class))).thenReturn(List.of(admin));
+
+        assertThatThrownBy(() -> authService.setUserAdmin(1L, false))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("The last enabled administrator cannot lose the 
role");
+
+        verify(userMapper, never()).updateById(any(RmqStudioUser.class));
+        verify(sessionMapper, never()).update(isNull(), any(Wrapper.class));
+    }
+
+    @Test
+    void revokingTheRoleKeepsTheOtherEnabledAdministratorTest() {
+        RmqStudioUser target = user(1L, "admin-one", true, true, "password-1");
+        RmqStudioUser other = user(2L, "admin-two", true, true, "password-1");
+        when(userMapper.selectById(1L)).thenReturn(target);
+        
when(userMapper.selectList(any(Wrapper.class))).thenReturn(List.of(target, 
other));
+
+        RmqStudioUser result = authService.setUserAdmin(1L, false);
+
+        assertThat(result.getAdmin()).isFalse();
+        verify(userMapper).updateById(argThat((RmqStudioUser update) -> 
update.getId().equals(1L)
+                && Boolean.FALSE.equals(update.getAdmin())));
+        verify(sessionMapper).update(isNull(), any(Wrapper.class));
+    }
+
+    @Test
+    void settingTheCurrentRoleShouldBeIdempotentTest() {
+        RmqStudioUser admin = user(1L, "admin", true, true, "password-1");
+        when(userMapper.selectById(1L)).thenReturn(admin);
+
+        assertThat(authService.setUserAdmin(1L, true)).isSameAs(admin);
+
+        verify(userMapper, never()).selectList(any(Wrapper.class));
+        verify(userMapper, never()).updateById(any(RmqStudioUser.class));
+        verify(sessionMapper, never()).update(isNull(), any(Wrapper.class));
+    }
+
+    @Test
+    void deletingAUserRemovesTheAccountAndItsSessionsTest() {
+        AuthenticatedUserContext.setUser(1L, "operator", true);
+        RmqStudioUser contractor = user(2L, "contractor", false, true, 
"password-1");
+        when(userMapper.selectById(2L)).thenReturn(contractor);
+
+        authService.deleteUser(2L);
+
+        org.mockito.ArgumentCaptor<QueryWrapper<RmqStudioSession>> queryCaptor 
=
+                org.mockito.ArgumentCaptor.forClass(QueryWrapper.class);
+        verify(sessionMapper).delete(queryCaptor.capture());
+        assertThat(queryCaptor.getValue().getSqlSegment()).contains("user_id");
+        
assertThat(queryCaptor.getValue().getParamNameValuePairs().values()).contains(2L);
+        verify(userMapper).deleteById(2L);
+    }
+
+    @Test
+    void deletingTheOperatorsOwnAccountIsRejectedTest() {
+        AuthenticatedUserContext.setUser(1L, "operator", true);
+        when(userMapper.selectById(1L)).thenReturn(user(1L, "operator", true, 
true, "password-1"));
+
+        assertThatThrownBy(() -> authService.deleteUser(1L))
+                .isInstanceOf(BusinessException.class)
+                .satisfies(exception ->
+                        assertThat(((BusinessException) 
exception).getCode()).isEqualTo(400))
+                .hasMessage("The current account cannot delete itself; disable 
it instead");
+
+        verify(sessionMapper, never()).delete(any(Wrapper.class));
+        verify(userMapper, never()).deleteById(2L);
+    }
+
+    @Test
+    void deletingTheLastEnabledAdministratorIsRejectedTest() {
+        AuthenticatedUserContext.setUser(1L, "operator", true);
+        RmqStudioUser soleAdmin = user(2L, "sole-admin", true, true, 
"password-1");
+        when(userMapper.selectById(2L)).thenReturn(soleAdmin);
+        
when(userMapper.selectList(any(Wrapper.class))).thenReturn(List.of(soleAdmin));
+
+        assertThatThrownBy(() -> authService.deleteUser(2L))
+                .isInstanceOf(BusinessException.class)
+                .satisfies(exception ->
+                        assertThat(((BusinessException) 
exception).getCode()).isEqualTo(409))
+                .hasMessage("The last enabled administrator cannot be 
deleted");
+
+        verify(sessionMapper, never()).delete(any(Wrapper.class));
+        verify(userMapper, never()).deleteById(2L);
+    }
+
+    @Test
+    void deletingAnAdministratorKeepsTheOtherEnabledAdministratorTest() {
+        AuthenticatedUserContext.setUser(1L, "operator", true);
+        RmqStudioUser target = user(2L, "admin-two", true, true, "password-1");
+        RmqStudioUser other = user(3L, "admin-three", true, true, 
"password-1");
+        when(userMapper.selectById(2L)).thenReturn(target);
+        
when(userMapper.selectList(any(Wrapper.class))).thenReturn(List.of(target, 
other));
+
+        authService.deleteUser(2L);
+
+        verify(sessionMapper).delete(any(Wrapper.class));
+        verify(userMapper).deleteById(2L);
+    }
+
+    @Test
+    void deletingADisabledAdministratorSkipsTheLastAdminGuardTest() {
+        // A disabled administrator is not part of the enabled-administrator 
set, so removing
+        // the account cannot lock the deployment out even when no other admin 
exists.
+        AuthenticatedUserContext.setUser(1L, "operator", true);
+        when(userMapper.selectById(2L)).thenReturn(user(2L, "retired-admin", 
true, false, "password-1"));
+
+        authService.deleteUser(2L);
+
+        verify(userMapper, never()).selectList(any(Wrapper.class));
+        verify(sessionMapper).delete(any(Wrapper.class));
+        verify(userMapper).deleteById(2L);
+    }
+
+    @Test
+    void deletingAMissingUserIsRejectedBeforeAnyDeleteTest() {
+        AuthenticatedUserContext.setUser(1L, "operator", true);
+        when(userMapper.selectById(404L)).thenReturn(null);
+
+        assertThatThrownBy(() -> authService.deleteUser(404L))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("User not found");
+
+        verify(sessionMapper, never()).delete(any(Wrapper.class));
+        verify(userMapper, never()).deleteById(404L);
+    }
+
     @Test
     void databaseAuthenticationThrottlesLastSeenWrites() {
         RmqStudioUser user = user(1L, "operator", false, true, "password-1");
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/auth/LoginRateLimiterTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/auth/LoginRateLimiterTest.java
index 617510167..d72f4e2b2 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/auth/LoginRateLimiterTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/auth/LoginRateLimiterTest.java
@@ -77,6 +77,60 @@ class LoginRateLimiterTest {
         assertThatCode(() -> 
limiter.checkAllowed("operator")).doesNotThrowAnyException();
     }
 
+    @Test
+    void activeLockoutsListLockedUsernamesSortedByRemainingSecondsTest() {
+        for (int attempt = 0; attempt < LoginRateLimiter.MAX_FAILED_ATTEMPTS; 
attempt++) {
+            limiter.recordFailure(" Operator ");   // normalized to 
lowercase/trimmed
+        }
+        clock.advance(Duration.ofMinutes(1));
+        for (int attempt = 0; attempt < LoginRateLimiter.MAX_FAILED_ATTEMPTS; 
attempt++) {
+            limiter.recordFailure("contractor");
+        }
+        for (int attempt = 1; attempt < LoginRateLimiter.MAX_FAILED_ATTEMPTS; 
attempt++) {
+            limiter.recordFailure("reader");
+        }
+        clock.advance(Duration.ofSeconds(30));
+
+        java.util.List<LoginRateLimiter.LoginLockout> lockouts = 
limiter.activeLockouts();
+
+        // Two locks (the tracked-but-not-locked "reader" stays hidden), the 
one expiring
+        // sooner first: operator's lock started at t=0, contractor's at t=60.
+        
assertThat(lockouts).extracting(LoginRateLimiter.LoginLockout::username)
+                .containsExactly("operator", "contractor");
+        assertThat(lockouts.get(0).remainingSeconds())
+                .isEqualTo(LoginRateLimiter.LOCK_DURATION.toSeconds() - 90);
+        assertThat(lockouts.get(1).remainingSeconds())
+                .isEqualTo(LoginRateLimiter.LOCK_DURATION.toSeconds() - 30);
+    }
+
+    @Test
+    void activeLockoutsEmptyWhenNothingIsTrackedOrOnlyCountedTest() {
+        assertThat(limiter.activeLockouts()).isEmpty();
+
+        // Tracked-but-not-locked usernames (fewer failures than the 
threshold) stay hidden.
+        limiter.recordFailure("reader");
+        assertThat(limiter.activeLockouts()).isEmpty();
+    }
+
+    @Test
+    void activeLockoutsDropExpiredLocksTest() {
+        for (int attempt = 0; attempt < LoginRateLimiter.MAX_FAILED_ATTEMPTS; 
attempt++) {
+            limiter.recordFailure("first-locked");
+        }
+        clock.advance(Duration.ofMinutes(1));
+        for (int attempt = 0; attempt < LoginRateLimiter.MAX_FAILED_ATTEMPTS; 
attempt++) {
+            limiter.recordFailure("second-locked");
+        }
+        // t = 330s: the first lock (300s) has expired and disappears on its 
own, while the
+        // second (locked at t=60 until t=360) still holds 30 seconds.
+        
clock.advance(LoginRateLimiter.LOCK_DURATION.minus(Duration.ofSeconds(30)));
+
+        java.util.List<LoginRateLimiter.LoginLockout> lockouts = 
limiter.activeLockouts();
+        assertThat(lockouts).hasSize(1);
+        assertThat(lockouts.get(0).username()).isEqualTo("second-locked");
+        assertThat(lockouts.get(0).remainingSeconds()).isEqualTo(30);
+    }
+
     @Test
     void successResetsFailureCountTest() {
         for (int attempt = 1; attempt < LoginRateLimiter.MAX_FAILED_ATTEMPTS; 
attempt++) {
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 a5f339101..f06bd7bda 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
@@ -136,6 +136,22 @@ class StudioUserControllerTest extends 
WebMvcAuthTestSupport {
         verify(authService).getSessionOverview();
     }
 
+    @Test
+    void loginLockoutsReturnTheCurrentlyLockedUsernames() throws Exception {
+        when(authService.listLoginLockouts()).thenReturn(List.of(
+                
StudioLoginLockoutVO.builder().username("operator").remainingSeconds(280).build(),
+                
StudioLoginLockoutVO.builder().username("contractor").remainingSeconds(240).build()));
+
+        mockMvc.perform(get("/api/studio-users/login-lockouts"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data[0].username").value("operator"))
+                .andExpect(jsonPath("$.data[0].remainingSeconds").value(280))
+                .andExpect(jsonPath("$.data[1].username").value("contractor"))
+                .andExpect(jsonPath("$.data[1].remainingSeconds").value(240));
+
+        verify(authService).listLoginLockouts();
+    }
+
     @Test
     void listActiveSessionsReturnsSafeSessionDetails() throws Exception {
         when(authService.listActiveSessionsForUser(7L))
@@ -179,6 +195,43 @@ class StudioUserControllerTest extends 
WebMvcAuthTestSupport {
         verify(authService).revokeSessionsForUser(7L);
     }
 
+    @Test
+    void updateRolePassesTheAdminFlagAndReturnsTheUser() throws Exception {
+        RmqStudioUser promoted = new RmqStudioUser();
+        promoted.setId(7L);
+        promoted.setUsername("operator");
+        promoted.setAdmin(true);
+        promoted.setEnabled(true);
+        when(authService.setUserAdmin(7L, true)).thenReturn(promoted);
+
+        mockMvc.perform(post("/api/studio-users/7/role")
+                        .contentType("application/json")
+                        .content("{\"admin\": true}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.id").value(7))
+                .andExpect(jsonPath("$.data.admin").value(true));
+
+        verify(authService).setUserAdmin(7L, true);
+    }
+
+    @Test
+    void updateRoleRejectsAMissingAdminFlag() throws Exception {
+        mockMvc.perform(post("/api/studio-users/7/role")
+                        .contentType("application/json")
+                        .content("{}"))
+                .andExpect(status().isBadRequest());
+
+        verify(authService, org.mockito.Mockito.never()).setUserAdmin(7L, 
true);
+    }
+
+    @Test
+    void deleteRemovesTheUserAccount() throws Exception {
+        mockMvc.perform(delete("/api/studio-users/7"))
+                .andExpect(status().isOk());
+
+        verify(authService).deleteUser(7L);
+    }
+
     @Test
     void revokeAllSessionsReturnsTheRevokedCount() throws Exception {
         when(authService.revokeAllSessions()).thenReturn(11);
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/auth/StudioUserSchemaMigrationTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/auth/StudioUserSchemaMigrationTest.java
new file mode 100644
index 000000000..eda83db1d
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/auth/StudioUserSchemaMigrationTest.java
@@ -0,0 +1,86 @@
+/*
+ * 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.h2.jdbcx.JdbcDataSource;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.DefaultApplicationArguments;
+
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.Statement;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class StudioUserSchemaMigrationTest {
+
+    @Test
+    void addsRotationFlagToExistingUsersWithoutDroppingRowsAndIsIdempotent() 
throws Exception {
+        JdbcDataSource dataSource = new JdbcDataSource();
+        
dataSource.setURL("jdbc:h2:mem:studio-user-schema-migration;MODE=MySQL;"
+                + "DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE");
+        dataSource.setUser("sa");
+        try (Connection connection = dataSource.getConnection(); Statement 
statement = connection.createStatement()) {
+            statement.execute("CREATE TABLE rmq_studio_user ("
+                    + "id BIGINT PRIMARY KEY, username VARCHAR(128) NOT NULL, "
+                    + "password_hash VARCHAR(512) NOT NULL, admin TINYINT(1) 
NOT NULL DEFAULT 0, "
+                    + "enabled TINYINT(1) NOT NULL DEFAULT 1, "
+                    + "password_changed_at TIMESTAMP NOT NULL, gmt_create 
TIMESTAMP, gmt_modified TIMESTAMP)");
+            statement.execute("INSERT INTO rmq_studio_user (id, username, 
password_hash, password_changed_at) "
+                    + "VALUES (7, 'operator', 'stored-hash', '2026-08-22 
08:00:00')");
+        }
+
+        StudioUserSchemaMigration migration = new 
StudioUserSchemaMigration(dataSource);
+        migration.run(new DefaultApplicationArguments());
+        migration.run(new DefaultApplicationArguments());
+
+        try (Connection connection = dataSource.getConnection(); Statement 
statement = connection.createStatement()) {
+            try (ResultSet columns = statement.executeQuery("SELECT COUNT(*) 
FROM information_schema.columns "
+                    + "WHERE table_name = 'rmq_studio_user' AND column_name = 
'password_must_change'")) {
+                columns.next();
+                assertThat(columns.getInt(1)).isEqualTo(1);
+            }
+            try (ResultSet rows = statement.executeQuery(
+                    "SELECT username, password_must_change FROM 
rmq_studio_user")) {
+                rows.next();
+                assertThat(rows.getString("username")).isEqualTo("operator");
+                // Pre-existing accounts owned their passwords under the 
previous behavior;
+                // the upgrade must not force a rotation on them.
+                assertThat(rows.getBoolean("password_must_change")).isFalse();
+                assertThat(rows.next()).isFalse();
+            }
+        }
+    }
+
+    @Test
+    void skipsMissingUserTableSoTheMainSchemaCanCreateItLater() throws 
Exception {
+        JdbcDataSource dataSource = new JdbcDataSource();
+        dataSource.setURL("jdbc:h2:mem:studio-user-schema-missing;MODE=MySQL;"
+                + "DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE");
+        dataSource.setUser("sa");
+
+        StudioUserSchemaMigration migration = new 
StudioUserSchemaMigration(dataSource);
+
+        migration.run(new DefaultApplicationArguments());
+
+        try (Connection connection = dataSource.getConnection();
+             ResultSet tables = connection.getMetaData().getTables(null, null, 
"rmq_studio_user",
+                     new String[] {"TABLE"})) {
+            assertThat(tables.next()).isFalse();
+        }
+    }
+}
diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts
index 32c3df5bb..af263439f 100644
--- a/web/src/api/auth.ts
+++ b/web/src/api/auth.ts
@@ -32,6 +32,8 @@ export interface AuthenticatedUser {
   userId: number | null;
   username: string;
   admin: boolean;
+  /** The current password was chosen by someone else; the login flow must 
make the owner rotate it. */
+  mustChangePassword?: boolean;
 }
 
 export interface AuthStatus {
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index 747988653..cb05680e8 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -83,9 +83,8 @@ export function handleSessionUnauthorized(): void {
   // Carry the page the operator was on so the login flow can return to it 
after
   // re-authenticating; an expired session while already on /login must not 
self-reference.
   const current = `${window.location.pathname}${window.location.search}`;
-  const target = current && current !== '/login'
-    ? `/login?redirect=${encodeURIComponent(current)}`
-    : '/login';
+  const target =
+    current && current !== '/login' ? 
`/login?redirect=${encodeURIComponent(current)}` : '/login';
   window.location.href = target;
 }
 
diff --git a/web/src/api/studioUsers.ts b/web/src/api/studioUsers.ts
index c2ec38cf4..a3fafce5e 100644
--- a/web/src/api/studioUsers.ts
+++ b/web/src/api/studioUsers.ts
@@ -23,6 +23,8 @@ export interface StudioUser {
   username: string;
   admin: boolean;
   enabled: boolean;
+  /** The account's password awaits rotation by its owner (set by creation or 
admin reset). */
+  passwordMustChange?: boolean;
   activeSessionCount: number;
   lastSessionSeenAt?: string | null;
   nearestSessionExpiresAt?: string | null;
@@ -91,6 +93,16 @@ export async function getStudioUserSessionOverview() {
   return response.data.data;
 }
 
+export interface StudioLoginLockout {
+  username: string;
+  remainingSeconds: number;
+}
+
+export async function listStudioLoginLockouts() {
+  const response = await client.get<{ data: StudioLoginLockout[] 
}>('/studio-users/login-lockouts');
+  return response.data.data;
+}
+
 export async function listStudioUserSessions(userId: number) {
   const response = await client.get<{ data: StudioUserSessionDetail[] }>(
     `/studio-users/${userId}/sessions`,
@@ -130,6 +142,12 @@ export async function setStudioUserEnabled(userId: number, 
enabled: boolean) {
   return response.data.data;
 }
 
+export async function setStudioUserRole(userId: number, admin: boolean) {
+  const rolePath = `/studio-users/${userId}/role`;
+  const response = await client.post<{ data: StudioUser }>(rolePath, { admin 
});
+  return response.data.data;
+}
+
 export async function resetStudioUserPassword(userId: number, newPassword: 
string) {
   await client.post(`/studio-users/${userId}/password`, { newPassword });
 }
@@ -141,6 +159,10 @@ export async function revokeStudioUserSessions(userId: 
number) {
   return response.data.data;
 }
 
+export async function deleteStudioUser(userId: number) {
+  await client.delete(`/studio-users/${userId}`);
+}
+
 export async function revokeAllStudioUserSessions() {
   const response = await client.post<{ data: { revokedSessionCount: number } 
}>(
     '/studio-users/sessions/revoke-all',
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index ca2312a0f..5b73c6a49 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -1373,7 +1373,10 @@ const translations: Record<string, Record<Lang, string>> 
= {
     zh: '令牌连续未使用的时长超过该值后会话即失效;0 表示仅按绝对超时判定,低于 10 分钟按 10 分钟生效。',
     en: 'A session whose token stays unused longer than this is revoked; 0 
keeps only the absolute timeout, and values below 10 minutes apply as 10.',
   },
-  'settings.sessionIdleTimeoutRequired': { zh: '请输入会话空闲超时时长', en: 'Enter a 
session idle timeout.' },
+  'settings.sessionIdleTimeoutRequired': {
+    zh: '请输入会话空闲超时时长',
+    en: 'Enter a session idle timeout.',
+  },
   'settings.sessionIdleTimeoutUnit': { zh: '会话空闲超时单位', en: 'Session idle 
timeout unit' },
   'settings.minutes': { zh: '分钟', en: 'minutes' },
   'settings.saveSettings': { zh: '保存设置', en: 'Save settings' },
@@ -3164,6 +3167,24 @@ const translations: Record<string, Record<Lang, string>> 
= {
   'userMgmt.allRoles': { zh: '全部权限', en: 'All roles' },
   'userMgmt.roleAdmin': { zh: '管理员', en: 'Administrator' },
   'userMgmt.roleUser': { zh: '普通用户', en: 'Regular user' },
+  'userMgmt.roleGranted': { zh: '已授予管理员权限', en: 'Administrator role granted' },
+  'userMgmt.roleRevoked': { zh: '已撤销管理员权限', en: 'Administrator role revoked' },
+  'userMgmt.updateRoleFailed': {
+    zh: '修改权限失败,请稍后重试',
+    en: 'Failed to change the role. Please try again later.',
+  },
+  'userMgmt.grantRoleConfirmTitle': {
+    zh: '将「{username}」提升为管理员?',
+    en: 'Grant the administrator role to "{username}"?',
+  },
+  'userMgmt.revokeRoleConfirmTitle': {
+    zh: '撤销「{username}」的管理员权限?',
+    en: 'Revoke the administrator role from "{username}"?',
+  },
+  'userMgmt.roleChangeConfirmBody': {
+    zh: '该用户的所有会话将被注销,需要重新登录后新权限才会生效。',
+    en: "All of this user's sessions will be signed out; the new role takes 
effect after they sign in again.",
+  },
   'userMgmt.filterByStatus': { zh: '按状态筛选', en: 'Filter by status' },
   'userMgmt.allStatuses': { zh: '全部状态', en: 'All statuses' },
   'userMgmt.totalUsers': { zh: '共 {count} 个用户', en: '{count} users in total' },
@@ -3201,6 +3222,20 @@ const translations: Record<string, Record<Lang, string>> 
= {
   'userMgmt.sessionRevoked': { zh: '已注销会话 #{id}', en: 'Revoked session #{id}' 
},
   'userMgmt.accountStatus': { zh: '账号状态', en: 'Account Status' },
   'userMgmt.passwordChangedAt': { zh: '密码修改时间', en: 'Password Changed At' },
+  'userMgmt.loginLockouts': { zh: '登录锁定', en: 'Login lockouts' },
+  'userMgmt.loginLockoutRemaining': {
+    zh: '{seconds} 秒后自动解锁',
+    en: 'unlocks in {seconds}s',
+  },
+  'userMgmt.loginLockoutHelp': {
+    zh: '该用户名连续登录失败达到上限被临时锁定,锁定期满自动解除,无需人工干预。',
+    en: 'This username exhausted its failed-login budget and is temporarily 
locked; the lock lifts by itself when it expires.',
+  },
+  'userMgmt.passwordMustChange': { zh: '待改密', en: 'Rotation due' },
+  'userMgmt.passwordMustChangeHelp': {
+    zh: '该账号的当前密码由他人设定(创建或管理员重置),所有者下次登录时必须修改。',
+    en: 'The current password was chosen by someone else (creation or admin 
reset); its owner must replace it at the next login.',
+  },
   'userMgmt.clientOrigin': { zh: '来源', en: 'Origin' },
   'userMgmt.noActiveSessions': { zh: '暂无活跃会话', en: 'No active sessions' },
   'userMgmt.createTitle': { zh: '新建 Studio 用户', en: 'Create Studio User' },
@@ -3244,6 +3279,16 @@ const translations: Record<string, Record<Lang, string>> 
= {
   },
   'userMgmt.nothingToRevoke': { zh: '没有可注销的活跃会话', en: 'No active sessions to 
revoke' },
   'userMgmt.revokeFailed': { zh: '注销用户会话失败', en: 'Failed to revoke user 
sessions' },
+  'userMgmt.deleteConfirm': {
+    zh: '删除用户 {username}?',
+    en: 'Delete user {username}?',
+  },
+  'userMgmt.deleteDescription': {
+    zh: '该账号及其全部会话将被永久删除,此操作不可恢复。',
+    en: 'The account and all of its sessions are removed permanently; this 
cannot be undone.',
+  },
+  'userMgmt.deleted': { zh: '已删除用户 {username}', en: 'Deleted user {username}' 
},
+  'userMgmt.deleteFailed': { zh: '删除用户失败', en: 'Failed to delete user' },
   'userMgmt.revokeAllSessions': { zh: '注销全部会话', en: 'Revoke all sessions' },
   'userMgmt.revokeAllConfirmTitle': {
     zh: '注销其他全部用户的活跃会话?',
@@ -3277,6 +3322,23 @@ const translations: Record<string, Record<Lang, string>> 
= {
   'login.passwordRequired': { zh: '密码为必填项', en: 'Password is required' },
   'login.success': { zh: '登录成功', en: 'Login successful' },
   'login.failed': { zh: '登录失败', en: 'Login failed' },
+  'login.changeFailed': { zh: '修改密码失败', en: 'Failed to change password' },
+  'login.mustChangePrompt': {
+    zh: '当前密码由管理员设定,请先修改密码',
+    en: 'This password was set by an administrator; change it to continue',
+  },
+  'login.rotationDescription': {
+    zh: '账号 {username} 的当前密码由他人设定,请设置新密码后进入控制台。',
+    en: 'The current password of {username} was chosen by someone else. Set a 
new one to enter the console.',
+  },
+  'login.newPassword': { zh: '新密码', en: 'New password' },
+  'login.newPasswordPlaceholder': { zh: '请输入新密码', en: 'Enter the new password' 
},
+  'login.newPasswordRequired': { zh: '请输入新密码', en: 'Enter the new password' },
+  'login.confirmNewPassword': { zh: '确认新密码', en: 'Confirm new password' },
+  'login.confirmNewPasswordPlaceholder': { zh: '请再次输入新密码', en: 'Repeat the new 
password' },
+  'login.confirmNewPasswordRequired': { zh: '请再次输入新密码', en: 'Repeat the new 
password' },
+  'login.passwordMismatch': { zh: '两次输入的密码不一致', en: 'The passwords do not 
match' },
+  'login.rotationSubmit': { zh: '修改密码并进入', en: 'Change password and continue' 
},
   'login.brandEyebrow': { zh: '控制台', en: 'Control plane' },
   'login.brandTitle': {
     zh: '统一管理每一条消息链路',
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx 
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index 6cfb89171..4b2bba94b 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -363,7 +363,9 @@ describe('Consumer page', () => {
 
     const modeSelect = screen.getAllByRole('combobox')[1];
     fireEvent.mouseDown(modeSelect.parentElement!);
-    await user.click(await screen.findByText('Pop', { selector: 
'.ant-select-item-option-content' }));
+    await user.click(
+      await screen.findByText('Pop', { selector: 
'.ant-select-item-option-content' }),
+    );
 
     await waitFor(() =>
       expect(consumerService.listConsumerGroupPage).toHaveBeenLastCalledWith({
diff --git a/web/src/pages/login/index.test.tsx 
b/web/src/pages/login/index.test.tsx
index ddad85aaa..b339ca110 100644
--- a/web/src/pages/login/index.test.tsx
+++ b/web/src/pages/login/index.test.tsx
@@ -20,6 +20,7 @@ Object.defineProperty(window, 'matchMedia', {
 
 const navigateMock = vi.hoisted(() => vi.fn());
 const loginApiMock = vi.hoisted(() => vi.fn());
+const changePasswordMock = vi.hoisted(() => vi.fn());
 const loginStoreMock = vi.hoisted(() => vi.fn());
 
 vi.mock('react-router-dom', async (importOriginal) => {
@@ -27,7 +28,7 @@ vi.mock('react-router-dom', async (importOriginal) => {
   return { ...actual, useNavigate: () => navigateMock };
 });
 
-vi.mock('../../api/auth', () => ({ login: loginApiMock }));
+vi.mock('../../api/auth', () => ({ login: loginApiMock, changePassword: 
changePasswordMock }));
 
 vi.mock('../../stores/authStore', () => ({
   default: (selector: (state: { login: typeof loginStoreMock }) => unknown) =>
@@ -127,4 +128,34 @@ describe('LoginPage', () => {
     expect(screen.getByText('login.passwordRequired')).toBeTruthy();
     expect(loginApiMock).not.toHaveBeenCalled();
   });
+
+  it('forces a password rotation before entering, then signs in with the new 
password', async () => {
+    loginApiMock
+      .mockResolvedValueOnce({
+        user: { username: 'alice', userId: 42, admin: false, 
mustChangePassword: true },
+      })
+      .mockResolvedValueOnce({
+        user: { username: 'alice', userId: 42, admin: false },
+      });
+    changePasswordMock.mockResolvedValue(undefined);
+    renderPage();
+    fillCredentials();
+    fireEvent.click(screen.getByRole('button', { name: 'login.title' }));
+
+    // The rotation form replaces the login form; the session must not be 
entered yet.
+    const newPasswordInput = await 
screen.findByPlaceholderText('login.newPasswordPlaceholder');
+    expect(loginStoreMock).not.toHaveBeenCalled();
+    expect(navigateMock).not.toHaveBeenCalled();
+    fireEvent.change(newPasswordInput, { target: { value: 'fresh-secret' } });
+    
fireEvent.change(screen.getByPlaceholderText('login.confirmNewPasswordPlaceholder'),
 {
+      target: { value: 'fresh-secret' },
+    });
+    fireEvent.click(screen.getByRole('button', { name: 'login.rotationSubmit' 
}));
+
+    await waitFor(() => 
expect(changePasswordMock).toHaveBeenCalledWith('secret', 'fresh-secret'));
+    // The rotation revokes the session, so the flow signs in again with the 
new password.
+    await waitFor(() => expect(loginApiMock).toHaveBeenLastCalledWith('alice', 
'fresh-secret'));
+    await waitFor(() => expect(loginStoreMock).toHaveBeenCalledWith('alice', 
42, false));
+    expect(navigateMock).toHaveBeenCalledWith('/', { replace: true });
+  });
 });
diff --git a/web/src/pages/login/index.tsx b/web/src/pages/login/index.tsx
index f938f507d..0337f9c69 100644
--- a/web/src/pages/login/index.tsx
+++ b/web/src/pages/login/index.tsx
@@ -27,7 +27,7 @@ import { App, Button, Form, Input, Typography } from 'antd';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { useLang } from '../../i18n/LangContext';
 import useAuthStore from '../../stores/authStore';
-import { login as loginApi } from '../../api/auth';
+import { login as loginApi, changePassword } from '../../api/auth';
 import { useTheme } from '../../theme/useTheme';
 import './index.css';
 
@@ -38,6 +38,11 @@ interface LoginFormValues {
   password: string;
 }
 
+interface PasswordChangeFormValues {
+  newPassword: string;
+  confirmPassword: string;
+}
+
 /**
  * Only same-app absolute paths may be used as the post-login destination: 
protocol-relative
  * URLs (`//evil.com`) and anything with a scheme or a backslash must fall 
back to home.
@@ -53,7 +58,11 @@ function sanitizeReturnPath(candidate: string | null): 
string | null {
 const LoginPage = () => {
   const [loading, setLoading] = useState(false);
   const loginInFlightRef = useRef(false);
+  // Set when the just-authenticated account must rotate its password (initial 
or admin-reset
+  // credential) before the session may proceed.
+  const [rotation, setRotation] = useState<{ username: string; password: 
string } | null>(null);
   const [form] = Form.useForm<LoginFormValues>();
+  const [rotationForm] = Form.useForm<PasswordChangeFormValues>();
   const { t } = useLang();
   const { message } = App.useApp();
   const navigate = useNavigate();
@@ -61,6 +70,14 @@ const LoginPage = () => {
   const authLogin = useAuthStore((s) => s.login);
   const { darkMode, toggleTheme } = useTheme();
 
+  const enterApp = (user: { username: string; userId: number | null; admin: 
boolean }) => {
+    authLogin(user.username, user.userId, user.admin);
+    message.success(t('login.success'));
+    // A session that expired mid-work redirects here with ?redirect=<page>; 
go back to it. Both
+    // entry points share this, so completing a forced rotation lands on the 
same page.
+    navigate(sanitizeReturnPath(searchParams.get('redirect')) ?? '/', { 
replace: true });
+  };
+
   const onFinish = async (values: LoginFormValues) => {
     // React state is applied on the next render, so it cannot prevent two 
submit
     // events in the same tick. Own the request synchronously before awaiting 
it.
@@ -69,10 +86,14 @@ const LoginPage = () => {
     setLoading(true);
     try {
       const data = await loginApi(values.username, values.password);
-      authLogin(data.user.username, data.user.userId, data.user.admin);
-      message.success(t('login.success'));
-      // A session that expired mid-work redirects here with ?redirect=<page>; 
go back to it.
-      navigate(sanitizeReturnPath(searchParams.get('redirect')) ?? '/', { 
replace: true });
+      if (data.user.mustChangePassword) {
+        // The credential was chosen by someone else; the owner replaces it 
before entering.
+        // The change revokes this session, so completion signs in again with 
the new password.
+        message.info(t('login.mustChangePrompt'));
+        setRotation({ username: values.username, password: values.password });
+        return;
+      }
+      enterApp(data.user);
     } catch (err: unknown) {
       const errorMsg = err instanceof Error ? err.message : t('login.failed');
       message.error(errorMsg);
@@ -82,6 +103,25 @@ const LoginPage = () => {
     }
   };
 
+  const onRotationFinish = async (values: PasswordChangeFormValues) => {
+    if (loginInFlightRef.current || !rotation) return;
+    loginInFlightRef.current = true;
+    setLoading(true);
+    try {
+      await changePassword(rotation.password, values.newPassword);
+      const data = await loginApi(rotation.username, values.newPassword);
+      setRotation(null);
+      enterApp(data.user);
+    } catch (err: unknown) {
+      const errorMsg = err instanceof Error ? err.message : 
t('login.changeFailed');
+      message.error(errorMsg);
+      setRotation(null);
+    } finally {
+      loginInFlightRef.current = false;
+      setLoading(false);
+    }
+  };
+
   return (
     <main className="login-page" data-theme={darkMode ? 'dark' : 'light'}>
       {/* ── Animated Orbs Background (same recipe as the home page) ── */}
@@ -188,43 +228,97 @@ const LoginPage = () => {
         </div>
 
         <div className="login-form-shell">
-          <Form
-            form={form}
-            name="login_form"
-            layout="vertical"
-            onFinish={onFinish}
-            initialValues={{ username: '', password: '' }}
-          >
-            <Form.Item
-              label={t('login.username')}
-              name="username"
-              rules={[{ required: true, message: t('login.usernameRequired') 
}]}
+          {rotation ? (
+            <Form
+              form={rotationForm}
+              name="password_rotation_form"
+              layout="vertical"
+              onFinish={onRotationFinish}
+              initialValues={{ newPassword: '', confirmPassword: '' }}
             >
-              <Input
-                prefix={<UserOutlined />}
-                placeholder={t('login.usernamePlaceholder')}
-                autoComplete="username"
-              />
-            </Form.Item>
-
-            <Form.Item
-              label={t('login.password')}
-              name="password"
-              rules={[{ required: true, message: t('login.passwordRequired') 
}]}
+              <Typography.Paragraph className="login-headline-description">
+                {t('login.rotationDescription', { username: rotation.username 
})}
+              </Typography.Paragraph>
+              <Form.Item
+                label={t('login.newPassword')}
+                name="newPassword"
+                rules={[
+                  { required: true, message: t('login.newPasswordRequired') },
+                  { min: 8, message: t('userMgmt.passwordMinLength') },
+                ]}
+              >
+                <Input.Password
+                  prefix={<LockOutlined />}
+                  placeholder={t('login.newPasswordPlaceholder')}
+                  autoComplete="new-password"
+                />
+              </Form.Item>
+              <Form.Item
+                label={t('login.confirmNewPassword')}
+                name="confirmPassword"
+                dependencies={['newPassword']}
+                rules={[
+                  { required: true, message: 
t('login.confirmNewPasswordRequired') },
+                  ({ getFieldValue }) => ({
+                    validator(_, value) {
+                      return !value || getFieldValue('newPassword') === value
+                        ? Promise.resolve()
+                        : Promise.reject(new 
Error(t('login.passwordMismatch')));
+                    },
+                  }),
+                ]}
+              >
+                <Input.Password
+                  prefix={<LockOutlined />}
+                  placeholder={t('login.confirmNewPasswordPlaceholder')}
+                  autoComplete="new-password"
+                />
+              </Form.Item>
+              <Form.Item className="login-submit-item">
+                <Button type="primary" htmlType="submit" block 
loading={loading}>
+                  {t('login.rotationSubmit')}
+                </Button>
+              </Form.Item>
+            </Form>
+          ) : (
+            <Form
+              form={form}
+              name="login_form"
+              layout="vertical"
+              onFinish={onFinish}
+              initialValues={{ username: '', password: '' }}
             >
-              <Input.Password
-                prefix={<LockOutlined />}
-                placeholder={t('login.passwordPlaceholder')}
-                autoComplete="current-password"
-              />
-            </Form.Item>
-
-            <Form.Item className="login-submit-item">
-              <Button type="primary" htmlType="submit" block loading={loading}>
-                {t('login.title')}
-              </Button>
-            </Form.Item>
-          </Form>
+              <Form.Item
+                label={t('login.username')}
+                name="username"
+                rules={[{ required: true, message: t('login.usernameRequired') 
}]}
+              >
+                <Input
+                  prefix={<UserOutlined />}
+                  placeholder={t('login.usernamePlaceholder')}
+                  autoComplete="username"
+                />
+              </Form.Item>
+
+              <Form.Item
+                label={t('login.password')}
+                name="password"
+                rules={[{ required: true, message: t('login.passwordRequired') 
}]}
+              >
+                <Input.Password
+                  prefix={<LockOutlined />}
+                  placeholder={t('login.passwordPlaceholder')}
+                  autoComplete="current-password"
+                />
+              </Form.Item>
+
+              <Form.Item className="login-submit-item">
+                <Button type="primary" htmlType="submit" block 
loading={loading}>
+                  {t('login.title')}
+                </Button>
+              </Form.Item>
+            </Form>
+          )}
         </div>
 
         <div className="login-card-footer">
diff --git a/web/src/pages/studio/UserManagement.tsx 
b/web/src/pages/studio/UserManagement.tsx
index 00fda6402..ee52845e9 100644
--- a/web/src/pages/studio/UserManagement.tsx
+++ b/web/src/pages/studio/UserManagement.tsx
@@ -42,6 +42,7 @@ import {
   ListBullets,
   Plus,
   SignOut,
+  Trash,
 } from '@phosphor-icons/react';
 import { useNavigate } from 'react-router-dom';
 import PageHeader from '../../components/PageHeader';
@@ -49,8 +50,10 @@ import InfoBanner from '../../components/InfoBanner';
 import { changePassword } from '../../api/auth';
 import {
   createStudioUser,
+  deleteStudioUser,
   getStudioUserSessionOverview,
   listAllStudioUsers as exportStudioUsers,
+  listStudioLoginLockouts,
   listStudioUserSessions,
   listStudioUsers,
   resetStudioUserPassword,
@@ -58,6 +61,8 @@ import {
   revokeStudioSession,
   revokeStudioUserSessions,
   setStudioUserEnabled,
+  setStudioUserRole,
+  type StudioLoginLockout,
   type StudioUser,
   type StudioUserSessionDetail,
   type StudioUserSessionOverview,
@@ -136,6 +141,7 @@ const UserManagementPage = () => {
   const [sessionOverview, setSessionOverview] = 
useState<StudioUserSessionOverview | null>(null);
   const [sessionOverviewFailed, setSessionOverviewFailed] = useState(false);
   const [sessionModalUser, setSessionModalUser] = useState<StudioUser | 
null>(null);
+  const [loginLockouts, setLoginLockouts] = useState<StudioLoginLockout[]>([]);
   const [sessionDetails, setSessionDetails] = 
useState<StudioUserSessionDetail[]>([]);
   const [sessionDetailsLoading, setSessionDetailsLoading] = useState(false);
   const [sessionDetailsFailed, setSessionDetailsFailed] = useState(false);
@@ -166,6 +172,7 @@ const UserManagementPage = () => {
       setSessionOverviewFailed(false);
       sessionModalUserIdRef.current = null;
       setSessionModalUser(null);
+      setLoginLockouts([]);
       setSessionDetails([]);
       return;
     }
@@ -174,7 +181,7 @@ const UserManagementPage = () => {
       if (requestId === requestSeqRef.current) setLoading(true);
     });
     try {
-      const [result, overview] = await Promise.all([
+      const [result, overview, lockouts] = await Promise.all([
         listStudioUsers({
           search: debouncedSearch || undefined,
           admin: roleFilter === undefined ? undefined : roleFilter === 'admin',
@@ -186,10 +193,12 @@ const UserManagementPage = () => {
           (value) => ({ value, failed: false }),
           () => ({ value: null, failed: true }),
         ),
+        listStudioLoginLockouts().catch(() => []),
       ]);
       if (requestId !== requestSeqRef.current) return;
       setSessionOverview(overview.value);
       setSessionOverviewFailed(overview.failed);
+      setLoginLockouts(lockouts);
       if (result.items.length === 0 && result.total > 0 && page > 1) {
         const lastPage = Math.max(1, Math.ceil(result.total / result.size));
         if (page > lastPage) {
@@ -324,6 +333,30 @@ const UserManagementPage = () => {
       t('userMgmt.updateStatusFailed'),
     );
 
+  const setRole = (record: StudioUser, admin: boolean) =>
+    runUserMutation(
+      record.id,
+      async () => {
+        await setStudioUserRole(record.id, admin);
+        message.success(admin ? t('userMgmt.roleGranted') : 
t('userMgmt.roleRevoked'));
+        await loadUsers();
+      },
+      t('userMgmt.updateRoleFailed'),
+    );
+
+  const confirmRoleChange = (record: StudioUser, admin: boolean) => {
+    Modal.confirm({
+      title: admin
+        ? t('userMgmt.grantRoleConfirmTitle', { username: record.username })
+        : t('userMgmt.revokeRoleConfirmTitle', { username: record.username }),
+      content: t('userMgmt.roleChangeConfirmBody'),
+      okText: admin ? t('userMgmt.roleAdmin') : t('userMgmt.roleUser'),
+      cancelText: t('common.cancel'),
+      okButtonProps: admin ? undefined : { danger: true },
+      onOk: () => setRole(record, admin),
+    });
+  };
+
   const updatePassword = async () => {
     if (!passwordTarget) return;
     try {
@@ -371,6 +404,20 @@ const UserManagementPage = () => {
       t('userMgmt.revokeFailed'),
     );
 
+  const deleteUser = (record: StudioUser) =>
+    runUserMutation(
+      record.id,
+      async () => {
+        await deleteStudioUser(record.id);
+        message.success(t('userMgmt.deleted', { username: record.username }));
+        if (sessionModalUser?.id === record.id) {
+          setSessionDrawerUser(null);
+        }
+        await loadUsers();
+      },
+      t('userMgmt.deleteFailed'),
+    );
+
   // Shares the per-user in-flight guard with revoke-all, so the two can never 
overlap.
   const revokeSession = (record: StudioUser, sessionId: number) =>
     runUserMutation(
@@ -514,8 +561,18 @@ const UserManagementPage = () => {
       title: t('userMgmt.role'),
       dataIndex: 'admin',
       width: 88,
-      render: (value: boolean) =>
-        value ? (
+      // The role is fixed at creation unless an administrator changes it 
here; readers keep
+      // the read-only tag so the mutation surface stays admin-only like every 
other action.
+      render: (value: boolean, record: StudioUser) =>
+        admin ? (
+          <Switch
+            checked={value}
+            loading={mutatingUserIds.has(record.id)}
+            checkedChildren={t('userMgmt.roleAdmin')}
+            unCheckedChildren={t('userMgmt.roleUser')}
+            onChange={(next: boolean) => confirmRoleChange(record, next)}
+          />
+        ) : value ? (
           <Tag color="blue">{t('userMgmt.roleAdmin')}</Tag>
         ) : (
           <Tag>{t('userMgmt.roleUser')}</Tag>
@@ -524,13 +581,21 @@ const UserManagementPage = () => {
     {
       title: t('common.status'),
       dataIndex: 'enabled',
-      width: 88,
-      render: (value: boolean) =>
-        value ? (
-          <Tag color="green">{t('common.enabled')}</Tag>
-        ) : (
-          <Tag color="default">{t('common.disabled')}</Tag>
-        ),
+      width: 150,
+      render: (value: boolean, record) => (
+        <>
+          {value ? (
+            <Tag color="green">{t('common.enabled')}</Tag>
+          ) : (
+            <Tag color="default">{t('common.disabled')}</Tag>
+          )}
+          {record.passwordMustChange && (
+            <Tooltip title={t('userMgmt.passwordMustChangeHelp')}>
+              <Tag color="orange">{t('userMgmt.passwordMustChange')}</Tag>
+            </Tooltip>
+          )}
+        </>
+      ),
     },
     {
       title: t('userMgmt.activeSessions'),
@@ -612,6 +677,25 @@ const UserManagementPage = () => {
             unCheckedChildren={t('userMgmt.disable')}
             onChange={(enabled) => void setEnabled(record, enabled)}
           />
+          <Popconfirm
+            title={t('userMgmt.deleteConfirm', { username: record.username })}
+            description={t('userMgmt.deleteDescription')}
+            okText={t('common.delete')}
+            cancelText={t('common.cancel')}
+            okButtonProps={{ danger: true }}
+            disabled={record.id === userId}
+            onConfirm={() => void deleteUser(record)}
+          >
+            <Button
+              size="small"
+              danger
+              icon={<Trash size={14} />}
+              disabled={record.id === userId}
+              loading={mutatingUserIds.has(record.id)}
+            >
+              {t('common.delete')}
+            </Button>
+          </Popconfirm>
         </Space>
       ),
     },
@@ -720,7 +804,26 @@ const UserManagementPage = () => {
               })}
               value={sessionOverview.staleSessionCount}
             />
+            <Statistic title={t('userMgmt.loginLockouts')} 
value={loginLockouts.length} />
           </Flex>
+          {loginLockouts.length > 0 && (
+            <Flex gap={8} wrap style={{ marginTop: 8 }}>
+              {loginLockouts.map((lockout) => (
+                <Tooltip
+                  key={lockout.username}
+                  title={t('userMgmt.loginLockoutHelp')}
+                  placement="top"
+                >
+                  <Tag color="red">
+                    {lockout.username} ·{' '}
+                    {t('userMgmt.loginLockoutRemaining', {
+                      seconds: lockout.remainingSeconds,
+                    })}
+                  </Tag>
+                </Tooltip>
+              ))}
+            </Flex>
+          )}
         </Card>
       )}
       {admin && (
diff --git a/web/src/pages/studio/__tests__/UserManagement.test.tsx 
b/web/src/pages/studio/__tests__/UserManagement.test.tsx
index 36ebd0b81..741c23f41 100644
--- a/web/src/pages/studio/__tests__/UserManagement.test.tsx
+++ b/web/src/pages/studio/__tests__/UserManagement.test.tsx
@@ -15,20 +15,23 @@
  * limitations under the License.
  */
 
-import { App } from 'antd';
+import { App, Modal } from 'antd';
 import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
 import { act, fireEvent, render, screen, waitFor, within } from 
'@testing-library/react';
 import userEvent, { type UserEvent } from '@testing-library/user-event';
 import { MemoryRouter } from 'react-router-dom';
 import {
+  deleteStudioUser,
   getStudioUserSessionOverview,
   listAllStudioUsers as downloadStudioUsers,
+  listStudioLoginLockouts,
   listStudioUserSessions,
   listStudioUsers,
   revokeAllStudioUserSessions,
   revokeStudioSession,
   revokeStudioUserSessions,
   setStudioUserEnabled,
+  setStudioUserRole,
   type StudioUser,
   type StudioUserSessionDetail,
 } from '../../../api/studioUsers';
@@ -41,8 +44,10 @@ import UserManagementPage from '../UserManagement';
 type MockAuthState = { admin: boolean; userId: number; logout: () => void };
 vi.mock('../../../api/studioUsers', () => ({
   createStudioUser: vi.fn(),
+  deleteStudioUser: vi.fn(),
   getStudioUserSessionOverview: vi.fn(),
   listAllStudioUsers: vi.fn(),
+  listStudioLoginLockouts: vi.fn(),
   listStudioUserSessions: vi.fn(),
   listStudioUsers: vi.fn(),
   resetStudioUserPassword: vi.fn(),
@@ -50,6 +55,7 @@ vi.mock('../../../api/studioUsers', () => ({
   revokeStudioSession: vi.fn(),
   revokeStudioUserSessions: vi.fn(),
   setStudioUserEnabled: vi.fn(),
+  setStudioUserRole: vi.fn(),
 }));
 
 vi.mock('../../../stores/authStore', () => ({
@@ -72,6 +78,7 @@ const studioUserPage = {
       username: 'operator',
       admin: false,
       enabled: true,
+      passwordMustChange: true,
       activeSessionCount: 2,
       lastSessionSeenAt: '2026-08-22T09:30:00',
       nearestSessionExpiresAt: '2026-08-22T10:00:00',
@@ -181,12 +188,32 @@ describe('UserManagementPage', () => {
     });
     vi.mocked(downloadStudioUsers).mockResolvedValue(studioUserPage.items);
     vi.mocked(listStudioUserSessions).mockResolvedValue(sessionDetails);
+    vi.mocked(listStudioLoginLockouts).mockResolvedValue([]);
     vi.mocked(revokeStudioUserSessions).mockResolvedValue({
       userId: 7,
       revokedSessionCount: 2,
     });
   });
 
+  it('shows currently locked logins on the session overview', async () => {
+    vi.mocked(listStudioLoginLockouts).mockResolvedValue([
+      { username: 'contractor', remainingSeconds: 240 },
+    ]);
+    renderPage();
+
+    await screen.findByText('operator');
+    expect(screen.getByText('登录锁定')).toBeInTheDocument();
+    expect(screen.getByText('contractor · 240 秒后自动解锁')).toBeInTheDocument();
+  });
+
+  it('renders a zero login-lockout statistic without any lock tags', async () 
=> {
+    renderPage();
+
+    await screen.findByText('operator');
+    expect(screen.getByText('登录锁定')).toBeInTheDocument();
+    expect(screen.queryByText(/秒后自动解锁/)).not.toBeInTheDocument();
+  });
+
   it('loads a bounded first page and renders the server total', async () => {
     renderPage();
 
@@ -202,6 +229,7 @@ describe('UserManagementPage', () => {
     expect(getStudioUserSessionOverview).toHaveBeenCalledTimes(1);
     expect(screen.getAllByText('活跃会话').length).toBeGreaterThan(0);
     expect(screen.getByText('未来 5 分钟过期')).toBeInTheDocument();
+    expect(screen.getByText('待改密')).toBeInTheDocument();
   });
 
   it('reports an independent session overview failure and recovers on retry', 
async () => {
@@ -380,7 +408,9 @@ describe('UserManagementPage', () => {
     await user.click(screen.getByRole('button', { name: '注销全部会话' }));
     await screen.findByText('注销其他全部用户的活跃会话?');
     expect(
-      screen.getByText('所有用户(你自己除外)都会被退出登录并需要重新登录。此操作用于疑似凭据泄露时的事件响应。'),
+      screen.getByText(
+        '所有用户(你自己除外)都会被退出登录并需要重新登录。此操作用于疑似凭据泄露时的事件响应。',
+      ),
     ).toBeInTheDocument();
     await waitFor(() => 
expect(document.querySelector('.ant-popover')).toBeTruthy());
     const popover = document.querySelector('.ant-popover') as HTMLElement;
@@ -418,7 +448,6 @@ describe('UserManagementPage', () => {
     await waitFor(() => 
expect(listStudioUserSessions).toHaveBeenCalledTimes(2));
     expect(within(drawer).getByText('20')).toBeInTheDocument();
     expect(within(drawer).queryByText('19')).not.toBeInTheDocument();
-  
   });
 
   it('renders session timestamps as UTC values in the viewer timezone', async 
() => {
@@ -586,13 +615,16 @@ describe('UserManagementPage', () => {
     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');
+    // The row exposes two switches (role and status); the status one carries 
启用/停用 copy.
+    const toggle = screen
+      .getAllByRole('switch')
+      .find((element) => element.textContent?.includes('启用'))!;
     expect(toggle).toBeDisabled();
     fireEvent.click(toggle);
     expect(setStudioUserEnabled).not.toHaveBeenCalled();
 
     await act(async () => resolveRevoke());
-    await waitFor(() => expect(screen.getByRole('switch')).not.toBeDisabled());
+    await waitFor(() => expect(toggle).not.toBeDisabled());
     expect(setStudioUserEnabled).not.toHaveBeenCalled();
   });
 
@@ -606,7 +638,9 @@ describe('UserManagementPage', () => {
     );
     renderPage();
 
-    const toggle = await screen.findByRole('switch');
+    const toggle = await screen
+      .findAllByRole('switch')
+      .then((switches) => switches.find((element) => 
element.textContent?.includes('启用'))!);
     fireEvent.click(toggle);
     fireEvent.click(toggle);
 
@@ -615,6 +649,84 @@ describe('UserManagementPage', () => {
     await act(async () => resolveUpdate());
   });
 
+  it('grants the administrator role after a confirmation', async () => {
+    const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config) 
=> {
+      void config.onOk?.();
+      return { destroy: vi.fn(), update: vi.fn() } as unknown as 
ReturnType<typeof Modal.confirm>;
+    });
+    vi.mocked(setStudioUserRole).mockResolvedValue({ 
...studioUserPage.items[0], admin: true });
+    vi.mocked(listStudioUsers).mockResolvedValue(studioUserPage);
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderPage();
+
+    await screen.findByText('operator');
+    // The role switch carries the 管理员/普通用户 copy in the Role column.
+    const roleSwitch = await screen
+      .findAllByRole('switch')
+      .then((switches) => switches.find((element) => 
element.textContent?.includes('普通用户'))!);
+    await user.click(roleSwitch);
+
+    expect(confirmSpy).toHaveBeenCalled();
+    await waitFor(() => expect(setStudioUserRole).toHaveBeenCalledWith(7, 
true));
+    await waitFor(() => expect(listStudioUsers).toHaveBeenCalledTimes(2));
+  });
+
+  it('revokes the administrator role after a danger confirmation', async () => 
{
+    vi.spyOn(Modal, 'confirm').mockImplementation((config) => {
+      void config.onOk?.();
+      return { destroy: vi.fn(), update: vi.fn() } as unknown as 
ReturnType<typeof Modal.confirm>;
+    });
+    const adminRow = { ...studioUserPage.items[0], admin: true };
+    vi.mocked(listStudioUsers).mockResolvedValue({ ...studioUserPage, items: 
[adminRow] });
+    vi.mocked(setStudioUserRole).mockResolvedValue({ ...adminRow, admin: false 
});
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderPage();
+
+    await screen.findByText('operator');
+    const roleSwitch = await screen
+      .findAllByRole('switch')
+      .then((switches) => switches.find((element) => 
element.textContent?.includes('管理员'))!);
+    await user.click(roleSwitch);
+
+    await waitFor(() => expect(setStudioUserRole).toHaveBeenCalledWith(7, 
false));
+  });
+
+  it('deletes a user after row confirmation and reloads the page', async () => 
{
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderPage();
+
+    await screen.findByText('operator');
+    await user.click(screen.getByRole('button', { name: '删除' }));
+
+    await screen.findByText('删除用户 operator?');
+    expect(
+      screen.getByText('该账号及其全部会话将被永久删除,此操作不可恢复。'),
+    ).toBeInTheDocument();
+    await waitFor(() => 
expect(document.querySelector('.ant-popover')).toBeTruthy());
+    const popover = document.querySelector('.ant-popover') as HTMLElement;
+    await user.click(within(popover).getByRole('button', { name: /删\s*除/ }));
+
+    await waitFor(() => expect(deleteStudioUser).toHaveBeenCalledWith(7));
+    expect(listStudioUsers).toHaveBeenCalledTimes(2);
+  });
+
+  it('does not offer deletion for the current account row', async () => {
+    vi.mocked(listStudioUsers).mockResolvedValue({
+      ...studioUserPage,
+      items: [{ ...studioUserPage.items[0], id: 1, username: 'self' }],
+    });
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderPage();
+
+    await screen.findByText('self');
+    const deleteButton = screen.getByRole('button', { name: '删除' });
+    expect(deleteButton).toBeDisabled();
+    await user.click(deleteButton);
+
+    expect(document.querySelector('.ant-popover')).toBeNull();
+    expect(deleteStudioUser).not.toHaveBeenCalled();
+  });
+
   it('renders the page in English when the stored language preference is en', 
async () => {
     localStorage.setItem(LANGUAGE_STORAGE_KEY, 'en');
     try {

Reply via email to