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 ace85d75ffb..be0ca733595 100644 --- a/frontend/packages/console-shared/src/hooks/index.ts +++ b/frontend/packages/console-shared/src/hooks/index.ts @@ -35,3 +35,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 42fcb57d1c5..dcd70902066 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 afefbafa52a..6bbff647afb 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, fireEvent } from '@testing-library/react'; +import { screen, 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'; @@ -32,7 +32,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(); }); @@ -58,7 +58,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')); }); @@ -84,7 +86,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: RootState) => ({ + const { clusterID, alertCount, canAccessNS } = useSelector((state: RootState) => ({ 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); @@ -178,7 +182,6 @@ const MastheadToolbarContents: React.FCC = ({ const kebabMenuRef = useRef(null); const reportBugLink = cv ? getReportBugLink(cv) : null; const userInactivityTimeout = useRef(null); - const username = user?.username ?? ''; const isKubeAdmin = username === 'kube:admin'; const drawerToggle = useCallback(() => dispatch(UIActions.notificationDrawerToggleExpanded()), [ @@ -615,7 +618,7 @@ const MastheadToolbarContents: React.FCC = ({ 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 a0f9f2f8f11..61b5df2a7df 100644 --- a/frontend/public/locales/en/public.json +++ b/frontend/public/locales/en/public.json @@ -1740,6 +1740,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",