diff --git a/frontend/packages/console-app/src/components/user-preferences/__tests__/UserPreferenceCheckboxField.spec.tsx b/frontend/packages/console-app/src/components/user-preferences/__tests__/UserPreferenceCheckboxField.spec.tsx index dc9c2f43ea9..77c80c1e75c 100644 --- a/frontend/packages/console-app/src/components/user-preferences/__tests__/UserPreferenceCheckboxField.spec.tsx +++ b/frontend/packages/console-app/src/components/user-preferences/__tests__/UserPreferenceCheckboxField.spec.tsx @@ -2,14 +2,19 @@ import * as React from 'react'; import { Checkbox, Skeleton } from '@patternfly/react-core'; import { shallow, ShallowWrapper } from 'enzyme'; import { UserPreferenceFieldType } from '@console/dynamic-plugin-sdk/src/extensions/user-preferences'; -import { useUserSettings } from '@console/shared'; +import { useUserSettings, useTelemetry } from '@console/shared'; import UserPreferenceCheckboxField from '../UserPreferenceCheckboxField'; jest.mock('@console/shared/src/hooks/useUserSettings', () => ({ useUserSettings: jest.fn(), })); +jest.mock('@console/shared/src/hooks/useTelemetry', () => ({ + useTelemetry: jest.fn(), +})); + const mockUserSettings = useUserSettings as jest.Mock; +const mockUseTelemetry = useTelemetry as jest.Mock; describe('UserPreferenceCheckboxField', () => { type UserPreferenceCheckboxFieldProps = React.ComponentProps; @@ -23,6 +28,10 @@ describe('UserPreferenceCheckboxField', () => { }; let wrapper: ShallowWrapper; + beforeEach(() => { + mockUseTelemetry.mockReturnValue(jest.fn()); + }); + afterEach(() => { jest.resetAllMocks(); }); diff --git a/frontend/packages/console-app/src/components/user-preferences/__tests__/UserPreferenceDropdownField.spec.tsx b/frontend/packages/console-app/src/components/user-preferences/__tests__/UserPreferenceDropdownField.spec.tsx index ab11d2dbc18..c09013fa410 100644 --- a/frontend/packages/console-app/src/components/user-preferences/__tests__/UserPreferenceDropdownField.spec.tsx +++ b/frontend/packages/console-app/src/components/user-preferences/__tests__/UserPreferenceDropdownField.spec.tsx @@ -2,14 +2,19 @@ import * as React from 'react'; import { Select, SelectProps } from '@patternfly/react-core'; import { shallow, ShallowWrapper } from 'enzyme'; import { UserPreferenceFieldType } from '@console/dynamic-plugin-sdk/src/extensions/user-preferences'; -import { useUserSettings } from '@console/shared'; +import { useUserSettings, useTelemetry } from '@console/shared'; import UserPreferenceDropdownField from '../UserPreferenceDropdownField'; jest.mock('@console/shared/src/hooks/useUserSettings', () => ({ useUserSettings: jest.fn(), })); +jest.mock('@console/shared/src/hooks/useTelemetry', () => ({ + useTelemetry: jest.fn(), +})); + const mockUserSettings = useUserSettings as jest.Mock; +const mockUseTelemetry = useTelemetry as jest.Mock; describe('UserPreferenceDropdownField', () => { type UserPreferenceDropdownFieldProps = React.ComponentProps; @@ -24,6 +29,10 @@ describe('UserPreferenceDropdownField', () => { }; let wrapper: ShallowWrapper; + beforeEach(() => { + mockUseTelemetry.mockReturnValue(jest.fn()); + }); + afterEach(() => { jest.resetAllMocks(); }); diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/app/core/actions/core.ts b/frontend/packages/console-dynamic-plugin-sdk/src/app/core/actions/core.ts index 6b7577064af..c60f2345bb9 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/src/app/core/actions/core.ts +++ b/frontend/packages/console-dynamic-plugin-sdk/src/app/core/actions/core.ts @@ -1,9 +1,11 @@ import { action, ActionType as Action } from 'typesafe-actions'; +import type { UserKind } from '@console/internal/module/k8s/types'; import { UserInfo } from '../../../extensions'; import { AdmissionWebhookWarning } from '../../redux-types'; export enum ActionType { SetUser = 'setUser', + SetUserResource = 'setUserResource', BeginImpersonate = 'beginImpersonate', EndImpersonate = 'endImpersonate', SetActiveCluster = 'setActiveCluster', @@ -12,6 +14,8 @@ export enum ActionType { } export const setUser = (userInfo: UserInfo) => action(ActionType.SetUser, { userInfo }); +export const setUserResource = (userResource: UserKind) => + action(ActionType.SetUserResource, { userResource }); export const beginImpersonate = (kind: string, name: string, subprotocols: string[]) => action(ActionType.BeginImpersonate, { kind, name, subprotocols }); export const endImpersonate = () => action(ActionType.EndImpersonate); @@ -21,6 +25,7 @@ export const removeAdmissionWebhookWarning = (id) => action(ActionType.RemoveAdmissionWebhookWarning, { id }); const coreActions = { setUser, + setUserResource, beginImpersonate, endImpersonate, setAdmissionWebhookWarning, diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/app/core/reducers/core.ts b/frontend/packages/console-dynamic-plugin-sdk/src/app/core/reducers/core.ts index 3598222a686..6706a125361 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/src/app/core/reducers/core.ts +++ b/frontend/packages/console-dynamic-plugin-sdk/src/app/core/reducers/core.ts @@ -14,6 +14,7 @@ import { ActionType, CoreAction } from '../actions/core'; export const coreReducer = ( state: CoreState = { user: {}, + userResource: null, admissionWebhookWarnings: ImmutableMap(), }, action: CoreAction, @@ -47,6 +48,12 @@ export const coreReducer = ( user: action.payload.userInfo, }; + case ActionType.SetUserResource: + return { + ...state, + userResource: action.payload.userResource, + }; + case ActionType.SetAdmissionWebhookWarning: return { ...state, diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/app/core/reducers/coreSelectors.ts b/frontend/packages/console-dynamic-plugin-sdk/src/app/core/reducers/coreSelectors.ts index 99a287dfd88..2db2fc3bca0 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/src/app/core/reducers/coreSelectors.ts +++ b/frontend/packages/console-dynamic-plugin-sdk/src/app/core/reducers/coreSelectors.ts @@ -1,9 +1,11 @@ import { Map as ImmutableMap } from 'immutable'; +import type { UserKind } from '@console/internal/module/k8s/types'; import { UserInfo } from '../../../extensions'; import { ImpersonateKind, SDKStoreState, AdmissionWebhookWarning } from '../../redux-types'; type GetImpersonate = (state: SDKStoreState) => ImpersonateKind; type GetUser = (state: SDKStoreState) => UserInfo; +type GetUserResource = (state: SDKStoreState) => UserKind; type GetAdmissionWebhookWarnings = ( state: SDKStoreState, ) => ImmutableMap; @@ -31,6 +33,13 @@ export const impersonateStateToProps = (state: SDKStoreState) => { */ export const getUser: GetUser = (state) => state.sdkCore.user; +/** + * It provides user resource details from the redux store. + * @param state the root state + * @returns The user resource state. + */ +export const getUserResource: GetUserResource = (state) => state.sdkCore.userResource; + /** * It provides admission webhook warning data from the redux store. * @param state the root state diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/app/redux-types.ts b/frontend/packages/console-dynamic-plugin-sdk/src/app/redux-types.ts index 5f2f5a1f13d..503b7a954f1 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/src/app/redux-types.ts +++ b/frontend/packages/console-dynamic-plugin-sdk/src/app/redux-types.ts @@ -1,4 +1,5 @@ import { Map as ImmutableMap } from 'immutable'; +import type { UserKind } from '@console/internal/module/k8s/types'; import { UserInfo } from '../extensions/console-types'; export type K8sState = ImmutableMap; @@ -16,6 +17,7 @@ export type ImpersonateKind = { export type CoreState = { user?: UserInfo; + userResource?: UserKind; impersonate?: ImpersonateKind; admissionWebhookWarnings?: ImmutableMap; }; diff --git a/frontend/packages/console-shared/src/hooks/__tests__/useTelemetry.spec.ts b/frontend/packages/console-shared/src/hooks/__tests__/useTelemetry.spec.ts index f8df18d22b2..646f87e9ca9 100644 --- a/frontend/packages/console-shared/src/hooks/__tests__/useTelemetry.spec.ts +++ b/frontend/packages/console-shared/src/hooks/__tests__/useTelemetry.spec.ts @@ -24,6 +24,18 @@ jest.mock('@console/shared/src/hooks/useUserSettings', () => ({ useUserSettings: jest.fn(), })); +jest.mock('@console/shared/src/hooks/useUser', () => ({ + useUser: jest.fn(() => ({ + user: {}, + userResource: {}, + userResourceLoaded: true, + userResourceError: null, + username: 'testuser', + fullName: 'Test User', + displayName: 'Test User', + })), +})); + const mockUserResource = {}; const exampleReturnValue = { diff --git a/frontend/packages/console-shared/src/hooks/__tests__/useUser.spec.ts b/frontend/packages/console-shared/src/hooks/__tests__/useUser.spec.ts new file mode 100644 index 00000000000..a9942ace264 --- /dev/null +++ b/frontend/packages/console-shared/src/hooks/__tests__/useUser.spec.ts @@ -0,0 +1,119 @@ +import { useSelector, useDispatch } from 'react-redux'; +import { useK8sGet } from '@console/internal/components/utils/k8s-get-hook'; +import { testHook } from '@console/shared/src/test-utils/hooks-utils'; +import { useUser } from '../useUser'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), + useDispatch: jest.fn(), +})); + +jest.mock('@console/internal/components/utils/k8s-get-hook', () => ({ + useK8sGet: jest.fn(), +})); + +const mockSetUserResource = jest.fn((userResource) => ({ + type: 'setUserResource', + payload: { userResource }, +})); + +jest.mock('@console/dynamic-plugin-sdk', () => ({ + ...jest.requireActual('@console/dynamic-plugin-sdk'), + getUser: jest.fn(), + getUserResource: jest.fn(), + setUserResource: (...args) => mockSetUserResource(...args), +})); + +const mockDispatch = jest.fn(); +const mockUseSelector = useSelector as jest.Mock; +const mockUseK8sGet = useK8sGet as jest.Mock; +const mockUseDispatch = useDispatch as jest.Mock; + +describe('useUser', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); + }); + + it('should return user data with displayName from fullName when available', () => { + const mockUser = { username: 'testuser@example.com', uid: '123' }; + const mockUserResource = { fullName: 'Test User', identities: ['testuser'] }; + + mockUseSelector + .mockReturnValueOnce(mockUser) // for getUser + .mockReturnValueOnce(mockUserResource); // for getUserResource + + mockUseK8sGet.mockReturnValue([mockUserResource, true, null]); + + const { result } = testHook(() => useUser()); + + expect(result.current.user).toEqual(mockUser); + expect(result.current.userResource).toEqual(mockUserResource); + expect(result.current.username).toBe('testuser@example.com'); + expect(result.current.fullName).toBe('Test User'); + expect(result.current.displayName).toBe('Test User'); // Should prefer fullName + }); + + it('should fallback to username when fullName is not available', () => { + const mockUser = { username: 'testuser@example.com', uid: '123' }; + const mockUserResource = { identities: ['testuser'] }; // No fullName + + mockUseSelector.mockReturnValueOnce(mockUser).mockReturnValueOnce(mockUserResource); + + mockUseK8sGet.mockReturnValue([mockUserResource, true, null]); + + const { result } = testHook(() => useUser()); + + expect(result.current.displayName).toBe('testuser@example.com'); // Should fallback to username + expect(result.current.fullName).toBeUndefined(); + }); + + it('should dispatch setUserResource when user resource is loaded', () => { + const mockUser = { username: 'testuser@example.com' }; + const mockUserResource = { fullName: 'Test User' }; + + mockUseSelector.mockReturnValueOnce(mockUser).mockReturnValueOnce(null); // No userResource in Redux yet + + mockUseK8sGet.mockReturnValue([mockUserResource, true, null]); + + testHook(() => useUser()); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'setUserResource', + payload: { userResource: mockUserResource }, + }); + }); + + it('should handle edge cases with empty strings and fallback to "Unknown user"', () => { + const mockUser = { username: '' }; // Empty username + const mockUserResource = { fullName: ' ' }; // Whitespace-only fullName + + mockUseSelector.mockReturnValueOnce(mockUser).mockReturnValueOnce(mockUserResource); + + mockUseK8sGet.mockReturnValue([mockUserResource, true, null]); + + const { result } = testHook(() => useUser()); + + expect(result.current.displayName).toBe('Unknown user'); // Should fallback to translated "Unknown user" + }); + + it('should trim whitespace from fullName and username', () => { + const mockUser = { username: ' testuser@example.com ' }; + const mockUserResource = { fullName: ' Test User ' }; + + mockUseSelector.mockReturnValueOnce(mockUser).mockReturnValueOnce(mockUserResource); + + mockUseK8sGet.mockReturnValue([mockUserResource, true, null]); + + const { result } = testHook(() => useUser()); + + expect(result.current.displayName).toBe('Test User'); // Should be trimmed + }); +}); diff --git a/frontend/packages/console-shared/src/hooks/index.ts b/frontend/packages/console-shared/src/hooks/index.ts index c833450d802..8ce5e626c49 100644 --- a/frontend/packages/console-shared/src/hooks/index.ts +++ b/frontend/packages/console-shared/src/hooks/index.ts @@ -37,3 +37,4 @@ export * from './usePrometheusGate'; export * from './useCopyCodeModal'; export * from './useCopyLoginCommands'; export * from './useQuickStartContext'; +export * from './useUser'; diff --git a/frontend/packages/console-shared/src/hooks/useTelemetry.ts b/frontend/packages/console-shared/src/hooks/useTelemetry.ts index edb26fa7126..e318cfe15e6 100644 --- a/frontend/packages/console-shared/src/hooks/useTelemetry.ts +++ b/frontend/packages/console-shared/src/hooks/useTelemetry.ts @@ -6,14 +6,13 @@ import { TelemetryEventListener, UserInfo, } from '@console/dynamic-plugin-sdk'; -import { useK8sGet } from '@console/internal/components/utils/k8s-get-hook'; -import { UserModel } from '@console/internal/models'; import type { UserKind } from '@console/internal/module/k8s/types'; import { CLUSTER_TELEMETRY_ANALYTICS, PREFERRED_TELEMETRY_USER_SETTING_KEY, USER_TELEMETRY_ANALYTICS, } from '../constants'; +import { useUser } from './useUser'; import { useUserSettings } from './useUserSettings'; export interface ClusterProperties { @@ -81,7 +80,8 @@ export const useTelemetry = () => { true, ); - const [userResource, userResourceIsLoaded] = useK8sGet(UserModel, '~'); + // Use centralized user data instead of fetching directly + const { userResource, userResourceLoaded: userResourceIsLoaded } = useUser(); const [extensions] = useResolvedExtensions(isTelemetryListener); diff --git a/frontend/packages/console-shared/src/hooks/useUser.ts b/frontend/packages/console-shared/src/hooks/useUser.ts new file mode 100644 index 00000000000..822ec9ce2b6 --- /dev/null +++ b/frontend/packages/console-shared/src/hooks/useUser.ts @@ -0,0 +1,67 @@ +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { getUser, getUserResource, setUserResource } from '@console/dynamic-plugin-sdk'; +import { useK8sGet } from '@console/internal/components/utils/k8s-get-hook'; +import { UserModel } from '@console/internal/models'; +import type { UserKind } from '@console/internal/module/k8s/types'; + +/** + * Custom hook that provides centralized user data fetching and management. + * This hook fetches both the UserInfo (from authentication) and UserKind (from k8s API) + * and stores them in Redux for use throughout the application. + * + * @returns Object containing user info, user resource, and loading states + */ +export const useUser = () => { + const { t } = useTranslation('public'); + const dispatch = useDispatch(); + + // Get current user info from Redux (username, groups, etc.) + const user = useSelector(getUser); + + // Get current user resource from Redux (fullName, identities, etc.) + const userResource = useSelector(getUserResource); + + // Fetch user resource from k8s API + const [userResourceData, userResourceLoaded, userResourceError] = useK8sGet( + UserModel, + '~', + ); + + // Update Redux when user resource is loaded + useEffect(() => { + if (userResourceLoaded && userResourceData && !userResourceError) { + dispatch(setUserResource(userResourceData)); + } + }, [dispatch, userResourceData, userResourceLoaded, userResourceError]); + + const currentUserResource = userResource || userResourceData; + const currentUsername = user?.username; + const currentFullName = currentUserResource?.fullName; + + // Create a robust display name that always has a fallback + const getDisplayName = () => { + // Prefer fullName if it exists and is not empty + if (currentFullName && currentFullName.trim()) { + return currentFullName.trim(); + } + // Fallback to username if it exists and is not empty + if (currentUsername && currentUsername.trim()) { + return currentUsername.trim(); + } + // Final fallback for edge cases + return t('Unknown user'); + }; + + return { + user, + userResource: currentUserResource, + userResourceLoaded, + userResourceError, + // Computed properties for convenience + username: currentUsername, + fullName: currentFullName, + displayName: getDisplayName(), + }; +}; diff --git a/frontend/packages/topology/src/components/export-app/__tests__/ExportApplicationModal.spec.tsx b/frontend/packages/topology/src/components/export-app/__tests__/ExportApplicationModal.spec.tsx index 0175fe2208b..bfc3eee0f4d 100644 --- a/frontend/packages/topology/src/components/export-app/__tests__/ExportApplicationModal.spec.tsx +++ b/frontend/packages/topology/src/components/export-app/__tests__/ExportApplicationModal.spec.tsx @@ -1,4 +1,4 @@ -import { screen, render, configure, fireEvent } from '@testing-library/react'; +import { screen, configure, fireEvent } from '@testing-library/react'; import * as _ from 'lodash'; import { act } from 'react-dom/test-utils'; import * as k8sResourceModule from '@console/dynamic-plugin-sdk/src/utils/k8s/k8s-resource'; @@ -35,7 +35,7 @@ describe('ExportApplicationModal', () => { }); it('should show cancel and ok buttons when export app resource is not found', async () => { - render(); + renderWithProviders(); expect(screen.getByTestId('cancel-btn')).toBeInTheDocument(); expect(screen.getByTestId('close-btn')).toBeInTheDocument(); }); @@ -61,7 +61,7 @@ describe('ExportApplicationModal', () => { }); it('should show cancel and ok buttons when export app resource is created', async () => { - render( + renderWithProviders( { it('should call k8sCreate with correct data on click of Ok button when the export resource is not created', async () => { const spyk8sCreate = jest.spyOn(k8sResourceModule, 'k8sCreate'); - render(); + renderWithProviders( + , + ); await act(async () => { fireEvent.click(screen.getByTestId('close-btn')); }); @@ -87,7 +89,7 @@ describe('ExportApplicationModal', () => { const spyk8sKill = jest.spyOn(k8sResourceModule, 'k8sKill'); const spyk8sCreate = jest.spyOn(k8sResourceModule, 'k8sCreate'); - render( + renderWithProviders( { t('public~Login with this command'), externalLoginCommand, ); - const { clusterID, user, alertCount, canAccessNS } = useSelector((state) => ({ + const { clusterID, alertCount, canAccessNS } = useSelector((state) => ({ clusterID: state.UI.get('clusterID'), - user: getUser(state), alertCount: state.observe.getIn(['alertCount']), canAccessNS: !!state[featureReducerName].get(FLAGS.CAN_GET_NS), })); + + // Use centralized user hook for user data + const { displayName, username } = useUser(); + const [isAppLauncherDropdownOpen, setIsAppLauncherDropdownOpen] = useState(false); const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false); const [isKebabDropdownOpen, setIsKebabDropdownOpen] = useState(false); @@ -138,7 +138,6 @@ const MastheadToolbarContents = ({ consoleLinks, cv, isMastheadStacked }) => { const kebabMenuRef = useRef(null); const reportBugLink = cv?.data ? getReportBugLink(cv.data, t) : null; const userInactivityTimeout = useRef(null); - const username = user?.username ?? ''; const isKubeAdmin = username === 'kube:admin'; const drawerToggle = useCallback(() => dispatch(UIActions.notificationDrawerToggleExpanded()), [ @@ -561,7 +560,7 @@ const MastheadToolbarContents = ({ consoleLinks, cv, isMastheadStacked }) => { const userToggle = ( - {authEnabledFlag ? username : t('public~Auth disabled')} + {authEnabledFlag ? displayName : t('public~Auth disabled')} ); diff --git a/frontend/public/locales/en/public.json b/frontend/public/locales/en/public.json index cc31b1069b8..e5c623d0ac1 100644 --- a/frontend/public/locales/en/public.json +++ b/frontend/public/locales/en/public.json @@ -1744,6 +1744,7 @@ "The resulting dataset is too large to graph.": "The resulting dataset is too large to graph.", "Stacked": "Stacked", "unknown host": "unknown host", + "Unknown user": "Unknown user", "Just now": "Just now", "Disable": "Disable", "Disabled": "Disabled",