diff --git a/frontend/public/components/modals/__tests__/impersonate-user-modal-integration.spec.tsx b/frontend/public/components/modals/__tests__/impersonate-user-modal-integration.spec.tsx
index 850531b327c..fea715635df 100644
--- a/frontend/public/components/modals/__tests__/impersonate-user-modal-integration.spec.tsx
+++ b/frontend/public/components/modals/__tests__/impersonate-user-modal-integration.spec.tsx
@@ -1,6 +1,6 @@
/**
* Integration tests for ImpersonateUserModal
- * Tests the modal integrated with Redux actions and state
+ * Tests the modal rendering and user interaction workflows
*/
import { render, screen, waitFor } from '@testing-library/react';
@@ -8,7 +8,6 @@ import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import { ProjectModel } from '@console/dynamic-plugin-sdk/src/models';
-import * as UIActions from '../../../actions/ui';
import type { GroupKind } from '../../../module/k8s';
import { useK8sWatchResource } from '../../utils/k8s-watch-hook';
import { ImpersonateUserModal } from '../impersonate-user-modal';
@@ -18,14 +17,52 @@ jest.mock('../../utils/k8s-watch-hook', () => ({
useK8sWatchResource: jest.fn(),
}));
-jest.mock('../../../actions/ui', () => ({
- startImpersonate: jest.fn(),
- stopImpersonate: jest.fn(),
-}));
-
+// Mock NsDropdown to avoid deep import chain (list-dropdown → useCreateNamespaceModal → CreateNamespaceModal → resource-link → k8s-models → plugins → loadSchema)
jest.mock('../../utils/list-dropdown', () => ({
- ...jest.requireActual('../../utils/list-dropdown'),
useProjectOrNamespaceModel: () => [ProjectModel, true],
+ NsDropdown: ({
+ selectedKey,
+ onChange,
+ dataTest,
+ }: {
+ selectedKey?: string;
+ dataTest?: string;
+ onChange: (key: string, kind?: string, resource?: { metadata: { name: string } }) => void;
+ }) => (
+
+ ),
+}));
+
+// Mock ResourceDropdown to avoid deep import chain (plugins.ts → loadSchema)
+jest.mock('@console/shared/src/components/dropdown/ResourceDropdown', () => ({
+ ResourceDropdown: ({
+ selectedKey,
+ onChange,
+ dataTest,
+ disabled,
+ placeholder,
+ }: {
+ selectedKey?: string | null;
+ dataTest?: string;
+ disabled?: boolean;
+ placeholder?: string;
+ onChange: (key: string, name?: string, resource?: { metadata: { name: string } }) => void;
+ }) => (
+
+ ),
}));
const mockGroups: GroupKind[] = [
@@ -45,27 +82,21 @@ const mockGroups: GroupKind[] = [
describe('ImpersonateUserModal Integration Tests', () => {
let mockStore: any;
- let mockStartImpersonate: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
(useK8sWatchResource as jest.Mock).mockReturnValue([mockGroups, true, null]);
- mockStartImpersonate = jest.fn();
- (UIActions.startImpersonate as jest.Mock).mockImplementation(mockStartImpersonate);
-
// Create a simple mock store
const reducer = (state = {}) => state;
mockStore = createStore(reducer);
});
- describe('Form submission with Redux integration', () => {
- it('should dispatch startImpersonate action with user only', async () => {
+ describe('Form submission', () => {
+ it('should call onImpersonate with user only', async () => {
const user = userEvent.setup();
const onClose = jest.fn();
- const onImpersonate = jest.fn((username) => {
- mockStartImpersonate('User', username);
- });
+ const onImpersonate = jest.fn();
render(
@@ -82,20 +113,13 @@ describe('ImpersonateUserModal Integration Tests', () => {
await waitFor(() => {
expect(onImpersonate).toHaveBeenCalledWith('testuser', [], 'User');
- expect(mockStartImpersonate).toHaveBeenCalledWith('User', 'testuser');
});
});
- it('should dispatch startImpersonate action with user and groups', async () => {
+ it('should call onImpersonate with user and groups', async () => {
const user = userEvent.setup();
const onClose = jest.fn();
- const onImpersonate = jest.fn((username, groups) => {
- if (groups.length > 0) {
- mockStartImpersonate('UserWithGroups', username, groups);
- } else {
- mockStartImpersonate('User', username);
- }
- });
+ const onImpersonate = jest.fn();
render(
@@ -120,9 +144,6 @@ describe('ImpersonateUserModal Integration Tests', () => {
await waitFor(() => {
expect(onImpersonate).toHaveBeenCalledWith('multiuser', ['developers'], 'User');
- expect(mockStartImpersonate).toHaveBeenCalledWith('UserWithGroups', 'multiuser', [
- 'developers',
- ]);
});
});
});
@@ -256,7 +277,7 @@ describe('ImpersonateUserModal Integration Tests', () => {
});
});
- it('should show no results when filter matches nothing', async () => {
+ it('should show "Create" option when filter matches nothing in available groups', async () => {
const user = userEvent.setup();
render(
@@ -274,31 +295,88 @@ describe('ImpersonateUserModal Integration Tests', () => {
// Type to filter with non-matching text
await user.type(groupsInput, 'nonexistent');
- expect(await screen.findByText('No results found')).toBeVisible();
+ // Should show "Create" option instead of just "No results found"
+ expect(await screen.findByText('Create "nonexistent"')).toBeVisible();
});
});
- describe('Error handling workflow', () => {
- it('should show error when groups fail to load', async () => {
- const error = new Error('Failed to fetch groups');
- (useK8sWatchResource as jest.Mock).mockReturnValue([[], false, error]);
+ describe('Direct Authentication / model-absent workflow', () => {
+ it('should allow group impersonation when Group model does not exist', async () => {
+ const error = new Error('Model does not exist');
+ (useK8sWatchResource as jest.Mock).mockReturnValue([[], true, error]);
+
+ const user = userEvent.setup();
+ const onImpersonate = jest.fn();
render(
-
+
,
);
- expect(await screen.findByText('Failed to load groups')).toBeVisible();
+ // Should NOT show error alert
+ expect(screen.queryByText('Failed to load groups')).not.toBeInTheDocument();
+
+ // Should show helper text for manual entry
+ expect(
+ screen.getByText('Type group names manually. Press Enter to add each group.'),
+ ).toBeInTheDocument();
+
+ // Enter username
+ const usernameInput = screen.getByTestId('username-input');
+ await user.clear(usernameInput);
+ await user.type(usernameInput, 'oidc-user');
+
+ // Enter groups via free-form
+ const groupInput = screen.getByPlaceholderText('Enter groups');
+ await user.click(groupInput);
+ await user.type(groupInput, 'oidc-admins{Enter}');
+ await user.type(groupInput, 'oidc-devs{Enter}');
+
+ // Both groups should appear as chips
+ await waitFor(() => {
+ expect(screen.getByText('oidc-admins')).toBeInTheDocument();
+ expect(screen.getByText('oidc-devs')).toBeInTheDocument();
+ });
+
+ // Submit
+ const submitButton = screen.getByTestId('impersonate-button');
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(onImpersonate).toHaveBeenCalledWith(
+ 'oidc-user',
+ ['oidc-admins', 'oidc-devs'],
+ 'User',
+ );
+ });
+ });
+
+ it('should still allow impersonation without groups when model is absent', async () => {
+ const error = new Error('Model does not exist');
+ (useK8sWatchResource as jest.Mock).mockReturnValue([[], true, error]);
- // Should still allow impersonation without groups
const ue = userEvent.setup();
+ const onImpersonate = jest.fn();
+
+ render(
+
+
+ ,
+ );
+
const usernameInput = screen.getByTestId('username-input');
await ue.clear(usernameInput);
await ue.type(usernameInput, 'erroruser');
const submitButton = screen.getByTestId('impersonate-button');
expect(submitButton).not.toBeDisabled();
+
+ await ue.click(submitButton);
+
+ await waitFor(() => {
+ expect(onImpersonate).toHaveBeenCalledWith('erroruser', [], 'User');
+ });
});
});
diff --git a/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx b/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx
index c3fff12a8c7..ae71b5aefcd 100644
--- a/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx
+++ b/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx
@@ -233,17 +233,262 @@ describe('ImpersonateUserModal', () => {
expect(screen.getByPlaceholderText('Enter groups')).toBeInTheDocument();
});
- it('should show error alert when groups fail to load', () => {
- const error = new Error('Failed to load groups');
+ it('should gracefully handle group load errors without showing error alert', () => {
+ const error = new Error('Model does not exist');
(useK8sWatchResource as jest.Mock).mockReturnValue([[], false, error]);
render(
,
);
- // Check for alert with danger variant
- const alerts = screen.getAllByText('Failed to load groups');
- expect(alerts.length).toBeGreaterThan(0);
+ // Should NOT show error alert — free-form entry is available instead
+ expect(screen.queryByText('Failed to load groups')).not.toBeInTheDocument();
+ // Should show helper text for manual entry
+ expect(
+ screen.getByText('Type group names manually. Press Enter to add each group.'),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe('Free-form Group Entry', () => {
+ it('should add a group on Enter key press', async () => {
+ const user = userEvent.setup();
+ // Groups model unavailable
+ const error = new Error('Model does not exist');
+ (useK8sWatchResource as jest.Mock).mockReturnValue([[], true, error]);
+
+ render(
+ ,
+ );
+
+ const groupInput = screen.getByPlaceholderText('Enter groups');
+ await user.click(groupInput);
+ await user.type(groupInput, 'my-custom-group{Enter}');
+
+ // Group chip should appear
+ await waitFor(() => {
+ expect(screen.getByText('my-custom-group')).toBeInTheDocument();
+ });
+ });
+
+ it('should add multiple free-form groups', async () => {
+ const user = userEvent.setup();
+ const error = new Error('Model does not exist');
+ (useK8sWatchResource as jest.Mock).mockReturnValue([[], true, error]);
+
+ render(
+ ,
+ );
+
+ const groupInput = screen.getByPlaceholderText('Enter groups');
+ await user.click(groupInput);
+ await user.type(groupInput, 'group-a{Enter}');
+ await user.type(groupInput, 'group-b{Enter}');
+
+ await waitFor(() => {
+ expect(screen.getByText('group-a')).toBeInTheDocument();
+ expect(screen.getByText('group-b')).toBeInTheDocument();
+ });
+ });
+
+ it('should not add duplicate groups on Enter', async () => {
+ const user = userEvent.setup();
+ const error = new Error('Model does not exist');
+ (useK8sWatchResource as jest.Mock).mockReturnValue([[], true, error]);
+
+ render(
+ ,
+ );
+
+ const groupInput = screen.getByPlaceholderText('Enter groups');
+ await user.click(groupInput);
+ await user.type(groupInput, 'my-group{Enter}');
+ await user.type(groupInput, 'my-group{Enter}');
+
+ await waitFor(() => {
+ // eslint-disable-next-line testing-library/no-node-access -- checking chip count
+ const chips = document.querySelectorAll('.pf-v6-c-label');
+ expect(chips.length).toBe(1);
+ });
+ });
+
+ it('should show "Create" option in dropdown for new group name', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ const groupInput = screen.getByPlaceholderText('Enter groups');
+ await user.click(groupInput);
+ await user.type(groupInput, 'new-custom-group');
+
+ await waitFor(() => {
+ expect(screen.getByText('Create "new-custom-group"')).toBeInTheDocument();
+ });
+ });
+
+ it('should add group via "Create" option click', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ const groupInput = screen.getByPlaceholderText('Enter groups');
+ await user.click(groupInput);
+ await user.type(groupInput, 'new-custom-group');
+
+ const createOption = await screen.findByText('Create "new-custom-group"');
+ await user.click(createOption);
+
+ await waitFor(() => {
+ // eslint-disable-next-line testing-library/no-node-access -- checking chip appearance
+ const chips = document.querySelectorAll('.pf-v6-c-label');
+ expect(chips.length).toBe(1);
+ });
+ });
+
+ it('should submit free-form groups with onImpersonate', async () => {
+ const user = userEvent.setup();
+ const error = new Error('Model does not exist');
+ (useK8sWatchResource as jest.Mock).mockReturnValue([[], true, error]);
+
+ render(
+ ,
+ );
+
+ const usernameInput = screen.getByTestId('username-input');
+ await user.clear(usernameInput);
+ await user.type(usernameInput, 'testuser');
+
+ const groupInput = screen.getByPlaceholderText('Enter groups');
+ await user.click(groupInput);
+ await user.type(groupInput, 'oidc-admins{Enter}');
+ await user.type(groupInput, 'oidc-developers{Enter}');
+
+ const submitButton = screen.getByTestId('impersonate-button');
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(mockOnImpersonate).toHaveBeenCalledWith(
+ 'testuser',
+ ['oidc-admins', 'oidc-developers'],
+ 'User',
+ );
+ });
+ });
+
+ it('should not add a group via Enter when it case-insensitively matches an available group', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ const usernameInput = screen.getByTestId('username-input');
+ await user.clear(usernameInput);
+ await user.type(usernameInput, 'testuser');
+
+ // Type "Admins" (differs in case from available "admins" group)
+ const groupInput = screen.getByPlaceholderText('Enter groups');
+ await user.click(groupInput);
+ await user.type(groupInput, 'Admins{Enter}');
+
+ // Submit — "Admins" should NOT have been added as a free-form group
+ const submitButton = screen.getByTestId('impersonate-button');
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(mockOnImpersonate).toHaveBeenCalledWith('testuser', [], 'User');
+ });
+ });
+
+ it('should allow Select all to toggle correctly after adding a freeform group', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ // First select all API groups (known working pattern)
+ const groupInput = screen.getByPlaceholderText('Enter groups');
+ await user.click(groupInput);
+ expect(await screen.findByText('Select all')).toBeVisible();
+ await user.click(screen.getByText('Select all'));
+
+ // Add a freeform group via Create option
+ await user.type(groupInput, 'custom-freeform');
+ const createOption = await screen.findByText('Create "custom-freeform"');
+ await user.click(createOption);
+
+ // Verify freeform group chip appears
+ await waitFor(() => {
+ expect(screen.getByText('custom-freeform')).toBeInTheDocument();
+ });
+
+ // Submit and check all groups (freeform + API) are included
+ const usernameInput = screen.getByTestId('username-input');
+ await user.clear(usernameInput);
+ await user.type(usernameInput, 'testuser');
+ await user.click(screen.getByTestId('impersonate-button'));
+
+ await waitFor(() => {
+ expect(mockOnImpersonate).toHaveBeenCalledWith(
+ 'testuser',
+ expect.arrayContaining(['custom-freeform', 'admins', 'developers', 'testers']),
+ 'User',
+ );
+ expect(mockOnImpersonate.mock.calls[0][1]).toHaveLength(4);
+ });
+ });
+
+ it('should keep freeform group when removing API groups via chip close buttons', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ // Select an API group
+ const groupInput = screen.getByPlaceholderText('Enter groups');
+ await user.click(groupInput);
+ await user.click(await screen.findByText('admins'));
+
+ // Add a freeform group via Create option
+ await user.type(groupInput, 'custom-freeform');
+ const createOption = await screen.findByText('Create "custom-freeform"');
+ await user.click(createOption);
+
+ // Verify both chips exist
+ await waitFor(() => {
+ expect(screen.getByText('custom-freeform')).toBeInTheDocument();
+ });
+
+ // Remove the API group via chip close button
+ await user.click(screen.getByRole('button', { name: /close.*admins/i }));
+
+ // Submit — should only have freeform group
+ const usernameInput = screen.getByTestId('username-input');
+ await user.clear(usernameInput);
+ await user.type(usernameInput, 'testuser');
+ await user.click(screen.getByTestId('impersonate-button'));
+
+ await waitFor(() => {
+ expect(mockOnImpersonate).toHaveBeenCalledWith('testuser', ['custom-freeform'], 'User');
+ });
+ });
+
+ it('should show hint text when model unavailable and no text typed', async () => {
+ const user = userEvent.setup();
+ const error = new Error('Model does not exist');
+ (useK8sWatchResource as jest.Mock).mockReturnValue([[], true, error]);
+
+ render(
+ ,
+ );
+
+ const groupInput = screen.getByPlaceholderText('Enter groups');
+ await user.click(groupInput);
+
+ await waitFor(() => {
+ expect(screen.getByText('Type a group name and press Enter')).toBeInTheDocument();
+ });
});
});
diff --git a/frontend/public/components/modals/impersonate-user-modal.tsx b/frontend/public/components/modals/impersonate-user-modal.tsx
index c79000bc435..3080d749134 100644
--- a/frontend/public/components/modals/impersonate-user-modal.tsx
+++ b/frontend/public/components/modals/impersonate-user-modal.tsx
@@ -1,4 +1,4 @@
-import type { FC, Ref, MouseEvent } from 'react';
+import type { FC, KeyboardEvent, Ref, MouseEvent } from 'react';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import type { MenuToggleElement } from '@patternfly/react-core';
import {
@@ -38,6 +38,7 @@ import { useK8sWatchResource } from '../utils/k8s-watch-hook';
import { NsDropdown, useProjectOrNamespaceModel } from '../utils/list-dropdown';
const SELECT_ALL_KEY = '__select_all__';
+const CREATE_KEY = '__create__';
const MAX_VISIBLE_CHIPS = 5;
type ImpersonateSubjectKind = 'User' | 'ServiceAccount';
@@ -94,13 +95,16 @@ export const ImpersonateUserModal: FC = ({
isList: true,
});
+ // Whether groups are available from the API (model exists and loaded successfully)
+ const groupsAvailable = groupsLoaded && !groupsLoadError;
+
// Extract group names from the API response
const availableGroups = useMemo(() => {
- if (!groupsLoaded || groupsLoadError) {
+ if (!groupsAvailable) {
return [];
}
return groups.map((group) => group.metadata.name).sort();
- }, [groups, groupsLoaded, groupsLoadError]);
+ }, [groups, groupsAvailable]);
// Fetch available service accounts from the selected namespace.
// Pass `null` until a namespace is chosen to avoid a cluster-wide watch.
@@ -160,21 +164,66 @@ export const ImpersonateUserModal: FC = ({
);
}, [groupSearchFilter, availableGroups]);
+ // Check if typed text can be created as a new group entry
+ const isCreatableGroup = useMemo(() => {
+ const trimmed = groupSearchFilter.trim();
+ if (!trimmed) {
+ return false;
+ }
+ // Don't show "Create" if it exactly matches an existing available group or is already selected
+ const alreadyExists = availableGroups.some((g) => g.toLowerCase() === trimmed.toLowerCase());
+ const alreadySelected = selectedGroups.some((g) => g.toLowerCase() === trimmed.toLowerCase());
+ return !alreadyExists && !alreadySelected;
+ }, [groupSearchFilter, availableGroups, selectedGroups]);
+
+ // Add a free-form group name (case-insensitive duplicate check)
+ const handleCreateGroup = useCallback(
+ (groupName: string) => {
+ const trimmed = groupName.trim();
+ if (!trimmed) {
+ return;
+ }
+ const lowerTrimmed = trimmed.toLowerCase();
+ const alreadyExists =
+ availableGroups.some((g) => g.toLowerCase() === lowerTrimmed) ||
+ selectedGroups.some((g) => g.toLowerCase() === lowerTrimmed);
+ if (!alreadyExists) {
+ setSelectedGroups([...selectedGroups, trimmed]);
+ setGroupSearchFilter('');
+ }
+ },
+ [selectedGroups, availableGroups],
+ );
+
+ // Check if all filtered groups are selected (needed before handleSelectAll)
+ const areAllFilteredGroupsSelected = useMemo(() => {
+ if (filteredGroups.length === 0) {
+ return false;
+ }
+ return filteredGroups.every((group) => selectedGroups.includes(group));
+ }, [filteredGroups, selectedGroups]);
+
const handleSelectAll = useCallback(() => {
- if (selectedGroups.length === filteredGroups.length) {
- // If all filtered groups are selected, deselect all
+ if (areAllFilteredGroupsSelected) {
+ // Deselect all filtered groups (preserve freeform groups not in filteredGroups)
setSelectedGroups(selectedGroups.filter((g) => !filteredGroups.includes(g)));
} else {
- // Select all filtered groups (merge with existing selections from other filters)
+ // Select all filtered groups (merge with existing selections including freeform)
const newSelections = new Set([...selectedGroups, ...filteredGroups]);
setSelectedGroups(Array.from(newSelections));
}
- }, [selectedGroups, filteredGroups]);
+ }, [selectedGroups, filteredGroups, areAllFilteredGroupsSelected]);
const handleGroupSelect = useCallback(
(_event: MouseEvent | undefined, value: string | number) => {
const group = value as string;
+ // Handle "Create" option
+ if (group === CREATE_KEY) {
+ handleCreateGroup(groupSearchFilter);
+ return;
+ }
+
// Handle "Select all" option
if (group === SELECT_ALL_KEY) {
handleSelectAll();
@@ -190,13 +239,23 @@ export const ImpersonateUserModal: FC = ({
}
// Keep dropdown open - don't call setIsGroupSelectOpen(false)
},
- [selectedGroups, handleSelectAll],
+ [selectedGroups, handleSelectAll, handleCreateGroup, groupSearchFilter],
);
const handleGroupRemove = (groupToRemove: string) => {
setSelectedGroups(selectedGroups.filter((g) => g !== groupToRemove));
};
+ // Handle Enter key to add free-form group (uses same predicate as Create option)
+ const handleGroupInputKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ if (isCreatableGroup) {
+ handleCreateGroup(groupSearchFilter);
+ }
+ }
+ };
+
const validateForm = (): boolean => {
setUsernameError('');
setServiceAccountNamespaceError('');
@@ -265,14 +324,6 @@ export const ImpersonateUserModal: FC = ({
const visibleGroups = showAllGroups ? selectedGroups : selectedGroups.slice(0, MAX_VISIBLE_CHIPS);
const remainingCount = selectedGroups.length - MAX_VISIBLE_CHIPS;
- // Check if all filtered groups are selected
- const areAllFilteredGroupsSelected = useMemo(() => {
- if (filteredGroups.length === 0) {
- return false;
- }
- return filteredGroups.every((group) => selectedGroups.includes(group));
- }, [filteredGroups, selectedGroups]);
-
const textInputGroupRef = useRef(null);
const isImpersonateDisabled =
@@ -298,12 +349,14 @@ export const ImpersonateUserModal: FC = ({
setIsGroupSelectOpen(true);
}
}}
+ onKeyDown={handleGroupInputKeyDown}
autoComplete="off"
innerRef={textInputGroupRef}
placeholder={t('Enter groups')}
role="combobox"
isExpanded={isGroupSelectOpen}
aria-controls="impersonate-groups-listbox"
+ aria-describedby="groups-help-text"
/>
{groupSearchFilter && (
@@ -323,6 +376,61 @@ export const ImpersonateUserModal: FC = ({
);
+ // Build the dropdown options list
+ const renderSelectOptions = () => {
+ const options: JSX.Element[] = [];
+
+ // Show "Select all" only when API groups are available and there are filtered results
+ if (filteredGroups.length > 0) {
+ options.push(
+
+ {t('Select all')}
+ ,
+ );
+
+ filteredGroups.forEach((group) => {
+ options.push(
+
+ {group}
+ ,
+ );
+ });
+ }
+
+ // Show "Create" option for free-form entry when typed text is new
+ if (isCreatableGroup) {
+ options.push(
+
+ {t('Create "{{groupName}}"', { groupName: groupSearchFilter.trim() })}
+ ,
+ );
+ }
+
+ // Show hint when no options and no creatable text
+ if (options.length === 0) {
+ if (groupSearchFilter.trim()) {
+ // Text is typed but it's already selected
+ options.push(
+
+ {t('Group already added')}
+ ,
+ );
+ } else {
+ options.push(
+
+ {groupsAvailable ? t('No results found') : t('Type a group name and press Enter')}
+ ,
+ );
+ }
+ }
+
+ return options;
+ };
+
return (
@@ -365,12 +473,6 @@ export const ImpersonateUserModal: FC = ({
/>
- {groupsLoadError && (
-
- {groupsLoadError.message}
-
- )}
-
{impersonateKind === 'User' ? (
= ({
aria-label={t('Select groups to impersonate')}
aria-describedby="groups-help-text"
>
-
- {filteredGroups.length === 0 ? (
- {t('No results found')}
- ) : (
- <>
-
- {t('Select all')}
-
- {filteredGroups.map((group) => (
-
- {group}
-
- ))}
- >
- )}
-
+ {renderSelectOptions()}
+ {!groupsAvailable && (
+
+
+
+ {t('Type group names manually. Press Enter to add each group.')}
+
+
+
+ )}
+
{selectedGroups.length > 0 && (
{visibleGroups.map((group) => (
diff --git a/frontend/public/locales/en/public.json b/frontend/public/locales/en/public.json
index 550451ba1a0..c194121a7eb 100644
--- a/frontend/public/locales/en/public.json
+++ b/frontend/public/locales/en/public.json
@@ -443,6 +443,7 @@
"CrashLoopBackOff indicates that the application in the container is repeatedly failing to start.": "CrashLoopBackOff indicates that the application in the container is repeatedly failing to start.",
"CRD versions": "CRD versions",
"Create": "Create",
+ "Create \"{{groupName}}\"": "Create \"{{groupName}}\"",
"Create {{formType}} secret": "Create {{formType}} secret",
"Create {{label}}": "Create {{label}}",
"Create {{objLabel}}": "Create {{objLabel}}",
@@ -666,7 +667,6 @@
"Extra scopes": "Extra scopes",
"Failed": "Failed",
"Failed pods": "Failed pods",
- "Failed to load groups": "Failed to load groups",
"Failed to parse YAML sample": "Failed to parse YAML sample",
"Failing": "Failing",
"false": "false",
@@ -727,6 +727,7 @@
"greater than pod_one": "greater than pod",
"greater than pod_other": "greater than pods",
"Group": "Group",
+ "Group already added": "Group already added",
"Group by": "Group by",
"Group details": "Group details",
"Group interval": "Group interval",
@@ -1641,6 +1642,8 @@
"Try the OpenShift Pipelines tutorial": "Try the OpenShift Pipelines tutorial",
"Try the sample AI Chatbot Helm chart": "Try the sample AI Chatbot Helm chart",
"Type": "Type",
+ "Type a group name and press Enter": "Type a group name and press Enter",
+ "Type group names manually. Press Enter to add each group.": "Type group names manually. Press Enter to add each group.",
"Unable to load VolumeAttributesClass resources": "Unable to load VolumeAttributesClass resources",
"Unable to resolve": "Unable to resolve",
"Unable to Rollback": "Unable to Rollback",