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 c935378c0c2..21212e88316 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 @@ -250,7 +250,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( @@ -268,31 +268,84 @@ 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']); + }); + }); + + 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', []); + }); }); }); 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 1f2de4520e8..0b4802a986b 100644 --- a/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx +++ b/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx @@ -163,17 +163,164 @@ 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.findByTestId('create-group-option'); + 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', + ]); + }); + }); + + 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 902e7210d9a..e5e3d3c9a03 100644 --- a/frontend/public/components/modals/impersonate-user-modal.tsx +++ b/frontend/public/components/modals/impersonate-user-modal.tsx @@ -1,13 +1,13 @@ -import type { FC, Ref, MouseEvent } from 'react'; -import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import type { FC, KeyboardEvent, Ref, MouseEvent } from 'react'; +import { useState, useMemo, useCallback, useRef } from 'react'; import type { MenuToggleElement } from '@patternfly/react-core'; import { + Alert, + AlertVariant, Button, Form, FormGroup, TextInput, - Alert, - AlertVariant, Select, SelectList, SelectOption, @@ -35,6 +35,7 @@ import { FieldLevelHelp } from '../utils/field-level-help'; import { useK8sWatchResource } from '../utils/k8s-watch-hook'; const SELECT_ALL_KEY = '__select_all__'; +const CREATE_KEY = '__create__'; const MAX_VISIBLE_CHIPS = 5; export interface ImpersonateUserModalProps { @@ -47,6 +48,16 @@ export interface ImpersonateUserModalProps { export const ImpersonateUserModal: FC = ({ isOpen, + onClose, + ...rest +}) => ( + + {isOpen && } + +); + +/** Inner content component that mounts/unmounts with the modal, resetting state naturally. */ +const ImpersonateUserModalContent: FC> = ({ onClose, onImpersonate, prefilledUsername = '', @@ -70,20 +81,20 @@ 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]); const handleClose = useCallback(() => { - setUsername(prefilledUsername); - setSelectedGroups([]); - setUsernameError(''); onClose(); - }, [prefilledUsername, onClose]); + }, [onClose]); const handleUsernameChange = (value: string) => { setUsername(value); @@ -102,6 +113,31 @@ 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(), + ); + return !alreadyExists && !selectedGroups.includes(trimmed); + }, [groupSearchFilter, availableGroups, selectedGroups]); + + // Add a free-form group name + const handleCreateGroup = useCallback( + (groupName: string) => { + const trimmed = groupName.trim(); + if (trimmed && !selectedGroups.includes(trimmed)) { + setSelectedGroups([...selectedGroups, trimmed]); + setGroupSearchFilter(''); + } + }, + [selectedGroups], + ); + const handleSelectAll = useCallback(() => { if (selectedGroups.length === filteredGroups.length) { // If all filtered groups are selected, deselect all @@ -117,6 +153,12 @@ export const ImpersonateUserModal: FC = ({ (_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(); @@ -132,13 +174,24 @@ 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 + const handleGroupInputKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + const trimmed = groupSearchFilter.trim(); + if (trimmed && !selectedGroups.includes(trimmed)) { + handleCreateGroup(trimmed); + } + } + }; + const validateForm = (): boolean => { if (!username.trim()) { setUsernameError(t('Username is required')); @@ -154,25 +207,12 @@ export const ImpersonateUserModal: FC = ({ } }; - // Reset form when modal opens with new prefilled username - useEffect(() => { - if (isOpen) { - setUsername(prefilledUsername); - setSelectedGroups([]); - setUsernameError(''); - setGroupSearchFilter(''); - setShowAllGroups(false); - } - }, [isOpen, prefilledUsername]); - - // Reset showAllGroups when selected groups drop to or below MAX_VISIBLE_CHIPS - useEffect(() => { - if (selectedGroups.length <= MAX_VISIBLE_CHIPS) { - setShowAllGroups(false); - } - }, [selectedGroups.length]); + // Derive effective showAllGroups — auto-collapse when groups drop to/below threshold + const effectiveShowAllGroups = showAllGroups && selectedGroups.length > MAX_VISIBLE_CHIPS; - const visibleGroups = showAllGroups ? selectedGroups : selectedGroups.slice(0, MAX_VISIBLE_CHIPS); + const visibleGroups = effectiveShowAllGroups + ? selectedGroups + : selectedGroups.slice(0, MAX_VISIBLE_CHIPS); const remainingCount = selectedGroups.length - MAX_VISIBLE_CHIPS; // Check if all filtered groups are selected @@ -203,6 +243,7 @@ export const ImpersonateUserModal: FC = ({ setIsGroupSelectOpen(true); } }} + onKeyDown={handleGroupInputKeyDown} autoComplete="off" innerRef={textInputGroupRef} placeholder={t('Enter groups')} @@ -228,8 +269,63 @@ 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 ( - + <>
@@ -241,12 +337,6 @@ export const ImpersonateUserModal: FC = ({ )} /> - {groupsLoadError && ( - - {groupsLoadError.message} - - )} - @@ -304,32 +394,19 @@ export const ImpersonateUserModal: FC = ({ 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) => ( @@ -365,6 +442,6 @@ export const ImpersonateUserModal: FC = ({ {t('Cancel')} - + ); }; diff --git a/frontend/public/locales/en/public.json b/frontend/public/locales/en/public.json index 7078a415270..599eda8411c 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", @@ -1632,6 +1633,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",