You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@rocketmq.apache.org by li...@apache.org on 2026/07/28 11:41:23 UTC
(rocketmq-dashboard) 40/45: fix: ACL page API integration (#516)
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
commit 22f4550484763472ec973f460eb3784694b3d663
Author: aias00 <li...@apache.org>
AuthorDate: Thu Jul 23 23:21:37 2026 -0700
fix: ACL page API integration (#516)
Wire ACL management page to backend API, add update endpoints for ACL rules and users with proper validation.
---
.../studio/instance/acl/AclController.java | 10 ++
.../rocketmq/studio/instance/acl/AclService.java | 25 +++
.../studio/instance/acl/AclControllerTest.java | 42 +++++
.../studio/instance/acl/AclServiceTest.java | 61 +++++++
web/src/api/acl.test.ts | 55 +++++-
web/src/api/acl.ts | 12 +-
web/src/pages/instance/__tests__/AclPage.test.tsx | 108 ++++++++++++
web/src/pages/instance/acl.tsx | 186 ++++++++++++++++-----
web/src/services/aclService.ts | 26 +++
9 files changed, 481 insertions(+), 44 deletions(-)
diff --git a/server/src/main/java/com/rocketmq/studio/instance/acl/AclController.java b/server/src/main/java/com/rocketmq/studio/instance/acl/AclController.java
index 3d1d83cb..9c35cd6b 100644
--- a/server/src/main/java/com/rocketmq/studio/instance/acl/AclController.java
+++ b/server/src/main/java/com/rocketmq/studio/instance/acl/AclController.java
@@ -47,6 +47,11 @@ public class AclController {
return Result.ok(aclService.createRule(rule));
}
+ @PostMapping("/rules/update")
+ public Result<AclRuleVO> updateRule(@RequestBody AclRuleVO rule) {
+ return Result.ok(aclService.updateRule(rule));
+ }
+
@PostMapping("/rules/delete")
public Result<Void> deleteRule(@RequestBody Map<String, String> request) {
aclService.deleteRule(request.get("id"));
@@ -63,6 +68,11 @@ public class AclController {
return Result.ok(aclService.createUser(user));
}
+ @PostMapping("/users/update")
+ public Result<AclUserVO> updateUser(@RequestBody AclUserVO user) {
+ return Result.ok(aclService.updateUser(user));
+ }
+
@PostMapping("/users/delete")
public Result<Void> deleteUser(@RequestBody Map<String, String> request) {
aclService.deleteUser(request.get("id"));
diff --git a/server/src/main/java/com/rocketmq/studio/instance/acl/AclService.java b/server/src/main/java/com/rocketmq/studio/instance/acl/AclService.java
index 537b81e8..ceff7680 100644
--- a/server/src/main/java/com/rocketmq/studio/instance/acl/AclService.java
+++ b/server/src/main/java/com/rocketmq/studio/instance/acl/AclService.java
@@ -16,6 +16,7 @@
*/
package com.rocketmq.studio.instance.acl;
+import com.rocketmq.studio.common.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -45,6 +46,16 @@ public class AclService {
return aclRepository.saveRule(rule);
}
+ public AclRuleVO updateRule(AclRuleVO rule) {
+ if (isBlank(rule.getId())) {
+ throw new BusinessException(400, "ACL rule id is required");
+ }
+ log.info("Updating ACL rule id={}, principal={}", rule.getId(), rule.getPrincipal());
+ if (rule.getCreatedAt() == null) {
+ rule.setCreatedAt(LocalDateTime.now());
+ }
+ return aclRepository.saveRule(rule);
+ }
public void deleteRule(String id) {
log.info("Deleting ACL rule id={}", id);
@@ -67,9 +78,23 @@ public class AclService {
return aclRepository.saveUser(user);
}
+ public AclUserVO updateUser(AclUserVO user) {
+ if (isBlank(user.getId())) {
+ throw new BusinessException(400, "ACL user id is required");
+ }
+ log.info("Updating ACL user id={}, username={}", user.getId(), user.getUsername());
+ if (user.getCreatedAt() == null) {
+ user.setCreatedAt(LocalDateTime.now());
+ }
+ return aclRepository.saveUser(user);
+ }
public void deleteUser(String id) {
log.info("Deleting ACL user id={}", id);
aclRepository.deleteUser(id);
}
+
+ private boolean isBlank(String value) {
+ return value == null || value.trim().isEmpty();
+ }
}
diff --git a/server/src/test/java/com/rocketmq/studio/instance/acl/AclControllerTest.java b/server/src/test/java/com/rocketmq/studio/instance/acl/AclControllerTest.java
index 15250820..3db19851 100644
--- a/server/src/test/java/com/rocketmq/studio/instance/acl/AclControllerTest.java
+++ b/server/src/test/java/com/rocketmq/studio/instance/acl/AclControllerTest.java
@@ -116,6 +116,27 @@ class AclControllerTest {
.andExpect(jsonPath("$.data.principal").value("user1"));
}
+ @Test
+ void updateRuleShouldReturnUpdatedRule() throws Exception {
+ AclRuleVO input = AclRuleVO.builder()
+ .id("rule-1")
+ .principal("user1")
+ .resource("topic-1")
+ .resourceType("TOPIC")
+ .decision("DENY")
+ .build();
+
+ when(aclService.updateRule(any(AclRuleVO.class))).thenReturn(input);
+
+ mockMvc.perform(post("/api/acl/rules/update")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(input)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.id").value("rule-1"))
+ .andExpect(jsonPath("$.data.decision").value("DENY"));
+ }
+
@Test
void listUsersShouldReturnAllUsers() throws Exception {
AclUserVO user = AclUserVO.builder()
@@ -137,4 +158,25 @@ class AclControllerTest {
.andExpect(jsonPath("$.data[0].username").value("admin"))
.andExpect(jsonPath("$.data[0].admin").value(true));
}
+
+ @Test
+ void updateUserShouldReturnUpdatedUser() throws Exception {
+ AclUserVO input = AclUserVO.builder()
+ .id("user-1")
+ .username("admin")
+ .accessKey("ak123")
+ .secretKey("sk456")
+ .admin(false)
+ .build();
+
+ when(aclService.updateUser(any(AclUserVO.class))).thenReturn(input);
+
+ mockMvc.perform(post("/api/acl/users/update")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(input)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.id").value("user-1"))
+ .andExpect(jsonPath("$.data.admin").value(false));
+ }
}
diff --git a/server/src/test/java/com/rocketmq/studio/instance/acl/AclServiceTest.java b/server/src/test/java/com/rocketmq/studio/instance/acl/AclServiceTest.java
index c24a7a23..c27008cb 100644
--- a/server/src/test/java/com/rocketmq/studio/instance/acl/AclServiceTest.java
+++ b/server/src/test/java/com/rocketmq/studio/instance/acl/AclServiceTest.java
@@ -26,6 +26,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -91,6 +92,36 @@ class AclServiceTest {
verify(aclRepository).deleteRule("rule-1");
}
+ @Test
+ void updateRuleShouldRequireId() {
+ AclRuleVO input = AclRuleVO.builder()
+ .principal("user1")
+ .resource("topic-1")
+ .build();
+
+ assertThatThrownBy(() -> aclService.updateRule(input))
+ .hasMessage("ACL rule id is required");
+ }
+
+ @Test
+ void updateRuleShouldSaveExistingRule() {
+ AclRuleVO input = AclRuleVO.builder()
+ .id("rule-1")
+ .principal("user1")
+ .resource("topic-1")
+ .decision("DENY")
+ .build();
+
+ when(aclRepository.saveRule(any(AclRuleVO.class))).thenAnswer(inv -> inv.getArgument(0));
+
+ AclRuleVO result = aclService.updateRule(input);
+
+ assertThat(result.getId()).isEqualTo("rule-1");
+ assertThat(result.getCreatedAt()).isNotNull();
+ assertThat(result.getDecision()).isEqualTo("DENY");
+ verify(aclRepository).saveRule(any(AclRuleVO.class));
+ }
+
@Test
void listUsersShouldReturnAllUsers() {
List<AclUserVO> users = List.of(
@@ -133,4 +164,34 @@ class AclServiceTest {
verify(aclRepository).deleteUser("user-1");
}
+
+ @Test
+ void updateUserShouldRequireId() {
+ AclUserVO input = AclUserVO.builder()
+ .username("newuser")
+ .build();
+
+ assertThatThrownBy(() -> aclService.updateUser(input))
+ .hasMessage("ACL user id is required");
+ }
+
+ @Test
+ void updateUserShouldSaveExistingUser() {
+ AclUserVO input = AclUserVO.builder()
+ .id("user-1")
+ .username("newuser")
+ .accessKey("ak")
+ .secretKey("sk")
+ .admin(true)
+ .build();
+
+ when(aclRepository.saveUser(any(AclUserVO.class))).thenAnswer(inv -> inv.getArgument(0));
+
+ AclUserVO result = aclService.updateUser(input);
+
+ assertThat(result.getId()).isEqualTo("user-1");
+ assertThat(result.getCreatedAt()).isNotNull();
+ assertThat(result.isAdmin()).isTrue();
+ verify(aclRepository).saveUser(any(AclUserVO.class));
+ }
}
diff --git a/web/src/api/acl.test.ts b/web/src/api/acl.test.ts
index 127a0322..499a6c4c 100644
--- a/web/src/api/acl.test.ts
+++ b/web/src/api/acl.test.ts
@@ -18,7 +18,15 @@
import MockAdapter from 'axios-mock-adapter';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import client from './client';
-import { createAclRule, createAclUser, listAclRules } from './acl';
+import {
+ createAclRule,
+ createAclUser,
+ deleteAclRule,
+ deleteAclUser,
+ listAclRules,
+ updateAclRule,
+ updateAclUser,
+} from './acl';
const mock = new MockAdapter(client);
@@ -71,4 +79,49 @@ describe('ACL API contract', () => {
await expect(createAclRule({ principal: rule.principal })).resolves.toEqual(rule);
await expect(createAclUser({ username: user.username })).resolves.toEqual(user);
});
+
+ it('uses backend update and delete endpoints for rules and users', async () => {
+ const rule = {
+ id: 'rule-1',
+ principal: 'orders',
+ resource: 'orders-*',
+ resourceType: 'Topic',
+ resourcePattern: 'PREFIX',
+ actions: ['SUB'],
+ decision: 'DENY',
+ scope: 'cluster',
+ aclVersion: 2,
+ createdAt: '2026-07-17T00:00:00Z',
+ };
+ const user = {
+ id: 'user-1',
+ username: 'orders',
+ accessKey: 'ak',
+ secretKey: 'sk',
+ admin: true,
+ clusters: ['cluster-a'],
+ createdAt: '2026-07-17T00:00:00Z',
+ };
+ mock.onPost('/acl/rules/update').reply((config) => {
+ expect(JSON.parse(config.data)).toMatchObject({ id: rule.id, decision: 'DENY' });
+ return [200, { code: 200, data: rule }];
+ });
+ mock.onPost('/acl/users/update').reply((config) => {
+ expect(JSON.parse(config.data)).toMatchObject({ id: user.id, admin: true });
+ return [200, { code: 200, data: user }];
+ });
+ mock.onPost('/acl/rules/delete').reply((config) => {
+ expect(JSON.parse(config.data)).toEqual({ id: rule.id });
+ return [200, { code: 200 }];
+ });
+ mock.onPost('/acl/users/delete').reply((config) => {
+ expect(JSON.parse(config.data)).toEqual({ id: user.id });
+ return [200, { code: 200 }];
+ });
+
+ await expect(updateAclRule({ id: rule.id, decision: 'DENY' })).resolves.toEqual(rule);
+ await expect(updateAclUser({ id: user.id, admin: true })).resolves.toEqual(user);
+ await expect(deleteAclRule(rule.id)).resolves.toBeUndefined();
+ await expect(deleteAclUser(user.id)).resolves.toBeUndefined();
+ });
});
diff --git a/web/src/api/acl.ts b/web/src/api/acl.ts
index bc6ce955..14ab320c 100644
--- a/web/src/api/acl.ts
+++ b/web/src/api/acl.ts
@@ -10,7 +10,7 @@ export interface AclRule {
actions: string[];
decision: string;
scope: string;
- aclVersion: number;
+ aclVersion: number | string;
createdAt: string;
}
@@ -39,6 +39,11 @@ export async function createAclRule(data: Partial<AclRule>) {
return res.data.data;
}
+export async function updateAclRule(data: Partial<AclRule>) {
+ const res = await client.post<{ data: AclRule }>('/acl/rules/update', data);
+ return res.data.data;
+}
+
export async function deleteAclRule(id: string) {
await client.post('/acl/rules/delete', { id });
}
@@ -53,6 +58,11 @@ export async function createAclUser(data: Partial<AclUser>) {
return res.data.data;
}
+export async function updateAclUser(data: Partial<AclUser>) {
+ const res = await client.post<{ data: AclUser }>('/acl/users/update', data);
+ return res.data.data;
+}
+
export async function deleteAclUser(id: string) {
await client.post('/acl/users/delete', { id });
}
diff --git a/web/src/pages/instance/__tests__/AclPage.test.tsx b/web/src/pages/instance/__tests__/AclPage.test.tsx
new file mode 100644
index 00000000..e25de3d0
--- /dev/null
+++ b/web/src/pages/instance/__tests__/AclPage.test.tsx
@@ -0,0 +1,108 @@
+/*
+ * 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.
+ */
+
+import { App } from 'antd';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import type React from 'react';
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { LangProvider } from '../../../i18n/LangContext';
+import * as aclService from '../../../services/aclService';
+import AclPage from '../acl';
+
+vi.mock('../../../services/aclService', () => ({
+ createAclRule: vi.fn(),
+ createAclUser: vi.fn(),
+ deleteAclRule: vi.fn(),
+ deleteAclUser: vi.fn(),
+ listAclRules: vi.fn(),
+ listAclUsers: vi.fn(),
+ updateAclRule: vi.fn(),
+ updateAclUser: vi.fn(),
+}));
+
+beforeAll(() => {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+});
+
+const renderWithProviders = (ui: React.ReactElement) =>
+ render(
+ <App>
+ <LangProvider>{ui}</LangProvider>
+ </App>,
+ );
+
+describe('ACL page', () => {
+ beforeEach(() => {
+ vi.mocked(aclService.listAclRules).mockResolvedValue([
+ {
+ id: 'rule-remote',
+ principal: 'remote-user',
+ resource: 'remote-topic',
+ resourceType: 'Topic',
+ resourcePattern: 'LITERAL',
+ actions: ['PUB'],
+ decision: 'ALLOW',
+ scope: 'cluster',
+ aclVersion: 2,
+ createdAt: '2026-07-23T00:00:00Z',
+ },
+ ]);
+ vi.mocked(aclService.listAclUsers).mockResolvedValue([
+ {
+ id: 'user-remote',
+ username: 'remote-admin',
+ accessKey: 'ak-remote',
+ secretKey: 'sk-remote',
+ admin: true,
+ clusters: ['cluster-a'],
+ createdAt: '2026-07-23T00:00:00Z',
+ },
+ ]);
+ });
+
+ it('loads ACL rules and users through the service layer', async () => {
+ renderWithProviders(<AclPage />);
+
+ expect(await screen.findByText('remote-user')).toBeInTheDocument();
+ expect(screen.getByText('remote-topic')).toBeInTheDocument();
+ expect(aclService.listAclRules).toHaveBeenCalledTimes(1);
+ expect(aclService.listAclUsers).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders backend users on the user tab', async () => {
+ const user = userEvent.setup();
+ renderWithProviders(<AclPage />);
+
+ await user.click(await screen.findByText('用户管理'));
+
+ expect(await screen.findByText('remote-admin')).toBeInTheDocument();
+ expect(screen.getByText('cluster-a')).toBeInTheDocument();
+ });
+});
diff --git a/web/src/pages/instance/acl.tsx b/web/src/pages/instance/acl.tsx
index a663ecd7..97295085 100644
--- a/web/src/pages/instance/acl.tsx
+++ b/web/src/pages/instance/acl.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import {
Table,
Card,
@@ -40,8 +40,49 @@ import { EditOutlined, DeleteOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import PageHeader from '../../components/PageHeader';
import { useLang } from '../../i18n/LangContext';
-import { aclRules, aclUsers } from '../../mock/acl';
-import type { AclRule, AclUser } from '../../mock/acl';
+import {
+ createAclRule,
+ createAclUser,
+ deleteAclRule,
+ deleteAclUser,
+ listAclRules,
+ listAclUsers,
+ updateAclRule,
+ updateAclUser,
+} from '../../services/aclService';
+import type { AclRule, AclUser } from '../../api/acl';
+
+type AclRuleFormValues = Pick<
+ AclRule,
+ 'principal' | 'resource' | 'resourceType' | 'resourcePattern' | 'actions' | 'decision' | 'scope'
+>;
+type AclUserFormValues = Pick<AclUser, 'username' | 'accessKey' | 'secretKey' | 'admin'>;
+
+const normalizeRule = (rule: AclRule): AclRule => ({
+ ...rule,
+ principal: rule.principal ?? '',
+ resource: rule.resource ?? '',
+ resourceType: rule.resourceType ?? '',
+ resourcePattern: rule.resourcePattern ?? '',
+ actions: rule.actions ?? [],
+ decision: rule.decision ?? '',
+ scope: rule.scope ?? '',
+ aclVersion: rule.aclVersion ?? '2.0',
+ createdAt: rule.createdAt ?? new Date().toISOString(),
+});
+
+const normalizeUser = (user: AclUser): AclUser => ({
+ ...user,
+ username: user.username ?? '',
+ accessKey: user.accessKey ?? '',
+ secretKey: user.secretKey ?? '',
+ admin: user.admin ?? false,
+ clusters: user.clusters ?? [],
+ createdAt: user.createdAt ?? new Date().toISOString(),
+});
+
+const isFormValidationError = (error: unknown) =>
+ typeof error === 'object' && error !== null && 'errorFields' in error;
/* ═══════════════════════════════════════════
ACL Management Page
@@ -50,8 +91,12 @@ const AclPage = () => {
const { t } = useLang();
/* ─── State ─── */
- const [rules, setRules] = useState<AclRule[]>(aclRules);
- const [users, setUsers] = useState<AclUser[]>(aclUsers);
+ const [rules, setRules] = useState<AclRule[]>([]);
+ const [users, setUsers] = useState<AclUser[]>([]);
+ const [rulesLoading, setRulesLoading] = useState(true);
+ const [usersLoading, setUsersLoading] = useState(true);
+ const [ruleSubmitting, setRuleSubmitting] = useState(false);
+ const [userSubmitting, setUserSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState('rules');
// Rule filters
@@ -72,13 +117,37 @@ const AclPage = () => {
// Secret key reveal
const [revealedKeys, setRevealedKeys] = useState<Set<string>>(new Set());
+ useEffect(() => {
+ let mounted = true;
+
+ Promise.all([listAclRules(), listAclUsers()])
+ .then(([nextRules, nextUsers]) => {
+ if (!mounted) return;
+ setRules(nextRules.map(normalizeRule));
+ setUsers(nextUsers.map(normalizeUser));
+ })
+ .catch(() => {
+ if (mounted) message.error(t('common.fetchDataFailed'));
+ })
+ .finally(() => {
+ if (!mounted) return;
+ setRulesLoading(false);
+ setUsersLoading(false);
+ });
+
+ return () => {
+ mounted = false;
+ };
+ }, [t]);
+
/* ─── Filtered rules ─── */
const filteredRules = rules.filter((r) => {
+ const aclVersion = String(r.aclVersion);
const matchSearch =
!ruleSearch ||
r.principal.toLowerCase().includes(ruleSearch.toLowerCase()) ||
r.resource.toLowerCase().includes(ruleSearch.toLowerCase());
- const matchVersion = ruleVersionFilter === 'all' || r.aclVersion === ruleVersionFilter;
+ const matchVersion = ruleVersionFilter === 'all' || aclVersion === ruleVersionFilter;
const matchDecision = ruleDecisionFilter === 'all' || r.decision === ruleDecisionFilter;
return matchSearch && matchVersion && matchDecision;
});
@@ -125,28 +194,40 @@ const AclPage = () => {
setRuleModalOpen(true);
};
- const handleRuleSubmit = () => {
- ruleForm.validateFields().then((values) => {
+ const handleRuleSubmit = async () => {
+ try {
+ const values = (await ruleForm.validateFields()) as AclRuleFormValues;
+ setRuleSubmitting(true);
if (editingRule) {
- setRules((prev) => prev.map((r) => (r.id === editingRule.id ? { ...r, ...values } : r)));
+ const updated = await updateAclRule({ ...editingRule, ...values });
+ const normalized = normalizeRule(updated);
+ setRules((prev) => prev.map((r) => (r.id === editingRule.id ? normalized : r)));
message.success(t('acl.ruleUpdated'));
} else {
- const newRule: AclRule = {
- id: `acl-${Date.now()}`,
+ const created = await createAclRule({
...values,
- aclVersion: '2.0',
- createdAt: new Date().toISOString(),
- };
- setRules((prev) => [newRule, ...prev]);
+ aclVersion: 2,
+ });
+ setRules((prev) => [normalizeRule(created), ...prev]);
message.success(t('acl.ruleAdded'));
}
setRuleModalOpen(false);
- });
+ } catch (error) {
+ if (isFormValidationError(error)) return;
+ message.error(t('common.operationFailed'));
+ } finally {
+ setRuleSubmitting(false);
+ }
};
- const handleDeleteRule = (id: string) => {
- setRules((prev) => prev.filter((r) => r.id !== id));
- message.success(t('acl.ruleDeleted'));
+ const handleDeleteRule = async (id: string) => {
+ try {
+ await deleteAclRule(id);
+ setRules((prev) => prev.filter((r) => r.id !== id));
+ message.success(t('acl.ruleDeleted'));
+ } catch {
+ message.error(t('common.operationFailed'));
+ }
};
/* ─── User helpers ─── */
@@ -180,42 +261,59 @@ const AclPage = () => {
setUserModalOpen(true);
};
- const handleUserSubmit = () => {
- userForm.validateFields().then((values) => {
+ const handleUserSubmit = async () => {
+ try {
+ const values = (await userForm.validateFields()) as AclUserFormValues;
+ setUserSubmitting(true);
if (editingUser) {
- setUsers((prev) => prev.map((u) => (u.id === editingUser.id ? { ...u, ...values } : u)));
+ const updated = await updateAclUser({ ...editingUser, ...values });
+ const normalized = normalizeUser(updated);
+ setUsers((prev) => prev.map((u) => (u.id === editingUser.id ? normalized : u)));
message.success(t('acl.userUpdated'));
} else {
- const newUser: AclUser = {
- id: `u-${Date.now()}`,
+ const created = await createAclUser({
username: values.username,
- accessKey: values.accessKey || `LTAI****${values.username.slice(-4)}`,
- secretKey:
- values.secretKey ||
- `${Math.random().toString(36).slice(2, 6)}****${Math.random().toString(36).slice(2, 6)}`,
+ accessKey: values.accessKey,
+ secretKey: values.secretKey,
admin: values.admin ?? false,
clusters: ['rmq-cn-v5-prod-01'],
- createdAt: new Date().toISOString(),
- };
- setUsers((prev) => [newUser, ...prev]);
+ });
+ setUsers((prev) => [normalizeUser(created), ...prev]);
message.success(t('acl.userAdded'));
}
setUserModalOpen(false);
- });
+ } catch (error) {
+ if (isFormValidationError(error)) return;
+ message.error(t('common.operationFailed'));
+ } finally {
+ setUserSubmitting(false);
+ }
};
- const handleDeleteUser = (id: string) => {
- setUsers((prev) => prev.filter((u) => u.id !== id));
- message.success(t('acl.userDeleted'));
+ const handleDeleteUser = async (id: string) => {
+ try {
+ await deleteAclUser(id);
+ setUsers((prev) => prev.filter((u) => u.id !== id));
+ message.success(t('acl.userDeleted'));
+ } catch {
+ message.error(t('common.operationFailed'));
+ }
};
- const handleToggleAdmin = (userId: string, checked: boolean) => {
- setUsers((prev) => prev.map((u) => (u.id === userId ? { ...u, admin: checked } : u)));
- message.success(checked ? t('acl.adminSet') : t('acl.adminRemoved'));
+ const handleToggleAdmin = async (user: AclUser, checked: boolean) => {
+ try {
+ const updated = await updateAclUser({ ...user, admin: checked });
+ const normalized = normalizeUser(updated);
+ setUsers((prev) => prev.map((u) => (u.id === user.id ? normalized : u)));
+ message.success(checked ? t('acl.adminSet') : t('acl.adminRemoved'));
+ } catch {
+ message.error(t('common.operationFailed'));
+ }
};
const formatDate = (iso: string) => {
const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return '-';
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
@@ -291,9 +389,9 @@ const AclPage = () => {
dataIndex: 'aclVersion',
key: 'aclVersion',
width: 100,
- sorter: (a, b) => a.aclVersion.localeCompare(b.aclVersion),
- render: (version: string) => (
- <Tag color={version === '2.0' ? 'geekblue' : 'default'}>{version}</Tag>
+ sorter: (a, b) => String(a.aclVersion).localeCompare(String(b.aclVersion)),
+ render: (version: AclRule['aclVersion']) => (
+ <Tag color={String(version) === '2.0' ? 'geekblue' : 'default'}>{version}</Tag>
),
},
{
@@ -409,7 +507,7 @@ const AclPage = () => {
<Switch
checked={val}
size="small"
- onChange={(checked) => handleToggleAdmin(record.id, checked)}
+ onChange={(checked) => handleToggleAdmin(record, checked)}
/>
),
},
@@ -554,6 +652,7 @@ const AclPage = () => {
columns={ruleColumns}
dataSource={filteredRules}
rowKey="id"
+ loading={rulesLoading}
pagination={{
pageSize: 20,
showSizeChanger: true,
@@ -590,6 +689,7 @@ const AclPage = () => {
columns={userColumns}
dataSource={users}
rowKey="id"
+ loading={usersLoading}
pagination={{
pageSize: 20,
showSizeChanger: true,
@@ -612,6 +712,7 @@ const AclPage = () => {
onOk={handleRuleSubmit}
okText={editingRule ? t('acl.save') : t('acl.add')}
cancelText={t('common.cancel')}
+ confirmLoading={ruleSubmitting}
width={560}
destroyOnClose
>
@@ -713,6 +814,7 @@ const AclPage = () => {
onOk={handleUserSubmit}
okText={editingUser ? t('acl.save') : t('acl.add')}
cancelText={t('common.cancel')}
+ confirmLoading={userSubmitting}
width={520}
destroyOnClose
>
diff --git a/web/src/services/aclService.ts b/web/src/services/aclService.ts
index d8962d20..3ef7b6e0 100644
--- a/web/src/services/aclService.ts
+++ b/web/src/services/aclService.ts
@@ -51,6 +51,19 @@ export async function createAclRule(data: Partial<AclRule>): Promise<AclRule> {
return aclApi.createAclRule(data);
}
+export async function updateAclRule(data: Partial<AclRule>): Promise<AclRule> {
+ if (USE_MOCK) {
+ const idx = aclRulesState.findIndex((rule) => rule.id === data.id);
+ if (idx < 0) throw new Error(`ACL rule not found: ${data.id}`);
+ aclRulesState[idx] = {
+ ...aclRulesState[idx],
+ ...data,
+ };
+ return aclRulesState[idx];
+ }
+ return aclApi.updateAclRule(data);
+}
+
export async function deleteAclRule(id: string): Promise<void> {
if (USE_MOCK) {
const idx = aclRulesState.findIndex((rule) => rule.id === id);
@@ -78,6 +91,19 @@ export async function createAclUser(data: Partial<AclUser>): Promise<AclUser> {
return aclApi.createAclUser(data);
}
+export async function updateAclUser(data: Partial<AclUser>): Promise<AclUser> {
+ if (USE_MOCK) {
+ const idx = aclUsersState.findIndex((user) => user.id === data.id);
+ if (idx < 0) throw new Error(`ACL user not found: ${data.id}`);
+ aclUsersState[idx] = {
+ ...aclUsersState[idx],
+ ...data,
+ };
+ return aclUsersState[idx];
+ }
+ return aclApi.updateAclUser(data);
+}
+
export async function deleteAclUser(id: string): Promise<void> {
if (USE_MOCK) {
const idx = aclUsersState.findIndex((user) => user.id === id);