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:
parent
f903d617c0
commit
5cc5dd1258
5 changed files with 260 additions and 150 deletions
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export const PasswordValidatedInput = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<FormGroup label="Password" isRequired>
|
||||
<FormGroup label="Password" className="pf-v5-u-pb-md">
|
||||
<>
|
||||
<InputGroup>
|
||||
<InputGroupItem isFill>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue