Add ability in Fleet UI for admin to create new users without email invitations (#1261)

This commit is contained in:
gillespi314 2021-07-12 10:26:11 -05:00 committed by GitHub
parent 2c277ba28a
commit 2bb2bf2d5d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 347 additions and 216 deletions

View file

@ -0,0 +1,8 @@
- Add `USERS_ADMIN` endpoint to `fleet/endpoints`
- Add `createUserWithoutInvitation` action to `redux/nodes/entities/users`
- Revise `UserManagementPage`
- Revise `UserForm`
- Update existing Cypress test
- Remove `renderSmtpWarning` from `UserManagementPage`
Implements #369

View file

@ -16,6 +16,10 @@ describe("User invite and activation", () => {
cy.findByLabelText(/email/i).click().type("ash@example.com");
cy.get(".create-user-form__new-user-radios").within(() => {
cy.findByRole("radio", { name: "Invite user" }).parent().click();
});
cy.findByRole("button", { name: /^create$/i }).click();
// Ensure the email has been delivered

View file

@ -30,7 +30,7 @@
z-index: 6;
overflow: hidden;
border: 0;
width: 150px;
width: 188px;
right: 28px;
left: unset;
top: unset;

View file

@ -27,8 +27,12 @@ const Radio = (props: IRadioProps): JSX.Element => {
} = props;
const wrapperClasses = classnames(baseClass, className);
const radioControlClass = classnames({
[`disabled`]: disabled,
});
return (
<label htmlFor={id} className={wrapperClasses}>
<label htmlFor={id} className={`${wrapperClasses} ${radioControlClass}`}>
<span className={`${baseClass}__input`}>
<input
type="radio"

View file

@ -36,6 +36,7 @@ export default {
STATUS_LABEL_COUNTS: "/v1/fleet/host_summary",
TARGETS: "/v1/fleet/targets",
USERS: "/v1/fleet/users",
USERS_ADMIN: "/v1/fleet/users/admin",
UPDATE_USER_ADMIN: (id: number): string => {
return `/v1/fleet/users/${id}/admin`;
},

View file

@ -11,6 +11,16 @@ export default (client) => {
.authenticatedPost(client._endpoint(USERS), JSON.stringify(formData))
.then((response) => helpers.addGravatarUrlToResource(response.user));
},
createUserWithoutInvitation: (formData) => {
const { USERS_ADMIN } = endpoints;
return client
.authenticatedPost(
client._endpoint(USERS_ADMIN),
JSON.stringify(formData)
)
.then((response) => helpers.addGravatarUrlToResource(response.user)); // TODO confirm
},
deleteSessions: (user) => {
const { USER_SESSIONS } = endpoints;
const endpoint = client._endpoint(USER_SESSIONS(user.id));

View file

@ -5,10 +5,8 @@ import { isEqual } from "lodash";
import { push } from "react-router-redux";
import memoize from "memoize-one";
import Button from "components/buttons/Button";
import TableContainer from "components/TableContainer";
import Modal from "components/modals/Modal";
import WarningBanner from "components/WarningBanner";
import inviteInterface from "interfaces/invite";
import configInterface from "interfaces/config";
import userInterface from "interfaces/user";
@ -28,6 +26,7 @@ import { generateTableHeaders, combineDataSets } from "./UsersTableConfig";
import DeleteUserForm from "./components/DeleteUserForm";
import ResetPasswordModal from "./components/ResetPasswordModal";
import ResetSessionsModal from "./components/ResetSessionsModal";
import { NewUserType } from "./components/UserForm/UserForm";
const baseClass = "user-management";
@ -148,28 +147,53 @@ export class UserManagementPage extends Component {
onCreateUserSubmit = (formData) => {
const { dispatch, config } = this.props;
// Do some data formatting adding `invited_by` for the request to be correct.
const requestData = {
...formData,
invited_by: formData.currentUserId,
};
delete requestData.currentUserId; // dont need this for the request.
dispatch(inviteActions.create(requestData))
.then(() => {
dispatch(
renderFlash(
"success",
`An invitation email was sent from ${config.sender_address} to ${formData.email}.`
)
);
this.toggleCreateUserModal();
})
.catch(() => {
dispatch(
renderFlash("error", "Could not create user. Please try again.")
);
this.toggleCreateUserModal();
});
if (formData.newUserType === NewUserType.AdminInvited) {
// Do some data formatting adding `invited_by` for the request to be correct and deleteing uncessary fields
const requestData = {
...formData,
invited_by: formData.currentUserId,
};
delete requestData.currentUserId; // this field is not needed for the request
delete requestData.newUserType; // this field is not needed for the request
delete requestData.password; // this field is not needed for the request
dispatch(inviteActions.create(requestData))
.then(() => {
dispatch(
renderFlash(
"success",
`An invitation email was sent from ${config.sender_address} to ${formData.email}.`
)
);
this.toggleCreateUserModal();
})
.catch(() => {
dispatch(
renderFlash("error", "Could not create user. Please try again.")
);
this.toggleCreateUserModal();
});
} else {
// Do some data formatting deleteing uncessary fields
const requestData = {
...formData,
};
delete requestData.currentUserId; // this field is not needed for the request
delete requestData.newUserType; // this field is not needed for the request
dispatch(userActions.createUserWithoutInvitation(requestData))
.then(() => {
dispatch(
renderFlash("success", `Successfully created ${requestData.name}.`)
);
this.toggleCreateUserModal();
})
.catch(() => {
dispatch(
renderFlash("error", "Could not create user. Please try again.")
);
this.toggleCreateUserModal();
});
}
};
onCreateCancel = (evt) => {
@ -453,9 +477,11 @@ export class UserManagementPage extends Component {
availableTeams={teams}
defaultGlobalRole={"observer"}
defaultTeams={[]}
defaultNewUserType={false}
submitText={"Create"}
isBasicTier={isBasicTier}
smtpConfigured={config.configured}
isSmtpConfigured={config.configured}
isNewUser
/>
</Modal>
);
@ -514,34 +540,6 @@ export class UserManagementPage extends Component {
);
};
renderSmtpWarning = () => {
const { appConfigLoading, config } = this.props;
const { goToAppConfigPage } = this;
if (appConfigLoading) {
return false;
}
return (
<div className={`${baseClass}__smtp-warning-wrapper`}>
<WarningBanner shouldShowWarning={!config.configured}>
<span>
SMTP is not currently configured in Fleet. The &quot;Create
User&quot; feature requires that SMTP is configured in order to send
invitation emails.
</span>
<Button
className={`${baseClass}__config-button`}
onClick={goToAppConfigPage}
variant={"unstyled"}
>
Configure SMTP
</Button>
</WarningBanner>
</div>
);
};
render() {
const {
tableHeaders,
@ -550,18 +548,11 @@ export class UserManagementPage extends Component {
renderDeleteUserModal,
renderResetPasswordModal,
renderResetSessionsModal,
renderSmtpWarning,
toggleCreateUserModal,
onTableQueryChange,
onActionSelect,
} = this;
const {
config,
loadingTableData,
users,
invites,
currentUser,
} = this.props;
const { loadingTableData, users, invites, currentUser } = this.props;
let tableData = [];
if (!loadingTableData) {
@ -579,7 +570,6 @@ export class UserManagementPage extends Component {
Create new users, customize user permissions, and remove users from
Fleet.
</p>
{renderSmtpWarning()}
{/* TODO: find a way to move these controls into the table component */}
<TableContainer
columns={tableHeaders}
@ -588,8 +578,7 @@ export class UserManagementPage extends Component {
defaultSortHeader={"name"}
defaultSortDirection={"desc"}
inputPlaceHolder={"Search"}
disableActionButton={!config.configured}
actionButtonText={"Create User"}
actionButtonText={"Create user"}
onActionButtonClick={toggleCreateUserModal}
onQueryChange={onTableQueryChange}
resultsTitle={"users"}
@ -599,7 +588,6 @@ export class UserManagementPage extends Component {
{renderEditUserModal()}
{renderDeleteUserModal()}
{renderResetSessionsModal()}
{renderResetPasswordModal()}
</div>
);

View file

@ -1,139 +0,0 @@
import { mount } from "enzyme";
import { connectedComponent, reduxMockStore } from "test/helpers";
import ConnectedUserManagementPage from "pages/admin/UserManagementPage/UserManagementPage";
import inviteActions from "redux/nodes/entities/invites/actions";
import userActions from "redux/nodes/entities/users/actions";
const currentUser = {
email: "hi@gnar.dog",
enabled: true,
name: "Gnar Dog",
position: "Head of Gnar",
username: "gnardog",
teams: [],
global_role: "admin",
};
const store = {
app: {
config: {
configured: true,
},
},
auth: {
user: {
...currentUser,
},
},
entities: {
users: {
loading: false,
data: {
1: {
...currentUser,
},
},
originalOrder: [1],
},
invites: {
loading: false,
data: {
1: {
global_role: "admin",
email: "other@user.org",
name: "Other user",
teams: [],
},
},
originalOrder: [1],
},
},
};
describe("UserManagementPage - component", () => {
beforeEach(() => {
jest
.spyOn(userActions, "loadAll")
.mockImplementation(() => () => Promise.resolve([]));
jest
.spyOn(inviteActions, "loadAll")
.mockImplementation(() => () => Promise.resolve([]));
});
describe("rendering", () => {
it('displays a disabled "Create user" button if email is not configured', () => {
const notConfiguredStore = {
...store,
app: { config: { configured: false } },
};
const notConfiguredMockStore = reduxMockStore(notConfiguredStore);
const notConfiguredPage = mount(
connectedComponent(ConnectedUserManagementPage, {
mockStore: notConfiguredMockStore,
})
);
const configuredStore = store;
const configuredMockStore = reduxMockStore(configuredStore);
const configuredPage = mount(
connectedComponent(ConnectedUserManagementPage, {
mockStore: configuredMockStore,
})
);
expect(notConfiguredPage.find("Button").at(1).prop("disabled")).toEqual(
true
);
expect(configuredPage.find("Button").first().prop("disabled")).toEqual(
false
);
});
it("displays a SmtpWarning if email is not configured", () => {
const notConfiguredStore = {
...store,
app: { config: { configured: false } },
};
const notConfiguredMockStore = reduxMockStore(notConfiguredStore);
const notConfiguredPage = mount(
connectedComponent(ConnectedUserManagementPage, {
mockStore: notConfiguredMockStore,
})
);
const configuredStore = store;
const configuredMockStore = reduxMockStore(configuredStore);
const configuredPage = mount(
connectedComponent(ConnectedUserManagementPage, {
mockStore: configuredMockStore,
})
);
expect(notConfiguredPage.find("WarningBanner").html()).toBeTruthy();
expect(configuredPage.find("WarningBanner").html()).toBeFalsy();
});
});
it("goes to the app settings page for the user to resolve their smtp settings", () => {
const notConfiguredStore = {
...store,
app: { config: { configured: false } },
};
const mockStore = reduxMockStore(notConfiguredStore);
const page = mount(
connectedComponent(ConnectedUserManagementPage, { mockStore })
);
const smtpWarning = page.find("WarningBanner");
smtpWarning.find("Button").simulate("click");
const goToAppSettingsAction = {
type: "@@router/CALL_HISTORY_METHOD",
payload: { method: "push", args: ["/settings/organization"] },
};
expect(mockStore.getActions()).toContainEqual(goToAppSettingsAction);
});
});

View file

@ -209,7 +209,7 @@ const generateActionDropdownOptions = (
value: "delete",
},
];
if (isCurrentUser || isSSOEnabled) {
if (isSSOEnabled) {
// remove "Require password reset" from dropdownOptions
dropdownOptions = dropdownOptions.filter(
(option) => option.label !== "Require password reset"

View file

@ -8,6 +8,12 @@ import validEmail from "components/forms/validators/valid_email";
// ignore TS error for now until these are rewritten in ts.
// @ts-ignore
import validPassword from "components/forms/validators/valid_password";
// @ts-ignore
import IconToolTip from "components/IconToolTip";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
// @ts-ignore
import InputFieldWithIcon from "components/forms/fields/InputFieldWithIcon";
// @ts-ignore
import Checkbox from "components/forms/fields/Checkbox";
@ -20,6 +26,11 @@ import OpenNewTabIcon from "../../../../../../assets/images/open-new-tab-12x12@2
const baseClass = "create-user-form";
export enum NewUserType {
AdminInvited = "ADMIN_INVITED",
AdminCreated = "ADMIN_CREATED",
}
enum UserTeamType {
GlobalUser = "GLOBAL_USER",
AssignTeams = "ASSIGN_TEAMS",
@ -46,6 +57,8 @@ const globalUserRoles = [
export interface IFormData {
email: string;
name: string;
newUserType?: NewUserType | null;
password?: string | null;
sso_enabled: boolean;
global_role: string | null;
teams: ITeam[];
@ -65,6 +78,8 @@ interface ICreateUserFormProps {
defaultGlobalRole?: string | null;
defaultTeams?: ITeam[];
isBasicTier: boolean;
isSmtpConfigured?: boolean;
isNewUser?: boolean;
validationErrors: any[]; // TODO: proper interface for validationErrors
smtpConfigured: boolean;
}
@ -73,6 +88,7 @@ interface ICreateUserFormState {
errors: {
email: string | null;
name: string | null;
password: string | null;
sso_enabled: boolean | null;
};
formData: IFormData;
@ -87,12 +103,17 @@ class UserForm extends Component<ICreateUserFormProps, ICreateUserFormState> {
errors: {
email: null,
name: null,
password: null,
sso_enabled: null,
},
formData: {
email: props.defaultEmail || "",
name: props.defaultName || "",
sso_enabled: props.canUseSSO || false,
newUserType: props.isNewUser ? NewUserType.AdminCreated : null,
password: null,
sso_enabled: props.isNewUser
? props.canUseSSO || false
: props.canUseSSO || false, // TODO revisit the else case for editing an existing user; shouldn't this be pulling from the user data instead of global app config?
global_role: props.defaultGlobalRole || null,
teams: props.defaultTeams || [],
currentUserId: props.currentUserId,
@ -126,6 +147,12 @@ class UserForm extends Component<ICreateUserFormProps, ICreateUserFormState> {
};
};
onRadioChange = (formField: string): ((evt: string) => void) => {
return (evt: string) => {
return this.onInputChange(formField)(evt);
};
};
onIsGlobalUserChange = (value: string): void => {
const { formData } = this.state;
const isGlobalUser = value === UserTeamType.GlobalUser;
@ -168,19 +195,39 @@ class UserForm extends Component<ICreateUserFormProps, ICreateUserFormState> {
}
};
// UserForm component can be used to create a new user or edit an existing user so submitData will be assembled accordingly
createSubmitData = (): IFormData => {
const { currentUserId } = this.props;
const { currentUserId, isNewUser } = this.props;
const {
isGlobalUser,
formData: { email, name, sso_enabled, global_role, teams },
formData: {
email,
name,
newUserType,
password,
sso_enabled,
global_role,
teams,
},
} = this.state;
const submitData = {
email,
name,
newUserType,
password,
sso_enabled,
currentUserId,
};
if (!isNewUser) {
delete submitData.newUserType; // this field will not be submitted when form is used to edit an existing user
}
if (submitData.sso_enabled || newUserType === NewUserType.AdminInvited) {
delete submitData.password; // this field will not be submitted with the form
}
return isGlobalUser
? { ...submitData, global_role, teams: [] }
: { ...submitData, global_role: null, teams };
@ -189,8 +236,9 @@ class UserForm extends Component<ICreateUserFormProps, ICreateUserFormState> {
validate = (): boolean => {
const {
errors,
formData: { email },
formData: { email, password, newUserType, sso_enabled },
} = this.state;
const { isNewUser } = this.props;
if (!validatePresence(email)) {
this.setState({
@ -214,6 +262,29 @@ class UserForm extends Component<ICreateUserFormProps, ICreateUserFormState> {
return false;
}
if (isNewUser && newUserType === NewUserType.AdminCreated && !sso_enabled) {
if (!validatePresence(password)) {
this.setState({
errors: {
...errors,
password: "Password field must be completed",
},
});
return false;
}
if (!validPassword(password)) {
this.setState({
errors: {
...errors,
password: "Password must meet the criteria below",
},
});
return false;
}
}
return true;
};
@ -288,14 +359,21 @@ class UserForm extends Component<ICreateUserFormProps, ICreateUserFormState> {
render(): JSX.Element {
const {
errors,
formData: { email, name, sso_enabled },
formData: { email, name, newUserType, password, sso_enabled },
isGlobalUser,
} = this.state;
const { onCancel, submitText, isBasicTier, smtpConfigured } = this.props;
const {
onCancel,
submitText,
isBasicTier,
isSmtpConfigured,
isNewUser,
} = this.props;
const {
onFormSubmit,
onInputChange,
onCheckboxChange,
onRadioChange,
onIsGlobalUserChange,
renderGlobalRoleForm,
renderTeamsForm,
@ -323,7 +401,7 @@ class UserForm extends Component<ICreateUserFormProps, ICreateUserFormState> {
className="smtp-not-configured"
data-tip
data-for="smtp-tooltip"
data-tip-disable={smtpConfigured}
data-tip-disable={isSmtpConfigured}
>
<InputFieldWithIcon
error={errors.email}
@ -331,7 +409,7 @@ class UserForm extends Component<ICreateUserFormProps, ICreateUserFormState> {
onChange={onInputChange("email")}
placeholder="Email"
value={email}
disabled={!smtpConfigured}
disabled={!isSmtpConfigured}
/>
</div>
<ReactTooltip
@ -365,6 +443,83 @@ class UserForm extends Component<ICreateUserFormProps, ICreateUserFormState> {
Password authentication will be disabled for this user.
</p>
</div>
{isNewUser && (
<div className={`${baseClass}__new-user-container`}>
<div className={`${baseClass}__new-user-radios`}>
<Radio
className={`${baseClass}__radio-input`}
label={"Create user"}
id={"create-user"}
checked={newUserType !== NewUserType.AdminInvited}
value={NewUserType.AdminCreated}
name={"newUserType"}
onChange={onRadioChange("newUserType")}
/>
<div
className="smtp-not-configured"
data-tip
data-for="invite-user-tooltip"
data-tip-disable={isSmtpConfigured}
>
<Radio
className={`${baseClass}__radio-input`}
label={"Invite user"}
id={"invite-user"}
disabled={!isSmtpConfigured}
checked={newUserType === NewUserType.AdminInvited}
value={NewUserType.AdminInvited}
name={"newUserType"}
onChange={onRadioChange("newUserType")}
/>
<ReactTooltip
place="bottom"
type="dark"
effect="solid"
id="invite-user-tooltip"
backgroundColor="#3e4771"
data-html
>
<span className={`${baseClass}__tooltip-text`}>
The Invite user feature requires that SMTP is
<br />
configured in order to send an invitation email. <br />
<br />
SMTP can be configured in Organization settings.
</span>
</ReactTooltip>
</div>
</div>
{newUserType !== NewUserType.AdminInvited && !sso_enabled && (
<>
<div className={`${baseClass}__password`}>
<InputField
error={errors.password}
name="password"
onChange={onInputChange("password")}
placeholder="Password"
value={password}
type="password"
hint={[
"Must include 7 characters, at least 1 number (e.g. 0 - 9), and at least 1 symbol (e.g. &*#)",
]}
/>
</div>
<div className={`${baseClass}__details`}>
<IconToolTip
isHtml
text={`\
<div class="tooltip-text">\
<p>This password is temporary.</p>\
<p> This user will be asked to set a new password after logging in to the Fleet UI.</p>\
<p>This user will not be asked to set a new password after logging in to fleetctl or the Fleet API.</p>\
</div>\
`}
/>
</div>
</>
)}
</div>
)}
{isBasicTier && (
<div className={`${baseClass}__selected-teams-container`}>
<div className={`${baseClass}__team-radios`}>

View file

@ -1,4 +1,9 @@
.create-user-form {
&__new-user-container {
margin-top: $pad-large;
margin-bottom: $pad-large;
}
&__sso-input {
margin-top: $pad-large;
margin-bottom: $pad-large;
@ -51,6 +56,15 @@
&__radio-input {
margin-bottom: $pad-medium;
&.disabled {
.radio__control {
background-color: $ui-fleet-black-25;
}
.radio__label {
color: $ui-fleet-black-25;
}
}
}
&__btn-wrap {
@ -66,9 +80,38 @@
margin-bottom: 8px;
}
&__password {
width: 98%;
float: left;
padding: 0 $pad-medium 0 0;
box-sizing: border-box;
.input-icon-field {
width: 100%;
}
}
&__details {
float: right;
width: 2%;
.icon-tooltip {
margin-top: 12px;
}
.hint {
color: $core-fleet-black;
&--brand {
color: $core-vibrant-blue;
}
}
}
.sublabel {
margin: 0px;
}
&__tooltip-text {
width: 300px;
}

View file

@ -1,5 +1,5 @@
import { userStub, userTeamStub } from "test/stubs";
import { IFormData } from "../components/UserForm/UserForm";
import { IFormData, NewUserType } from "../components/UserForm/UserForm";
import userManagementHelpers from "./userManagementHelpers";
describe("userManagementHelpers module", () => {
@ -19,6 +19,7 @@ describe("userManagementHelpers module", () => {
email: "newemail@test.com",
sso_enabled: false,
name: "Gnar Mike",
newUserType: NewUserType.AdminCreated, // TODO revisit test
global_role: "admin",
teams: [updatedTeam, newTeam],
};

View file

@ -3,12 +3,17 @@ import Fleet from "fleet";
import config from "redux/nodes/entities/users/config";
import { formatErrorResponse } from "redux/nodes/entities/base/helpers";
import { logoutUser, updateUserSuccess } from "redux/nodes/auth/actions";
import { create } from "lodash";
const { actions } = config;
// Actions for admin to require password reset for a user
export const REQUIRE_PASSWORD_RESET_SUCCESS = "REQUIRE_PASSWORD_RESET_SUCCESS";
export const REQUIRE_PASSWORD_RESET_FAILURE = "REQUIRE_PASSWORD_RESET_FAILURE";
export const CREATE_USER_WITHOUT_INVITE_SUCCESS =
"CREATE_USER_WITHOUT_INVITE_SUCCESS";
export const CREATE_USER_WITHOUT_INVITE_FAILURE =
"CREATE_USER_WITHOUT_INVITE_FAILURE";
export const requirePasswordResetSuccess = (user) => {
return {
@ -24,6 +29,21 @@ export const requirePasswordResetFailure = (errors) => {
};
};
// TODO does below need user
export const createUserWithoutInviteSuccess = () => {
return {
type: CREATE_USER_WITHOUT_INVITE_SUCCESS,
payload: {},
};
};
export const createUserWithoutInviteFailure = (errors) => {
return {
type: CREATE_USER_WITHOUT_INVITE_FAILURE,
payload: { errors },
};
};
export const changePassword = (
user,
{ new_password: newPassword, old_password: oldPassword }
@ -77,6 +97,23 @@ export const confirmEmailChange = (user, token) => {
};
};
export const createUserWithoutInvitation = (formData) => {
return (dispatch) => {
return Fleet.users
.createUserWithoutInvitation(formData)
.then((response) => {
return dispatch(createUserWithoutInviteSuccess(response));
})
.catch((response) => {
const errorsObject = formatErrorResponse(response);
dispatch(createUserWithoutInviteFailure(errorsObject));
throw errorsObject;
});
};
};
export const deleteSessions = (user) => {
const { successAction, destroyFailure, destroySuccess } = actions;
@ -156,6 +193,7 @@ export default {
...actions,
changePassword,
confirmEmailChange,
createUserWithoutInvitation,
enableUser,
requirePasswordReset,
deleteSessions,

View file

@ -1,6 +1,8 @@
import {
REQUIRE_PASSWORD_RESET_FAILURE,
REQUIRE_PASSWORD_RESET_SUCCESS,
CREATE_USER_WITHOUT_INVITE_FAILURE,
CREATE_USER_WITHOUT_INVITE_SUCCESS,
} from "./actions";
import config, { initialState } from "./config";
@ -22,6 +24,22 @@ export default (state = initialState, { type, payload }) => {
loading: false,
errors: payload.errors,
};
case CREATE_USER_WITHOUT_INVITE_SUCCESS:
return {
...state,
errors: {},
loading: false,
data: {
...state.data,
},
};
case CREATE_USER_WITHOUT_INVITE_FAILURE:
return {
...state,
loading: false,
errors: payload.errors,
};
default:
return config.reducer(state, { type, payload });
}