diff --git a/packages/javascript/bh-shared-ui/src/utils/object.ts b/packages/javascript/bh-shared-ui/src/utils/object.ts index 1ebfc4a70be..a1295129aa3 100644 --- a/packages/javascript/bh-shared-ui/src/utils/object.ts +++ b/packages/javascript/bh-shared-ui/src/utils/object.ts @@ -16,3 +16,5 @@ /** Returns Object.entries with results retaining their types */ export const typedEntries = (obj: T): [keyof T, T[keyof T]][] => Object.entries(obj) as any; + +export const noop = () => undefined; diff --git a/packages/javascript/bh-shared-ui/src/views/UserProfile/UserProfile.test.tsx b/packages/javascript/bh-shared-ui/src/views/UserProfile/UserProfile.test.tsx index 0932ee9bc72..a9d3542cbd8 100644 --- a/packages/javascript/bh-shared-ui/src/views/UserProfile/UserProfile.test.tsx +++ b/packages/javascript/bh-shared-ui/src/views/UserProfile/UserProfile.test.tsx @@ -19,12 +19,40 @@ import { render, screen, waitFor, within } from '../../test-utils'; import UserProfile from './UserProfile'; +import { ConfigurationKey } from 'js-client-library'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; +import { QueryClient } from 'react-query'; +import { configurationKeys } from '../../hooks'; + +const CONFIG_ENABLED_RESPONSE = { + data: [ + { + key: ConfigurationKey.APITokens, + value: { + enabled: true, + }, + }, + ], +}; + +const CONFIG_DISABLED_RESPONSE = { + data: [ + { + key: ConfigurationKey.APITokens, + value: { + enabled: false, + }, + }, + ], +}; const server = setupServer( rest.get(`/api/v2/self`, (req, res) => { return res(); + }), + rest.get(`/api/v2/config`, async (_req, res, ctx) => { + return res(ctx.json(CONFIG_ENABLED_RESPONSE)); }) ); @@ -166,3 +194,26 @@ describe('UserProfile', () => { }); }); }); + +describe('Api Keys', () => { + it('should display api key management button', async () => { + render(); + const apiKeyManagementButton = await screen.findByRole('button', { name: 'API Key Management' }); + expect(apiKeyManagementButton).toBeInTheDocument(); + }); + + it('should not display api key management button', async () => { + server.use( + rest.get(`/api/v2/config`, async (_req, res, ctx) => { + return res(ctx.json(CONFIG_DISABLED_RESPONSE)); + }) + ); + + const queryClient = new QueryClient(); + render(, { queryClient }); + + await queryClient.invalidateQueries(configurationKeys.all); + const apiKeyManagementButton = screen.queryByRole('button', { name: 'API Key Management' }); + expect(apiKeyManagementButton).not.toBeInTheDocument(); + }); +}); diff --git a/packages/javascript/bh-shared-ui/src/views/UserProfile/UserProfile.tsx b/packages/javascript/bh-shared-ui/src/views/UserProfile/UserProfile.tsx index 4058fc97489..f1fff18da5b 100644 --- a/packages/javascript/bh-shared-ui/src/views/UserProfile/UserProfile.tsx +++ b/packages/javascript/bh-shared-ui/src/views/UserProfile/UserProfile.tsx @@ -28,6 +28,7 @@ import { TextWithFallback, UserTokenManagementDialog, } from '../../components'; +import { useAPITokensConfiguration } from '../../hooks'; import { useSelf } from '../../hooks/useBloodHoundUsers'; import { useNotifications } from '../../providers'; import { apiClient, getUsername } from '../../utils'; @@ -45,6 +46,7 @@ const UserProfile = () => { const [disable2FASecret, setDisable2FASecret] = useState(''); const getSelfQuery = useSelf(); + const apiTokensEnabled = useAPITokensConfiguration(); const updateUserPasswordMutation = useMutation( ({ userId, ...payload }: { userId: string } & PutUserAuthSecretRequest) => @@ -131,19 +133,21 @@ const UserProfile = () => { Authentication - - - API Key Management - - - + {apiTokensEnabled && ( + + + API Key Management + + + + - + )} {user && user.sso_provider_id === null && ( <> diff --git a/packages/javascript/bh-shared-ui/src/views/Users/UserActionsMenu.test.tsx b/packages/javascript/bh-shared-ui/src/views/Users/UserActionsMenu.test.tsx new file mode 100644 index 00000000000..bbb7bf86cc0 --- /dev/null +++ b/packages/javascript/bh-shared-ui/src/views/Users/UserActionsMenu.test.tsx @@ -0,0 +1,126 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// 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. +// +// SPDX-License-Identifier: Apache-2.0 +import userEvent from '@testing-library/user-event'; +import { ConfigurationKey } from 'js-client-library'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { render, screen } from '../../test-utils'; +import { noop } from '../../utils'; +import UserActionsMenu from './UserActionsMenu'; + +const CONFIG_ENABLED_RESPONSE = { + data: [ + { + key: ConfigurationKey.APITokens, + value: { + enabled: true, + }, + }, + ], +}; + +const CONFIG_DISABLED_RESPONSE = { + data: [ + { + key: ConfigurationKey.APITokens, + value: { + enabled: false, + }, + }, + ], +}; + +type ComponentProps = React.ComponentProps; + +const server = setupServer( + rest.get(`/api/v2/config`, async (_req, res, ctx) => { + return res(ctx.json(CONFIG_ENABLED_RESPONSE)); + }) +); + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe('Api Keys', () => { + const setup = ( + { + userId = '', + onOpen = noop, + showPasswordOptions = false, + showAuthMgmtButtons = false, + showDisableMfaButton = false, + userDisabled = false, + onUpdateUser = noop, + onDisableUser = noop, + onEnableUser = noop, + onDeleteUser = noop, + onUpdateUserPassword = noop, + onExpireUserPassword = noop, + onManageUserTokens = noop, + onDisableUserMfa = noop, + index = 0, + } = {} as ComponentProps + ) => { + const user = userEvent.setup(); + + const screen = render( + + ); + + return { screen, user }; + }; + + it('should display generate/revoke api tokens button', async () => { + const { user } = setup(); + + const button = screen.getByRole('button', { name: /show user actions/i }); + await user.click(button); + + await screen.findByRole('menuitem', { name: /generate \/ revoke api tokens/i }); + }); + + it('should not display generate/revoke api tokens button', async () => { + server.use( + rest.get(`/api/v2/config`, async (_req, res, ctx) => { + return res(ctx.json(CONFIG_DISABLED_RESPONSE)); + }) + ); + const { user } = setup(); + + const button = screen.getByRole('button', { name: /show user actions/i }); + await user.click(button); + + const apiKeyManagementButton = screen.queryByRole('menuitem', { name: /generate \/ revoke api tokens/i }); + expect(apiKeyManagementButton).not.toBeInTheDocument(); + }); +}); diff --git a/packages/javascript/bh-shared-ui/src/views/Users/UserActionsMenu.tsx b/packages/javascript/bh-shared-ui/src/views/Users/UserActionsMenu.tsx index 6a8615dbd3b..d59b6a08c0f 100644 --- a/packages/javascript/bh-shared-ui/src/views/Users/UserActionsMenu.tsx +++ b/packages/javascript/bh-shared-ui/src/views/Users/UserActionsMenu.tsx @@ -29,6 +29,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { IconButton, ListItemIcon, ListItemText, Menu, MenuItem, MenuProps } from '@mui/material'; import withStyles from '@mui/styles/withStyles'; import React from 'react'; +import { useAPITokensConfiguration } from '../../hooks'; const StyledMenu = withStyles({ paper: { @@ -87,6 +88,7 @@ const UserActionsMenu: React.FC = ({ /* Hooks */ const [anchorEl, setAnchorEl] = React.useState(null); + const apiTokensEnabled = useAPITokensConfiguration(); /* Event Handlers */ @@ -186,17 +188,18 @@ const UserActionsMenu: React.FC = ({ )} - ) => { - onManageUserTokens(e); - setAnchorEl(null); - }}> - - - - - - + {apiTokensEnabled && ( + ) => { + onManageUserTokens(e); + setAnchorEl(null); + }}> + + + + + + )} {showDisableMfaButton && ( ) => {