Skip to content

Commit 2cadf8d

Browse files
committed
CONSOLE-4782: Group Impersonation Modal
Add modal component for multi-group user impersonation: - TypeScript React component with group selection - Multi-select group input with search/filter - Chip display for selected groups - Form validation for username - Internationalization support
1 parent d62c016 commit 2cadf8d

File tree

2 files changed

+368
-0
lines changed

2 files changed

+368
-0
lines changed
Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
import * as React from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
import {
4+
Button,
5+
Form,
6+
FormGroup,
7+
TextInput,
8+
Alert,
9+
AlertVariant,
10+
Popover,
11+
Select,
12+
SelectList,
13+
SelectOption,
14+
MenuToggle,
15+
MenuToggleElement,
16+
Label,
17+
Badge,
18+
TextInputGroup,
19+
TextInputGroupMain,
20+
TextInputGroupUtilities,
21+
} from '@patternfly/react-core';
22+
import { HelpIcon } from '@patternfly/react-icons/dist/esm/icons/help-icon';
23+
import { TimesIcon } from '@patternfly/react-icons/dist/esm/icons/times-icon';
24+
import { Modal, ModalVariant } from '@patternfly/react-core/deprecated';
25+
26+
export interface ImpersonateUserModalProps {
27+
isOpen: boolean;
28+
onClose: () => void;
29+
onImpersonate: (username: string, groups: string[]) => void;
30+
prefilledUsername?: string;
31+
isUsernameReadonly?: boolean;
32+
}
33+
34+
export const ImpersonateUserModal: React.FC<ImpersonateUserModalProps> = ({
35+
isOpen,
36+
onClose,
37+
onImpersonate,
38+
prefilledUsername = '',
39+
isUsernameReadonly = false,
40+
}) => {
41+
const { t } = useTranslation();
42+
const [username, setUsername] = React.useState(prefilledUsername);
43+
const [selectedGroups, setSelectedGroups] = React.useState<string[]>([]);
44+
const [usernameError, setUsernameError] = React.useState('');
45+
const [isGroupSelectOpen, setIsGroupSelectOpen] = React.useState(false);
46+
const [showAllGroups, setShowAllGroups] = React.useState(false);
47+
const [groupSearchFilter, setGroupSearchFilter] = React.useState('');
48+
49+
// Mock group options - in real implementation, these would come from API
50+
const availableGroups = React.useMemo(
51+
() => [
52+
'developers',
53+
'test-group-1',
54+
'test-group-2',
55+
'test-group-3',
56+
'admins',
57+
'monitoring',
58+
'operators',
59+
'viewers',
60+
'editors',
61+
'system:authenticated',
62+
'system:unauthenticated',
63+
'system:serviceaccounts',
64+
'system:serviceaccounts:kube-system',
65+
'system:serviceaccounts:openshift-kube-apiserver',
66+
'system:serviceaccounts:openshift-kube-controller-manager',
67+
'system:serviceaccounts:openshift-kube-scheduler',
68+
'system:serviceaccounts:openshift-kube-proxy',
69+
'system:serviceaccounts:openshift-kube-router',
70+
],
71+
[],
72+
);
73+
74+
const handleClose = React.useCallback(() => {
75+
setUsername(prefilledUsername);
76+
setSelectedGroups([]);
77+
setUsernameError('');
78+
onClose();
79+
}, [prefilledUsername, onClose]);
80+
81+
const handleUsernameChange = (value: string) => {
82+
setUsername(value);
83+
if (usernameError) {
84+
setUsernameError('');
85+
}
86+
};
87+
88+
const handleGroupSelect = (_event: React.MouseEvent | undefined, value: string | number) => {
89+
const group = value as string;
90+
if (selectedGroups.includes(group)) {
91+
// Deselect if already selected
92+
setSelectedGroups(selectedGroups.filter((g) => g !== group));
93+
} else {
94+
// Add to selection
95+
setSelectedGroups([...selectedGroups, group]);
96+
}
97+
// Keep dropdown open - don't call setIsGroupSelectOpen(false)
98+
};
99+
100+
const handleGroupRemove = (groupToRemove: string) => {
101+
setSelectedGroups(selectedGroups.filter((g) => g !== groupToRemove));
102+
};
103+
104+
const validateForm = (): boolean => {
105+
if (!username.trim()) {
106+
setUsernameError(t('public~Username is required'));
107+
return false;
108+
}
109+
return true;
110+
};
111+
112+
const handleImpersonate = () => {
113+
if (validateForm()) {
114+
handleClose();
115+
onImpersonate(username.trim(), selectedGroups);
116+
}
117+
};
118+
119+
// Reset form when modal opens with new prefilled username
120+
React.useEffect(() => {
121+
if (isOpen) {
122+
setUsername(prefilledUsername);
123+
setSelectedGroups([]);
124+
setUsernameError('');
125+
setGroupSearchFilter('');
126+
}
127+
}, [isOpen, prefilledUsername]);
128+
129+
// Show first 2 groups, then +N badge (unless expanded)
130+
const MAX_VISIBLE_CHIPS = 2;
131+
const visibleGroups = showAllGroups ? selectedGroups : selectedGroups.slice(0, MAX_VISIBLE_CHIPS);
132+
const remainingCount = selectedGroups.length - MAX_VISIBLE_CHIPS;
133+
134+
// Filter groups based on search input
135+
const filteredGroups = React.useMemo(() => {
136+
if (!groupSearchFilter) {
137+
return availableGroups;
138+
}
139+
return availableGroups.filter((group) =>
140+
group.toLowerCase().includes(groupSearchFilter.toLowerCase()),
141+
);
142+
}, [groupSearchFilter, availableGroups]);
143+
144+
const textInputGroupRef = React.useRef<HTMLDivElement>(null);
145+
146+
const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
147+
<MenuToggle
148+
ref={toggleRef}
149+
variant="typeahead"
150+
onClick={() => setIsGroupSelectOpen(!isGroupSelectOpen)}
151+
isExpanded={isGroupSelectOpen}
152+
style={{ width: '100%' }}
153+
>
154+
<TextInputGroup isPlain>
155+
<TextInputGroupMain
156+
value={groupSearchFilter}
157+
onClick={() => setIsGroupSelectOpen(!isGroupSelectOpen)}
158+
onChange={(_event, value) => {
159+
setGroupSearchFilter(value);
160+
if (!isGroupSelectOpen) {
161+
setIsGroupSelectOpen(true);
162+
}
163+
}}
164+
autoComplete="off"
165+
innerRef={textInputGroupRef}
166+
placeholder={t('public~Enter groups')}
167+
role="combobox"
168+
isExpanded={isGroupSelectOpen}
169+
aria-controls="select-typeahead-listbox"
170+
/>
171+
<TextInputGroupUtilities>
172+
{groupSearchFilter && (
173+
<Button
174+
variant="plain"
175+
onClick={() => {
176+
setGroupSearchFilter('');
177+
textInputGroupRef?.current?.focus();
178+
}}
179+
aria-label="Clear input"
180+
>
181+
<TimesIcon aria-hidden />
182+
</Button>
183+
)}
184+
</TextInputGroupUtilities>
185+
</TextInputGroup>
186+
</MenuToggle>
187+
);
188+
189+
return (
190+
<Modal
191+
variant={ModalVariant.small}
192+
title={t('public~Impersonate user')}
193+
isOpen={isOpen}
194+
onClose={handleClose}
195+
actions={[
196+
<Button
197+
key="impersonate"
198+
variant="primary"
199+
onClick={handleImpersonate}
200+
data-test="impersonate-button"
201+
>
202+
{t('public~Impersonate')}
203+
</Button>,
204+
<Button key="cancel" variant="link" onClick={handleClose} data-test="cancel-button">
205+
{t('public~Cancel')}
206+
</Button>,
207+
]}
208+
>
209+
<Form>
210+
<Alert
211+
variant={AlertVariant.warning}
212+
isInline
213+
title={t('public~Impersonating a user or group grants you their exact permissions.')}
214+
/>
215+
216+
<FormGroup
217+
label={
218+
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}>
219+
{t('public~Username')}
220+
<Popover
221+
headerContent={t('public~Username')}
222+
bodyContent={t('public~The name of the user to impersonate')}
223+
>
224+
<button
225+
type="button"
226+
aria-label={t('public~More info for username field')}
227+
onClick={(e) => e.preventDefault()}
228+
style={{
229+
background: 'none',
230+
border: 'none',
231+
padding: 0,
232+
cursor: 'pointer',
233+
color: '#6A6E73',
234+
display: 'inline-flex',
235+
alignItems: 'center',
236+
lineHeight: 1,
237+
}}
238+
>
239+
<HelpIcon style={{ fontSize: '14px' }} />
240+
</button>
241+
</Popover>
242+
</span>
243+
}
244+
fieldId="impersonate-username"
245+
isRequired
246+
>
247+
<TextInput
248+
id="impersonate-username"
249+
name="username"
250+
value={username}
251+
onChange={(_event, value) => handleUsernameChange(value)}
252+
readOnly={isUsernameReadonly}
253+
placeholder={t('public~Enter a username')}
254+
data-test="username-input"
255+
validated={usernameError ? 'error' : 'default'}
256+
/>
257+
{usernameError && (
258+
<div style={{ color: '#C9190B', fontSize: '14px', marginTop: '8px' }}>
259+
<span style={{ marginRight: '4px' }}></span>
260+
{usernameError}
261+
</div>
262+
)}
263+
</FormGroup>
264+
265+
<FormGroup
266+
label={
267+
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}>
268+
{t('public~Groups')}
269+
<Popover
270+
headerContent={t('public~Groups')}
271+
bodyContent={t('public~The groups to impersonate the user with')}
272+
>
273+
<button
274+
type="button"
275+
aria-label={t('public~More info for groups field')}
276+
onClick={(e) => e.preventDefault()}
277+
style={{
278+
background: 'none',
279+
border: 'none',
280+
padding: 0,
281+
cursor: 'pointer',
282+
color: '#6A6E73',
283+
display: 'inline-flex',
284+
alignItems: 'center',
285+
lineHeight: 1,
286+
}}
287+
>
288+
<HelpIcon style={{ fontSize: '14px' }} />
289+
</button>
290+
</Popover>
291+
</span>
292+
}
293+
fieldId="impersonate-groups"
294+
>
295+
<Select
296+
id="impersonate-groups"
297+
isOpen={isGroupSelectOpen}
298+
onOpenChange={(open) => setIsGroupSelectOpen(open)}
299+
onSelect={handleGroupSelect}
300+
toggle={toggle}
301+
isScrollable
302+
maxMenuHeight="300px"
303+
>
304+
<SelectList id="select-typeahead-listbox">
305+
{filteredGroups.length === 0 ? (
306+
<SelectOption isDisabled>{t('public~No results found')}</SelectOption>
307+
) : (
308+
filteredGroups.map((group) => (
309+
<SelectOption
310+
key={group}
311+
value={group}
312+
isSelected={selectedGroups.includes(group)}
313+
>
314+
{group}
315+
</SelectOption>
316+
))
317+
)}
318+
</SelectList>
319+
</Select>
320+
321+
{selectedGroups.length > 0 && (
322+
<div
323+
style={{
324+
marginTop: '12px',
325+
display: 'flex',
326+
flexWrap: 'wrap',
327+
gap: '8px',
328+
alignItems: 'center',
329+
}}
330+
>
331+
{visibleGroups.map((group) => (
332+
<Label key={group} onClose={() => handleGroupRemove(group)} color="blue">
333+
{group}
334+
</Label>
335+
))}
336+
{!showAllGroups && remainingCount > 0 && (
337+
<Badge
338+
isRead
339+
style={{
340+
backgroundColor: '#0066CC',
341+
color: 'white',
342+
borderRadius: '12px',
343+
padding: '2px 8px',
344+
cursor: 'pointer',
345+
}}
346+
onClick={() => setShowAllGroups(true)}
347+
>
348+
+{remainingCount}
349+
</Badge>
350+
)}
351+
</div>
352+
)}
353+
</FormGroup>
354+
</Form>
355+
</Modal>
356+
);
357+
};

frontend/public/locales/en/public.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,17 @@
937937
"Total size": "Total size",
938938
"requestSize": "requestSize",
939939
"Expand": "Expand",
940+
"Username is required": "Username is required",
941+
"Enter groups": "Enter groups",
942+
"Impersonate user": "Impersonate user",
943+
"Impersonate": "Impersonate",
944+
"Impersonating a user or group grants you their exact permissions.": "Impersonating a user or group grants you their exact permissions.",
945+
"The name of the user to impersonate": "The name of the user to impersonate",
946+
"More info for username field": "More info for username field",
947+
"Enter a username": "Enter a username",
948+
"The groups to impersonate the user with": "The groups to impersonate the user with",
949+
"More info for groups field": "More info for groups field",
950+
"No results found": "No results found",
940951
"Edit {{description}}": "Edit {{description}}",
941952
"Edit labels": "Edit labels",
942953
"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.",

0 commit comments

Comments
 (0)