This is an automated email from the ASF dual-hosted git repository. imbajin pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/hugegraph-toolchain.git
commit e237e6f066454df2280699faa8fbca9f0848dbd5 Author: dark <[email protected]> AuthorDate: Sat Aug 29 21:28:04 2026 +0800 fix(hubble): polish account navigation recovery - improve account access workflows - polish navigation interactions and route refresh - retry failed PD authentication probes --- .../hugegraph/controller/ConfigController.java | 18 ++- .../controller/auth/GraphSpaceUserController.java | 5 +- .../service/auth/GraphSpaceUserService.java | 38 ++++- .../service/auth/GraphSpaceUserServiceTest.java | 33 +++++ .../hugegraph/unit/ConfigControllerTest.java | 55 +++++++ hugegraph-hubble/hubble-fe/src/App.js | 4 +- hugegraph-hubble/hubble-fe/src/App.test.js | 13 +- hugegraph-hubble/hubble-fe/src/api/auth.js | 10 +- .../hubble-fe/src/components/Sidebar/index.ant.js | 2 +- .../src/components/Sidebar/index.ant.test.js | 2 +- .../src/i18n/resources/en-US/modules/pages.json | 5 +- .../src/i18n/resources/zh-CN/modules/pages.json | 5 +- .../src/modules/navigation/Home/index.module.scss | 4 +- .../hubble-fe/src/pages/Account/EditLayer.js | 3 +- .../hubble-fe/src/pages/Account/SpaceAccess.js | 165 ++++++++++++++++----- .../src/pages/Account/SpaceAccess.test.js | 89 +++++++++-- .../pages/Account/account-edit-recovery.test.js | 13 ++ .../src/pages/Account/account-recovery.test.js | 66 ++++++++- .../hubble-fe/src/pages/Account/index.js | 50 ++++++- 19 files changed, 499 insertions(+), 81 deletions(-) diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ConfigController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ConfigController.java index a908100e0..6c212acfe 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ConfigController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ConfigController.java @@ -59,11 +59,23 @@ public class ConfigController { Map<String, Object> capabilities = new HashMap<>(); boolean pdEnabled = config.get(HubbleOptions.PD_ENABLED); if (pdEnabled) { - capabilities.put("server_capabilities_verified", true); - capabilities.put("auth_enabled", this.authModeService.enabled()); + capabilities.put("server_capabilities_verified", false); + capabilities.put("auth_enabled", true); capabilities.put("graph_create_enabled", true); capabilities.put("cypher_enabled", true); - return capabilities; + if (hugeClientPoolService == null) { + return capabilities; + } + try (HugeClient client = this.createUnauthClient()) { + capabilities.put("auth_enabled", + this.authModeService.update( + client.isServerAuthEnabled())); + capabilities.put("server_capabilities_verified", true); + return capabilities; + } catch (RuntimeException ignored) { + // Let the frontend retry after transient Server failures. + return capabilities; + } } capabilities.put("server_capabilities_verified", false); capabilities.put("auth_enabled", true); diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/GraphSpaceUserController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/GraphSpaceUserController.java index 5302227f0..621125d96 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/GraphSpaceUserController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/GraphSpaceUserController.java @@ -76,9 +76,10 @@ public class GraphSpaceUserController extends AuthController { @GetMapping("{id}") public UserView get(@PathVariable("graphspace") String graphSpace, - @PathVariable("id") String userId) { + @PathVariable("id") String accountId) { HugeClient client = this.requireGraphSpaceManager(graphSpace); - return this.userService.getUser(client, graphSpace, userId); + return this.userService.getUserByAccountId( + client, graphSpace, accountId); } @PostMapping("spaceadmin/{id}") diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/GraphSpaceUserService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/GraphSpaceUserService.java index 8096a7db5..58500cd50 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/GraphSpaceUserService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/GraphSpaceUserService.java @@ -85,6 +85,19 @@ public class GraphSpaceUserService extends AuthService { public UserView getUser(HugeClient client, String graphSpace, String userId) { + User account = client.auth().getUser(userId); + return this.buildUserView(client, graphSpace, userId, account); + } + + public UserView getUserByAccountId(HugeClient client, String graphSpace, + String accountId) { + User account = this.resolveAccountId(client, accountId); + String userId = account.id().toString(); + return this.buildUserView(client, graphSpace, userId, account); + } + + private UserView buildUserView(HugeClient client, String graphSpace, + String userId, User account) { List<BelongEntity> belongs = this.belongService.list( client, graphSpace, null, userId); UserView user = new UserView(null, null, @@ -95,10 +108,9 @@ public class GraphSpaceUserService extends AuthService { user.addRole(new RoleEntity(belong.getRoleId(), belong.getRoleName())); }); - User account = client.auth().getUser(userId); if (account != null) { if (user.getId() == null) { - user.setId(account.id().toString()); + user.setId(userId); user.setName(account.name()); } if (client.supportsDefaultRole()) { @@ -111,6 +123,28 @@ public class GraphSpaceUserService extends AuthService { return user; } + private User resolveAccountId(HugeClient client, String accountId) { + try { + // HugeGraph's "name" is the unique account ID; nickname may repeat. + User account = client.findUserByName(accountId); + if (account != null) { + return account; + } + } catch (RuntimeException e) { + if (!missingAccount(e)) { + throw e; + } + } + throw new ParameterizedException("auth.account.not-exist", accountId); + } + + private static boolean missingAccount(RuntimeException error) { + String detail = error.getMessage(); + return detail != null && + detail.toLowerCase().contains("user") && + detail.toLowerCase().contains("not exist"); + } + public IPage<UserView> queryPage(HugeClient client, String graphSpace, String query, int pageNo, int pageSize) { List<UserView> results = diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/GraphSpaceUserServiceTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/GraphSpaceUserServiceTest.java index 4fef361ce..b69aaf72d 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/GraphSpaceUserServiceTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/GraphSpaceUserServiceTest.java @@ -79,6 +79,39 @@ public class GraphSpaceUserServiceTest { error.getMessage()); } + @Test + public void testGetUserResolvesUniqueAccountIdToRealId() { + User account = new User(); + account.setId("user-42"); + account.name("[email protected]"); + Mockito.when(this.client.findUserByName("[email protected]")) + .thenReturn(account); + Mockito.when(this.belongService.list( + this.client, "team", null, "user-42")) + .thenReturn(Collections.emptyList()); + + UserView result = this.service.getUserByAccountId( + this.client, "team", "[email protected]"); + + Assert.assertEquals("user-42", result.getId()); + Assert.assertEquals("[email protected]", result.getName()); + } + + @Test + public void testGetUserDoesNotHideConnectionFailure() { + RuntimeException failure = new RuntimeException("connection timeout"); + Mockito.when(this.client.findUserByName("alice")).thenThrow(failure); + + Throwable actual = Assert.assertThrows( + RuntimeException.class, + () -> this.service.getUserByAccountId( + this.client, "team", "alice")); + + Assert.assertSame(failure, actual); + Mockito.verify(this.auth, Mockito.never()) + .getUser(Mockito.anyString()); + } + @Test public void testGraphSpacePresetRequiresAtLeastOneGraphSpace() { for (String preset : Arrays.asList( diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/ConfigControllerTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/ConfigControllerTest.java index 630e44add..07e528fb1 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/ConfigControllerTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/ConfigControllerTest.java @@ -34,6 +34,61 @@ import org.apache.hugegraph.service.auth.AuthModeService; public class ConfigControllerTest { + @Test + public void testPdConfigMarksSuccessfulAuthProbeVerified() { + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); + HugeClient client = Mockito.mock(HugeClient.class); + AuthModeService authMode = Mockito.mock(AuthModeService.class); + Mockito.when(client.isServerAuthEnabled()).thenReturn(false); + Mockito.when(authMode.update(false)).thenReturn(false); + + ConfigController controller = new ConfigController() { + @Override + protected HugeClient createUnauthClient() { + return client; + } + }; + ReflectionTestUtils.setField(controller, "config", config); + ReflectionTestUtils.setField(controller, "hugeClientPoolService", + new HugeClientPoolService()); + ReflectionTestUtils.setField(controller, "authModeService", authMode); + + Map<String, Object> result = controller.getConfig(); + + Assert.assertEquals(Map.of("pd_enabled", true, + "server_capabilities_verified", true, + "auth_enabled", false, + "graph_create_enabled", true, + "cypher_enabled", true), result); + Mockito.verify(client).close(); + } + + @Test + public void testPdConfigRetriesAfterAuthProbeFailure() { + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); + AuthModeService authMode = Mockito.mock(AuthModeService.class); + ConfigController controller = new ConfigController() { + @Override + protected HugeClient createUnauthClient() { + throw new IllegalStateException("server unavailable"); + } + }; + ReflectionTestUtils.setField(controller, "config", config); + ReflectionTestUtils.setField(controller, "hugeClientPoolService", + new HugeClientPoolService()); + ReflectionTestUtils.setField(controller, "authModeService", authMode); + + Map<String, Object> result = controller.getConfig(); + + Assert.assertEquals(Map.of("pd_enabled", true, + "server_capabilities_verified", false, + "auth_enabled", true, + "graph_create_enabled", true, + "cypher_enabled", true), result); + } + @Test public void testBootstrapConfigDoesNotExposeBackendUrl() { HugeConfig config = Mockito.mock(HugeConfig.class); diff --git a/hugegraph-hubble/hubble-fe/src/App.js b/hugegraph-hubble/hubble-fe/src/App.js index 5b6b951c0..e08c9a6c7 100644 --- a/hugegraph-hubble/hubble-fe/src/App.js +++ b/hugegraph-hubble/hubble-fe/src/App.js @@ -32,6 +32,7 @@ const CONFIG_RETRY_DELAY_MS = 2000; function App() { const [configReady, setConfigReady] = useState(false); const [configError, setConfigError] = useState(false); + const [configRevision, setConfigRevision] = useState(0); useEffect(() => { let active = true; @@ -43,6 +44,7 @@ function App() { } if (active) { setConfig(response.data); + setConfigRevision(value => value + 1); setConfigReady(true); setConfigError(false); if (response.data.server_capabilities_verified === false) { @@ -79,7 +81,7 @@ function App() { } return ( <div> - <AuthContextProvider> + <AuthContextProvider key={configRevision}> <Route element={<Layout />} /> </AuthContextProvider> </div> diff --git a/hugegraph-hubble/hubble-fe/src/App.test.js b/hugegraph-hubble/hubble-fe/src/App.test.js index 20eb51794..9c0e69361 100644 --- a/hugegraph-hubble/hubble-fe/src/App.test.js +++ b/hugegraph-hubble/hubble-fe/src/App.test.js @@ -28,7 +28,14 @@ jest.mock('./api', () => ({ })); jest.mock('./routes', () => ({element}) => ( - <div data-testid="app-route">{element}</div> + <div + data-testid="app-route" + data-auth-enabled={String( + jest.requireActual('./utils/config').isAuthEnabled() + )} + > + {element} + </div> )); jest.mock('./layout.ant', () => () => <div>Hubble layout</div>); @@ -99,6 +106,8 @@ test('revalidates fail-closed server capabilities until verified', async () => { </MemoryRouter> ); expect(await screen.findByTestId('app-route')).toBeInTheDocument(); + expect(screen.getByTestId('app-route')) + .toHaveAttribute('data-auth-enabled', 'true'); await act(async () => { jest.advanceTimersByTime(2000); @@ -109,5 +118,7 @@ test('revalidates fail-closed server capabilities until verified', async () => { auth_enabled: false, server_capabilities_verified: true, }); + expect(screen.getByTestId('app-route')) + .toHaveAttribute('data-auth-enabled', 'false'); jest.useRealTimers(); }); diff --git a/hugegraph-hubble/hubble-fe/src/api/auth.js b/hugegraph-hubble/hubble-fe/src/api/auth.js index 5e7b70dce..75b3fc6ac 100644 --- a/hugegraph-hubble/hubble-fe/src/api/auth.js +++ b/hugegraph-hubble/hubble-fe/src/api/auth.js @@ -97,8 +97,8 @@ const getSpaceMembers = (graphspace, params, config = {}) => { return request.get(scopedAuthPath(graphspace, 'users'), {...config, params}); }; -const getSpaceAccount = (graphspace, id, config = {}) => { - return request.get(scopedAuthPath(graphspace, 'users', id), config); +const getSpaceAccount = (graphspace, accountId, config = {}) => { + return request.get(scopedAuthPath(graphspace, 'users', accountId), config); }; const getSpaceAdmins = (graphspace, params, config = {}) => { @@ -113,10 +113,10 @@ const removeSpaceAdmin = (graphspace, id, config) => { return request.delete(scopedAuthPath(graphspace, 'users/spaceadmin', id), undefined, config); }; -const setSpacePreset = (graphspace, id, username, preset, config) => { +const setSpacePreset = (graphspace, id, accountId, preset, config) => { return request.put(`${scopedAuthPath(graphspace, 'users', id)}/preset`, { - user_id: id === username ? undefined : id, - username, + user_id: id === accountId ? undefined : id, + username: accountId, permission_preset: preset, }, config); }; diff --git a/hugegraph-hubble/hubble-fe/src/components/Sidebar/index.ant.js b/hugegraph-hubble/hubble-fe/src/components/Sidebar/index.ant.js index a75b2e76a..5efe4203c 100644 --- a/hugegraph-hubble/hubble-fe/src/components/Sidebar/index.ant.js +++ b/hugegraph-hubble/hubble-fe/src/components/Sidebar/index.ant.js @@ -228,7 +228,7 @@ const Sidebar = () => { setTemporaryExpanded(false); setOpenKeys([]); temporaryCollapseTimerRef.current = null; - }, 1000); + }, 500); }, [clearTemporaryCollapseTimer, collapsed, narrow, temporaryExpanded]); const renderedCollapsed = collapsed && !temporaryExpanded; const toggleLabel = temporaryExpanded && collapsed diff --git a/hugegraph-hubble/hubble-fe/src/components/Sidebar/index.ant.test.js b/hugegraph-hubble/hubble-fe/src/components/Sidebar/index.ant.test.js index 2fe9f7aa8..680f3d43e 100644 --- a/hugegraph-hubble/hubble-fe/src/components/Sidebar/index.ant.test.js +++ b/hugegraph-hubble/hubble-fe/src/components/Sidebar/index.ant.test.js @@ -312,7 +312,7 @@ test('temporarily expands a user-collapsed sidebar and restores it after leaving .toHaveAttribute('aria-expanded', 'true'); fireEvent.mouseLeave(navigation); - act(() => jest.advanceTimersByTime(999)); + act(() => jest.advanceTimersByTime(499)); expect(sider).not.toHaveClass('ant-layout-sider-collapsed'); act(() => jest.advanceTimersByTime(1)); expect(sider).toHaveClass('ant-layout-sider-collapsed'); diff --git a/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/pages.json b/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/pages.json index f3e13cdbf..d5bd10cfc 100644 --- a/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/pages.json +++ b/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/pages.json @@ -466,8 +466,11 @@ "id": "Account ID", "name": "Account Name", "existing_account": "Existing Account ID", - "account_not_found": "This account does not exist. Create it under Accounts before assigning access.", + "account_not_found": "This account does not exist. Create it to continue assigning access.", + "account_not_found_contact_admin": "This account does not exist. Ask a super administrator to create the user before assigning access.", + "create_account": "Create this account", "account_check_failed": "Could not verify this account. Check the connection and retry.", + "batch_failed": "Assigned {{success}} of {{total}} GraphSpaces. Failed: {{spaces}}. Check these spaces and retry.", "roles": "Roles", "replace": "Replace with preset", "custom_title": "This account uses legacy or custom access", diff --git a/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/pages.json b/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/pages.json index 2c984b8b8..e9bf99f62 100644 --- a/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/pages.json +++ b/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/pages.json @@ -466,8 +466,11 @@ "id": "账号 ID", "name": "账号名", "existing_account": "已有账号 ID", - "account_not_found": "账号不存在,请先在“账号”中创建后再分配权限。", + "account_not_found": "账号不存在,可创建此账号后继续分配权限。", + "account_not_found_contact_admin": "账号不存在,请联系超级管理员创建新用户后再分配权限。", + "create_account": "创建此账号", "account_check_failed": "无法确认账号是否存在,请检查连接后重试。", + "batch_failed": "已成功分配 {{success}}/{{total}} 个 GraphSpace;以下空间失败:{{spaces}}。请检查后重试。", "roles": "角色", "replace": "替换为权限预设", "custom_title": "当前账号使用旧版或自定义权限", diff --git a/hugegraph-hubble/hubble-fe/src/modules/navigation/Home/index.module.scss b/hugegraph-hubble/hubble-fe/src/modules/navigation/Home/index.module.scss index 821239825..26e650edd 100644 --- a/hugegraph-hubble/hubble-fe/src/modules/navigation/Home/index.module.scss +++ b/hugegraph-hubble/hubble-fe/src/modules/navigation/Home/index.module.scss @@ -190,7 +190,7 @@ padding-left: 0; } - > :global(*):last-child { + > :global(*):last-child:not(:first-child) { padding-right: 0; border-left: 1px solid var(--workbench-color-border); } @@ -216,7 +216,7 @@ padding-top: 0; } - > :global(*):last-child { + > :global(*):last-child:not(:first-child) { padding-bottom: 0; border-top: 1px solid var(--workbench-color-border); border-left: 0; diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js b/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js index b22718677..e83b6afd3 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js @@ -212,9 +212,10 @@ const EditLayer = ({ else { setDetail({}); form.resetFields(); + form.setFieldsValue({user_name: data.user_name}); setLoading(false); } - }, [visible, data.id, form, op, t]); + }, [visible, data.id, data.user_name, form, op, t]); if (op !== 'detail' && !allowedOperations[op]) { return null; diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.js b/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.js index 3584e97eb..1eee4df43 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.js @@ -38,11 +38,17 @@ import {PERMISSION_PRESETS} from './permissionPresets'; import {loadAllPages, PAGE_ERROR_CONFIG} from './pagedRecords'; const responseRecords = response => response?.data?.records ?? []; +const errorStatus = value => ( + value?.response?.data?.status ?? value?.response?.status ?? value?.status +); const errorDetail = value => { const response = value?.response ?? value; return response?.data?.message ?? response?.message; }; const isMissingAccount = value => { + if (errorStatus(value) === 400) { + return true; + } const detail = errorDetail(value); if (typeof detail !== 'string') { return false; @@ -184,7 +190,11 @@ const ErrorAlert = ({error, retry, t}) => (error ? ( /> ) : null); -const SpaceAccess = ({pendingAccount, onPendingAccountHandled}) => { +const SpaceAccess = ({ + onCreateAccount, + onPendingAccountHandled, + pendingAccount, +}) => { const {t} = useTranslation(); const {context} = useAuthContext(); const contextVersion = context?.context_version; @@ -199,8 +209,14 @@ const SpaceAccess = ({pendingAccount, onPendingAccountHandled}) => { const [spacesRevision, setSpacesRevision] = useState(0); const spacesRequest = useRef(null); const [memberDialog, setMemberDialog] = useState(null); + const [missingAccountId, setMissingAccountId] = useState(null); const [submitting, setSubmitting] = useState(false); const [memberForm] = Form.useForm(); + const accountNotFoundMessage = useCallback(() => t( + onCreateAccount + ? 'account.space_access.member.account_not_found' + : 'account.space_access.member.account_not_found_contact_admin' + ), [onCreateAccount, t]); const scopedSpaces = useMemo( () => scopes.admin_graphspaces ?? [], @@ -305,12 +321,20 @@ const SpaceAccess = ({pendingAccount, onPendingAccountHandled}) => { && spaces.includes(row?.graphspace) ? row.graphspace : graphSpace; + const requestedSpaces = graphSpaceSelectable + && Array.isArray(row?.graphspaces) + ? row.graphspaces.filter(space => spaces.includes(space)) + : [memberGraphSpace].filter(Boolean); memberForm.setFieldsValue({ user_id: row?.user_id, - username: row?.user_name, - permission_preset: rolesPreset(row?.roles), - graphspace: memberGraphSpace, + account_id: row?.user_name, + permission_preset: row?.permission_preset + ?? rolesPreset(row?.roles), + graphspace: graphSpaceSelectable + ? requestedSpaces + : memberGraphSpace, }); + setMissingAccountId(null); setMemberDialog({ ...(row ?? {}), graphSpaceSelectable, @@ -331,13 +355,18 @@ const SpaceAccess = ({pendingAccount, onPendingAccountHandled}) => { ]); const closeMember = useCallback(() => { setMemberDialog(null); + setMissingAccountId(null); memberForm.resetFields(); }, [memberForm]); const validateExistingAccount = useCallback(async (_, value) => { + setMissingAccountId(null); if (!value) { return; } - const targetSpace = memberForm.getFieldValue('graphspace'); + const graphSpaceValue = memberForm.getFieldValue('graphspace'); + const targetSpace = Array.isArray(graphSpaceValue) + ? graphSpaceValue[0] + : graphSpaceValue; if (!targetSpace) { return; } @@ -349,9 +378,8 @@ const SpaceAccess = ({pendingAccount, onPendingAccountHandled}) => { } catch (error) { if (isMissingAccount(error)) { - throw new Error( - t('account.space_access.member.account_not_found') - ); + setMissingAccountId(value); + throw new Error(accountNotFoundMessage()); } throw new Error( t('account.space_access.member.account_check_failed') @@ -360,32 +388,69 @@ const SpaceAccess = ({pendingAccount, onPendingAccountHandled}) => { const account = response?.data; if (response?.status !== 200) { if (isMissingAccount(response)) { - throw new Error( - t('account.space_access.member.account_not_found') - ); + setMissingAccountId(value); + throw new Error(accountNotFoundMessage()); } throw new Error( t('account.space_access.member.account_check_failed') ); } if (!(account?.user_id ?? account?.id)) { - throw new Error( - t('account.space_access.member.account_not_found') - ); + setMissingAccountId(value); + throw new Error(accountNotFoundMessage()); } - }, [memberForm, t]); + memberForm.setFieldValue('user_id', account.user_id ?? account.id); + }, [accountNotFoundMessage, memberForm, t]); + const startAccountCreation = useCallback(() => { + if (!missingAccountId || !onCreateAccount) { + return; + } + const graphSpaceValue = memberForm.getFieldValue('graphspace'); + const graphspaces = Array.isArray(graphSpaceValue) + ? graphSpaceValue + : [graphSpaceValue].filter(Boolean); + const request = { + graphspaces, + permission_preset: memberForm.getFieldValue('permission_preset'), + user_name: missingAccountId, + }; + closeMember(); + onCreateAccount(request); + }, [closeMember, memberForm, missingAccountId, onCreateAccount]); const submitMember = useCallback(values => { + const graphSpaces = Array.isArray(values.graphspace) + ? values.graphspace + : [values.graphspace]; runMutation( - () => api.auth.setSpacePreset( - values.graphspace, - values.user_id ?? values.username, - values.username, - values.permission_preset, - PAGE_ERROR_CONFIG - ), + async () => { + const results = await Promise.allSettled( + graphSpaces.map(space => api.auth.setSpacePreset( + space, + values.user_id ?? values.account_id, + values.account_id, + values.permission_preset, + PAGE_ERROR_CONFIG + )) + ); + const failedSpaces = graphSpaces.filter((_, index) => { + const result = results[index]; + return result.status === 'rejected' + || result.value?.status !== 200; + }); + if (failedSpaces.length > 0) { + refreshAll(); + const options = { + success: graphSpaces.length - failedSpaces.length, + total: graphSpaces.length, + spaces: failedSpaces.join(', '), + }; + throw new Error(t('account.space_access.member.batch_failed', options)); + } + return {status: 200}; + }, closeMember ); - }, [closeMember, runMutation]); + }, [closeMember, refreshAll, runMutation, t]); const confirmDelete = useCallback((title, operation) => { Modal.confirm({ @@ -415,8 +480,7 @@ const SpaceAccess = ({pendingAccount, onPendingAccountHandled}) => { ); const memberColumns = [ - {title: t('account.space_access.member.id'), dataIndex: 'user_id'}, - {title: t('account.space_access.member.name'), dataIndex: 'user_name'}, + {title: t('account.space_access.member.id'), dataIndex: 'user_name'}, { title: t('account.space_access.member.roles'), dataIndex: 'roles', @@ -535,6 +599,9 @@ const SpaceAccess = ({pendingAccount, onPendingAccountHandled}) => { <Select aria-label={t('account.space_access.graphspace')} disabled={!memberDialog?.graphSpaceSelectable} + mode={memberDialog?.graphSpaceSelectable + ? 'multiple' + : undefined} options={spaces.map(space => ({ label: space, value: space, @@ -544,31 +611,47 @@ const SpaceAccess = ({pendingAccount, onPendingAccountHandled}) => { {memberDialog?.user_id ? ( <> <Form.Item - name="user_id" + name="account_id" label={t('account.space_access.member.id')} rules={[{required: true}]} > <Input disabled /> </Form.Item> - <Form.Item name="username" hidden> + <Form.Item name="user_id" hidden> <Input /> </Form.Item> </> ) : ( - <Form.Item - name="username" - label={t( - 'account.space_access.member.existing_account' - )} - dependencies={['graphspace']} - validateTrigger="onBlur" - rules={[ - {required: true}, - {validator: validateExistingAccount}, - ]} - > - <Input /> - </Form.Item> + <> + <Form.Item + name="account_id" + label={t( + 'account.space_access.member.existing_account' + )} + dependencies={['graphspace']} + extra={missingAccountId && onCreateAccount ? ( + <Button + type="link" + size="small" + onClick={startAccountCreation} + > + {t( + 'account.space_access.member.create_account' + )} + </Button> + ) : null} + validateTrigger="onBlur" + rules={[ + {required: true}, + {validator: validateExistingAccount}, + ]} + > + <Input /> + </Form.Item> + <Form.Item name="user_id" hidden> + <Input /> + </Form.Item> + </> )} <Form.Item name="permission_preset" diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.test.js b/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.test.js index 8368f91c1..5d5eabef8 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.test.js @@ -327,17 +327,24 @@ test('submits only the selected preset when adding a member', async () => { fireEvent.click(screen.getByRole('button', { name: 'account.space_access.member.add', })); - const textboxes = screen.getAllByRole('textbox'); - fireEvent.change(textboxes[textboxes.length - 1], { + const dialog = screen.getByRole('dialog'); + const account = within(dialog).getByLabelText( + 'account.space_access.member.existing_account' + ); + fireEvent.change(account, { target: {value: 'bob'}, }); - const comboboxes = screen.getAllByRole('combobox'); + fireEvent.blur(account); + await waitFor(() => expect(api.auth.getSpaceAccount).toHaveBeenCalledWith( + 'SPACE_A', 'bob', expect.any(Object) + )); + const comboboxes = within(dialog).getAllByRole('combobox'); fireEvent.mouseDown(comboboxes[comboboxes.length - 1]); fireEvent.click(screen.getByText('account.permission_preset.GS_READ_WRITE')); fireEvent.click(screen.getByRole('button', {name: 'common.action.save'})); await waitFor(() => expect(api.auth.setSpacePreset).toHaveBeenCalledWith( - 'SPACE_A', 'bob', 'bob', 'GS_READ_WRITE', expect.any(Object))); + 'SPACE_A', 'account-id', 'bob', 'GS_READ_WRITE', expect.any(Object))); }); test('uses the preset API for GS admin', async () => { @@ -355,17 +362,24 @@ test('uses the preset API for GS admin', async () => { fireEvent.click(screen.getByRole('button', { name: 'account.space_access.member.add', })); - const textboxes = screen.getAllByRole('textbox'); - fireEvent.change(textboxes[textboxes.length - 1], { + const dialog = screen.getByRole('dialog'); + const account = within(dialog).getByLabelText( + 'account.space_access.member.existing_account' + ); + fireEvent.change(account, { target: {value: 'bob'}, }); - const comboboxes = screen.getAllByRole('combobox'); + fireEvent.blur(account); + await waitFor(() => expect(api.auth.getSpaceAccount).toHaveBeenCalledWith( + 'SPACE_A', 'bob', expect.any(Object) + )); + const comboboxes = within(dialog).getAllByRole('combobox'); fireEvent.mouseDown(comboboxes[comboboxes.length - 1]); fireEvent.click(screen.getByText('account.permission_preset.GS_ADMIN')); fireEvent.click(screen.getByRole('button', {name: 'common.action.save'})); await waitFor(() => expect(api.auth.setSpacePreset).toHaveBeenCalledWith( - 'SPACE_A', 'bob', 'bob', 'GS_ADMIN', expect.any(Object))); + 'SPACE_A', 'account-id', 'bob', 'GS_ADMIN', expect.any(Object))); expect(api.auth.addSpaceMember).not.toHaveBeenCalled(); }); @@ -419,7 +433,7 @@ test('opens the scoped member flow with account and GraphSpace prefilled', expect(onHandled).toHaveBeenCalledTimes(1); }); -test('assigns an account to the GraphSpace selected in the dialog', async () => { +test('assigns an account to multiple GraphSpaces selected in the dialog', async () => { mockAuthContext.context.scopes = { all_graphspaces: true, admin_graphspaces: [], @@ -435,7 +449,11 @@ test('assigns an account to the GraphSpace selected in the dialog', async () => const onHandled = jest.fn(); render( <SpaceAccess - pendingAccount={{user_name: 'bob', graphspace: 'SPACE_B'}} + pendingAccount={{ + user_id: 'bob-id', + user_name: 'bob', + graphspace: 'SPACE_B', + }} onPendingAccountHandled={onHandled} /> ); @@ -458,9 +476,13 @@ test('assigns an account to the GraphSpace selected in the dialog', async () => name: 'common.action.save', })); - await waitFor(() => expect(api.auth.setSpacePreset).toHaveBeenCalledWith( - 'SPACE_A', 'bob', 'bob', 'GS_READ_ONLY', expect.any(Object) - )); + await waitFor(() => expect(api.auth.setSpacePreset).toHaveBeenCalledTimes(2)); + expect(api.auth.setSpacePreset).toHaveBeenCalledWith( + 'SPACE_A', 'bob-id', 'bob', 'GS_READ_ONLY', expect.any(Object) + ); + expect(api.auth.setSpacePreset).toHaveBeenCalledWith( + 'SPACE_B', 'bob-id', 'bob', 'GS_READ_ONLY', expect.any(Object) + ); expect(onHandled).toHaveBeenCalledTimes(1); }); @@ -480,11 +502,12 @@ test('keeps the current GraphSpace fixed for member-list operations', async () = }); test('explains that an unknown account must be created first', async () => { + const onCreateAccount = jest.fn(); api.auth.getSpaceAccount.mockResolvedValue({ status: 400, message: 'The user or group is not exist', }); - render(<SpaceAccess />); + render(<SpaceAccess onCreateAccount={onCreateAccount} />); await screen.findAllByText('alice'); fireEvent.click(screen.getByRole('button', { @@ -504,6 +527,44 @@ test('explains that an unknown account must be created first', async () => { 'SPACE_A', 'missing-user', expect.any(Object) ); expect(api.auth.setSpacePreset).not.toHaveBeenCalled(); + + const roleSelects = within(dialog).getAllByRole('combobox'); + fireEvent.mouseDown(roleSelects[roleSelects.length - 1]); + fireEvent.click(screen.getByText('account.permission_preset.GS_READ_ONLY')); + fireEvent.click(within(dialog).getByRole('button', { + name: 'account.space_access.member.create_account', + })); + expect(onCreateAccount).toHaveBeenCalledWith({ + graphspaces: ['SPACE_A'], + permission_preset: 'GS_READ_ONLY', + user_name: 'missing-user', + }); +}); + +test('tells a GraphSpace administrator to contact a global administrator', async () => { + api.auth.getSpaceAccount.mockResolvedValue({ + status: 400, + message: 'The user or group is not exist', + }); + render(<SpaceAccess />); + + await screen.findAllByText('alice'); + fireEvent.click(screen.getByRole('button', { + name: 'account.space_access.member.add', + })); + const dialog = screen.getByRole('dialog'); + const account = within(dialog).getByLabelText( + 'account.space_access.member.existing_account' + ); + fireEvent.change(account, {target: {value: 'missing-user'}}); + fireEvent.blur(account); + + expect(await within(dialog).findByText( + 'account.space_access.member.account_not_found_contact_admin' + )).toBeInTheDocument(); + expect(within(dialog).queryByRole('button', { + name: 'account.space_access.member.create_account', + })).not.toBeInTheDocument(); }); test('requires an explicit replacement for legacy custom access', async () => { diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/account-edit-recovery.test.js b/hugegraph-hubble/hubble-fe/src/pages/Account/account-edit-recovery.test.js index 530c7465c..8f8172ee4 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/account-edit-recovery.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/account-edit-recovery.test.js @@ -411,6 +411,19 @@ test('requires an explicit password when creating an account', async () => { )).toBeRequired(); }); +test('prefills a guided account creation with the requested account ID', () => { + render( + <EditLayer + {...props} + data={{user_name: 'guided_user'}} + op='create' + /> + ); + + expect(screen.getByPlaceholderText('account.form.id_placeholder')) + .toHaveValue('guided_user'); +}); + test('keeps a specific account creation error visible in the form', async () => { api.auth.addUser.mockResolvedValue({ status: 400, diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/account-recovery.test.js b/hugegraph-hubble/hubble-fe/src/pages/Account/account-recovery.test.js index 352620c05..f672c4120 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/account-recovery.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/account-recovery.test.js @@ -39,8 +39,43 @@ jest.mock('../../utils/user', () => ({getUser: () => mockCurrentUser})); jest.mock('../../auth/AuthContext', () => ({ useAuthContext: () => mockAuthContext, })); -jest.mock('./EditLayer', () => () => null); -jest.mock('./SpaceAccess', () => () => <div>space access</div>); +jest.mock('./EditLayer', () => props => ( + props.visible ? ( + <button + onClick={() => props.onCreated?.({ + user_id: 'created-id', + user_name: props.data.user_name, + })} + > + guided account {props.data.user_name} + </button> + ) : null +)); +jest.mock('./SpaceAccess', () => props => ( + <div> + space access + {props.onCreateAccount && ( + <button + onClick={() => props.onCreateAccount({ + graphspaces: ['SPACE_A', 'SPACE_B'], + permission_preset: 'GS_READ_ONLY', + user_name: 'guided_user', + })} + > + request guided account + </button> + )} + {props.pendingAccount && ( + <span> + pending {props.pendingAccount.user_name} + {' '} + {props.pendingAccount.graphspaces.join(',')} + {' '} + {props.pendingAccount.permission_preset} + </span> + )} + </div> +)); beforeEach(() => { jest.clearAllMocks(); @@ -206,3 +241,30 @@ test('super administrators retain all account management actions', async () => { expect(screen.getByText('account.action.manage_membership')).toBeInTheDocument(); expect(screen.getByText('common.action.delete')).toBeInTheDocument(); }); + +test('returns a guided account creation to the selected GraphSpaces', async () => { + mockAuthContext.context.actions.members = ['read', 'add', 'remove']; + api.auth.getAllUserList.mockResolvedValue({ + status: 200, + data: {records: [], total: 0}, + }); + render(<Account />); + + await userEvent.click(screen.getByRole('tab', { + name: 'account.space_access.scoped_tab', + })); + await userEvent.click(screen.getByRole('button', { + name: 'request guided account', + })); + + expect(screen.getByRole('button', { + name: 'guided account guided_user', + })).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { + name: 'guided account guided_user', + })); + expect(await screen.findByText( + 'pending guided_user SPACE_A,SPACE_B GS_READ_ONLY' + )) + .toBeInTheDocument(); +}); diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/index.js b/hugegraph-hubble/hubble-fe/src/pages/Account/index.js index a616c13e3..6c5ab50b3 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/index.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/index.js @@ -53,7 +53,11 @@ const RowAction = ({onAction, row, children}) => { return <Button type='link' onClick={handleClick}>{children}</Button>; }; -const GlobalAccounts = ({onAssignMember}) => { +const GlobalAccounts = ({ + onAssignMember, + onPendingCreateHandled, + pendingCreate, +}) => { const {t} = useTranslation(); const {context} = useAuthContext(); const accountActions = context?.actions?.accounts ?? []; @@ -66,6 +70,7 @@ const GlobalAccounts = ({onAssignMember}) => { || context.capabilities?.includes('account_permission_presets'); const hasRowMutations = canUpdateAccount || canDeleteAccount || canGrantAuthorization; const [editLayerVisible, setEditLayerVisible] = useState(false); + const [creationContext, setCreationContext] = useState(null); const [op, setOp] = useState('detail'); const [detail, setDetail] = useState({}); const [data, setData] = useState([]); @@ -76,12 +81,14 @@ const GlobalAccounts = ({onAssignMember}) => { const [pagination, setPagination] = useState({toatal: 0, current: 1, pageSize: 10}); const showDetail = useCallback(row => { + setCreationContext(null); setDetail(row); setOp('detail'); setEditLayerVisible(true); }, []); const showEdit = useCallback(row => { + setCreationContext(null); setDetail(row); setOp('edit'); setEditLayerVisible(true); @@ -96,6 +103,7 @@ const GlobalAccounts = ({onAssignMember}) => { }, [onAssignMember]); const showAdd = useCallback(() => { + setCreationContext(null); setDetail({}); setOp('create'); setEditLayerVisible(true); @@ -110,6 +118,15 @@ const GlobalAccounts = ({onAssignMember}) => { }, []); const handleCreated = useCallback(account => { + if (creationContext) { + setCreationContext(null); + onAssignMember?.({ + ...account, + graphspaces: creationContext.graphspaces, + permission_preset: creationContext.permission_preset, + }); + return; + } if (!canGrantAuthorization || !onAssignMember || account.is_superadmin) { return; } @@ -122,7 +139,18 @@ const GlobalAccounts = ({onAssignMember}) => { cancelText: t('account.created.done'), onOk: () => onAssignMember(account), }); - }, [canGrantAuthorization, onAssignMember, t]); + }, [canGrantAuthorization, creationContext, onAssignMember, t]); + + useEffect(() => { + if (!pendingCreate || !canCreateAccount) { + return; + } + setCreationContext(pendingCreate); + setDetail({user_name: pendingCreate.user_name}); + setOp('create'); + setEditLayerVisible(true); + onPendingCreateHandled?.(); + }, [canCreateAccount, onPendingCreateHandled, pendingCreate]); const handleDelete = useCallback(row => { Modal.confirm({ @@ -356,6 +384,7 @@ const Account = () => { const {context, refresh: refreshPermissions} = useAuthContext(); const actions = context?.actions ?? {}; const canReadGlobalAccounts = (actions.accounts ?? []).includes('read'); + const canCreateGlobalAccount = (actions.accounts ?? []).includes('create'); const canReadScopedAccess = [ ...(actions.members ?? []), ...(actions.roles ?? []), @@ -363,11 +392,17 @@ const Account = () => { ].includes('read'); const [activeTab, setActiveTab] = useState('global'); const [pendingMember, setPendingMember] = useState(null); + const [pendingCreate, setPendingCreate] = useState(null); const assignMember = useCallback(account => { setPendingMember(account); setActiveTab('scoped'); }, []); const clearPendingMember = useCallback(() => setPendingMember(null), []); + const createAccount = useCallback(request => { + setPendingCreate(request); + setActiveTab('global'); + }, []); + const clearPendingCreate = useCallback(() => setPendingCreate(null), []); const refreshPermissionContext = useCallback( () => Promise.resolve(refreshPermissions?.()).catch(() => undefined), [refreshPermissions] @@ -383,7 +418,13 @@ const Account = () => { { key: 'global', label: t('account.space_access.global_tab'), - children: <GlobalAccounts onAssignMember={assignMember} />, + children: ( + <GlobalAccounts + onAssignMember={assignMember} + pendingCreate={pendingCreate} + onPendingCreateHandled={clearPendingCreate} + /> + ), }, { key: 'scoped', @@ -392,6 +433,9 @@ const Account = () => { <SpaceAccess pendingAccount={pendingMember} onPendingAccountHandled={clearPendingMember} + onCreateAccount={canCreateGlobalAccount + ? createAccount + : undefined} /> ), },
