Fleet UI: No role = no access, refactor jsx class components to typescript functional components (#12953)

This commit is contained in:
RachelElysia 2023-08-02 11:29:49 -04:00 committed by GitHub
parent 2e6589b66c
commit 3477178758
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 573 additions and 470 deletions

View File

@ -0,0 +1 @@
- Users with no global or team role cannot access the UI

View File

@ -0,0 +1 @@
- Fix login form cut off when viewport is too short

View File

@ -4,7 +4,6 @@
justify-content: center;
flex-grow: 1;
padding: $pad-medium 0;
height: 100vh;
&__logo {
width: 120px;

View File

@ -1,98 +0,0 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import { Link } from "react-router";
import classnames from "classnames";
import CloseIcon from "../../../assets/images/icon-close-fleet-black-16x16@2x.png";
const baseClass = "stacked-white-boxes";
class StackedWhiteBoxes extends Component {
static propTypes = {
children: PropTypes.element,
headerText: PropTypes.string,
className: PropTypes.string,
leadText: PropTypes.string,
onLeave: PropTypes.func,
previousLocation: PropTypes.string,
};
constructor(props) {
super(props);
this.state = {
isLoading: false,
isLoaded: false,
isLeaving: false,
};
}
componentWillMount() {
this.setState({
isLoading: true,
});
}
componentDidMount() {
const { didLoad } = this;
didLoad();
return false;
}
didLoad = () => {
this.setState({
isLoading: false,
isLoaded: true,
});
};
renderBackButton = () => {
const { previousLocation } = this.props;
if (!previousLocation) return false;
return (
<div className={`${baseClass}__back`}>
<Link to={previousLocation} className={`${baseClass}__back-link`}>
<img src={CloseIcon} alt="close icon" />
</Link>
</div>
);
};
renderHeader = () => {
const { headerText } = this.props;
return (
<div className={`${baseClass}__header`}>
<p className={`${baseClass}__header-text`}>{headerText}</p>
</div>
);
};
render() {
const { children, className, leadText } = this.props;
const { isLoading, isLoaded, isLeaving } = this.state;
const { renderBackButton, renderHeader } = this;
const boxClass = classnames(baseClass, className, {
[`${baseClass}--loading`]: isLoading,
[`${baseClass}--loaded`]: isLoaded,
[`${baseClass}--leaving`]: isLeaving,
});
return (
<div className={boxClass}>
<div className={`${baseClass}__box`}>
{renderBackButton()}
{renderHeader()}
<p className={`${baseClass}__box-text`}>{leadText}</p>
{children}
</div>
</div>
);
}
}
export default StackedWhiteBoxes;

View File

@ -0,0 +1,70 @@
import React, { useEffect } from "react";
import { InjectedRouter, Link } from "react-router";
import classnames from "classnames";
import paths from "router/paths";
import Icon from "components/Icon/Icon";
const baseClass = "stacked-white-boxes";
interface IStackedWhiteBoxesProps {
children?: JSX.Element;
headerText?: string;
className?: string;
leadText?: string;
previousLocation?: string;
router: InjectedRouter;
}
const StackedWhiteBoxes = ({
children,
headerText,
className,
leadText,
previousLocation,
router,
}: IStackedWhiteBoxesProps): JSX.Element => {
const boxClass = classnames(baseClass, className);
useEffect(() => {
const closeWithEscapeKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
router.push(paths.LOGIN);
}
};
document.addEventListener("keydown", closeWithEscapeKey);
return () => {
document.removeEventListener("keydown", closeWithEscapeKey);
};
}, []);
const renderBackButton = () => {
if (!previousLocation) return false;
return (
<div className={`${baseClass}__back`}>
<Link to={previousLocation} className={`${baseClass}__back-link`}>
<Icon name="ex" color="core-fleet-black" />
</Link>
</div>
);
};
return (
<div className={boxClass}>
<div className={`${baseClass}__box`}>
{renderBackButton()}
{headerText && (
<p className={`${baseClass}__header-text`}>{headerText}</p>
)}
{leadText && <p className={`${baseClass}__box-text`}>{leadText}</p>}
{children}
</div>
</div>
);
};
export default StackedWhiteBoxes;

View File

