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 <[email protected]> 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);
