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 c9c250046d0..5f3965e0fdc 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 mockUserSettings = useUserSettings as jest.Mock; const useResolvedExtensionsMock = useResolvedExtensions as jest.Mock; 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..67be4e04337 --- /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 '../../../../../__tests__/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/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/public/components/masthead-toolbar.jsx b/frontend/public/components/masthead-toolbar.jsx index 5d55d86d18a..b22e1c48a1f 100644 --- a/frontend/public/components/masthead-toolbar.jsx +++ b/frontend/public/components/masthead-toolbar.jsx @@ -29,13 +29,13 @@ import { useFlag, usePerspectiveExtension, useTelemetry, + useUser, YellowExclamationTriangleIcon, } from '@console/shared'; import { formatNamespacedRouteForResource } from '@console/shared/src/utils'; import { LinkTo } from '@console/shared/src/components/links/LinkTo'; import CloudShellMastheadButton from '@console/webterminal-plugin/src/components/cloud-shell/CloudShellMastheadButton'; import CloudShellMastheadAction from '@console/webterminal-plugin/src/components/cloud-shell/CloudShellMastheadAction'; -import { getUser } from '@console/dynamic-plugin-sdk'; import * as UIActions from '../actions/ui'; import { flagPending, featureReducerName } from '../reducers/features'; import { authSvc } from '../module/auth'; @@ -133,12 +133,14 @@ const MastheadToolbarContents = ({ consoleLinks, cv, isMastheadStacked }) => { 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] = React.useState(false); const [isUserDropdownOpen, setIsUserDropdownOpen] = React.useState(false); const [isKebabDropdownOpen, setIsKebabDropdownOpen] = React.useState(false); @@ -152,7 +154,6 @@ const MastheadToolbarContents = ({ consoleLinks, cv, isMastheadStacked }) => { const kebabMenuRef = React.useRef(null); const reportBugLink = cv?.data ? getReportBugLink(cv.data, t) : null; const userInactivityTimeout = React.useRef(null); - const username = user?.username ?? ''; const isKubeAdmin = username === 'kube:admin'; const drawerToggle = React.useCallback( @@ -574,7 +575,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 e604a16c47a..5a1c76b77f5 100644 --- a/frontend/public/locales/en/public.json +++ b/frontend/public/locales/en/public.json @@ -1814,6 +1814,7 @@ "Query result is a string, which cannot be graphed.": "Query result is a string, which cannot be graphed.", "The resulting dataset is too large to graph.": "The resulting dataset is too large to graph.", "Stacked": "Stacked", + "Unknown user": "Unknown user", "Just now": "Just now", "Disable": "Disable", "default": "default",