@ -3,46 +3,32 @@
margin: 2.5rem auto;
width: 516px;
&--loading {
opacity: 0;
}
&--loaded {
opacity: 1;
}
&--leaving {
opacity: 0;
}
&__box {
background-color: $core-white;
border-radius: 10px;
min-height: 330px;
box-sizing: border-box;
padding: $pad-xxlarge;
font-weight: $regular;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $pad-medium;
&-text {
color: $core-fleet-black;
font-size: $small;
margin: $pad-xxlarge 0 $pad-large;
p {
font-size: $x-small;
margin: 0;
}
}
&__header {
width: 100%;
&-text {
font-size: $large;
font-weight: 300;
&__header-text {
font-size: $x-small;
font-weight: $bold;
color: $core-fleet-black;
line-height: 32px;
margin-top: 0;
margin-bottom: 0;
}
}
&__back {
text-align: right;

View File

@ -1,125 +0,0 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import { Link } from "react-router";
import classnames from "classnames";
import Button from "components/buttons/Button";
import Form from "components/forms/Form";
import formFieldInterface from "interfaces/form_field";
import InputFieldWithIcon from "components/forms/fields/InputFieldWithIcon";
import paths from "router/paths";
import validate from "components/forms/LoginForm/validate";
import ssoSettingsInterface from "interfaces/ssoSettings";
const baseClass = "login-form";
const formFields = ["email", "password"];
class LoginForm extends Component {
static propTypes = {
baseError: PropTypes.string,
fields: PropTypes.shape({
password: formFieldInterface.isRequired,
email: formFieldInterface.isRequired,
}).isRequired,
handleSubmit: PropTypes.func,
isHidden: PropTypes.bool,
ssoSettings: ssoSettingsInterface,
handleSSOSignOn: PropTypes.func,
};
showLegendWithImage = (image, idpName) => {
let legend = "Single sign-on";
if (idpName !== "") {
legend = `Sign on with ${idpName}`;
}
return (
<div>
<img src={image} alt={idpName} className={`${baseClass}__sso-image`} />
<span className={`${baseClass}__sso-legend`}>{legend}</span>
</div>
);
};
showSingleSignOnButton = () => {
const { ssoSettings, handleSSOSignOn } = this.props;
const { idp_name: idpName, idp_image_url: imageURL } = ssoSettings;
const { showLegendWithImage } = this;
let legend = "Single sign-on";
if (idpName !== "") {
legend = `Sign on with ${idpName}`;
}
if (imageURL !== "") {
legend = showLegendWithImage(imageURL, idpName);
}
return (
<Button
className={`${baseClass}__sso-btn`}
type="button"
title="Single sign-on"
variant="inverse"
onClick={handleSSOSignOn}
>
<div>{legend}</div>
</Button>
);
};
render() {
const {
baseError,
fields,
handleSubmit,
isHidden,
ssoSettings,
} = this.props;
const { sso_enabled: ssoEnabled } = ssoSettings || {};
const { showSingleSignOnButton } = this;
const loginFormClass = classnames(baseClass, {
[`${baseClass}--hidden`]: isHidden,
});
return (
<form onSubmit={handleSubmit} className={loginFormClass}>
<div className={`${baseClass}__container`}>
{baseError && <div className="form__base-error">{baseError}</div>}
<InputFieldWithIcon
{...fields.email}
autofocus
label="Email"
placeholder="Email"
/>
<InputFieldWithIcon
{...fields.password}
label="Password"
placeholder="Password"
type="password"
/>
<div className={`${baseClass}__forgot-wrap`}>
<Link
className={`${baseClass}__forgot-link`}
to={paths.FORGOT_PASSWORD}
>
Forgot password?
</Link>
</div>
<Button
className={`${baseClass}__submit-btn button button--brand`}
onClick={handleSubmit}
type="submit"
>
Login
</Button>
{ssoEnabled && showSingleSignOnButton()}
</div>
</form>
);
}
}
export default Form(LoginForm, {
fields: formFields,
validate,
});

View File

@ -16,7 +16,7 @@ describe("LoginForm - component", () => {
it("renders the base error", () => {
render(
<LoginForm
serverErrors={{ base: baseError }}
baseError={baseError}
handleSubmit={submitSpy}
ssoSettings={settings}
/>
@ -34,7 +34,7 @@ describe("LoginForm - component", () => {
it("renders 2 InputField components", () => {
render(<LoginForm handleSubmit={submitSpy} ssoSettings={settings} />);
expect(screen.getByRole("textbox", { name: "Email" })).toBeInTheDocument();
expect(screen.getByPlaceholderText("Email")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Password")).toBeInTheDocument();
});
@ -69,7 +69,7 @@ describe("LoginForm - component", () => {
<LoginForm handleSubmit={submitSpy} ssoSettings={settings} />
);
await user.type(screen.getByRole("textbox", { name: "Email" }), validEmail);
await user.type(screen.getByPlaceholderText("Email"), validEmail);
// try to log in without entering a password
await user.click(screen.getByRole("button", { name: "Login" }));
@ -95,7 +95,7 @@ describe("LoginForm - component", () => {
<LoginForm handleSubmit={submitSpy} ssoSettings={settings} />
);
await user.type(screen.getByRole("textbox", { name: "Email" }), validEmail);
await user.type(screen.getByPlaceholderText("Email"), validEmail);
await user.type(screen.getByPlaceholderText("Password"), password);
await user.click(screen.getByRole("button", { name: "Login" }));

View File

@ -0,0 +1,165 @@
import React, { FormEvent, useState } from "react";
import { Link } from "react-router";
import { size } from "lodash";
import classnames from "classnames";
import { ILoginUserData } from "interfaces/user";
import Button from "components/buttons/Button";
// @ts-ignore
import InputFieldWithIcon from "components/forms/fields/InputFieldWithIcon";
import paths from "router/paths";
import { ISSOSettings } from "interfaces/ssoSettings";
import validatePresence from "components/forms/validators/validate_presence";
import validateEmail from "components/forms/validators/valid_email";
const baseClass = "login-form";
interface ILoginFormProps {
baseError?: string;
handleSubmit: (formData: ILoginUserData) => Promise<false | void>;
ssoSettings?: ISSOSettings;
handleSSOSignOn?: () => void;
}
const LoginForm = ({
baseError,
handleSubmit,
ssoSettings,
handleSSOSignOn,
}: ILoginFormProps): JSX.Element => {
const {
idp_name: idpName,
idp_image_url: imageURL,
sso_enabled: ssoEnabled,
} = ssoSettings || {}; // TODO: Consider refactoring ssoSettings undefined
const loginFormClass = classnames(baseClass);
const [errors, setErrors] = useState<any>({});
const [formData, setFormData] = useState<ILoginUserData>({
email: "",
password: "",
});
const validate = () => {
const { password, email } = formData;
const validationErrors: { [key: string]: string } = {};
if (!validatePresence(email)) {
validationErrors.email = "Email field must be completed";
} else if (!validateEmail(email)) {
validationErrors.email = "Email must be a valid email address";
}
if (!validatePresence(password)) {
validationErrors.password = "Password field must be completed";
}
setErrors(validationErrors);
const valid = !size(validationErrors);
return valid;
};
const onFormSubmit = (evt: FormEvent): Promise<false | void> | boolean => {
evt.preventDefault();
const valid = validate();
if (valid) {
return handleSubmit(formData);
}
return false;
};
const showLegendWithImage = () => {
let legend = "Single sign-on";
if (idpName !== "") {
legend = `Sign on with ${idpName}`;
}
return (
<div>
<img
src={imageURL}
alt={idpName}
className={`${baseClass}__sso-image`}
/>
<span className={`${baseClass}__sso-legend`}>{legend}</span>
</div>
);
};
const renderSingleSignOnButton = () => {
let legend: string | JSX.Element = "Single sign-on";
if (idpName !== "") {
legend = `Sign on with ${idpName}`;
}
if (imageURL !== "") {
legend = showLegendWithImage();
}
return (
<Button
className={`${baseClass}__sso-btn`}
type="button"
title="Single sign-on"
variant="inverse"
onClick={handleSSOSignOn}
>
<div>{legend}</div>
</Button>
);
};
const onInputChange = (formField: string): ((value: string) => void) => {
return (value: string) => {
setErrors({});
setFormData({
...formData,
[formField]: value,
});
};
};
return (
<form onSubmit={onFormSubmit} className={loginFormClass}>
<div className={`${baseClass}__container`}>
{baseError && <div className="form__base-error">{baseError}</div>}
<InputFieldWithIcon
error={errors.email}
autofocus
label="Email"
placeholder="Email"
value={formData.email}
onChange={onInputChange("email")}
/>
<InputFieldWithIcon
error={errors.password}
label="Password"
placeholder="Password"
type="password"
value={formData.password}
onChange={onInputChange("password")}
/>
<div className={`${baseClass}__forgot-wrap`}>
<Link
className={`${baseClass}__forgot-link`}
to={paths.FORGOT_PASSWORD}
>
Forgot password?
</Link>
</div>
<Button
className={`${baseClass}__submit-btn button button--brand`}
type="submit"
>
Login
</Button>
{ssoEnabled && renderSingleSignOnButton()}
</div>
</form>
);
};
export default LoginForm;

View File

@ -4,7 +4,6 @@
width: 516px;
align-self: center;
border-radius: 10px;
overflow: hidden;
&--hidden {
opacity: 0;

View File

@ -1,24 +0,0 @@
import { size } from "lodash";
import validatePresence from "components/forms/validators/validate_presence";
import validateEmail from "components/forms/validators/valid_email";
const validate = (formData) => {
const errors = {};
const { password, email } = formData;
if (!validatePresence(email)) {
errors.email = "Email field must be completed";
} else if (!validateEmail(email)) {
errors.email = "Email must be a valid email address";
}
if (!validatePresence(password)) {
errors.password = "Password field must be completed";
}
const valid = !size(errors);
return { valid, errors };
};
export default validate;

View File

@ -1,65 +0,0 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import Button from "components/buttons/Button";
import Form from "components/forms/Form";
import formFieldInterface from "interfaces/form_field";
import InputFieldWithIcon from "components/forms/fields/InputFieldWithIcon";
import validate from "components/forms/ResetPasswordForm/validate";
const baseClass = "reset-password-form";
const formFields = ["new_password", "new_password_confirmation"];
class ResetPasswordForm extends Component {
static propTypes = {
baseError: PropTypes.string,
handleSubmit: PropTypes.func,
fields: PropTypes.shape({
new_password: formFieldInterface.isRequired,
new_password_confirmation: formFieldInterface.isRequired,
}),
};
render() {
const { baseError, fields, handleSubmit } = this.props;
return (
<form onSubmit={handleSubmit} className={baseClass}>
{baseError && <div className="form__base-error">{baseError}</div>}
<InputFieldWithIcon
{...fields.new_password}
autofocus
label="New password"
placeholder="New password"
className={`${baseClass}__input`}
type="password"
hint={[
"Must include 12 characters, at least 1 number (e.g. 0 - 9), and at least 1 symbol (e.g. &*#)",
]}
/>
<InputFieldWithIcon
{...fields.new_password_confirmation}
label="Confirm password"
placeholder="Confirm password"
className={`${baseClass}__input`}
type="password"
/>
<div className={`${baseClass}__button-wrap`}>
<Button
variant="brand"
onClick={handleSubmit}
className={`${baseClass}__btn`}
type="submit"
>
Reset password
</Button>
</div>
</form>
);
}
}
export default Form(ResetPasswordForm, {
fields: formFields,
validate,
});

View File

@ -11,8 +11,8 @@ describe("ResetPasswordForm - component", () => {
it("renders correctly", () => {
render(<ResetPasswordForm handleSubmit={submitSpy} />);
expect(screen.getByLabelText("New password")).toBeInTheDocument();
expect(screen.getByLabelText("Confirm password")).toBeInTheDocument();
expect(screen.getByPlaceholderText("New password")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Confirm password")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Reset password" })
@ -42,7 +42,7 @@ describe("ResetPasswordForm - component", () => {
<ResetPasswordForm handleSubmit={submitSpy} />
);
await user.type(screen.getByLabelText("New password"), newPassword);
await user.type(screen.getByPlaceholderText("New password"), newPassword);
await user.click(screen.getByRole("button", { name: "Reset password" }));
const passwordError = screen.getByText(
@ -58,7 +58,10 @@ describe("ResetPasswordForm - component", () => {
<ResetPasswordForm handleSubmit={submitSpy} />
);
await user.type(screen.getByLabelText("Confirm password"), newPassword);
await user.type(
screen.getByPlaceholderText("Confirm password"),
newPassword
);
await user.click(screen.getByRole("button", { name: "Reset password" }));
const passwordError = screen.getByText(
@ -73,9 +76,9 @@ describe("ResetPasswordForm - component", () => {
<ResetPasswordForm handleSubmit={submitSpy} />
);
await user.type(screen.getByLabelText("New password"), newPassword);
await user.type(screen.getByPlaceholderText("New password"), newPassword);
await user.type(
screen.getByLabelText("Confirm password"),
screen.getByPlaceholderText("Confirm password"),
"not my new password"
);
await user.click(screen.getByRole("button", { name: "Reset password" }));
@ -91,8 +94,14 @@ describe("ResetPasswordForm - component", () => {
<ResetPasswordForm handleSubmit={submitSpy} />
);
await user.type(screen.getByLabelText("New password"), invalidPassword);
await user.type(screen.getByLabelText("Confirm password"), invalidPassword);
await user.type(
screen.getByPlaceholderText("New password"),
invalidPassword
);
await user.type(
screen.getByPlaceholderText("Confirm password"),
invalidPassword
);
await user.click(screen.getByRole("button", { name: "Reset password" }));
const passwordError = screen.getByText(
@ -107,8 +116,11 @@ describe("ResetPasswordForm - component", () => {
<ResetPasswordForm handleSubmit={submitSpy} />
);
await user.type(screen.getByLabelText("New password"), newPassword);
await user.type(screen.getByLabelText("Confirm password"), newPassword);
await user.type(screen.getByPlaceholderText("New password"), newPassword);
await user.type(
screen.getByPlaceholderText("Confirm password"),
newPassword
);
await user.click(screen.getByRole("button", { name: "Reset password" }));
expect(submitSpy).toHaveBeenCalledWith({

View File

@ -0,0 +1,130 @@
import React, { FormEvent, useState } from "react";
import { size } from "lodash";
import { IResetPasswordForm, IResetPasswordFormErrors } from "interfaces/user";
import Button from "components/buttons/Button";
// @ts-ignore
import InputFieldWithIcon from "components/forms/fields/InputFieldWithIcon";
import validatePresence from "components/forms/validators/validate_presence";
import validatePassword from "components/forms/validators/valid_password";
import validateEquality from "components/forms/validators/validate_equality";
import { IOldApiError } from "interfaces/errors";
const baseClass = "reset-password-form";
export interface IFormData {
new_password: string;
new_password_confirmation: string;
}
interface IResetPasswordFormProps {
serverErrors: IOldApiError;
handleSubmit: (formData: IFormData) => void;
}
const ResetPasswordForm = ({
serverErrors,
handleSubmit,
}: IResetPasswordFormProps): JSX.Element => {
const [errors, setErrors] = useState<IResetPasswordFormErrors>({});
const [formData, setFormData] = useState<IResetPasswordForm>({
new_password: "",
new_password_confirmation: "",
});
const validate = (): boolean => {
const {
new_password: newPassword,
new_password_confirmation: newPasswordConfirmation,
} = formData;
const noMatch =
newPassword &&
newPasswordConfirmation &&
!validateEquality(newPassword, newPasswordConfirmation);
const validationErrors: { [key: string]: string } = {};
if (!validatePassword(newPassword)) {
validationErrors.new_password = "Password must meet the criteria below";
}
if (!validatePresence(newPasswordConfirmation)) {
validationErrors.new_password_confirmation =
"New password confirmation field must be completed";
}
if (!validatePresence(newPassword)) {
validationErrors.new_password = "New password field must be completed";
}
if (noMatch) {
validationErrors.new_password_confirmation = "Passwords do not match";
}
setErrors(validationErrors);
const valid = !size(validationErrors);
return valid;
};
const onFormSubmit = (evt: FormEvent): void => {
evt.preventDefault();
const valid = validate();
if (valid) {
return handleSubmit(formData);
}
};
const onInputChange = (formField: string): ((value: string) => void) => {
return (value: string) => {
setErrors({});
setFormData({
...formData,
[formField]: value,
});
};
};
return (
<form className={baseClass}>
{serverErrors?.base && (
<div className="form__base-error">{serverErrors.base}</div>
)}
<InputFieldWithIcon
error={errors.new_password}
autofocus
label="New password"
placeholder="New password"
onChange={onInputChange("new_password")}
value={formData.new_password || ""}
className={`${baseClass}__input`}
type="password"
hint={[
"Must include 12 characters, at least 1 number (e.g. 0 - 9), and at least 1 symbol (e.g. &*#)",
]}
/>
<InputFieldWithIcon
error={errors.new_password_confirmation}
label="Confirm password"
placeholder="Confirm password"
onChange={onInputChange("new_password_confirmation")}
value={formData.new_password_confirmation || ""}
className={`${baseClass}__input`}
type="password"
/>
<div className={`${baseClass}__button-wrap`}>
<Button
type="submit"
variant="brand"
onClick={onFormSubmit}
className={`${baseClass}__btn`}
>
Reset password
</Button>
</div>
</form>
);
};
export default ResetPasswordForm;

View File

@ -13,6 +13,5 @@
&__btn {
margin-top: $pad-xxlarge;
width: 160px;
margin-bottom: 20px;
}
}

View File

@ -1,40 +0,0 @@
import { size } from "lodash";
import validateEquality from "components/forms/validators/validate_equality";
import validatePresence from "components/forms/validators/validate_presence";
import validPassword from "components/forms/validators/valid_password";
const validate = (formData) => {
const errors = {};
const {
new_password: newPassword,
new_password_confirmation: newPasswordConfirmation,
} = formData;
const noMatch =
newPassword &&
newPasswordConfirmation &&
!validateEquality(newPassword, newPasswordConfirmation);
if (!validPassword(newPassword)) {
errors.new_password = "Password must meet the criteria below";
}
if (!validatePresence(newPasswordConfirmation)) {
errors.new_password_confirmation =
"New password confirmation field must be completed";
}
if (!validatePresence(newPassword)) {
errors.new_password = "New password field must be completed";
}
if (noMatch) {
errors.new_password_confirmation = "Passwords do not match";
}
const valid = !size(errors);
return { valid, errors };
};
export default validate;

View File

@ -1,5 +1,5 @@
import { isEqual } from "lodash";
export default (actual, expected) => {
export default (actual: any, expected: any) => {
return isEqual(actual, expected);
};

View File

@ -5,6 +5,7 @@ export default PropTypes.shape({
base: PropTypes.string,
});
// Response created by utilities/format_error_response
export interface IOldApiError {
http_status: number;
base: string;
@ -15,7 +16,9 @@ export interface IError {
reason: string;
}
// Response returned by API when there is an error
export interface IApiError {
message: string;
errors: IError[];
uuid?: string;
}

View File

@ -7,7 +7,7 @@ export default PropTypes.shape({
});
export interface ISSOSettings {
idp_image_url: string;
idp_name: string;
idp_image_url?: string;
idp_name?: string;
sso_enabled: boolean;
}

View File

@ -73,6 +73,20 @@ export interface IUserFormErrors {
password?: string | null;
sso_enabled?: boolean | null;
}
export interface IResetPasswordFormErrors {
new_password?: string | null;
new_password_confirmation?: string | null;
}
export interface IResetPasswordForm {
new_password: string;
new_password_confirmation: string;
}
export interface ILoginUserData {
email: string;
password: string;
}
export interface ICreateUserFormData {
email: string;

View File

@ -1,4 +1,8 @@
.gated-layout {
display: flex;
min-height: 100vh;
align-items: center;
.flash-message {
border: 0;
}

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { InjectedRouter } from "react-router";
import PATHS from "router/paths";
import usersAPI from "services/entities/users";
import formatErrorResponse from "utilities/format_error_response";
@ -12,7 +12,11 @@ import AuthenticationFormWrapper from "components/AuthenticationFormWrapper";
import Spinner from "components/Spinner";
import CustomLink from "components/CustomLink";
const ForgotPasswordPage = () => {
interface IForgotPasswordPage {
router: InjectedRouter;
}
const ForgotPasswordPage = ({ router }: IForgotPasswordPage) => {
const [email, setEmail] = useState("");
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const [isLoading, setIsLoading] = useState(false);
@ -81,7 +85,7 @@ const ForgotPasswordPage = () => {
return (
<AuthenticationFormWrapper>
<StackedWhiteBoxes previousLocation={PATHS.LOGIN}>
<StackedWhiteBoxes previousLocation={PATHS.LOGIN} router={router}>
<div className={baseClass}>{renderContent()}</div>
</StackedWhiteBoxes>
</AuthenticationFormWrapper>

View File

@ -1,7 +1,7 @@
.forgot-password {
p,
a {
font-size: 1rem;
font-size: $x-small;
}
&__text {

View File

@ -9,6 +9,7 @@ import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import { RoutingContext } from "context/routing";
import { ISSOSettings } from "interfaces/ssoSettings";
import { ILoginUserData } from "interfaces/user";
import local from "utilities/local";
import configAPI from "services/entities/config";
import sessionsAPI, { ISSOSettingsResponse } from "services/entities/sessions";
@ -28,11 +29,6 @@ interface ILoginPageProps {
};
}
interface ILoginData {
email: string;
password: string;
}
interface IStatusMessages {
account_disabled: string;
account_invalid: string;
@ -65,7 +61,6 @@ const LoginPage = ({ router, location }: ILoginPageProps) => {
const { redirectLocation } = useContext(RoutingContext);
const [errors, setErrors] = useState<Record<string, string>>({});
const [loginVisible, setLoginVisible] = useState(true);
const {
data: ssoSettings,
@ -83,17 +78,6 @@ const LoginPage = ({ router, location }: ILoginPageProps) => {
}
);
useEffect(() => {
if (
availableTeams &&
config &&
currentUser &&
!currentUser.force_password_reset
) {
router.push(redirectLocation || paths.DASHBOARD);
}
}, [availableTeams, config, currentUser, redirectLocation, router]);
useEffect(() => {
// this only needs to run once so we can wrap it in useEffect to avoid unneccesary third-party
// API calls
@ -111,6 +95,17 @@ const LoginPage = ({ router, location }: ILoginPageProps) => {
})();
}, []);
useEffect(() => {
if (
availableTeams &&
config &&
currentUser &&
!currentUser.force_password_reset
) {
router.push(redirectLocation || paths.DASHBOARD);
}
}, [availableTeams, config, currentUser, redirectLocation, router]);
// TODO: Fix this. If renderFlash is added as a dependency it causes infinite re-renders.
useEffect(() => {
let status = new URLSearchParams(location.search).get("status");
@ -120,36 +115,28 @@ const LoginPage = ({ router, location }: ILoginPageProps) => {
}
}, [location?.search]);
const onChange = useCallback(() => {
if (size(errors)) {
setErrors({});
}
return false;
}, [errors]);
const onSubmit = useCallback(
async (formData: ILoginData) => {
const { DASHBOARD, RESET_PASSWORD } = paths;
async (formData: ILoginUserData) => {
const { DASHBOARD, RESET_PASSWORD, NO_ACCESS } = paths;
try {
const { user, available_teams, token } = await sessionsAPI.create(
formData
);
const response = await sessionsAPI.create(formData);
const { user, available_teams, token } = response;
local.setItem("auth_token", token);
setLoginVisible(false);
setCurrentUser(user);
setAvailableTeams(user, available_teams);
setCurrentTeam(undefined);
if (!user.global_role && user.teams.length === 0) {
return router.push(NO_ACCESS);
}
// Redirect to password reset page if user is forced to reset password.
// Any other requests will fail.
if (user.force_password_reset) {
else if (user.force_password_reset) {
return router.push(RESET_PASSWORD);
}
if (!config) {
} else if (!config) {
const configResponse = await configAPI.loadAll();
setConfig(configResponse);
}
@ -201,10 +188,8 @@ const LoginPage = ({ router, location }: ILoginPageProps) => {
return (
<AuthenticationFormWrapper>
<LoginForm
onChangeFunc={onChange}
handleSubmit={onSubmit}
isHidden={!loginVisible}
serverErrors={errors}
baseError={errors.base}
ssoSettings={ssoSettings}
handleSSOSignOn={ssoSignOn}
/>

View File

@ -26,7 +26,6 @@ const LoginPreviewPage = ({ router }: ILoginPreviewPageProps): JSX.Element => {
setCurrentUser,
setCurrentTeam,
} = useContext(AppContext);
const [loginVisible, setLoginVisible] = useState(true);
const onSubmit = async (formData: ILoginData) => {
const { DASHBOARD } = paths;
@ -37,7 +36,6 @@ const LoginPreviewPage = ({ router }: ILoginPreviewPageProps): JSX.Element => {
);
local.setItem("auth_token", token);
setLoginVisible(false);
setCurrentUser(user);
setAvailableTeams(user, available_teams);
setCurrentTeam(undefined);
@ -61,7 +59,7 @@ const LoginPreviewPage = ({ router }: ILoginPreviewPageProps): JSX.Element => {
return (
<AuthenticationFormWrapper>
<LoginSuccessfulPage />
<LoginForm handleSubmit={onSubmit} isHidden={!loginVisible} />
<LoginForm handleSubmit={onSubmit} />
</AuthenticationFormWrapper>
);
};

View File

@ -0,0 +1,69 @@
// Page returned when a user has no access because they have no global or team role
import React, { useEffect } from "react";
import { InjectedRouter } from "react-router";
import PATHS from "router/paths";
import Button from "components/buttons/Button/Button";
// @ts-ignore
import StackedWhiteBoxes from "components/StackedWhiteBoxes";
import AuthenticationFormWrapper from "components/AuthenticationFormWrapper";
import CustomLink from "components/CustomLink/CustomLink";
const baseClass = "no-access-page";
interface INoAccessPageProps {
router: InjectedRouter;
orgContactUrl?: string;
}
const NoAccessPage = ({ router, orgContactUrl }: INoAccessPageProps) => {
const onBackToLogin = () => {
router.push(PATHS.LOGIN);
};
useEffect(() => {
if (onBackToLogin) {
const closeOrSaveWithEnterKey = (event: KeyboardEvent) => {
if (event.code === "Enter" || event.code === "NumpadEnter") {
event.preventDefault();
onBackToLogin();
}
};
document.addEventListener("keydown", closeOrSaveWithEnterKey);
return () => {
document.removeEventListener("keydown", closeOrSaveWithEnterKey);
};
}
}, [onBackToLogin]);
return (
<AuthenticationFormWrapper>
<StackedWhiteBoxes
router={router}
headerText="This account does not currently have access to Fleet."
>
<>
<p>
To get access,{" "}
<CustomLink
url={orgContactUrl || "https://fleetdm.com/contact"}
text="contact your administrator"
/>
.
</p>
<Button
variant="brand"
onClick={onBackToLogin}
className={`${baseClass}__btn`}
>
Back to login
</Button>
</>
</StackedWhiteBoxes>
</AuthenticationFormWrapper>
);
};
export default NoAccessPage;

View File

@ -0,0 +1,5 @@
.no-access-page {
&__btn {
margin-top: $pad-small;
}
}

View File

@ -0,0 +1 @@
export { default } from "./NoAccessPage";

View File

@ -1,6 +1,5 @@
import React, { useEffect, useState, useContext } from "react";
import { InjectedRouter } from "react-router";
import { size } from "lodash";
import PATHS from "router/paths";
import { AppContext } from "context/app";
@ -30,11 +29,12 @@ const ResetPasswordPage = ({ location, router }: IResetPasswordPageProps) => {
}
}, [currentUser, token]);
const onResetErrors = () => {
if (size(errors)) {
setErrors({});
// No access prompt if API errors due to no role or currentUser data has no role
useEffect(() => {
if (!currentUser?.global_role && currentUser?.teams.length === 0) {
router.push(PATHS.NO_ACCESS);
}
};
}, [errors, currentUser]);
const continueWithLoggedInUser = async (formData: any) => {
const { new_password } = formData;
@ -44,7 +44,15 @@ const ResetPasswordPage = ({ location, router }: IResetPasswordPageProps) => {
const config = await configAPI.loadAll();
setConfig(config);
return router.push(PATHS.DASHBOARD);
} catch (response) {
} catch (response: any) {
if (
response.data.message.includes(
"either global role or team role needs to be defined"
)
) {
router.push(PATHS.NO_ACCESS);
}
const errorObject = formatErrorResponse(response);
setErrors(errorObject);
return false;
@ -73,12 +81,11 @@ const ResetPasswordPage = ({ location, router }: IResetPasswordPageProps) => {
return (
<AuthenticationFormWrapper>
<StackedWhiteBoxes leadText="Create a new password. Your new password must include 12 characters, at least 1 number (e.g. 0 - 9), and at least 1 symbol (e.g. &*#)">
<ResetPasswordForm
handleSubmit={onSubmit}
onChangeFunc={onResetErrors}
serverErrors={errors}
/>
<StackedWhiteBoxes
router={router}
leadText="Create a new password. Your new password must include 12 characters, at least 1 number (e.g. 0 - 9), and at least 1 symbol (e.g. &*#)"
>
<ResetPasswordForm handleSubmit={onSubmit} serverErrors={errors} />
</StackedWhiteBoxes>
</AuthenticationFormWrapper>
);

View File

@ -33,6 +33,7 @@ import ManageSoftwarePage from "pages/software/ManageSoftwarePage";
import ManageQueriesPage from "pages/queries/ManageQueriesPage";
import ManagePacksPage from "pages/packs/ManagePacksPage";
import ManagePoliciesPage from "pages/policies/ManagePoliciesPage";
import NoAccessPage from "pages/NoAccessPage";
import PackComposerPage from "pages/packs/PackComposerPage";
import PolicyPage from "pages/policies/PolicyPage";
import QueryPage from "pages/queries/QueryPage";
@ -100,6 +101,7 @@ const routes = (
/>
<Route path="login/forgot" component={ForgotPasswordPage} />
<Route path="login/reset" component={ResetPasswordPage} />
<Route path="login/denied" component={NoAccessPage} />
<Route path="mdm/sso/callback" component={MDMAppleSSOCallbackPage} />
<Route path="mdm/sso" component={MDMAppleSSOPage} />
</Route>

View File

@ -55,6 +55,7 @@ export default {
}`;
},
FORGOT_PASSWORD: `${URL_PREFIX}/login/forgot`,
NO_ACCESS: `${URL_PREFIX}/login/denied`,
API_ONLY_USER: `${URL_PREFIX}/apionlyuser`,
FLEET_403: `${URL_PREFIX}/403`,
LOGIN: `${URL_PREFIX}/login`,

View File

@ -1,5 +1,5 @@
import { get, join } from "lodash";
import { IError } from "interfaces/errors";
import { IError, IOldApiError } from "interfaces/errors";
const formatServerErrors = (errors: IError[]) => {
if (!errors || !errors.length) {
@ -18,7 +18,7 @@ const formatServerErrors = (errors: IError[]) => {
}
});
return result;
return result; // TODO: Typing {base: string}
};
const formatErrorResponse = (errorResponse: any) => {
@ -30,7 +30,7 @@ const formatErrorResponse = (errorResponse: any) => {
return {
...formatServerErrors(errors),
http_status: errorResponse.status,
} as any;
} as any; // TODO: Fix type to IOldApiError
};
export default formatErrorResponse;