Wizard: Add support for multiple users

This adds support for multiple users in the Wizard. The users are rendered in tabs and can be both added and removed. When clicking on remove button, modal pops up to inform user that the changes cannot be undone.

Neither password nor SSH keys are further required, making it possible to create a user with just a username.
This commit is contained in:
regexowl 2025-03-28 16:35:12 +01:00 committed by Lucas Garfield
parent f903d617c0
commit 5cc5dd1258
5 changed files with 260 additions and 150 deletions

View file

@ -0,0 +1,72 @@
import React from 'react';
import { Button, Modal } from '@patternfly/react-core';
import { useAppDispatch, useAppSelector } from '../../../../../store/hooks';
import { removeUser, selectUsers } from '../../../../../store/wizardSlice';
type RemoveUserModalProps = {
setShowRemoveUserModal: React.Dispatch<React.SetStateAction<boolean>>;
activeTabKey: number;
setActiveTabKey: React.Dispatch<React.SetStateAction<number>>;
tabIndex: number;
setIndex: React.Dispatch<React.SetStateAction<number>>;
isOpen: boolean;
};
const RemoveUserModal = ({
setShowRemoveUserModal,
activeTabKey,
setActiveTabKey,
tabIndex,
setIndex,
isOpen,
}: RemoveUserModalProps) => {
const dispatch = useAppDispatch();
const users = useAppSelector(selectUsers);
const onClose = () => {
setShowRemoveUserModal(!isOpen);
};
const onConfirm = () => {
const tabIndexNum = tabIndex;
let nextTabIndex = activeTabKey;
if (tabIndexNum < activeTabKey) {
// if a preceding tab is closing, keep focus on the new index of the current tab
nextTabIndex = activeTabKey - 1 > 0 ? activeTabKey - 1 : 0;
} else if (activeTabKey === users.length - 1) {
// if the closing tab is the last tab, focus the preceding tab
nextTabIndex = users.length - 2 > 0 ? users.length - 2 : 0;
}
setActiveTabKey(nextTabIndex);
setIndex(nextTabIndex);
dispatch(removeUser(tabIndex));
setShowRemoveUserModal(!isOpen);
};
return (
<Modal
title="Remove user?"
isOpen={isOpen}
onClose={onClose}
width="50%"
actions={[
<Button key="confirm" variant="primary" onClick={onConfirm}>
Remove user
</Button>,
<Button key="cancel" variant="link" onClick={onClose}>
Cancel
</Button>,
]}
ouiaId="removeUserModal"
>
This action is permanent and cannot be undone. Once deleted all
information about the user will be lost.
</Modal>
);
};
export default RemoveUserModal;

View file

