Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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);
Expand All @@ -21,6 +25,7 @@ export const removeAdmissionWebhookWarning = (id) =>
action(ActionType.RemoveAdmissionWebhookWarning, { id });
const coreActions = {
setUser,
setUserResource,
beginImpersonate,
endImpersonate,
setAdmissionWebhookWarning,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ActionType, CoreAction } from '../actions/core';
export const coreReducer = (
state: CoreState = {
user: {},
userResource: null,
admissionWebhookWarnings: ImmutableMap<string, AdmissionWebhookWarning>(),
},
action: CoreAction,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, AdmissionWebhookWarning>;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, any>;
Expand All @@ -16,6 +17,7 @@ export type ImpersonateKind = {

export type CoreState = {
user?: UserInfo;
userResource?: UserKind;
impersonate?: ImpersonateKind;
admissionWebhookWarnings?: ImmutableMap<string, AdmissionWebhookWarning>;
};
Expand Down
114 changes: 114 additions & 0 deletions frontend/packages/console-shared/src/hooks/__tests__/useUser.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
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(),
}));

jest.mock('@console/dynamic-plugin-sdk', () => ({
...jest.requireActual('@console/dynamic-plugin-sdk'),
getUser: jest.fn(),
getUserResource: jest.fn(),
setUserResource: jest.fn(),
}));

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: '[email protected]', 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('[email protected]');
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: '[email protected]', 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('[email protected]'); // Should fallback to username
expect(result.current.fullName).toBeUndefined();
});

it('should dispatch setUserResource when user resource is loaded', () => {
const mockUser = { username: '[email protected]' };
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: ' [email protected] ' };
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
});
});
1 change: 1 addition & 0 deletions frontend/packages/console-shared/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ export * from './usePrometheusGate';
export * from './useCopyCodeModal';
export * from './useCopyLoginCommands';
export * from './useQuickStartContext';
export * from './useUser';
6 changes: 3 additions & 3 deletions frontend/packages/console-shared/src/hooks/useTelemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -81,7 +80,8 @@ export const useTelemetry = () => {
true,
);

const [userResource, userResourceIsLoaded] = useK8sGet<UserKind>(UserModel, '~');
// Use centralized user data instead of fetching directly
const { userResource, userResourceLoaded: userResourceIsLoaded } = useUser();

const [extensions] = useResolvedExtensions<TelemetryListener>(isTelemetryListener);

Expand Down
67 changes: 67 additions & 0 deletions frontend/packages/console-shared/src/hooks/useUser.ts
Original file line number Diff line number Diff line change
@@ -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<UserKind>(
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(),
};
};
13 changes: 8 additions & 5 deletions frontend/public/components/masthead/masthead-toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,15 @@ import {
useCopyLoginCommands,
useFlag,
useTelemetry,
useUser,
YellowExclamationTriangleIcon,
} from '@console/shared';
import { formatNamespacedRouteForResource } from '@console/shared/src/utils';
import { ExternalLinkButton } from '@console/shared/src/components/links/ExternalLinkButton';
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, useActivePerspective } from '@console/dynamic-plugin-sdk';
import { useActivePerspective } from '@console/dynamic-plugin-sdk';
import * as UIActions from '../../actions/ui';
import { flagPending, featureReducerName } from '../../reducers/features';
import { authSvc } from '../../module/auth';
Expand Down Expand Up @@ -162,12 +163,15 @@ const MastheadToolbarContents: React.FCC<MastheadToolbarContentsProps> = ({
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);
Expand All @@ -181,7 +185,6 @@ const MastheadToolbarContents: React.FCC<MastheadToolbarContentsProps> = ({
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()), [
Expand Down Expand Up @@ -614,7 +617,7 @@ const MastheadToolbarContents: React.FCC<MastheadToolbarContentsProps> = ({

const userToggle = (
<span className="co-username" data-test="username">
{authEnabledFlag ? username : t('public~Auth disabled')}
{authEnabledFlag ? displayName : t('public~Auth disabled')}
</span>
);

Expand Down
1 change: 1 addition & 0 deletions frontend/public/locales/en/public.json
Original file line number Diff line number Diff line change
Expand Up @@ -1748,6 +1748,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",
Expand Down