diff --git a/frontend/public/components/modals/impersonate-user-modal.tsx b/frontend/public/components/modals/impersonate-user-modal.tsx new file mode 100644 index 0000000000..66a3f435e4 --- /dev/null +++ b/frontend/public/components/modals/impersonate-user-modal.tsx @@ -0,0 +1,357 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Button, + Form, + FormGroup, + TextInput, + Alert, + AlertVariant, + Popover, + Select, + SelectList, + SelectOption, + MenuToggle, + MenuToggleElement, + Label, + Badge, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, +} from '@patternfly/react-core'; +import { HelpIcon } from '@patternfly/react-icons/dist/esm/icons/help-icon'; +import { TimesIcon } from '@patternfly/react-icons/dist/esm/icons/times-icon'; +import { Modal, ModalVariant } from '@patternfly/react-core/deprecated'; + +export interface ImpersonateUserModalProps { + isOpen: boolean; + onClose: () => void; + onImpersonate: (username: string, groups: string[]) => void; + prefilledUsername?: string; + isUsernameReadonly?: boolean; +} + +export const ImpersonateUserModal: React.FC = ({ + isOpen, + onClose, + onImpersonate, + prefilledUsername = '', + isUsernameReadonly = false, +}) => { + const { t } = useTranslation(); + const [username, setUsername] = React.useState(prefilledUsername); + const [selectedGroups, setSelectedGroups] = React.useState([]); + const [usernameError, setUsernameError] = React.useState(''); + const [isGroupSelectOpen, setIsGroupSelectOpen] = React.useState(false); + const [showAllGroups, setShowAllGroups] = React.useState(false); + const [groupSearchFilter, setGroupSearchFilter] = React.useState(''); + + // Mock group options - in real implementation, these would come from API + const availableGroups = React.useMemo( + () => [ + 'developers', + 'test-group-1', + 'test-group-2', + 'test-group-3', + 'admins', + 'monitoring', + 'operators', + 'viewers', + 'editors', + 'system:authenticated', + 'system:unauthenticated', + 'system:serviceaccounts', + 'system:serviceaccounts:kube-system', + 'system:serviceaccounts:openshift-kube-apiserver', + 'system:serviceaccounts:openshift-kube-controller-manager', + 'system:serviceaccounts:openshift-kube-scheduler', + 'system:serviceaccounts:openshift-kube-proxy', + 'system:serviceaccounts:openshift-kube-router', + ], + [], + ); + + const handleClose = React.useCallback(() => { + setUsername(prefilledUsername); + setSelectedGroups([]); + setUsernameError(''); + onClose(); + }, [prefilledUsername, onClose]); + + const handleUsernameChange = (value: string) => { + setUsername(value); + if (usernameError) { + setUsernameError(''); + } + }; + + const handleGroupSelect = (_event: React.MouseEvent | undefined, value: string | number) => { + const group = value as string; + if (selectedGroups.includes(group)) { + // Deselect if already selected + setSelectedGroups(selectedGroups.filter((g) => g !== group)); + } else { + // Add to selection + setSelectedGroups([...selectedGroups, group]); + } + // Keep dropdown open - don't call setIsGroupSelectOpen(false) + }; + + const handleGroupRemove = (groupToRemove: string) => { + setSelectedGroups(selectedGroups.filter((g) => g !== groupToRemove)); + }; + + const validateForm = (): boolean => { + if (!username.trim()) { + setUsernameError(t('public~Username is required')); + return false; + } + return true; + }; + + const handleImpersonate = () => { + if (validateForm()) { + handleClose(); + onImpersonate(username.trim(), selectedGroups); + } + }; + + // Reset form when modal opens with new prefilled username + React.useEffect(() => { + if (isOpen) { + setUsername(prefilledUsername); + setSelectedGroups([]); + setUsernameError(''); + setGroupSearchFilter(''); + } + }, [isOpen, prefilledUsername]); + + // Show first 2 groups, then +N badge (unless expanded) + const MAX_VISIBLE_CHIPS = 2; + const visibleGroups = showAllGroups ? selectedGroups : selectedGroups.slice(0, MAX_VISIBLE_CHIPS); + const remainingCount = selectedGroups.length - MAX_VISIBLE_CHIPS; + + // Filter groups based on search input + const filteredGroups = React.useMemo(() => { + if (!groupSearchFilter) { + return availableGroups; + } + return availableGroups.filter((group) => + group.toLowerCase().includes(groupSearchFilter.toLowerCase()), + ); + }, [groupSearchFilter, availableGroups]); + + const textInputGroupRef = React.useRef(null); + + const toggle = (toggleRef: React.Ref) => ( + setIsGroupSelectOpen(!isGroupSelectOpen)} + isExpanded={isGroupSelectOpen} + style={{ width: '100%' }} + > + + setIsGroupSelectOpen(!isGroupSelectOpen)} + onChange={(_event, value) => { + setGroupSearchFilter(value); + if (!isGroupSelectOpen) { + setIsGroupSelectOpen(true); + } + }} + autoComplete="off" + innerRef={textInputGroupRef} + placeholder={t('public~Enter groups')} + role="combobox" + isExpanded={isGroupSelectOpen} + aria-controls="select-typeahead-listbox" + /> + + {groupSearchFilter && ( + + )} + + + + ); + + return ( + + {t('public~Impersonate')} + , + , + ]} + > +
+ + + + {t('public~Username')} + + + + + } + fieldId="impersonate-username" + isRequired + > + handleUsernameChange(value)} + readOnly={isUsernameReadonly} + placeholder={t('public~Enter a username')} + data-test="username-input" + validated={usernameError ? 'error' : 'default'} + /> + {usernameError && ( +
+ + {usernameError} +
+ )} +
+ + + {t('public~Groups')} + + + + + } + fieldId="impersonate-groups" + > + + + {selectedGroups.length > 0 && ( +
+ {visibleGroups.map((group) => ( + + ))} + {!showAllGroups && remainingCount > 0 && ( + setShowAllGroups(true)} + > + +{remainingCount} + + )} +
+ )} +
+ +
+ ); +}; diff --git a/frontend/public/locales/en/public.json b/frontend/public/locales/en/public.json index 088b06321d..0ddb08fe23 100644 --- a/frontend/public/locales/en/public.json +++ b/frontend/public/locales/en/public.json @@ -937,6 +937,17 @@ "Total size": "Total size", "requestSize": "requestSize", "Expand": "Expand", + "Username is required": "Username is required", + "Enter groups": "Enter groups", + "Impersonate user": "Impersonate user", + "Impersonate": "Impersonate", + "Impersonating a user or group grants you their exact permissions.": "Impersonating a user or group grants you their exact permissions.", + "The name of the user to impersonate": "The name of the user to impersonate", + "More info for username field": "More info for username field", + "Enter a username": "Enter a username", + "The groups to impersonate the user with": "The groups to impersonate the user with", + "More info for groups field": "More info for groups field", + "No results found": "No results found", "Edit {{description}}": "Edit {{description}}", "Edit labels": "Edit labels", "Labels help you organize and select resources. Adding labels below will let you query for objects that have similar, overlapping or dissimilar labels.": "Labels help you organize and select resources. Adding labels below will let you query for objects that have similar, overlapping or dissimilar labels.",