@ -1,21 +1,26 @@
import React from 'react';
import React, { useState } from 'react';
import { Button, FormGroup, Checkbox, Tooltip } from '@patternfly/react-core';
import { ExternalLinkAltIcon, TrashIcon } from '@patternfly/react-icons';
import {
Button,
FormGroup,
Checkbox,
Tabs,
Tab,
TabTitleText,
} from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import RemoveUserModal from './RemoveUserModal';
import { GENERATING_SSH_KEY_PAIRS_URL } from '../../../../../constants';
import { useAppDispatch, useAppSelector } from '../../../../../store/hooks';
import {
selectUserAdministrator,
selectUserNameByIndex,
selectUserPasswordByIndex,
selectUserSshKeyByIndex,
setUserNameByIndex,
setUserPasswordByIndex,
setUserSshKeyByIndex,
setUserAdministratorByIndex,
removeUser,
selectUserGroupsByIndex,
addUser,
selectUsers,
addUserGroupByIndex,
removeUserGroupByIndex,
} from '../../../../../store/wizardSlice';
@ -27,17 +32,29 @@ import { isUserGroupValid } from '../../../validators';
const UserInfo = () => {
const dispatch = useAppDispatch();
const index = 0;
const userNameSelector = selectUserNameByIndex(index);
const userName = useAppSelector(userNameSelector);
const userPasswordSelector = selectUserPasswordByIndex(index);
const userPassword = useAppSelector(userPasswordSelector);
const userSshKeySelector = selectUserSshKeyByIndex(index);
const userSshKey = useAppSelector(userSshKeySelector);
const userIsAdministratorSelector = selectUserAdministrator(index);
const userIsAdministrator = useAppSelector(userIsAdministratorSelector);
const userGroupsSelector = selectUserGroupsByIndex(index);
const userGroups = useAppSelector(userGroupsSelector);
const users = useAppSelector(selectUsers);
const stepValidation = useUsersValidation();
const [index, setIndex] = useState(0);
const [activeTabKey, setActiveTabKey] = useState(0);
const [tabIndex, setTabIndex] = useState(0);
const [showRemoveUserModal, setShowRemoveUserModal] = useState(false);
const onSelect = (event: React.MouseEvent, tabIndex: number) => {
setActiveTabKey(tabIndex);
setIndex(tabIndex);
};
const onAdd = () => {
setActiveTabKey(users.length);
setIndex(index + 1);
dispatch(addUser());
};
const onClose = (_event: React.MouseEvent, tabIndex: number) => {
setShowRemoveUserModal(true);
setTabIndex(tabIndex);
};
const handleNameChange = (
_e: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
@ -60,8 +77,6 @@ const UserInfo = () => {
dispatch(setUserSshKeyByIndex({ index: index, sshKey: value }));
};
const stepValidation = useUsersValidation();
const handleCheckboxChange = (
_event: React.FormEvent<HTMLInputElement>,
value: boolean
@ -71,88 +86,111 @@ const UserInfo = () => {
);
};
const onRemoveUserClick = () => {
dispatch(removeUser(index));
const getValidationByIndex = (index: number) => {
return {
errors: {
userName: stepValidation?.errors[index]?.userName,
userSshKey: stepValidation?.errors[index]?.userSshKey,
},
disabledNext: stepValidation.disabledNext,
};
};
return (
<>
<FormGroup isRequired label="Username">
<ValidatedInputAndTextArea
ariaLabel="blueprint user name"
value={userName || ''}
placeholder="Enter username"
onChange={(_e, value) => handleNameChange(_e, value)}
stepValidation={stepValidation}
fieldName="userName"
/>
</FormGroup>
<PasswordValidatedInput
value={userPassword || ''}
ariaLabel="blueprint user password"
placeholder="Enter password"
onChange={(_e, value) => handlePasswordChange(_e, value)}
<RemoveUserModal
setShowRemoveUserModal={setShowRemoveUserModal}
activeTabKey={activeTabKey}
setActiveTabKey={setActiveTabKey}
tabIndex={tabIndex}
setIndex={setIndex}
isOpen={showRemoveUserModal}
/>
<FormGroup isRequired label="SSH key">
<ValidatedInputAndTextArea
inputType={'textArea'}
ariaLabel="public SSH key"
value={userSshKey || ''}
type={'text'}
onChange={(_e, value) => handleSshKeyChange(_e, value)}
placeholder="Paste your public SSH key"
stepValidation={stepValidation}
fieldName="userSshKey"
/>
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
href={GENERATING_SSH_KEY_PAIRS_URL}
className="pf-v5-u-pl-0"
>
Learn more about SSH keys
</Button>
</FormGroup>
<FormGroup>
<Checkbox
label="Administrator"
isChecked={userIsAdministrator || userGroups.includes('wheel')}
onChange={(_e, value) => handleCheckboxChange(_e, value)}
aria-label="Administrator"
id="user Administrator"
name="user Administrator"
/>
</FormGroup>
<FormGroup label="Groups">
<LabelInput
ariaLabel="Add user group"
placeholder="Add user group"
validator={isUserGroupValid}
list={userGroups}
item="Group"
addAction={(value) =>
addUserGroupByIndex({ index: index, group: value })
}
removeAction={(value) =>
removeUserGroupByIndex({ index: index, group: value })
}
stepValidation={stepValidation}
fieldName="groups"
/>
</FormGroup>
<Tooltip position="top-start" content={'Remove user'}>
<FormGroup>
<Button
aria-label="remove user"
onClick={onRemoveUserClick}
variant="tertiary"
icon={<TrashIcon />}
></Button>
</FormGroup>
</Tooltip>
<Tabs
aria-label="Users tabs"
activeKey={activeTabKey}
onSelect={onSelect}
onAdd={onAdd}
onClose={onClose}
>
{users.map((user, index) => (
<Tab
aria-label={`User ${user.name} tab`}
key={user.name}
eventKey={index}
title={<TabTitleText>{user.name || 'New user'}</TabTitleText>}
>
<FormGroup isRequired label="Username" className="pf-v5-u-pb-md">
<ValidatedInputAndTextArea
ariaLabel="blueprint user name"
value={user.name || ''}
placeholder="Enter username"
onChange={(_e, value) => handleNameChange(_e, value)}
stepValidation={getValidationByIndex(index)}
fieldName="userName"
/>
</FormGroup>
<PasswordValidatedInput
value={user.password || ''}
ariaLabel="blueprint user password"
placeholder="Enter password"
onChange={(_e, value) => handlePasswordChange(_e, value)}
/>
<FormGroup label="SSH key" className="pf-v5-u-pb-md">
<ValidatedInputAndTextArea
inputType={'textArea'}
ariaLabel="public SSH key"
value={user.ssh_key || ''}
type={'text'}
onChange={(_e, value) => handleSshKeyChange(_e, value)}
placeholder="Paste your public SSH key"
stepValidation={getValidationByIndex(index)}
fieldName="userSshKey"
/>
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
href={GENERATING_SSH_KEY_PAIRS_URL}
className="pf-v5-u-pl-0"
>
Learn more about SSH keys
</Button>
</FormGroup>
<FormGroup className="pf-v5-u-pb-md">
<Checkbox
label="Administrator"
isChecked={
user.isAdministrator || user.groups.includes('wheel')
}
onChange={(_e, value) => handleCheckboxChange(_e, value)}
aria-label="Administrator"
id="user Administrator"
name="user Administrator"
/>
</FormGroup>
<FormGroup label="Groups">
<LabelInput
ariaLabel="Add user group"
placeholder="Add user group"
validator={isUserGroupValid}
list={user.groups}
item="Group"
addAction={(value) =>
addUserGroupByIndex({ index: index, group: value })
}
removeAction={(value) =>
removeUserGroupByIndex({ index: index, group: value })
}
stepValidation={getValidationByIndex(index)}
fieldName="groups"
/>
</FormGroup>
</Tab>
))}
</Tabs>
</>
);
};

View file

@ -45,7 +45,7 @@ export const PasswordValidatedInput = ({
};
return (
<FormGroup label="Password" isRequired>
<FormGroup label="Password" className="pf-v5-u-pb-md">
<>
<InputGroup>
<InputGroupItem isFill>

View file

@ -21,19 +21,16 @@ import {
selectRegistrationType,
selectHostname,
selectKernel,
selectUserNameByIndex,
selectUsers,
selectUserPasswordByIndex,
selectUserSshKeyByIndex,
selectNtpServers,
selectFirewall,
selectServices,
selectLanguages,
selectKeyboard,
selectTimezone,
selectImageTypes,
selectSatelliteCaCertificate,
selectSatelliteRegistrationCommand,
selectImageTypes,
} from '../../../store/wizardSlice';
import { keyboardsList } from '../steps/Locale/keyboardsList';
import { languagesList } from '../steps/Locale/languagesList';
@ -61,6 +58,13 @@ export type StepValidation = {
disabledNext: boolean;
};
export type UsersStepValidation = {
errors: {
[key: string]: { [key: string]: string };
};
disabledNext: boolean;
};
export function useIsBlueprintValid(): boolean {
const registration = useRegistrationValidation();
const filesystem = useFilesystemValidation();
@ -90,12 +94,6 @@ export function useIsBlueprintValid(): boolean {
);
}
type ValidationFlags = {
isUserNameValidValue: boolean;
isSshKeyValidValue: boolean;
isPasswordValidValue: boolean;
};
type PasswordValidationResult = {
isValid: boolean;
strength: {
@ -453,70 +451,68 @@ export function useServicesValidation(): StepValidation {
};
}
const getUserNameErrorMsg = (userName: string): string => {
const validateUserName = (userName: string): string => {
if (!userName) {
return 'Required value';
}
if (userName && !isUserNameValid(userName)) {
return 'Invalid user name';
}
return '';
};
const getSshKeyErrorMsg = (userSshKey: string): string => {
const validateSshKey = (userSshKey: string): string => {
if (userSshKey && !isSshKeyValid(userSshKey)) {
return 'Invalid SSH key';
}
return '';
};
export function useUsersValidation(): StepValidation {
const index = 0;
export function useUsersValidation(): UsersStepValidation {
const environments = useAppSelector(selectImageTypes);
const userNameSelector = selectUserNameByIndex(index);
const userName = useAppSelector(userNameSelector);
const userPasswordSelector = selectUserPasswordByIndex(index);
const userPassword = useAppSelector(userPasswordSelector);
const userSshKeySelector = selectUserSshKeyByIndex(index);
const userSshKey = useAppSelector(userSshKeySelector);
const users = useAppSelector(selectUsers);
const errors: { [key: string]: { [key: string]: string } } = {};
const userNameError = getUserNameErrorMsg(userName);
const sshKeyError = getSshKeyErrorMsg(userSshKey);
if (users.length === 0) {
return {
errors: {},
disabledNext: false,
};
}
const { isUserNameValidValue, isSshKeyValidValue, isPasswordValidValue } =
calculateValidationFlags(
userName,
userPassword,
userSshKey,
for (let index = 0; index < users.length; index++) {
const userNameError = validateUserName(users[index].name);
const sshKeyError = validateSshKey(users[index].ssh_key);
const isPasswordValid = checkPasswordValidity(
users[index].password,
environments.includes('azure')
);
).isValid;
if (
userNameError ||
sshKeyError ||
(users[index].password && !isPasswordValid)
) {
errors[`${index}`] = {
userName: userNameError,
userSshKey: sshKeyError,
userPassword: !isPasswordValid ? 'Invalid password' : '',
};
}
}
const canProceed =
// Case 1: there is no users
users.length === 0 ||
// Case 2: userName is valid and SshKey or Password is valid
(isUserNameValidValue && (isSshKeyValidValue || isPasswordValidValue));
// Case 2: all users are valid
Object.keys(errors).length === 0;
return {
errors: {
userName: userNameError,
userSshKey: sshKeyError,
},
errors,
disabledNext: !canProceed,
};
}
const calculateValidationFlags = (
userName: string,
userPassword: string,
userSshKey: string,
isAzure: boolean
): ValidationFlags => {
const isUserNameValidValue = !!userName && isUserNameValid(userName);
const isSshKeyValidValue = !!userSshKey && isSshKeyValid(userSshKey);
const isPasswordValidValue =
!!userPassword && checkPasswordValidity(userPassword, isAzure).isValid;
return { isUserNameValidValue, isSshKeyValidValue, isPasswordValidValue };
};
export const checkPasswordValidity = (
password: string,
isAzure: boolean